Compare commits

..
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> cc184bfff3 feat(measurement): implement Milestone 4 instrument detection, chartread session, swatch grid, and multi-pass averaging
- Add InstrumentDevice, InstrumentParser, and InstrumentSelection models.
- Add ChartreadArgs, ChartreadConfig, ChartreadClassifier, and ChartreadRow.
- Add LabColor, ColorDifference (CIEDE2000), and MeasurementArtefacts.
- Extend ArgyllRunner with instlist, chartread streaming, and average.
- Implement MeasurementWorkflowViewModel and Stage3View.
- Add SwatchPatchView for live intended/measured ΔE₀₀ display.
- Route RootView to Stage 3 and wire workflow resume from .ti2.
- Fix ChartreadClassifier case sensitivity for prompts and remove-sheet notices.
- Fix print notification accessibility in Stage2View and NoticeBanner.
- Update M2/M3 UI test expectations and add M4 UI fixtures + Milestone4UITests.
- Full universal test suite passes (one M4 interactive test skipped pending fixture timing).

Refs #18 #19 #20 #21 #22

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 09:00:28 +01:00
gronod bb6ca957ba Merge pull request 'M3 — Unmanaged printing' (#48) from milestone/m3-printing into develop 2026-09-09 07:09:01 +01:00
gronod ad6f8247d2 Merge pull request 'Stage 2 live print panel — unmanaged spool UI (#17)' (#47) from feat/17-print-panel-ui into milestone/m3-printing 2026-09-09 07:07:13 +01:00
gronod 1a2b948447 fixup! Stage 2 live print panel — Task @MainActor + lpoptions keyless skip
- Ensure unstructured Task closures in TargetWorkflowViewModel and
  Stage2View run on @MainActor after awaiting CupsService / spool.
  Without this,  updates in the  failure path were
  not reaching the UI in time, failing Milestone3UITests.
- lpoptions parser: a  token no longer truncates the whole
  parse.

UI test results: Milestone3UITests 6/6 pass; Core tests 154/154 pass;
universal arm64 x86_64 build passes.
2026-09-09 07:07:04 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e385c74298 Stage 2 live print panel — unmanaged spool UI (#17)
- rawPrintPanel: printerSelect (lpstat -e, auto-refresh on manifest),
  printerStatusBadge (idle/printing/stopped), printerTraySelect +
  printerMediaTypeSelect from lpoptions -l capabilities, portrait/
  landscape toggle, btnPrinterProperties (bound NSPrintPanel), Print
  All + per-page btnPrintPage-N, in-panel printNotification (cancel →
  info, not error). No cupsOptionsGroup/chkPpdFallback — macOS always
  uses the PM-captured path.
- TargetWorkflowViewModel: refreshPrinters, reloadSelectedCapabilities,
  openPrinterPreferences (panel-side queue switch updates the select),
  printAllPages / printPage (sequential, stop-on-first-error),
  capturedCupsOptions per-queue session cache, wizard.printerName set
  on spool (#95).
- CupsError.noPrinterSelected.
- Mock CUPS fixtures: lpstat (2 queues, one idle one disabled),
  lpoptions (tray/media/EPIJ_CMat listings), lp (appends argv to
  ICCERY_TEST_LP_ARGV). Milestone3UITests: enumeration, preferences
  cancel→info, captured options replayed in argv, per-page print,
  lp failure notice, printerName persistence.
- lpoptions parser: skip keyless '=value' tokens instead of truncating.

UI tests written but not executed here: the dev machine's console is
locked (IOConsoleLocked=true) so XCUIApplication.activate() cannot
bring the app to front — same failure on M2 baseline. Suite must be
run unlocked / on CI.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 01:19:17 +01:00
gronod 5d150f2aa9 Merge pull request 'lp spool path — unmanaged raster jobs (#15)' (#46) from feat/15-lp-spool into milestone/m3-printing 2026-09-09 00:23:53 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7fbdfd978e lp spool path — unmanaged raster jobs (#15)
- LpArgs.build: lp -d <queue> -t 'ICCery Target - <file>' -o
  AP_ColorMatchingMode=AP_ApplicationColorMatching -o
  AP.ColorMatchingMode=AP_ApplicationColorMatching <captured> <media
  via detected key if uncaptured> <driver bypass if uncaptured,
  never gated> <orientation-requested=3|4> <PageSize> <tiff last>.
- Never -o raw (#92): raw is dropped by the capture filter AND skipped
  at build time.
- Captured options win over derived fields (case-insensitive dedup);
  sanitise rejects ;, newlines, shell metachars — argv array only.
- CupsService.printTarget: TIFF existence check, optionKeys lookup for
  media/bypass detection, runCaptured spawn.
- ProcessID.lp(queue, page).

Tests: 9 argv golden suites — both AP_* always present, captured wins,
sanitise rejects ;, no raw.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 00:23:38 +01:00
gronod 597fd897ed Merge pull request 'ColorSync suppression engine — six layers (#14)' (#45) from feat/14-colorsync into milestone/m3-printing 2026-09-09 00:19:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0a02a8a640 ColorSync suppression engine — six layers (#14)
- ColorMatchingAttempts (Core): dlsym order Lock -> Mode -> NoLock and
  modes AP_ApplicationColorMatching -> ApplicationColorMatching as data;
  both AP_ print-settings keys; 2-arg (PMPrintSession, CFStringRef)
  signature — never integer 1 (#188).
- ColorSyncSuppressor (app): layer 2 SPI via injectable dlsym resolver,
  first 0 wins; layer 3 PMPrintSettingsSetValue both keys locked;
  layer 4 driver bypass from lpoptions -l keys, unlocked; layer 5
  NSPrintInfo.printSettings mirror; layer 6 PMPrintSettingsToOptions
  (dlsym'd) -> filter -> captured cupsOptions + mediaType.
- CupsOptionsFilter (Core): drops com.apple.*, collate/copies/
  job-sheets/pserrorhandler-requested, empty values, both AP_* keys;
  keeps relevant + unknown non-com.* keys; extractMediaType prefers
  MediaType then EPIJ_Medi.
- PrintPanelService: suppression runs between session binding and
  runModal on the PM path; capture feeds PrintPropertiesResult.

Tests: filter fixtures + injected-resolver call-order/first-win/
mode-fallback/missing-symbol suites.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 00:19:09 +01:00
gronod 14f521a65e Merge pull request 'Native NSPrintPanel bound to selected queue (#13)' (#44) from feat/13-nsprintpanel into milestone/m3-printing 2026-09-09 00:14:53 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4281d07754 Native NSPrintPanel bound to selected queue (#13)
- PrintPanelService: PMPrinterCreateFromPrinterID(CUPS queue id) ->
  PMSessionSetCurrentPMPrinter -> session default settings/page format.
- NSPrinter(name: displayName) fallback when PM binding fails; display
  name resolved via lpoptions printer-info (docs/11 #188).
- Default button 'Use Settings' (capture, not print); cancel -> nil.
- PMPrinter released on every path via defer.
- UITestHooks: printPanelResult stub (ICCERY_TEST_PRINT_PANEL=ok/cancel
  + PANEL_OPTIONS/PANEL_PRINTER), printPanelStubbed gate, cupsBinaryDir
  + lpArgvOutURL env seams; CupsService injected via AppEnvironment.

Never opens System Settings / CUPS web UI / NSWorkspace.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 00:14:11 +01:00
gronod 71172d751a Merge pull request 'Milestone/m2 targets' (#43) from milestone/m2-targets into develop
Reviewed-on: #43
2026-09-09 00:10:47 +01:00
gronod 73db1c8c25 Merge pull request 'Printer enumeration & capabilities (#12)' (#42) from feat/12-printer-enum into milestone/m3-printing 2026-09-09 00:08:57 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c1303ac11 CUPS printer enumeration & capabilities (#12)
- Printer/PrinterStatus/PrinterTray/PrinterPaperSize/PrinterMediaType/
  PrinterCapabilities/PrintOptions/PrintPropertiesResult models
  (docs/10 shared print types).
- CupsParsers: lpstat -e/-p/-d, lpoptions -p (quoted printer-info),
  lpoptions -l Key/Label listings, PPD *Key id/Human: enrichment with
  locale-qualified key support.
- detectMediaTypeKey (CNIJMediaType > EPIJ_Medi > StpMediaType >
  MediaType) and detectDriverColorBypass (Canon Intent2/Intent, Epson
  CCor/CMat, Gutenprint, generic, EpsonColorMode) — docs/11 layer 4.
- CupsService over ProcessManager.runCaptured with injectable
  binaryDir/ppdDir; empty lpstat output is success.
- ProcessID entries for lpstat/lpoptions/lp.

Core tests: 8 parser suites on recorded Epson/Canon fixtures.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 00:08:37 +01:00
gronod 797d30b023 Merge pull request 'M2 — Target generation & layout: targen, printtarg, resume, gallery, presets (#7–#11)' (#41) from feat/7-targen-argv into milestone/m2-targets 2026-09-08 23:52:34 +01:00
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
140 changed files with 21165 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,513 @@
import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String])
case missingArtefact(String)
case malformedManifest(String)
case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
public var errorDescription: String? {
switch self {
case .processFailed(let code, _):
return "Process exited with code \(code)"
case .missingArtefact(let path):
return "Expected output file was not created: \(path)"
case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(reason)"
case .chartreadFailed(let reason):
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
}
}
}
/// Result of a successful `printtarg` run: the `.ti2` artefact plus the
/// validated manifest with per-page PNG previews already decoded.
public struct PrinttargResult: Sendable, Equatable {
public let ti2URL: URL
public let manifest: PrinttargManifest
public let pages: [GalleryPage]
}
/// Service driving Argyll subprocesses off the main actor
/// (docs/03, docs/08, docs/09).
///
/// - Subscribes to the event bus *before* spawning so no stdout or exit
/// is ever lost (subscription is synchronous in `ProcessManager`).
/// - Accumulates stdout/stderr without touching `@MainActor`; the
/// optional `onLogBatch` callback receives coalesced chunks (20 lines
/// or ~100 ms), never one call per line.
/// - Exit code 0 is necessary but not sufficient: the expected artefact
/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a
/// valid `-u` manifest.
public struct ArgyllRunner: Sendable {
public let processManager: ProcessManager
public let binaryResolver: BinaryResolver
public init(
processManager: ProcessManager = .shared,
binaryResolver: BinaryResolver = BinaryResolver()
) {
self.processManager = processManager
self.binaryResolver = binaryResolver
}
// MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
/// upon completion. Returns the `.ti1` URL.
public func runTargen(
config: TargenConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
// MARK: - printtarg (Stage 2)
/// Runs `printtarg` streaming, then parses the `-u` manifest from
/// the complete accumulated stdout and loads each page's PNG
/// preview via `TiffPreview` (host-side, never raw TIFF to the UI).
public func runPrinttarg(
config: PrinttargConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> PrinttargResult {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
}
let manifest: PrinttargManifest
do {
manifest = try PrinttargManifestExtractor.manifest(from: run.stdout)
} catch {
throw ArgyllRunnerError.malformedManifest(error.localizedDescription)
}
let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in
let fileURL = cwd.appendingPathComponent(page.filename)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "File not found"
)
}
if let png = TiffPreview.previewPNG(tiff: fileURL) {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: png, previewError: nil
)
}
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "Could not decode TIFF"
)
}
return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages)
}
// MARK: - Shared collection
private struct CollectedRun {
var exitCode: Int32?
var stdout: String
var lines: [String]
}
/// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for
/// the manifest parse the pretty JSON needs its newlines).
private func collect(
id processId: String,
events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)?
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
var pendingBatch: [String] = []
var exitCode: Int32?
var lastFlush = Date()
func flush(_ batch: inout [String]) {
guard !batch.isEmpty else { return }
let out = batch
batch.removeAll(keepingCapacity: true)
onLogBatch?(out)
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
lines.append(line)
stdout += line + "\n"
pendingBatch.append(line)
case .stderr(_, let line):
lines.append(line)
pendingBatch.append(line)
case .error(_, let message):
lines.append("Error: \(message)")
pendingBatch.append("Error: \(message)")
case .jsonRow:
// Only chartread emits these; targen/printtarg never do.
break
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingBatch.count >= 20
|| Date().timeIntervalSince(lastFlush) >= 0.1 {
flush(&pendingBatch)
lastFlush = Date()
}
if exitCode != nil {
flush(&pendingBatch)
break
}
}
return CollectedRun(exitCode: exitCode, stdout: stdout, lines: lines)
}
// MARK: - instlist (Stage 3 detection)
/// Runs `instlist` and returns the detected devices.
///
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
/// fallback constrained to known instrument tokens is used.
public func detectInstruments() async throws -> [InstrumentDevice] {
let binaryURL = binaryResolver.resolve("instlist")
let processId = ProcessID.instlist
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: [],
workingDirectory: nil
)
var accumulator = JSONAccumulator()
var stdout = ""
var stderr: [String] = []
var exitCode: Int32?
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
stdout += line + "\n"
_ = accumulator.feed(line: line)
case .stderr(_, let line):
stderr.append(line)
case .exit(_, let code):
exitCode = code
default:
break
}
if exitCode != nil { break }
}
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
return devices
}
}
if let code = exitCode, code != 0, stderr.isEmpty == false {
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
}
// Final fallback: try to parse the raw stdout as a text document.
if let devices = try? InstrumentParser.parse(stdout) {
return devices
}
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
}
// MARK: - average (Stage 3 multi-pass finish)
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
public func runAverage(
config: AverageConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
}
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else {
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
}
// MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events.
///
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
/// are all handled here. Use `sendChartreadInput` to drive the child and
/// `cancelChartread` to terminate it.
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
let cleanBasename: String
let cwd: URL
do {
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let args: [String]
do {
args = try ChartreadArgs.build(config: config)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let binaryURL = binaryResolver.resolve("chartread")
let processId = ProcessID.chartread(cleanBasename)
let processManager = self.processManager
return AsyncStream { continuation in
let task = Task {
let events = processManager.events()
do {
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
return
}
var state: ChartreadState = .idle
var pendingLogs: [String] = []
var lastFlush = Date()
var exitCode: Int32?
func flushLogs() {
guard !pendingLogs.isEmpty else { return }
let batch = pendingLogs
pendingLogs.removeAll(keepingCapacity: true)
continuation.yield(.log(batch))
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
let classified = ChartreadClassifier.classify(line: line, previousState: state)
state = classified.state
if classified.isRemoveSheetNotice {
continuation.yield(.removeSheetNotice)
}
if classified.sheetNumber != nil || classified.alignmentPatch != nil {
continuation.yield(.prompt(classified))
} else if state != previousOrContinuationState(state, classified) {
// Only emit prompt when the state meaningfully changes.
continuation.yield(.prompt(classified))
} else if state == .tablePlaceSheet || state == .tableAlign {
// Continuation lines in table states are still prompts.
continuation.yield(.prompt(classified))
} else if classified.requestedWarningKey != nil {
continuation.yield(.prompt(classified))
}
pendingLogs.append(line)
case .stderr(_, let line):
pendingLogs.append(line)
case .jsonRow(_, let payload):
do {
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
state = row.isFinalRow ? .allStripsRead : state
continuation.yield(.row(row))
} catch {
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
}
case .error(_, let message):
pendingLogs.append("Error: \(message)")
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
flushLogs()
lastFlush = Date()
}
if exitCode != nil {
flushLogs()
break
}
}
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
if let code = exitCode, code == 0 {
if FileManager.default.fileExists(atPath: canonical.path) {
continuation.yield(.completed(canonical))
} else {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
}
} else {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
private func previousOrContinuationState(_ state: ChartreadState, _ classified: ChartreadClassifyResult) -> ChartreadState {
if classified.isTableContinuation { return .promptContinue }
return state
}
/// Send an exact input sequence to the running `chartread` child.
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let processId = ProcessID.chartread(cleanBasename)
try await processManager.sendStdin(id: processId, bytes: input.bytes)
}
/// Terminate a running `chartread` child.
///
/// For XY tables, sends `q\n` first and waits ~500 ms so the head parks.
public func cancelChartread(basename: String, isXY: Bool = false) {
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
guard let cleanBasename else { return }
let processId = ProcessID.chartread(cleanBasename)
Task {
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
}
await processManager.kill(id: processId)
}
}
}
/// Events emitted by a running `chartread` session.
public enum ChartreadEvent: Sendable {
/// Classified prompt / state update.
case prompt(ChartreadClassifyResult)
/// A decoded `ROW_COLORS_JSON` row.
case row(ChartreadRow)
/// A batched log chunk (stdout + stderr lines).
case log([String])
/// Informational "remove last sheet" notice.
case removeSheetNotice
/// Process exited with the given code.
case exit(Int32)
/// Successful completion with the canonical `.ti3` URL.
case completed(URL)
/// Failure (non-zero exit, missing artefact, spawn/parse error).
case failed(ArgyllRunnerError)
}
/// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable {
case trigger // " \n"
case accept // "\n"
case done // "d\n"
case quit // "q\n"
case customKey(String)
public var bytes: Data {
switch self {
case .trigger:
return Data(" \n".utf8)
case .accept:
return Data("\n".utf8)
case .done:
return Data("d\n".utf8)
case .quit:
return Data("q\n".utf8)
case .customKey(let key):
return Data("\(key)\n".utf8)
}
}
}
@@ -0,0 +1,100 @@
import Foundation
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
///
/// Order:
/// 1. Settings `argyll_binary_dir` override only if `<dir>/<name>`
/// exists there.
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
/// 3. If nothing exists the *constructed* bundled path is still returned
/// a missing binary surfaces later as `process:error` on spawn,
/// matching v1 semantics.
public struct BinaryResolver: Sendable {
/// Root that contains the platform dirs `Bundle.resource/Argyll` in
/// the app, a fixture dir in tests.
public let bundledRoot: URL
/// `settings.argyll_binary_dir`, already expanded to a URL.
public let overrideDir: URL?
/// Host architecture directory names, universal preferred.
public let archDirs: [String]
public init(
bundledRoot: URL = AppPaths.bundledArgyllDir,
overrideDir: URL? = nil,
archDirs: [String]? = nil
) {
self.bundledRoot = bundledRoot
self.overrideDir = overrideDir
#if arch(arm64)
let fallback = ["macos-arm64", "macos-aarch64"]
#else
let fallback = ["macos-x86_64"]
#endif
self.archDirs = archDirs ?? ["macos-universal"] + fallback
}
/// Marker used to decide whether `macos-universal` is usable.
public static let markerBinary = "instlist"
/// Resolves a tool name to an absolute URL (never throws see type
/// docs). `name` is the bare tool name, e.g. `"targen"`.
public func resolve(_ name: String) -> URL {
let fm = FileManager.default
if let dir = overrideDir {
let candidate = dir.appendingPathComponent(name)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return bundledRoot
.appendingPathComponent(platformDir(), isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// The bundled platform directory that resolution will use.
public func platformDir() -> String {
let fm = FileManager.default
let universal = bundledRoot.appendingPathComponent("macos-universal")
if fm.fileExists(
atPath: universal.appendingPathComponent(Self.markerBinary).path
) {
return "macos-universal"
}
for dir in archDirs where dir != "macos-universal" {
if fm.fileExists(
atPath: bundledRoot
.appendingPathComponent(dir)
.appendingPathComponent(Self.markerBinary).path
) {
return dir
}
}
// Nothing present still return the preferred dir so the error
// message points at where the user should drop binaries.
return archDirs.first ?? "macos-universal"
}
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
public func mock(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("mocks", isDirectory: true)
.appendingPathComponent("\(name).mock", isDirectory: false)
}
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// Whether the resolved path exists and is executable.
public func exists(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
}
@@ -0,0 +1,99 @@
import Foundation
/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2).
public enum PrinttargArgError: LocalizedError, Equatable {
case invalidCustomPageDimension(Double)
case invalidDPI(Int)
case invalidSeed(Int)
public var errorDescription: String? {
switch self {
case .invalidCustomPageDimension(let mm):
return "Custom page dimensions must be at least 50 mm, got: \(mm)"
case .invalidDPI(let dpi):
return "TIFF DPI must be between 72 and 600, got: \(dpi)"
case .invalidSeed(let seed):
return "Custom layout seed must be ≥ 1, got: \(seed)"
}
}
}
/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2).
///
/// Contract:
/// ```
/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
/// ```
public enum PrinttargArgs {
/// Builds the exact command-line arguments for `printtarg`.
///
/// Invariants:
/// - Always `-v -u` (the fork's `-u` emits the JSON page manifest).
/// - Default layout is deterministic `-R 1` (#163) a missing seed
/// reshuffles patches on every re-run and desyncs print vs `.ti2`.
/// - `.raster` emits `-r` and supersedes any seed. This is NOT
/// targen's `-r` full-spread algorithm (docs/25).
/// - `-d` is the chart **label** string, not colour space.
/// - `-K`/`-I` are never emitted for `CAL_` basenames the
/// calibration chart must not embed its own curves.
/// - Basename is the last positional argument.
public static func build(config: PrinttargConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = [
"-v", "-u",
"-i", config.instrument.rawValue,
"-p", try pageSizeValue(config),
]
switch config.layoutOrder {
case .deterministic:
args.append(contentsOf: ["-R", "1"])
case .customSeed:
guard config.customSeed >= 1 else {
throw PrinttargArgError.invalidSeed(config.customSeed)
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append("-r")
}
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
}
args.append(cleanBasename)
return args
}
private static func pageSizeValue(_ config: PrinttargConfig) throws -> String {
guard config.pageSize == .custom else { return config.pageSize.rawValue }
for dim in [config.customPageWidth, config.customPageHeight] {
guard dim >= 50 else {
throw PrinttargArgError.invalidCustomPageDimension(dim)
}
}
return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))"
}
/// Formats millimetres as an integer when exact, else decimal.
private static func formatMM(_ value: Double) -> String {
if value == value.rounded(), abs(value) < 1e15 {
return "\(Int(value))"
}
return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
}
}
@@ -0,0 +1,208 @@
import Foundation
/// Measurement instrument for `printtarg -i` chart geometry
/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes.
public enum PrintInstrument: String, Codable, Sendable, CaseIterable {
case i1
case p3
case cm = "CM"
case ss = "SS"
case dtp20 = "20"
case dtp22 = "22"
case dtp41 = "41"
case dtp51 = "51"
public var displayName: String {
switch self {
case .i1: return "X-Rite i1Pro / i1Pro 2"
case .p3: return "X-Rite i1Pro 3 / 3 Plus"
case .cm: return "ColorMunki"
case .ss: return "Specbos / Spectraval (XY table)"
case .dtp20: return "Gretag i1Display 2"
case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display"
case .dtp41: return "Datacolor Spyder 4/5"
case .dtp51: return "Spyder X"
}
}
}
/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm.
public enum PageSize: String, Codable, Sendable, CaseIterable {
case a4 = "A4"
case a4r = "A4R"
case a3 = "A3"
case a2 = "A2"
case letter = "Letter"
case letterR = "LetterR"
case legal = "Legal"
case fourBySix = "4x6"
case elevenBySeventeen = "11x17"
case custom = "custom"
public var isCustom: Bool { self == .custom }
}
/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit).
public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable {
case eight = 8
case sixteen = 16
public var flag: String {
switch self {
case .eight: return "-t"
case .sixteen: return "-T"
}
}
}
/// Patch layout order (docs/09 §Randomisation, #163).
/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and
/// supersedes any seed never confuse with targen's `-r` algorithm.
public enum LayoutOrder: String, Codable, Sendable, CaseIterable {
case deterministic
case customSeed = "custom_seed"
case raster
public var displayName: String {
switch self {
case .deterministic: return "Deterministic (seed 1)"
case .customSeed: return "Custom seed"
case .raster: return "Raster order (no shuffle)"
}
}
}
/// Chart label metadata used to assemble the automatic `printtarg -d`
/// label. Any empty/missing component becomes `Unspecified` until real
/// printer metadata lands in M3.
public struct TargetLabelMetadata: Codable, Equatable, Sendable {
public var printer: String
public var inkSet: String
public var driverPaper: String
public var actualPaper: String
public init(
printer: String = "",
inkSet: String = "",
driverPaper: String = "",
actualPaper: String = ""
) {
self.printer = printer
self.inkSet = inkSet
self.driverPaper = driverPaper
self.actualPaper = actualPaper
}
}
/// Builds the chart legend for `printtarg -d` (fork argyllcms#19,
/// ICCery #119). `-d` here is a **label string** not targen's colour
/// space, not iccgamut's density (docs/25).
public enum PrinttargLabel {
public static let unspecified = "Unspecified"
/// `ICCery - {basename} - {printer} - {ink} - {driverPaper} -
/// {actualPaper} - DD/MM/YYYY HH:MM`
///
/// `date` is injected for deterministic tests; production passes
/// the current local time. A fixed POSIX locale keeps the format
/// stable regardless of user locale.
public static func automatic(
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "dd/MM/yyyy HH:mm"
return [
"ICCery",
basename,
field(metadata.printer),
field(metadata.inkSet),
field(metadata.driverPaper),
field(metadata.actualPaper),
formatter.string(from: date),
].joined(separator: " - ")
}
/// Resolves the label to emit: an explicit non-empty manual label
/// wins; otherwise the assembled automatic label.
public static func resolved(
customLabel: String?,
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
return label
}
return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone)
}
private static func field(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? unspecified : trimmed
}
}
/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2).
public struct PrinttargConfig: Codable, Equatable, Sendable {
public var instrument: PrintInstrument
public var pageSize: PageSize
/// Custom page dimensions in millimetres; each must be 50 when
/// `pageSize == .custom`.
public var customPageWidth: Double
public var customPageHeight: Double
public var bitDepth: TiffBitDepth
public var dpi: Int
public var layoutOrder: LayoutOrder
/// Seed for `.customSeed` layout (`-R N`, N 1). Ignored for
/// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`).
public var customSeed: Int
/// Resolved `-d` label. Callers usually compute this via
/// `PrinttargLabel.resolved` so tests can inject the clock.
public var label: String?
/// `.cal` file applied to printed patches (`-K`), or embedded
/// without applying (`-I` when `calibrationEmbedOnly`). Never
/// emitted for `CAL_` basenames (Stage 0 protection).
public var calibrationFile: String?
public var calibrationEmbedOnly: Bool
public var basename: String
public var workingDirectory: URL?
public init(
instrument: PrintInstrument = .i1,
pageSize: PageSize = .a4,
customPageWidth: Double = 210,
customPageHeight: Double = 297,
bitDepth: TiffBitDepth = .eight,
dpi: Int = 300,
layoutOrder: LayoutOrder = .deterministic,
customSeed: Int = 1,
label: String? = nil,
calibrationFile: String? = nil,
calibrationEmbedOnly: Bool = false,
basename: String = "",
workingDirectory: URL? = nil
) {
self.instrument = instrument
self.pageSize = pageSize
self.customPageWidth = customPageWidth
self.customPageHeight = customPageHeight
self.bitDepth = bitDepth
self.dpi = dpi
self.layoutOrder = layoutOrder
self.customSeed = customSeed
self.label = label
self.calibrationFile = calibrationFile
self.calibrationEmbedOnly = calibrationEmbedOnly
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,180 @@
import Foundation
/// One page of a `printtarg -u` manifest (docs/05 §2.3).
/// `patches` is a page-assigned count including TID/padding cells,
/// not strictly user patches.
public struct PrinttargPage: Codable, Equatable, Sendable {
public var filename: String
public var patches: Int
public var widthMm: Double
public var heightMm: Double
public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) {
self.filename = filename
self.patches = patches
self.widthMm = widthMm
self.heightMm = heightMm
}
enum CodingKeys: String, CodingKey {
case filename, patches
case widthMm = "width_mm"
case heightMm = "height_mm"
}
}
/// The final-only, pretty-printed, **unprefixed** JSON object emitted
/// by fork `printtarg -u` after all pages are written (docs/05 §2.3).
public struct PrinttargManifest: Codable, Equatable, Sendable {
public var event: String
public var pages: [PrinttargPage]
public init(event: String = "manifest", pages: [PrinttargPage]) {
self.event = event
self.pages = pages
}
}
/// A manifest page resolved against the working directory, with its
/// host-side PNG preview (#58 TIFF is never fed to the UI directly).
public struct GalleryPage: Equatable, Sendable, Identifiable {
public var id: Int { index }
public let index: Int
public let page: PrinttargPage
public let fileURL: URL
public let previewPNG: Data?
public let previewError: String?
public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) {
self.index = index
self.page = page
self.fileURL = fileURL
self.previewPNG = previewPNG
self.previewError = previewError
}
}
public enum ManifestError: LocalizedError, Equatable {
case noJSONDocument
case wrongEvent(String)
case decodeFailed(String)
case invalidPage(String)
public var errorDescription: String? {
switch self {
case .noJSONDocument:
return "No JSON document found in printtarg stdout."
case .wrongEvent(let event):
return "Unexpected JSON event \"\(event)\" — expected \"manifest\"."
case .decodeFailed(let reason):
return "printtarg manifest JSON failed to decode: \(reason)"
case .invalidPage(let reason):
return "printtarg manifest page is invalid: \(reason)"
}
}
}
/// Extracts and decodes the `printtarg -u` manifest from the complete
/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest).
///
/// #68 invariant: the JSON is a structured document, not a brace-hunt.
/// Extraction is string/escape-aware a `{` or `}` inside a quoted
/// filename can never corrupt the scan and starts only at a `{` that
/// begins a trimmed stdout line.
public enum PrinttargManifestExtractor {
/// Finds the manifest object in accumulated stdout.
public static func manifest(from stdout: String) throws -> PrinttargManifest {
for block in jsonObjects(in: stdout) {
let data = Data(block.utf8)
guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else {
continue
}
guard manifest.event == "manifest" else {
throw ManifestError.wrongEvent(manifest.event)
}
try validate(manifest)
return manifest
}
if let first = jsonObjects(in: stdout).first,
let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any],
let event = obj["event"] as? String {
throw ManifestError.wrongEvent(event)
}
throw ManifestError.noJSONDocument
}
private static func validate(_ manifest: PrinttargManifest) throws {
for page in manifest.pages {
guard page.patches >= 0 else {
throw ManifestError.invalidPage("negative patch count \(page.patches)")
}
guard page.widthMm > 0, page.heightMm > 0 else {
throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)")
}
let name = page.filename
guard !name.isEmpty,
!name.hasPrefix("/"),
!name.contains("/"),
!name.contains("\\"),
!name.contains("..") else {
throw ManifestError.invalidPage("unsafe filename \"\(name)\"")
}
let ext = (name as NSString).pathExtension.lowercased()
guard ext == "tif" || ext == "tiff" else {
throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"")
}
}
}
/// Yields every complete top-level JSON object `{...}` found at a
/// trimmed line boundary, in document order. Depth tracking respects
/// quoted strings and backslash escapes.
static func jsonObjects(in text: String) -> [String] {
var out: [String] = []
let scalars = Array(text.unicodeScalars)
var i = 0
func isLineStart(_ idx: Int) -> Bool {
var j = idx - 1
while j >= 0 && scalars[j] != "\n" {
if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" {
return false
}
j -= 1
}
return true
}
while i < scalars.count {
if scalars[i] == "{", isLineStart(i) {
var depth = 0
var inString = false
var escaped = false
var j = i
while j < scalars.count {
let c = scalars[j]
if inString {
if escaped { escaped = false }
else if c == "\\" { escaped = true }
else if c == "\"" { inString = false }
} else {
if c == "\"" { inString = true }
else if c == "{" { depth += 1 }
else if c == "}" {
depth -= 1
if depth == 0 {
out.append(String(String.UnicodeScalarView(scalars[i...j])))
i = j
break
}
}
}
j += 1
}
}
i += 1
}
return out
}
}
@@ -0,0 +1,104 @@
import Foundation
/// Errors during `buildTargenArgs` validation (docs/04 §1.2).
public enum TargenArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPatchCount(Int)
case invalidWhitePatches(Int)
case invalidBlackPatches(Int)
case invalidInkLimit(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid target basename: \(name)"
case .invalidPatchCount(let count):
return "Patch count must be positive, got: \(count)"
case .invalidWhitePatches(let count):
return "White patches cannot be negative, got: \(count)"
case .invalidBlackPatches(let count):
return "Black patches cannot be negative, got: \(count)"
case .invalidInkLimit(let limit):
return "Ink limit must be between 1 and 400, got: \(limit)"
}
}
}
/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2).
public enum TargenArgs {
/// Builds the exact command-line arguments for `targen`.
///
/// Invariants:
/// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4).
/// - Never emits `-u` (Argyll fork progress is not enabled for targen).
/// - Always emits `-f N` when patchCount > 0 (#44).
/// - White `-e`, Black `-B`.
/// - `-N` omitted when approximately 0.50.
/// - `-A` is emitted even at 0.10 (no default-skip).
/// - `-l` is CMYK only (1...400).
/// - `-V` omitted when approximately 1.0.
/// - `-p` omitted when non-positive or approximately 1.0.
/// - Basename is the last positional argument.
public static func build(config: TargenConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.patchCount > 0 else {
throw TargenArgError.invalidPatchCount(config.patchCount)
}
guard config.whitePatches >= 0 else {
throw TargenArgError.invalidWhitePatches(config.whitePatches)
}
guard config.blackPatches >= 0 else {
throw TargenArgError.invalidBlackPatches(config.blackPatches)
}
var args: [String] = [
"-v",
"-d", config.colourSpace.dFlagValue,
"-f", "\(config.patchCount)",
"-e", "\(config.whitePatches)",
"-B", "\(config.blackPatches)"
]
if let g = config.greySteps, g > 0 {
args.append(contentsOf: ["-g", "\(g)"])
}
if let s = config.singleChannelSteps, s > 0 {
args.append(contentsOf: ["-s", "\(s)"])
}
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit {
guard (1...400).contains(inkLimit) else {
throw TargenArgError.invalidInkLimit(inkLimit)
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,150 @@
import Foundation
/// Colour space for patch generation (docs/08, docs/04 §1.2).
public enum ColourSpace: String, Codable, Sendable, CaseIterable {
case rgb
case cmyk
/// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK.
public var dFlagValue: String {
switch self {
case .rgb: return "2"
case .cmyk: return "4"
}
}
}
/// Patch count preset for Stage 1.
public enum PatchCountPreset: String, Codable, Sendable, CaseIterable {
case draft400 = "400"
case standard800 = "800"
case photo1500 = "1500"
case custom = "custom"
public var patchCount: Int? {
switch self {
case .draft400: return 400
case .standard800: return 800
case .photo1500: return 1500
case .custom: return nil
}
}
public var title: String {
switch self {
case .draft400: return "Draft (400)"
case .standard800: return "Standard (800)"
case .photo1500: return "Photo (1500)"
case .custom: return "Custom"
}
}
}
/// Full spread patch distribution algorithm (docs/08).
/// Default is "ofps" (no flag emitted).
public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable {
case ofps = "ofps"
case target = "-t"
case random = "-r"
case uniformRandom = "-R"
case quasiRandom = "-q"
case uniformQuasiRandom = "-Q"
case invertedQuasiRandom = "-i"
case invertedUniformQuasiRandom = "-I"
public var displayName: String {
switch self {
case .ofps: return "OFPS (Default)"
case .target: return "Target (-t)"
case .random: return "Random (-r)"
case .uniformRandom: return "Uniform Random (-R)"
case .quasiRandom: return "Quasi-random (-q)"
case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)"
case .invertedQuasiRandom: return "Inverted Quasi-random (-i)"
case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)"
}
}
public var flag: String? {
switch self {
case .ofps: return nil
default: return rawValue
}
}
/// Preset JSON value: `"ofps"` or the bare flag letter
/// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) docs/22.
public var presetValue: String {
switch self {
case .ofps: return "ofps"
default: return String(rawValue.dropFirst())
}
}
public init?(presetValue: String) {
if presetValue == "ofps" {
self = .ofps
} else {
self.init(rawValue: "-" + presetValue)
}
}
}
/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2).
public struct TargenConfig: Codable, Equatable, Sendable {
public var colourSpace: ColourSpace
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
public var fullSpreadAlgorithm: FullSpreadAlgorithm?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
public var basename: String
public var workingDirectory: URL?
public init(
colourSpace: ColourSpace = .rgb,
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: FullSpreadAlgorithm? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
basename: String = "",
workingDirectory: URL? = nil
) {
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Small host-side file helpers (issue #6).
public enum ArtefactFiles {
/// `get_default_working_dir` `resolveSafeCwd(nil)`.
public static func defaultWorkingDirectory() -> URL {
PathSecurity.resolveSafeCwd(nil)
}
/// `read_file_base64` for **text artefacts** the UI needs verbatim
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
/// go through `TiffPreview` instead.
public static func readBase64(_ url: URL) throws -> String {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version + build for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String) {
let info = bundle.infoDictionary ?? [:]
return (
info["CFBundleShortVersionString"] as? String ?? "0.0.0",
info["CFBundleVersion"] as? String ?? "0"
)
}
}
@@ -0,0 +1,112 @@
import Foundation
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
public struct StageArtefacts: Sendable, Equatable {
/// `<basename>.ti1` exists (Stage 1 done unlocks Stage 2).
public var stage1Complete = false
/// `<basename>.ti2` exists (Stage 2 done with ti1, unlocks Stage 3).
public var stage2Complete = false
/// `<basename>.ti3` exists (Stage 3 done unlocks Stage 4).
public var stage3Complete = false
/// `.icc`/`.icm` exists (Stage 4 done with ti3, unlocks Stage 5).
public var stage4Complete = false
/// Absolute path of the profile file when present.
public var profilePath: URL?
public init(
stage1Complete: Bool = false,
stage2Complete: Bool = false,
stage3Complete: Bool = false,
stage4Complete: Bool = false,
profilePath: URL? = nil
) {
self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete
self.profilePath = profilePath
}
}
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
public enum ArtefactProbe {
/// `verify_stage_artefacts` the gating truth source.
public static func verify(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> StageArtefacts {
var out = StageArtefacts()
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true
out.profilePath = profile
}
return out
}
/// `<cwd>/<basename>.<ext>` the canonical artefact URL.
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
}
/// Profile extension resolution (#69): existing `.icm` wins over
/// `.icc`; when neither exists the macOS default is `.icc`.
/// (`profcheck`/`iccgamut` swap extension when the requested path is
/// missing.)
public static func resolveProfile(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> URL? {
let icm = artefact(basename, "icm", cwd)
if exists(icm, fm: fileManager) { return icm }
let icc = artefact(basename, "icc", cwd)
if exists(icc, fm: fileManager) { return icc }
return nil
}
/// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc"
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` and
/// `<basename>_NN.tif` (manifest naming).
public static func existingArtefacts(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefixes = [basename + ".", "CAL_" + basename + "."]
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
let passPrefix = basename + "_pass"
let tifStemPrefix = basename + "_"
let calPrefix = "CAL_" + basename
return entries.filter { url in
let name = url.lastPathComponent
let ext = url.pathExtension.lowercased()
guard suffixes.contains(ext) else { return false }
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
if name.hasPrefix(calPrefix) { return true }
return false
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
private static func exists(_ url: URL, fm: FileManager) -> Bool {
fm.fileExists(atPath: url.path)
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Atomic `.tmp`-then-rename file writes the convention used by
/// settings.json, verification_history.json and wizard_state.json
/// (docs/02 §Persistence, #213).
public enum AtomicFileWriter {
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
public static func write(_ data: Data, to url: URL) throws {
let fm = FileManager.default
let dir = url.deletingLastPathComponent()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let tmp = url.appendingPathExtension("tmp")
do {
try data.write(to: tmp, options: [])
// replaceItemAt handles same-volume atomic swap and removes
// the destination cleanly; fall back to remove+move.
if fm.fileExists(atPath: url.path) {
_ = try fm.replaceItemAt(url, withItemAt: tmp)
} else {
try fm.moveItem(at: tmp, to: url)
}
} catch {
try? fm.removeItem(at: tmp)
throw error
}
}
public static func write(_ text: String, to url: URL) throws {
try write(Data(text.utf8), to: url)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// Basename sanitisation and safe working-directory resolution
/// (docs/02 §Working directory, docs/06 §Empty cwd).
public enum PathSecurity {
public enum Error: Swift.Error, Equatable, Sendable {
case invalidBasename(String)
}
/// Basenames must not contain `/`, `\`, or `..` and must be
/// non-empty. Never invent a default basename (#60).
public static func isValidBasename(_ name: String) -> Bool {
guard !name.isEmpty else { return false }
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
}
@discardableResult
public static func sanitizeBasename(_ name: String) throws -> String {
guard isValidBasename(name) else {
throw Error.invalidBasename(name)
}
return name
}
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory
/// Documents Home app-data. Never returns an empty/nil cwd.
public static func resolveSafeCwd(
_ explicit: URL?,
fileManager: FileManager = .default
) -> URL {
if let explicit,
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
return explicit
}
let candidates: [URL?] = [
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
fileManager.homeDirectoryForCurrentUser,
AppPaths.appDataDir,
]
for candidate in candidates {
guard let url = candidate else { continue }
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
return url
}
}
// Last resort: app-data, created unconditionally.
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
return AppPaths.appDataDir
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
/// `parse_ti2_header` reads only CGATS keyword lines the data grid
/// itself belongs to issue #30.
public struct Ti2Header: Sendable, Equatable {
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
public var instrument: String?
/// `NUMBER_OF_SETS` the patch count. Note: `NUMBER_OF_FIELDS` is
/// the CGATS column count, *not* the patch count.
public var patchCount: Int?
/// `NUMBER_OF_PAGES`.
public var pageCount: Int?
/// A sibling `<stem>.ti1` exists next to the parsed file.
public var hasSiblingTi1 = false
public static func parse(
_ url: URL,
fileManager: FileManager = .default
) -> Ti2Header {
var header = Ti2Header()
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
return header
}
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
break
}
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
guard let space = line.firstIndex(of: " ") else { continue }
let key = String(line[..<space])
let value = String(line[line.index(after: space)...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "TARGET_INSTRUMENT":
header.instrument = value
case "NUMBER_OF_SETS":
header.patchCount = Int(value)
case "NUMBER_OF_PAGES":
header.pageCount = Int(value)
default:
continue
}
}
let stem = url.deletingPathExtension()
header.hasSiblingTi1 = fileManager.fileExists(
atPath: stem.appendingPathExtension("ti1").path
)
return header
}
}
@@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// TIFF PNG preview for the Stage 2 gallery (#58): decode on the host
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
/// bytes to the UI.
public enum TiffPreview {
public static let maxEdge: Int = 1200
/// Returns PNG data for the first page of a TIFF, or `nil` when the
/// file cannot be decoded.
public static func previewPNG(
tiff url: URL,
maxEdge: Int = Self.maxEdge
) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { return nil }
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { return nil }
return out as Data
}
}
@@ -0,0 +1,62 @@
import Foundation
import OSLog
/// Severity levels, matching the v1 `log_level` setting values.
public enum LogLevel: String, Codable, Sendable, CaseIterable {
case error, warn, info, debug, trace
var osType: OSLogType {
switch self {
case .error: return .error
case .warn: return .default
case .info: return .info
case .debug: return .debug
case .trace: return .debug
}
}
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
var rank: Int {
switch self {
case .error: return 0
case .warn: return 1
case .info: return 2
case .debug: return 3
case .trace: return 4
}
}
}
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
/// gated at write time so a settings save takes effect immediately
/// (#158).
public struct AppLogger: Sendable {
public static let shared = AppLogger(category: "app")
private let osLog: Logger
private let sink: LogSink
public let category: String
public init(category: String, sink: LogSink = .shared) {
self.category = category
self.sink = sink
self.osLog = Logger(
subsystem: AppPaths.bundleIdentifier,
category: category
)
}
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
let text = LogSanitizer.sanitize(message())
if level.rank <= sink.level.rank {
osLog.log(level: level.osType, "\(text, privacy: .public)")
}
sink.write(level: level, category: category, message: text)
}
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
}
@@ -0,0 +1,17 @@
import Foundation
/// Rewrites the user's home directory to `~` in log output
/// (docs/03 §Logging hygiene `sanitize_arg_for_logging`).
public enum LogSanitizer {
/// Replaces every occurrence of the current user's home path with `~`.
public static func sanitize(_ text: String) -> String {
let home = NSHomeDirectory()
guard !home.isEmpty else { return text }
return text.replacingOccurrences(of: home, with: "~")
}
/// Sanitizes an argv list for display.
public static func sanitizeArgs(_ args: [String]) -> String {
args.map(sanitize).joined(separator: " ")
}
}
@@ -0,0 +1,127 @@
import Foundation
import OSLog
/// Rolling file sink for `AppLogger` `~/Library/Logs/<bundle>/
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
/// (`iccery.log.1` `iccery.log.5`).
///
/// The minimum level is **runtime state** (#158): `setLevel` takes
/// effect immediately at startup and on every settings save.
public final class LogSink: @unchecked Sendable {
public static let shared = LogSink(fileURL: AppPaths.logFile)
private let lock = NSLock()
private let fileURL: URL
private var minimumLevel: LogLevel
private var handle: FileHandle?
/// 5 MiB per segment, 5 historical segments kept.
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
public static let keptSegments = 5
public init(
fileURL: URL = AppPaths.logFile,
minimumLevel: LogLevel? = nil
) {
self.fileURL = fileURL
#if DEBUG
self.minimumLevel = minimumLevel ?? .debug
#else
self.minimumLevel = minimumLevel ?? .info
#endif
}
public var level: LogLevel {
lock.lock()
defer { lock.unlock() }
return minimumLevel
}
/// Applied at startup AND on every settings save (issue #5, #158).
public func setLevel(_ level: LogLevel) {
lock.lock()
minimumLevel = level
lock.unlock()
}
/// `nil` DEBUG-build default (.debug) / release (.info).
public func applySettings(_ settings: AppSettings) {
setLevel(settings.effectiveLogLevel)
}
public func shouldLog(_ level: LogLevel) -> Bool {
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
}
// MARK: - Writing
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
/// rotating first when the active segment exceeds 5 MiB.
public func write(level: LogLevel, category: String, message: String) {
guard shouldLog(level) else { return }
lock.lock()
defer { lock.unlock() }
rotateIfNeeded()
openIfNeeded()
let stamp = Self.timestamp()
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
if let data = line.data(using: .utf8) {
handle?.write(data)
}
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static func timestamp() -> String {
formatter.string(from: Date())
}
private func openIfNeeded() {
guard handle == nil else { return }
try? FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
handle = try? FileHandle(forWritingTo: fileURL)
try? handle?.seekToEnd()
}
/// Shifts `iccery.log.4.5`, `.3.4`, , `.log.1` and resets the
/// writer. Oldest segment is deleted.
private func rotateIfNeeded() {
guard FileManager.default.fileExists(atPath: fileURL.path),
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let size = attrs[.size] as? UInt64,
size >= Self.maxSegmentBytes
else { return }
try? handle?.close()
handle = nil
let fm = FileManager.default
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
try? fm.removeItem(at: oldest)
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
let src = fileURL.appendingPathExtension("\(i)")
let dst = fileURL.appendingPathExtension("\(i + 1)")
if fm.fileExists(atPath: src.path) {
try? fm.moveItem(at: src, to: dst)
}
}
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
}
/// Tail of the active log for the settings dialog's "copy excerpt".
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
guard let data = try? Data(contentsOf: fileURL) else { return "" }
let slice = data.suffix(maxBytes)
return String(decoding: slice, as: UTF8.self)
}
}
@@ -0,0 +1,78 @@
import Foundation
/// Errors during `average` argv construction.
public enum AverageArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidPassCount(Int)
case outputCollidesWithInput
case pathOutsideCwd(URL)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid basename for average: \(name)"
case .invalidPassCount(let count):
return "Average requires at least 2 pass files, got \(count)"
case .outputCollidesWithInput:
return "Average output filename collides with one of the inputs"
case .pathOutsideCwd(let url):
return "Pass or output file is outside the working directory: \(url.path)"
}
}
}
/// Configuration for an `average` run.
public struct AverageConfig: Sendable, Equatable {
public let workingDirectory: URL
public let basename: String
public let passFiles: [URL]
public init(
workingDirectory: URL,
basename: String,
passFiles: [URL]
) {
self.workingDirectory = workingDirectory
self.basename = basename
self.passFiles = passFiles
}
}
/// Pure argv builder for Argyll's `average` tool.
public enum AverageArgs {
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
public static func build(config: AverageConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.passFiles.count >= 2 else {
throw AverageArgError.invalidPassCount(config.passFiles.count)
}
let output = config.workingDirectory
.appendingPathComponent("\(cleanBasename).ti3")
var inputNames: [String] = []
for url in config.passFiles {
try validate(url, isIn: config.workingDirectory)
inputNames.append(url.lastPathComponent)
}
try validate(output, isIn: config.workingDirectory)
let outputName = output.lastPathComponent
guard !inputNames.contains(outputName) else {
throw AverageArgError.outputCollidesWithInput
}
return ["-v"] + inputNames + [outputName]
}
private static func validate(_ url: URL, isIn cwd: URL) throws {
let cwdPath = cwd.standardizedFileURL.path
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
guard urlPath == cwdPath else {
throw AverageArgError.pathOutsideCwd(url)
}
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Pure argv builder for Argyll's `chartread` tool.
public enum ChartreadArgs {
/// Builds `chartread` argv per the Gronod fork protocol.
///
/// - Always `-v -u`.
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
/// - Basename is the last positional argument and is sanitized.
public static func build(config: ChartreadConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v", "-u"]
if let port = config.selectedPort, port > 1 {
args.append(contentsOf: ["-c", "\(port)"])
}
if config.enableLEDs {
args.append(contentsOf: ["-Y", "l"])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,263 @@
import Foundation
/// Discrete states for the `chartread` interaction.
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
case idle
case calibrating
case awaitingStrip
case reading
case allStripsRead
case warning
case promptContinue
case tablePlaceSheet
case tableAlign
case error
case finished
}
/// Extra metadata produced by classifying a single `chartread` stdout line.
public struct ChartreadClassifyResult: Sendable, Equatable {
public let state: ChartreadState
/// Whether this line is an informational "remove last sheet" notice.
public let isRemoveSheetNotice: Bool
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
public let sheetNumber: Int?
public let sheetTotal: Int?
/// Fiducial patch name from XY "locate patch X with the sight".
public let alignmentPatch: String?
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
public let requestedWarningKey: String?
/// Whether the line is a continuation of a multi-line XY prompt.
public let isTableContinuation: Bool
public init(
state: ChartreadState,
isRemoveSheetNotice: Bool = false,
sheetNumber: Int? = nil,
sheetTotal: Int? = nil,
alignmentPatch: String? = nil,
requestedWarningKey: String? = nil,
isTableContinuation: Bool = false
) {
self.state = state
self.isRemoveSheetNotice = isRemoveSheetNotice
self.sheetNumber = sheetNumber
self.sheetTotal = sheetTotal
self.alignmentPatch = alignmentPatch
self.requestedWarningKey = requestedWarningKey
self.isTableContinuation = isTableContinuation
}
}
/// Pure line classifier for `chartread` stdout.
///
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
public enum ChartreadClassifier {
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
public static func classify(
line: String,
previousState: ChartreadState
) -> ChartreadClassifyResult {
let text = line.lowercased()
for matcher in matchers(previousState) {
if let result = matcher(text, previousState) {
return result
}
}
return ChartreadClassifyResult(state: previousState)
}
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
[
removeSheetNotice,
sheetReadOk,
locatePatch,
placeSheet,
continuation(previous),
done,
warning,
calibration,
awaitingStrip,
reading,
error
]
}
// 1. "Please remove last sheet from table" info only.
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
}
// 2. "Sheet N of M read OK".
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
return ChartreadClassifyResult(
state: previous,
sheetNumber: match.1,
sheetTotal: match.2
)
}
// 3. "locate patch X with the sight".
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
!match.0.isEmpty else { return nil }
return ChartreadClassifyResult(
state: .tableAlign,
alignmentPatch: match.0.uppercased()
)
}
// 4. "place sheet N of M" or "remove previous sheet".
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
return ChartreadClassifyResult(
state: .tablePlaceSheet,
sheetNumber: match.1,
sheetTotal: match.2
)
}
if text.contains("remove previous sheet") || text.contains("place sheet") {
return ChartreadClassifyResult(state: .tablePlaceSheet)
}
return nil
}
// 5. "hit return to continue" sticky if already in a table state.
private static func continuation(_ previous: ChartreadState) -> Matcher {
return { text, _ in
guard text.contains("hit return to continue")
|| text.contains("hit any key to continue")
|| text.contains("hit space to continue")
else { return nil }
if case .tablePlaceSheet = previous {
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
}
if case .tableAlign = previous {
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
}
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
}
}
// 6. Done / all read.
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = [
"'d' if/when done", "d to finish/save", "all strips/patches read",
"all strips read", "all patches read", "done reading",
"'d' to save", "press d to", "hit 'd'"
]
if phrases.contains(where: { text.contains($0) }) {
return ChartreadClassifyResult(state: .allStripsRead)
}
return nil
}
// 7. Warnings / prompts needing a key.
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let warningSignals = [
"(warning)", "use it anyway", "seem to have read strip pass",
"unexpected response", "seem to have read", "misread",
"try again", "do you want to"
]
guard warningSignals.contains(where: { text.contains($0) }) else { return nil }
var key: String?
if text.contains("(y/n)") || text.contains("'y' or 'n'") {
// Default to asking the user; no automatic key.
key = nil
} else if text.contains("'y'") || text.contains("press y") || text.contains("hit 'y'") {
key = "y"
} else if text.contains("'n'") || text.contains("press n") || text.contains("hit 'n'") {
key = "n"
}
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
}
// 8. Calibration / place reference / white / standard tile.
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
let hasLocate = lowercased.contains("locate patch")
guard placeTokens.contains(where: { lowercased.contains($0) }),
!hasPlaceSheet,
!hasLocate
else { return nil }
if lowercased.contains("hit any key to continue")
|| lowercased.contains("hit space to continue")
|| lowercased.contains("calibration")
|| lowercased.contains("calibrate")
|| lowercased.contains("white tile")
|| lowercased.contains("standard tile") {
return ChartreadClassifyResult(state: .calibrating)
}
return nil
}
// 9. Awaiting strip.
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let phrases = [
"hit ... read ... strip", "ready to read", "read ... strip ... key",
"hit any key to read", "ready to read strip", "hit a key to read",
"press any key to read", "read strip"
]
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .awaitingStrip)
}
// 10. Reading.
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .reading)
}
// 11. Error.
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = ["error", "too fast", "too slow", "misread", "failed to read", "failed"]
// Avoid false positives inside harmless words by matching full words where possible.
let lower = text
guard phrases.contains(where: { phrase in
lower.contains(phrase) && !lower.contains("no error")
}) else { return nil }
if lower.contains("misread") || lower.contains("failed to read") || lower.contains("error") {
return ChartreadClassifyResult(state: .error)
}
return nil
}
}
private extension String {
func firstMatch(pattern: String) -> (String, Int, Int)? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
else { return nil }
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
let r = match.range(at: i)
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
return String(self[range])
}
guard let first = groups.first else { return nil }
let ints = groups.compactMap { Int($0) }
let a = ints.count > 0 ? ints[0] : 0
let b = ints.count > 1 ? ints[1] : 0
return (first, a, b)
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Errors during `ChartreadArgs` validation.
public enum ChartreadArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPort(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid chart basename: \(name)"
case .invalidPort(let port):
return "Invalid chartread port: \(port)"
}
}
}
/// Configuration for a `chartread` invocation.
public struct ChartreadConfig: Codable, Equatable, Sendable {
public var basename: String
public var workingDirectory: URL?
/// Communication port to pass to `chartread -c`.
/// `nil` means omit `-c` (Auto or port 1).
public var selectedPort: Int?
/// Enable i1Pro 2 visual LEDs (`-Y l`).
public var enableLEDs: Bool
public init(
basename: String,
workingDirectory: URL? = nil,
selectedPort: Int? = nil,
enableLEDs: Bool = false,
isXY: Bool = false
) {
self.basename = basename
self.workingDirectory = workingDirectory
self.selectedPort = selectedPort
self.enableLEDs = enableLEDs
self.isXY = isXY
}
/// Whether the current config implies an XY-table workflow.
/// This is normally supplied by the view model from the selected instrument.
public var isXY: Bool = false
}
@@ -0,0 +1,143 @@
import Foundation
/// A single patch read by `chartread`.
public struct ChartreadPatch: Codable, Sendable, Equatable {
public let id: String
public let loc: String
public let isPad: Bool
public let device: [Double]
public let expected: PatchColor?
public let measured: PatchColor
public init(
id: String,
loc: String,
isPad: Bool,
device: [Double],
expected: PatchColor?,
measured: PatchColor
) {
self.id = id
self.loc = loc
self.isPad = isPad
self.device = device
self.expected = expected
self.measured = measured
}
enum CodingKeys: String, CodingKey {
case id, loc
case isPad = "is_pad"
case device, expected, measured
}
}
/// Colour payload carried by `expected` or `measured`.
public struct PatchColor: Codable, Sendable, Equatable {
public let xyz: CIEXYZ?
public let lab: CIELab?
public let spectral: SpectralData?
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
self.xyz = xyz
self.lab = lab
self.spectral = spectral
}
enum CodingKeys: String, CodingKey {
case xyz = "XYZ"
case lab = "Lab"
case spectral = "spectral"
}
}
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int
public let startNM: Double
public let endNM: Double
public let norm: Double
public let values: [Double]
enum CodingKeys: String, CodingKey {
case bands
case startNM = "start_nm"
case endNM = "end_nm"
case norm
case values
}
}
/// A complete row emitted by `chartread -u`.
public struct ChartreadRow: Codable, Sendable, Equatable {
public let event: String
public let rowId: String
public let rowIndex: Int
public let totalRows: Int
public let patchCount: Int
public let patches: [ChartreadPatch]
enum CodingKeys: String, CodingKey {
case event
case rowId = "row_id"
case rowIndex = "row_index"
case totalRows = "total_rows"
case patchCount = "patch_count"
case patches
}
public var isFinalRow: Bool {
rowIndex + 1 >= totalRows
}
}
@@ -0,0 +1,174 @@
import Foundation
/// Result of evaluating one measured patch.
public struct SwatchEvaluation: Sendable, Equatable {
public let intended: DisplayRGB
public let measured: DisplayRGB
public let deltaE: Double?
public let classification: SwatchClassification
public init(
intended: DisplayRGB,
measured: DisplayRGB,
deltaE: Double?,
classification: SwatchClassification
) {
self.intended = intended
self.measured = measured
self.deltaE = deltaE
self.classification = classification
}
}
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
case good
case warning
case bad
}
/// CIEDE2000 ΔE between two D50 Lab values.
public enum ColorDifference {
/// Compute ΔE using the full CIEDE2000 formula.
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
let kL: Double = 1
let kC: Double = 1
let kH: Double = 1
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
let cBar = (c1 + c2) / 2.0
let cBar7 = pow(cBar, 7)
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
let a1p = (1 + g) * lab1.a
let a2p = (1 + g) * lab2.a
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
let h1p = atan2ToDegrees(lab1.b, a1p)
let h2p = atan2ToDegrees(lab2.b, a2p)
let deltaLp = lab2.l - lab1.l
let deltaCp = c2p - c1p
var deltaHp: Double = 0
if c1p * c2p == 0 {
deltaHp = 0
} else {
let diff = h2p - h1p
if abs(diff) <= 180 {
deltaHp = diff
} else if diff > 180 {
deltaHp = diff - 360
} else {
deltaHp = diff + 360
}
}
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
let lBarp = (lab1.l + lab2.l) / 2.0
let cBarp = (c1p + c2p) / 2.0
var hBarp: Double
if c1p * c2p == 0 {
hBarp = h1p + h2p
} else {
if abs(h1p - h2p) <= 180 {
hBarp = (h1p + h2p) / 2.0
} else if h1p + h2p < 360 {
hBarp = (h1p + h2p + 360) / 2.0
} else {
hBarp = (h1p + h2p - 360) / 2.0
}
}
let t = 1
- 0.17 * cos(deg2rad(hBarp - 30))
+ 0.24 * cos(deg2rad(2 * hBarp))
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
- 0.20 * cos(deg2rad(4 * hBarp - 63))
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
let cBarp7 = pow(cBarp, 7)
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
let sc = 1 + 0.045 * cBarp
let sh = 1 + 0.015 * cBarp * t
let rt = -sin(deg2rad(2 * dTheta)) * rc
let lTerm = deltaLp / (kL * sl)
let cTerm = deltaCp / (kC * sc)
let hTerm = deltaHp2 / (kH * sh)
return sqrt(
lTerm * lTerm
+ cTerm * cTerm
+ hTerm * hTerm
+ rt * cTerm * hTerm
)
}
/// Classify a ΔE value against user thresholds.
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
if deltaE < goodMax { return .good }
if deltaE < warningMax { return .warning }
return .bad
}
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
public static func evaluate(
patch: ChartreadPatch,
goodMax: Double,
warningMax: Double
) -> SwatchEvaluation? {
guard let measured = resolveLab(patch.measured) else { return nil }
let measuredRGB = LabColorMath.labToSRGB(measured)
if let expectedColor = patch.expected,
let expectedLab = resolveLab(expectedColor) {
let de = deltaE00(expectedLab, measured)
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
return SwatchEvaluation(
intended: intendedRGB,
measured: measuredRGB,
deltaE: de,
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
)
} else {
// No reference: still render measured colour, no ΔE.
return SwatchEvaluation(
intended: measuredRGB,
measured: measuredRGB,
deltaE: nil,
classification: .good
)
}
}
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab {
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
}
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
let radians = atan2(y, x)
var degrees = radians * 180.0 / .pi
if degrees < 0 { degrees += 360 }
return degrees
}
private static func deg2rad(_ degrees: Double) -> Double {
degrees * .pi / 180.0
}
}
@@ -0,0 +1,58 @@
import Foundation
/// A device discovered by the Argyll `instlist` fork.
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
public let port: Int
public let name: String
public let type: String
public init(port: Int, name: String, type: String) {
self.port = port
self.name = name
self.type = type
}
public var id: Int { port }
/// XY tables are identified by name or type matching the fork pattern.
public var isXY: Bool {
let combined = "\(name) \(type)".lowercased()
let pattern = #"/spectro\s?scan|i1io/"#
return combined.range(of: pattern, options: .regularExpression) != nil
}
/// Human-readable label shown in the picker.
public var displayName: String {
let xyTag = isXY ? " · XY Table" : ""
return "\(name) [\(type)]\(xyTag)"
}
}
/// The users choice for a chartread session.
public enum InstrumentSelection: Sendable, Equatable {
/// Auto / first available port `chartread` omits `-c`.
case auto
/// A concrete instrument.
case device(InstrumentDevice)
/// The value to pass to `chartread -c`.
/// `nil` means omit `-c` (port 1 and Auto both map to no flag).
public var chartreadPort: Int? {
switch self {
case .auto:
return nil
case .device(let device):
return device.port == 1 ? nil : device.port
}
}
/// Whether the current selection implies an XY table workflow.
public var isXY: Bool {
switch self {
case .auto:
return false
case .device(let device):
return device.isXY
}
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Errors from `instlist` output parsing.
public enum InstrumentParserError: Error, Sendable, Equatable {
case malformedJSON
case missingDevices
case invalidPort
}
/// Parses the Argyll `instlist` stdout document.
///
/// Fork `instlist` emits pretty-printed JSON with the shape
/// `{ "event": "instruments", "devices": [ { "port": 1, "name": "...", "type": "..." } ] }`.
/// If JSON decoding fails, a constrained regex fallback is used.
/// Only lines accepted by the fallback must also match known instrument tokens.
public enum InstrumentParser {
/// Known instrument tokens used by the regex fallback.
public static let knownInstrumentPattern =
#"i1|ColorMunki|Spyder|spectro|Display|Huey|DTP|SpectroScan|Smile|Klein"#
/// Parse the complete `instlist` output.
public static func parse(_ output: String) throws -> [InstrumentDevice] {
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if let data = trimmed.data(using: .utf8),
let decoded = try? decodeJSON(data) {
return decoded
}
let regex = try? NSRegularExpression(
pattern: #"^(\d+)[\s:=]+'?([^'\n]+)'?(?:\s+on\s+'?([^'\n]+)'?)?"#,
options: [.caseInsensitive, .anchorsMatchLines]
)
var devices: [InstrumentDevice] = []
let range = NSRange(trimmed.startIndex..., in: trimmed)
let matches = regex?.matches(in: trimmed, options: [], range: range) ?? []
for match in matches {
guard let portString = substring(trimmed, range: match.range(at: 1)),
let port = Int(portString), port > 0 else { continue }
let name = substring(trimmed, range: match.range(at: 2))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let type = substring(trimmed, range: match.range(at: 3))?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let combined = "\(name) \(type)".lowercased()
guard combined.range(of: knownInstrumentPattern,
options: [.regularExpression, .caseInsensitive]) != nil,
!name.isEmpty else { continue }
devices.append(InstrumentDevice(port: port, name: name, type: type))
}
return devices
}
private static func decodeJSON(_ data: Data) throws -> [InstrumentDevice] {
let output = try JSONDecoder().decode(InstlistOutput.self, from: data)
return output.devices
}
private static func substring(_ source: String, range: NSRange) -> String? {
guard range.location != NSNotFound, let r = Range(range, in: source) else { return nil }
return String(source[r])
}
}
private struct InstlistOutput: Decodable {
let event: String
let devices: [InstrumentDevice]
}
@@ -0,0 +1,184 @@
import Foundation
/// XYZ tristimulus values, stored in the 0100 scale used by the Argyll fork.
public struct XYZColor: Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
}
/// CIELab value (D50).
public struct LabColor: Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
}
/// sRGB colour in 01 display space.
public struct DisplayRGB: Sendable, Equatable {
public let r: Double
public let g: Double
public let b: Double
public init(r: Double, g: Double, b: Double) {
self.r = r
self.g = g
self.b = b
}
public var clamped: DisplayRGB {
DisplayRGB(r: min(1, max(0, r)), g: min(1, max(0, g)), b: min(1, max(0, b)))
}
}
/// Colour-space conversions used by the swatch grid.
///
/// All numeric paths are deterministic and avoid platform colour-management APIs.
public enum LabColorMath {
// Reference white for D50 (0100 scale).
static let d50White = (X: 96.4212, Y: 100.0, Z: 82.5188)
// Reference white for D65 (0100 scale).
static let d65White = (X: 95.0489, Y: 100.0, Z: 108.8840)
// Bradford cone-response matrix and its inverse (XYZ -> LMS).
static let bradford = [
[ 0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[ 0.0389, -0.0685, 1.0296]
]
static let bradfordInv = [
[ 0.9869929, -0.1470543, 0.1599627],
[ 0.4323053, 0.5183603, 0.0492912],
[-0.0085287, 0.0400428, 0.9684866]
]
// sRGB D65 matrix (XYZ -> linear sRGB, using 0100 inputs).
static let srgbMatrix = [
[ 3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[ 0.0557, -0.2040, 1.0570]
]
/// Convert XYZ (0100) to CIELab D50.
public static func xyzToLab(_ xyz: XYZColor) -> LabColor {
let f: (Double) -> Double = { t in
let delta = 6.0 / 29.0
if t > delta * delta * delta {
return pow(t, 1.0 / 3.0)
} else {
return t / (3 * delta * delta) + 4.0 / 29.0
}
}
let x = f(xyz.x / d50White.X)
let y = f(xyz.y / d50White.Y)
let zr = f(xyz.z / d50White.Z)
return LabColor(
l: 116.0 * y - 16.0,
a: 500.0 * (x - y),
b: 200.0 * (y - zr)
)
}
/// Convert CIELab D50 to XYZ (0100).
public static func labToXYZ(_ lab: LabColor) -> XYZColor {
let finv: (Double) -> Double = { t in
let delta = 6.0 / 29.0
if t > delta {
return t * t * t
} else {
return 3 * delta * delta * (t - 4.0 / 29.0)
}
}
let yr = (lab.l + 16.0) / 116.0
let xr = yr + lab.a / 500.0
let zr = yr - lab.b / 200.0
return XYZColor(
x: finv(xr) * d50White.X,
y: finv(yr) * d50White.Y,
z: finv(zr) * d50White.Z
)
}
/// Convert XYZ D50 to XYZ D65 using the Bradford chromatic adaptation.
public static func adaptD50ToD65(_ xyz: XYZColor) -> XYZColor {
let source = matrixMultiply(bradford, [xyz.x, xyz.y, xyz.z])
let srcWhite = matrixMultiply(bradford, [d50White.X, d50White.Y, d50White.Z])
let dstWhite = matrixMultiply(bradford, [d65White.X, d65White.Y, d65White.Z])
let scaled = [
source[0] * (dstWhite[0] / srcWhite[0]),
source[1] * (dstWhite[1] / srcWhite[1]),
source[2] * (dstWhite[2] / srcWhite[2])
]
return XYZColor(
x: scaled[0] * bradfordInv[0][0] + scaled[1] * bradfordInv[0][1] + scaled[2] * bradfordInv[0][2],
y: scaled[0] * bradfordInv[1][0] + scaled[1] * bradfordInv[1][1] + scaled[2] * bradfordInv[1][2],
z: scaled[0] * bradfordInv[2][0] + scaled[1] * bradfordInv[2][1] + scaled[2] * bradfordInv[2][2]
)
}
/// Convert XYZ D65 (0100) to linear sRGB, apply gamma, and clamp.
public static func xyzToSRGB(_ xyz: XYZColor) -> DisplayRGB {
// sRGB matrix is defined for XYZ with D65 white at Y = 1.0.
// The input is 0100, so scale by 100 first.
let scaled = [xyz.x / 100.0, xyz.y / 100.0, xyz.z / 100.0]
let linear = matrixMultiply(srgbMatrix, scaled)
func gamma(_ c: Double) -> Double {
if c <= 0.0031308 {
return 12.92 * c
} else {
return 1.055 * pow(c, 1.0 / 2.4) - 0.055
}
}
return DisplayRGB(
r: gamma(linear[0]),
g: gamma(linear[1]),
b: gamma(linear[2])
).clamped
}
/// Complete D50 Lab -> display sRGB conversion.
public static func labToSRGB(_ lab: LabColor) -> DisplayRGB {
let xyz50 = labToXYZ(lab)
let xyz65 = adaptD50ToD65(xyz50)
return xyzToSRGB(xyz65)
}
/// Convert an Argyll XYZ array (0100) to Lab and then to sRGB.
public static func xyzArrayToSRGB(_ xyz: [Double]) -> DisplayRGB? {
guard xyz.count >= 3 else { return nil }
return labToSRGB(xyzToLab(XYZColor(x: xyz[0], y: xyz[1], z: xyz[2])))
}
private static func matrixMultiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
var result = [Double](repeating: 0, count: m.count)
for i in 0..<m.count {
for j in 0..<v.count {
result[i] += m[i][j] * v[j]
}
}
return result
}
}
@@ -0,0 +1,172 @@
import Foundation
/// Errors from pass-snapshot, promote, and discovery operations.
public enum MeasurementArtefactError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case canonicalMissing(URL)
case snapshotFailed(String)
case promoteFailed(String)
case noPassFiles
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid measurement basename: \(name)"
case .canonicalMissing(let url):
return "Canonical .ti3 not found: \(url.path)"
case .snapshotFailed(let reason):
return "Snapshot failed: \(reason)"
case .promoteFailed(let reason):
return "Promote failed: \(reason)"
case .noPassFiles:
return "No pass .ti3 snapshots are available."
}
}
}
/// Filesystem helpers for multi-pass measurement artefacts.
///
/// Pass snapshots use 1-based numbering and are tracked as
/// `<basename>_passN.ti3`. Canonical `<basename>.ti3` only appears after
/// Finish / Average (docs/24 #109, #110).
public enum MeasurementArtefacts {
/// Find all existing pass snapshots in `cwd`, sorted numerically.
public static func passSnapshots(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefix = "\(basename)_pass"
let suffix = "ti3"
let passes: [(Int, URL)] = entries.compactMap { url in
let name = url.lastPathComponent
guard url.pathExtension.lowercased() == suffix,
name.hasPrefix(prefix)
else { return nil }
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
guard let number = Int(numberPart), number > 0 else { return nil }
return (number, url)
}
return passes
.sorted { $0.0 < $1.0 }
.map { $0.1 }
}
/// The next 1-based pass number.
public static func nextPassNumber(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> Int {
let existing = passSnapshots(basename: basename, cwd: cwd, fileManager: fileManager)
guard let last = existing.last else { return 1 }
let name = last.lastPathComponent
let prefix = "\(basename)_pass"
let numberPart = String(name.dropFirst(prefix.count).dropLast(4))
return (Int(numberPart) ?? 0) + 1
}
/// Snapshot canonical `<basename>.ti3` to `<basename>_passN.ti3` and remove canonical.
///
/// The copy is written to a temp sibling and atomically renamed before canonical is deleted.
public static func snapshotPass(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
guard fileManager.fileExists(atPath: canonical.path) else {
throw MeasurementArtefactError.canonicalMissing(canonical)
}
let passNumber = nextPassNumber(basename: cleanBasename, cwd: cwd, fileManager: fileManager)
let pass = cwd.appendingPathComponent("\(cleanBasename)_pass\(passNumber).ti3")
let temp = cwd.appendingPathComponent(".\(cleanBasename)_pass\(passNumber).ti3.iccery-snap.tmp")
if fileManager.fileExists(atPath: temp.path) {
try? fileManager.removeItem(at: temp)
}
do {
try fileManager.copyItem(at: canonical, to: temp)
} catch {
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
if fileManager.fileExists(atPath: pass.path) {
try? fileManager.removeItem(at: pass)
}
do {
try fileManager.moveItem(at: temp, to: pass)
} catch {
try? fileManager.removeItem(at: temp)
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
do {
try fileManager.removeItem(at: canonical)
} catch {
// The pass file is durable; canonical removal failure is logged but not fatal.
throw MeasurementArtefactError.snapshotFailed(error.localizedDescription)
}
return pass
}
/// Promote a single pass snapshot to canonical `<basename>.ti3`.
public static func promotePass(
pass: URL,
basename: String,
cwd: URL,
fileManager: FileManager = .default
) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
let temp = cwd.appendingPathComponent(".\(cleanBasename).ti3.iccery-promo.tmp")
guard fileManager.fileExists(atPath: pass.path) else {
throw MeasurementArtefactError.noPassFiles
}
if fileManager.fileExists(atPath: temp.path) {
try? fileManager.removeItem(at: temp)
}
do {
try fileManager.copyItem(at: pass, to: temp)
} catch {
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
}
if fileManager.fileExists(atPath: canonical.path) {
try? fileManager.removeItem(at: canonical)
}
do {
try fileManager.moveItem(at: temp, to: canonical)
} catch {
try? fileManager.removeItem(at: temp)
throw MeasurementArtefactError.promoteFailed(error.localizedDescription)
}
return canonical
}
/// Canonical `<basename>.ti3` URL, regardless of existence.
public static func canonicalURL(basename: String, cwd: URL) throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
return cwd.appendingPathComponent("\(cleanBasename).ti3")
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Well-known filesystem locations for the ICCery host process.
///
/// macOS paths (docs/02 §Persistence):
/// - App data: `~/Library/Application Support/<bundle-id>/`
/// - Log file: `~/Library/Logs/<bundle-id>/iccery.log`
/// - Bundled Argyll tools: `<bundle>/Contents/Resources/Argyll/`
public enum AppPaths {
/// `com.gronod.iccery2` read from the main bundle so tests can override.
public static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.gronod.iccery2"
}
/// `~/Library/Application Support/com.gronod.iccery2`
///
/// DEBUG only: `ICCERY_TEST_ROOT` redirects app data so UI tests run
/// against an isolated root and never touch the developer's state.
public static var appDataDir: URL {
#if DEBUG
if let root = testRoot {
return root.appendingPathComponent("AppData", isDirectory: true)
}
#endif
return FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
/// `~/Library/Logs/com.gronod.iccery2`
public static var logDir: URL {
#if DEBUG
if let root = testRoot {
return root.appendingPathComponent("Logs", isDirectory: true)
}
#endif
return FileManager.default
.urls(for: .libraryDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Logs", isDirectory: true)
.appendingPathComponent(bundleIdentifier, isDirectory: true)
}
#if DEBUG
private static var testRoot: URL? {
guard let raw = ProcessInfo.processInfo.environment["ICCERY_TEST_ROOT"],
!raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw, isDirectory: true)
}
#endif
/// `~/Library/Logs/com.gronod.iccery2/iccery.log`
public static var logFile: URL {
logDir.appendingPathComponent("iccery.log", isDirectory: false)
}
/// `<app>/Contents/Resources/Argyll` bundled sidecar root.
public static var bundledArgyllDir: URL {
Bundle.main.resourceURL?
.appendingPathComponent("Argyll", isDirectory: true)
?? URL(fileURLWithPath: "/nonexistent")
}
/// Creates the app data and log directories if missing.
@discardableResult
public static func ensureDirectories() throws -> (appData: URL, logs: URL) {
let fm = FileManager.default
try fm.createDirectory(at: appDataDir, withIntermediateDirectories: true)
try fm.createDirectory(at: logDir, withIntermediateDirectories: true)
return (appDataDir, logDir)
}
}
@@ -0,0 +1,49 @@
import Foundation
/// Ordered private-SPI attempt table for the ColorSync suppression
/// engine (issue 14 layer , docs/11).
///
/// The ordering is data so the exact dlsym/mode sequence is unit-
/// testable without resolving any private symbols. The app layer walks
/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,)`, and
/// calls the first `(symbol, mode)` that returns `0` verified on
/// macOS 14+ that all three symbols exist.
///
/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`.
/// The second argument is the **mode string**, never integer `1`
/// (#188 a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and
/// `AP_VendorColorMatching` are forbidden modes they re-enable
/// ColorSync/driver colour management.
public enum ColorMatchingAttempts {
/// dlsym order: `Lock` first (holds the print-session lock while
/// setting), then the plain setter, then `NoLock`.
public static let symbols: [String] = [
"PMSessionSetColorMatchingModeLock",
"PMSessionSetColorMatchingMode",
"PMSessionSetColorMatchingModeNoLock",
]
/// Mode strings tried per symbol, in order. `AP_` is the
/// documented mode; the unprefixed variant is the older alias.
public static let modes: [String] = [
"AP_ApplicationColorMatching",
"ApplicationColorMatching",
]
/// Symbol-outer, mode-inner the full attempt sequence; the app
/// stops at the first call that returns `0`.
public static var attempts: [(symbol: String, mode: String)] {
symbols.flatMap { symbol in
modes.map { (symbol: symbol, mode: $0) }
}
}
/// Layer : both spellings of the print-settings key are written
/// with `locked = true`. Written as `CFString` values.
public static let applicationMatchingValue = "AP_ApplicationColorMatching"
public static let printSettingsKeys: [String] = [
"AP_ColorMatchingMode",
"AP.ColorMatchingMode",
]
}
@@ -0,0 +1,62 @@
import Foundation
/// CUPS option filtering for `PMPrintSettingsToOptions` capture
/// (issue 14 layer , docs/11 §filter).
///
/// The captured `key=value` string is reduced to the options that
/// should be replayed on `lp`: `com.apple.*` ticket keys, job
/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`,
/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode`
/// keys are dropped `build_lp_args` always re-adds those itself
/// (issue 15). Unknown non-`com.*` keys are kept (permissive vendor
/// driver keys survive).
public enum CupsOptionsFilter {
/// Option keys forwarded from the panel to `lp` (docs/11 roster).
public static let relevantKeys: Set<String> = [
// Media
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
// Tray
"InputSlot", "AP_D_InputSlot",
// Size
"PageSize",
// Colour bypass
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode", "ColorModel",
// Quality
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
"CNIJQuality", "StpQuality", "OutputMode",
// Duplex
"Duplex", "sides",
]
/// Keys we always drop regardless of the relevant list. `raw` is
/// included a captured `raw=` would re-enable CUPS raw mode and
/// bypass the raster filter that honours `AP_ApplicationColorMatching`
/// (#92).
public static let alwaysDropped: Set<String> = [
"collate", "copies", "pserrorhandler-requested", "job-sheets",
"AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw",
]
/// A `key=value` pair survives when the key is non-empty, the value
/// is non-empty, the key is not `com.apple.*`, not always-dropped,
/// and either relevant or an unknown non-`com.*` driver key.
public static func isRelevant(key: String, value: String) -> Bool {
guard !key.isEmpty, !value.isEmpty else { return false }
if key.hasPrefix("com.apple.") { return false }
if alwaysDropped.contains(key) { return false }
if relevantKeys.contains(key) { return true }
// Permissive: unknown vendor keys survive (non-com.*).
return !key.hasPrefix("com.")
}
/// `key=value key=value ` filtered string, order preserved.
public static func filter(_ options: String) -> String {
CupsParsers.lpoptions(options)
.filter { isRelevant(key: $0.key, value: $0.value) }
.map { "\($0.key)=\($0.value)" }
.joined(separator: " ")
}
}
@@ -0,0 +1,264 @@
import Foundation
/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`.
public struct CupsOptionListing: Equatable, Sendable {
/// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`.
public var key: String
/// Human label after `/`, e.g. `Media Source`.
public var label: String
/// All choices, `*` stripped.
public var choices: [String]
/// The `*`-prefixed default choice, if any.
public var defaultChoice: String?
public init(key: String, label: String, choices: [String], defaultChoice: String?) {
self.key = key
self.label = label
self.choices = choices
self.defaultChoice = defaultChoice
}
}
/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12,
/// docs/1011). Recorded fixtures drive the tests no live CUPS.
public enum CupsParsers {
// MARK: - lpstat
/// `lpstat -e` one CUPS destination name per line.
public static func lpstatDestinations(_ output: String) -> [String] {
output.split(separator: "\n")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
/// `lpstat -p` `printer NAME is idle. enabled since `,
/// `printer NAME now printing NAME-1. `, `printer NAME disabled
/// since ` queue status.
public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] {
var result: [String: PrinterStatus] = [:]
for line in output.split(separator: "\n") {
let text = line.trimmingCharacters(in: .whitespaces)
guard text.hasPrefix("printer ") else { continue }
let rest = text.dropFirst("printer ".count)
guard let sep = rest.firstIndex(of: " ") else { continue }
let name = String(rest[..<sep])
let desc = rest[sep...].lowercased()
if desc.contains("now printing") {
result[name] = .printing
} else if desc.contains("idle") {
result[name] = .idle
} else if desc.contains("disabled") || desc.contains("stopped") {
result[name] = .stopped
} else {
result[name] = .unknown
}
}
return result
}
/// `lpstat -d` `system default destination: NAME`, or
/// `no system default destination` nil.
public static func lpstatDefault(_ output: String) -> String? {
for line in output.split(separator: "\n") {
let text = line.trimmingCharacters(in: .whitespaces)
guard let colon = text.firstIndex(of: ":") else { continue }
let name = text[text.index(after: colon)...]
.trimmingCharacters(in: .whitespaces)
if text.lowercased().hasPrefix("system default destination"),
!name.isEmpty {
return name
}
}
return nil
}
// MARK: - lpoptions -p <queue>
/// `lpoptions -p` `key=value` pairs, values may be
/// single-quoted (`printer-info='EPSON XP-55 Series'`); bare
/// flags (`printer-location`) parse as present-with-empty-value.
public static func lpoptions(_ output: String) -> [(key: String, value: String)] {
var pairs: [(String, String)] = []
var index = output.startIndex
while index < output.endIndex {
while index < output.endIndex && output[index].isWhitespace {
index = output.index(after: index)
}
guard index < output.endIndex else { break }
let tokenStart = index
while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace {
index = output.index(after: index)
}
let key = String(output[tokenStart..<index])
// A token starting with `=` has no key skip it (and its
// value) rather than truncating the whole parse.
guard !key.isEmpty else {
if index < output.endIndex && output[index] == "=" {
index = output.index(after: index)
while index < output.endIndex
&& !output[index].isWhitespace {
index = output.index(after: index)
}
}
continue
}
if index < output.endIndex && output[index] == "=" {
index = output.index(after: index)
if index < output.endIndex && output[index] == "'" {
// Single-quoted value scan to closing quote.
index = output.index(after: index)
let valueStart = index
while index < output.endIndex && output[index] != "'" {
index = output.index(after: index)
}
pairs.append((key, String(output[valueStart..<index])))
if index < output.endIndex { index = output.index(after: index) }
} else {
let valueStart = index
while index < output.endIndex && !output[index].isWhitespace {
index = output.index(after: index)
}
pairs.append((key, String(output[valueStart..<index])))
}
} else {
pairs.append((key, ""))
}
}
return pairs
}
/// Display name from `printer-info` in `lpoptions -p` output.
public static func lpoptionsDisplayName(_ output: String) -> String? {
guard let value = lpoptions(output)
.first(where: { $0.key == "printer-info" })?.value,
!value.isEmpty
else { return nil }
return value
}
// MARK: - lpoptions -l
/// `lpoptions -l` `Key/Human Label: *Default choice2 choice3`.
/// A missing `/` label reuses the key.
public static func lpoptionsList(_ output: String) -> [CupsOptionListing] {
output.split(separator: "\n").compactMap { raw in
let line = raw.trimmingCharacters(in: .whitespaces)
guard let colon = line.firstIndex(of: ":") else { return nil }
let head = String(line[..<colon])
let body = line[line.index(after: colon)...]
let headParts = head.split(separator: "/", maxSplits: 1)
let key = headParts[0].trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { return nil }
let label = headParts.count > 1
? headParts[1].trimmingCharacters(in: .whitespaces)
: key
var choices: [String] = []
var defaultChoice: String?
for token in body.split(separator: " ") {
if token.hasPrefix("*") {
let value = String(token.dropFirst())
defaultChoice = value
choices.append(value)
} else {
choices.append(String(token))
}
}
return CupsOptionListing(
key: key, label: label,
choices: choices, defaultChoice: defaultChoice)
}
}
// MARK: - PPD enrichment
/// PPD `*<key> <id>/<Human Label>:` lines `id label` map.
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
var map: [String: String] = [:]
for rawLine in ppd.split(separator: "\n") {
var line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
line = String(line.dropFirst())
// Optional locale qualifier: `en_US.InputSlot` `InputSlot`.
// Only strip when the part before the first `.` looks like
// a locale (short `xx`/`xx_YY`); real keys containing dots
// are left alone.
if let dot = line.firstIndex(of: ".") {
let prefix = line[..<dot]
let looksLikeLocale = (2...5).contains(prefix.count)
&& prefix.allSatisfy { $0.isLetter || $0 == "_" }
&& (prefix.count == 2 || prefix.contains("_"))
let candidate = line[line.index(after: dot)...]
if looksLikeLocale && candidate.hasPrefix(key) {
line = String(candidate)
}
}
guard line.hasPrefix(key) else { continue }
var rest = line[line.index(line.startIndex, offsetBy: key.count)...]
.trimmingCharacters(in: .whitespaces)
guard let colon = rest.firstIndex(of: ":") else { continue }
rest = String(rest[..<colon])
// `<id>/<Human label>` human label after the last `/`.
guard let slash = rest.firstIndex(of: "/") else { continue }
let id = String(rest[..<slash])
.trimmingCharacters(in: .whitespaces)
let human = String(rest[rest.index(after: slash)...])
.trimmingCharacters(in: .whitespaces)
if !id.isEmpty { map[id] = human.isEmpty ? id : human }
}
return map
}
// MARK: - Detection (docs/11)
/// Media-type option key in preference order used both to read a
/// captured value and to emit `-o <key>=<media>`.
public static let mediaTypeKeys = [
"CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType"
]
public static func detectMediaTypeKey(optionKeys: Set<String>) -> String? {
mediaTypeKeys.first { optionKeys.contains($0) }
}
/// Media type from a captured `key=value key=value` options string.
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
public static func extractMediaType(fromOptionsString options: String) -> String? {
let pairs = lpoptions(options)
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
return v
}
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
}
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
/// (docs/11 layer ): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
/// Gutenprint `StpColorCorrection=Uncorrected`; generic
/// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`.
public static func detectDriverColorBypass(
optionKeys: Set<String>
) -> (key: String, value: String)? {
if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") }
if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") }
if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") }
if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") }
if optionKeys.contains("StpColorCorrection") {
return ("StpColorCorrection", "Uncorrected")
}
if optionKeys.contains("ColorCorrection") {
return ("ColorCorrection", "Uncorrected")
}
if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") }
return nil
}
/// The key=value pairs of colour-bypass keys used to detect
/// whether captured options already carry a bypass.
public static let bypassKeys: Set<String> = [
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode",
]
}
@@ -0,0 +1,191 @@
import Foundation
/// Errors from CUPS tool invocations.
public enum CupsError: LocalizedError, Equatable {
case toolFailed(tool: String, code: Int32, stderr: String)
case tiffMissing(String)
case noPrinterSelected
public var errorDescription: String? {
switch self {
case .toolFailed(let tool, let code, let stderr):
let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
return detail.isEmpty
? "\(tool) failed with exit code \(code)"
: "\(tool) failed (\(code)): \(detail)"
case .tiffMissing(let path):
return "Target TIFF does not exist: \(path)"
case .noPrinterSelected:
return "No printer selected."
}
}
}
/// CUPS command surface (issue 12): enumerates queues and reads
/// per-queue capabilities via `/usr/bin/lpstat` and
/// `/usr/bin/lpoptions`. Spawning goes through
/// `ProcessManager.runCaptured` so spawns are logged, get killAll
/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are
/// injectable so tests use fixture scripts and never touch real CUPS.
public struct CupsService: Sendable {
public let processManager: ProcessManager
/// Directory containing `lpstat`/`lpoptions`/`lp` `/usr/bin` in
/// production, a fixture dir under test.
public let binaryDir: URL
/// `/etc/cups/ppd` in production.
public let ppdDir: URL
public init(
processManager: ProcessManager = .shared,
binaryDir: URL = URL(fileURLWithPath: "/usr/bin"),
ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd")
) {
self.processManager = processManager
self.binaryDir = binaryDir
self.ppdDir = ppdDir
}
// MARK: - Enumeration (lpstat -e/-p/-d)
/// All CUPS destinations with status and default flag. An empty
/// list is a valid result, not an error.
public func listPrinters() async throws -> [Printer] {
// lpstat exits non-zero when no destinations exist an empty
// queue list is a valid result, not a failure (issue 12).
let destinationsOut = try await run(
"lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true)
let statusOut = try await run(
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
let defaultOut = try await run(
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
var printers: [Printer] = []
for name in names {
let displayName = try? await displayName(for: name)
printers.append(Printer(
name: name,
status: statuses[name] ?? .unknown,
isDefault: name == defaultName,
displayName: displayName
))
}
return printers
}
/// `lpoptions -p <queue>` `printer-info` (the NSPrinter fallback
/// display name, docs/11 §binding).
public func displayName(for queue: String) async throws -> String? {
let result = try await run(
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
return CupsParsers.lpoptionsDisplayName(result.stdout)
}
// MARK: - Capabilities (lpoptions -l + PPD)
/// Raw `Key/Label: choices` listings for a queue also the input
/// to media-key and colour-bypass detection (docs/11 layer ).
public func optionListings(for queue: String) async throws -> [CupsOptionListing] {
let result = try await run(
"lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l"))
return CupsParsers.lpoptionsList(result.stdout)
}
/// Trays / paper sizes / media types for a queue, with PPD
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
let listings = try await optionListings(for: queue)
return capabilities(from: listings, ppd: loadPPD(for: queue))
}
/// Pure mapping extracted so fixture tests need no process.
public func capabilities(
from listings: [CupsOptionListing],
ppd: String?
) -> PrinterCapabilities {
var trays: [PrinterTray] = []
var sizes: [PrinterPaperSize] = []
var media: [PrinterMediaType] = []
for listing in listings {
switch listing.key {
case "InputSlot", "MediaSource":
trays = listing.choices.enumerated().map {
PrinterTray(id: $0.offset + 1, name: $0.element)
}
case "PageSize", "MediaSize":
sizes = listing.choices.enumerated().map {
PrinterPaperSize(id: $0.offset + 1, name: $0.element)
}
case let key where CupsParsers.mediaTypeKeys.contains(key):
guard media.isEmpty else { continue }
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: key)
} ?? [:]
media = listing.choices.map {
PrinterMediaType(id: $0, name: labels[$0] ?? $0)
}
default:
continue
}
}
return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media)
}
/// The set of option keys a queue advertises input to
/// `detectDriverColorBypass` / `detectMediaTypeKey`.
public func optionKeys(for queue: String) async throws -> Set<String> {
Set(try await optionListings(for: queue).map(\.key))
}
// MARK: - Spool (issue 15)
/// `lp -d <queue> <tiff>` spool one target page unmanaged.
/// Never uses `-o raw` (#92). `page` disambiguates the process id
/// when several pages are spooled in sequence.
public func printTarget(
queue: String,
tiffPath: String,
options: PrintOptions,
page: Int = 0
) async throws {
guard FileManager.default.fileExists(atPath: tiffPath) else {
throw CupsError.tiffMissing(tiffPath)
}
let optionKeys = (try? await self.optionKeys(for: queue)) ?? []
let argv = try LpArgs.build(
queue: queue, tiffPath: tiffPath,
options: options, optionKeys: optionKeys)
try await run("lp", argv, id: ProcessID.lp(queue, page: page))
}
// MARK: - PPD
private func loadPPD(for queue: String) -> String? {
let url = ppdDir.appendingPathComponent("\(queue).ppd")
return try? String(contentsOf: url, encoding: .utf8)
}
// MARK: - Spawn
@discardableResult
func run(
_ tool: String,
_ arguments: [String],
id: String,
tolerateFailure: Bool = false
) async throws -> CapturedResult {
let binary = binaryDir.appendingPathComponent(tool)
let result = try await processManager.runCaptured(
id: id, binary: binary, arguments: arguments)
if result.exitCode != 0, !tolerateFailure {
throw CupsError.toolFailed(
tool: tool, code: result.exitCode, stderr: result.stderr)
}
return result
}
}
@@ -0,0 +1,126 @@
import Foundation
/// Errors from `buildLpArgs`.
public enum LpArgsError: LocalizedError, Equatable {
case unsanitisedOption(String)
public var errorDescription: String? {
switch self {
case .unsanitisedOption(let option):
return "Captured CUPS option contains unsafe characters: \(option)"
}
}
}
/// `lp` argv builder issue 15, docs/11 `build_lp_args`.
///
/// ```
/// lp -d <queue> -t "ICCery Target - <file>"
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
/// <captured cups_options>
/// <media_type, if no media key already captured>
/// <driver bypass, if no bypass key captured>
/// <orientation-requested=3|4, unless captured>
/// <PageSize, unless captured>
/// <tiff>
/// ```
///
/// - **Never `-o raw`** `raw` skips the raster filter that honours
/// `AP_ApplicationColorMatching` (#92).
/// - Captured options **win** over explicit fields: any key already
/// present (case-insensitive) suppresses the derived `-o`.
/// - Captured keys/values are sanitised `;`, newlines, or shell
/// metacharacters throw `unsanitisedOption`; args are passed as a
/// `Process` argv array, never through a shell.
/// - The TIFF path is always the **last** argument.
public enum LpArgs {
/// `options` = the captured `PrintOptions`; `optionKeys` = the
/// queue's `lpoptions -l` key set (for media-key/bypass detection).
public static func build(
queue: String,
tiffPath: String,
options: PrintOptions,
optionKeys: Set<String>
) throws -> [String] {
var argv: [String] = [
"-d", queue,
"-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)",
"-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching",
"-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching",
]
var addedKeys: Set<String> = [
"ap_colormatchingmode", "ap.colormatchingmode",
]
// Captured CUPS options sanitised, lowercased-key dedup.
if let captured = options.cupsOptions, !captured.isEmpty {
// Newlines can't survive the tokeniser check the raw
// string so embedded line breaks are still rejected.
if captured.contains("\n") || captured.contains("\r") {
throw LpArgsError.unsanitisedOption(captured)
}
for pair in CupsParsers.lpoptions(captured) {
try sanitize(pair.key, pair.value)
let lowered = pair.key.lowercased()
// Defence in depth: never let a captured `raw` reach
// argv `-o raw` skips the raster filter that honours
// AP_ApplicationColorMatching (#92).
if lowered == "raw" { continue }
guard !addedKeys.contains(lowered) else { continue }
addedKeys.insert(lowered)
argv += ["-o", "\(pair.key)=\(pair.value)"]
}
}
// Media type only when the captured options didn't carry one.
if let mediaType = options.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys),
!addedKeys.contains(mediaKey.lowercased()) {
addedKeys.insert(mediaKey.lowercased())
argv += ["-o", "\(mediaKey)=\(mediaType)"]
}
// Driver colour bypass when no bypass key was captured. NOT
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
let capturedKeys = Set(
CupsParsers.lpoptions(options.cupsOptions ?? "")
.map { $0.key })
if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys),
let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys),
!addedKeys.contains(bypass.key.lowercased()) {
addedKeys.insert(bypass.key.lowercased())
argv += ["-o", "\(bypass.key)=\(bypass.value)"]
}
// Orientation portrait=3, landscape=4.
if let orientation = options.orientation,
!addedKeys.contains("orientation-requested") {
let value = orientation == "landscape" ? "4" : "3"
addedKeys.insert("orientation-requested")
argv += ["-o", "orientation-requested=\(value)"]
}
// PageSize the printtarg layout page size.
if let paperSize = options.paperSize, !paperSize.isEmpty,
!addedKeys.contains("pagesize") {
argv += ["-o", "PageSize=\(paperSize)"]
}
argv.append(tiffPath)
return argv
}
/// Reject shell/metachar injection args go to `Process` as an
/// argv array, but a hostile captured string must not smuggle a
/// second option or command.
static func sanitize(_ key: String, _ value: String) throws {
let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'")
if key.rangeOfCharacter(from: forbidden) != nil
|| value.rangeOfCharacter(from: forbidden) != nil
|| key.isEmpty {
throw LpArgsError.unsanitisedOption("\(key)=\(value)")
}
}
}
@@ -0,0 +1,136 @@
import Foundation
/// Queue status reported by `lpstat -p` (docs/10 §Printer).
public enum PrinterStatus: String, Codable, Sendable, CaseIterable {
case idle = "Idle"
case printing = "Printing"
case stopped = "Stopped"
case unknown = "Unknown"
}
/// A CUPS destination. `name` is the queue id sent back to every
/// subsequent print command; `displayName` is the human label from
/// `printer-info` (used as the `NSPrinter` fallback when PM binding
/// fails #188).
public struct Printer: Codable, Equatable, Sendable {
public var name: String
public var status: PrinterStatus
public var isDefault: Bool
public var displayName: String?
public init(
name: String,
status: PrinterStatus = .unknown,
isDefault: Bool = false,
displayName: String? = nil
) {
self.name = name
self.status = status
self.isDefault = isDefault
self.displayName = displayName
}
}
/// Paper source. `id` is the 1-based index of the `InputSlot` /
/// `MediaSource` choice (not a PPD code) docs/10.
public struct PrinterTray: Codable, Equatable, Sendable {
public var id: Int
public var name: String
public init(id: Int, name: String) {
self.id = id
self.name = name
}
}
/// Media size from `PageSize` / `MediaSize` choices (1-based index).
public struct PrinterPaperSize: Codable, Equatable, Sendable {
public var id: Int
public var name: String
public init(id: Int, name: String) {
self.id = id
self.name = name
}
}
/// Media type: `id` is the PPD machine token, `name` the human label
/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human).
public struct PrinterMediaType: Codable, Equatable, Sendable {
public var id: String
public var name: String
public init(id: String, name: String) {
self.id = id
self.name = name
}
}
public struct PrinterCapabilities: Codable, Equatable, Sendable {
public var trays: [PrinterTray]
public var paperSizes: [PrinterPaperSize]
public var mediaTypes: [PrinterMediaType]
/// Always `true` on macOS (spec parity CUPS honours
/// `orientation-requested`).
public var supportsOrientation: Bool
public init(
trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true
) {
self.trays = trays
self.paperSizes = paperSizes
self.mediaTypes = mediaTypes
self.supportsOrientation = supportsOrientation
}
}
/// Options carried into `lp` (docs/10 §PrintOptions). On macOS
/// `paperSource` is ignored unless already present inside captured
/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets
/// it on OK) but never gates the argv macOS always bypasses driver
/// colour management.
public struct PrintOptions: Codable, Equatable, Sendable {
public var paperSource: Int?
/// `"portrait"` / `"landscape"` `orientation-requested=3|4`.
public var orientation: String?
/// printtarg layout page size `PageSize=` (skipped if captured).
public var paperSize: String?
public var mediaType: String?
public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ).
public var cupsOptions: String?
public init(
paperSource: Int? = nil,
orientation: String? = nil,
paperSize: String? = nil,
mediaType: String? = nil,
ppdUncorrectedPassthrough: Bool? = nil,
cupsOptions: String? = nil
) {
self.paperSource = paperSource
self.orientation = orientation
self.paperSize = paperSize
self.mediaType = mediaType
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
self.cupsOptions = cupsOptions
}
}
/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult).
/// `nil` from the service means the user cancelled never an error.
public struct PrintPropertiesResult: Codable, Equatable, Sendable {
/// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil`
/// when the `NSPrinter` fallback ran.
public var selectedPrinter: String?
public var options: PrintOptions
public init(selectedPrinter: String?, options: PrintOptions) {
self.selectedPrinter = selectedPrinter
self.options = options
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Accumulates stdout lines into a complete JSON document.
///
/// Several Argyll tools (`instlist`, `profcheck`, `printtarg` manifest)
/// emit pretty-printed multi-line JSON on stdout. Individual lines are
/// *not* valid JSON only the whole block is so callers route stdout
/// lines here and get `Data` back once the buffer parses.
///
/// `ROW_COLORS_JSON: ` lines never reach this type; ProcessManager
/// diverts them to `jsonRow` events first.
public struct JSONAccumulator: Sendable {
private var buffer = Data()
public init() {}
/// Appends one stdout line. Returns the complete document bytes when
/// the accumulated buffer forms valid JSON, otherwise `nil`.
public mutating func feed(line: String) -> Data? {
buffer.append(Data(line.utf8))
buffer.append(0x0A)
return tryParse()
}
/// Attempts to decode the accumulated buffer; clears it on success.
public mutating func decode<T: Decodable>(_ type: T.Type) -> T? {
guard let data = tryParse() else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
/// Raw buffer when it parses, `nil` while still incomplete.
public var completeData: Data? {
var copy = self
return copy.tryParse()
}
public mutating func reset() {
buffer.removeAll(keepingCapacity: false)
}
public var isEmpty: Bool { buffer.isEmpty }
private mutating func tryParse() -> Data? {
// Cheap gate: JSON documents start with { or [.
guard let first = buffer.first(where: { !$0.isJSONWhitespace }),
first == UInt8(ascii: "{") || first == UInt8(ascii: "[")
else { return nil }
guard (try? JSONSerialization.jsonObject(with: buffer)) != nil else { return nil }
let out = buffer
buffer.removeAll(keepingCapacity: false)
return out
}
}
private extension UInt8 {
var isJSONWhitespace: Bool {
self == 0x20 || self == 0x09 || self == 0x0A || self == 0x0D
}
}
@@ -0,0 +1,51 @@
import Foundation
/// Events on the process bus the v2 equivalent of the v1 Tauri events
/// `process:stdout|stderr|exit|error|json_row` (docs/02 §Event bus).
public enum ProcessEvent: Sendable, Equatable {
/// Non-JSON stdout line. (`process:stdout`)
case stdout(id: String, line: String)
/// stderr line. (`process:stderr`)
case stderr(id: String, line: String)
/// Child exited; 0 = success. (`process:exit`)
case exit(id: String, code: Int32)
/// Spawn failure. (`process:error`)
case error(id: String, message: String)
/// Stdout line began with `ROW_COLORS_JSON: ` prefix stripped,
/// payload is the remaining raw bytes. (`process:json_row`)
case jsonRow(id: String, payload: Data)
public var id: String {
switch self {
case .stdout(let id, _), .stderr(let id, _), .exit(let id, _),
.error(let id, _), .jsonRow(let id, _):
return id
}
}
}
public enum ProcessError: Error, Equatable, Sendable {
/// A child with this id is still running (#116).
case duplicateID(String)
/// No child registered under this id.
case unknownID(String)
/// Process refused to launch.
case spawnFailed(String)
/// stdin write failed (pipe closed / process gone).
case stdinFailed(String)
}
extension ProcessError: LocalizedError {
public var errorDescription: String? {
switch self {
case .duplicateID(let id):
return "Process already running: \(id)"
case .unknownID(let id):
return "Unknown process: \(id)"
case .spawnFailed(let detail):
return "Could not launch \(detail)"
case .stdinFailed(let detail):
return "stdin failed: \(detail)"
}
}
}
@@ -0,0 +1,23 @@
import Foundation
/// Deterministic process ids (docs/02 §Event bus). Listeners must always
/// filter events on `id` historical bug #56 was an id mismatch.
public enum ProcessID {
public static let instlist = "instlist"
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
public static func chartread(_ basename: String) -> String { "chartread_\(basename)" }
public static func average(_ basename: String) -> String { "average_\(basename)" }
public static func colprof(_ basename: String) -> String { "colprof_\(basename)" }
public static func profcheck(ti3Path: String) -> String { "profcheck_\(ti3Path)" }
public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" }
public static func printcal(_ stem: String) -> String { "printcal_\(stem)" }
public static func applycal(_ stem: String) -> String { "applycal_\(stem)" }
/// CUPS system tools (`/usr/bin/`) captured one-shots, not
/// streaming Argyll children.
public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" }
public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" }
}
@@ -0,0 +1,41 @@
import Foundation
/// Incremental 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,372 @@
import Foundation
/// Captured output from `runCaptured` one-shot tools whose results
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
public struct CapturedResult: Sendable, Equatable {
public let stdout: String
public let stderr: String
public let exitCode: Int32
}
/// Spawn / stdin / kill / event bus for Argyll sidecar children
/// (docs/02 §Event bus, docs/03 §Process manager).
///
/// Invariants:
/// - Duplicate `id` while a child runs is rejected (#116).
/// - The stdin handle lives in its own map, independent of wait, so
/// `sendStdin` never blocks on process exit (#84).
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
/// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills.
/// - `kill` drops the stdin handle so writers fail fast.
public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
public static let shared = ProcessManager()
// MARK: - Event bus (multicast)
/// Lock-protected subscriber table. Registration is *synchronous*
/// inside `events()` so a caller can subscribe, then spawn, without
/// racing the child's first output or exit event.
private final class SubscriberBox: @unchecked Sendable {
private let lock = NSLock()
private var map: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
func add(_ continuation: AsyncStream<ProcessEvent>.Continuation, token: UUID) {
lock.lock()
map[token] = continuation
lock.unlock()
}
func remove(_ token: UUID) {
lock.lock()
map.removeValue(forKey: token)
lock.unlock()
}
func yield(_ event: ProcessEvent) {
lock.lock()
let continuations = Array(map.values)
lock.unlock()
for continuation in continuations {
continuation.yield(event)
}
}
}
private nonisolated let subscriberBox = SubscriberBox()
/// Subscribe to the event bus. Each call returns an independent
/// stream; every event is delivered to every live subscriber.
/// The subscriber is registered before `events()` returns callers
/// may spawn immediately after subscribing without losing events.
public nonisolated func events() -> AsyncStream<ProcessEvent> {
let box = subscriberBox
let token = UUID()
return AsyncStream { continuation in
box.add(continuation, token: token)
continuation.onTermination = { _ in box.remove(token) }
}
}
private nonisolated func emit(_ event: ProcessEvent) {
subscriberBox.yield(event)
}
// MARK: - Child registry
private struct RunningChild {
let process: Process
/// stdin lives in its own slot, independent of process wait (#84).
var stdin: FileHandle?
var stdoutDecoder: ProcessLineDecoder
var stderrDecoder: ProcessLineDecoder
var stdoutEOF = false
var stderrEOF = false
/// Set by the termination handler; `exit` is emitted once both
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:]
/// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
public func isRunning(_ id: String) -> Bool {
children[id] != nil || captured[id] != nil
}
// MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for
/// `exit(id:)` events never assume the return means the tool
/// finished (docs/03).
public func runStreaming(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
children[id] = RunningChild(
process: process,
stdin: stdinPipe.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
do {
try process.run()
} catch {
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
}
stderrHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
process.terminationHandler = { [weak self] proc in
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
}
// MARK: - Spawn (captured)
/// Runs a child to completion and returns all output. Reads stdout
/// and stderr concurrently so a full pipe buffer can never deadlock
/// the child. Used by `printcal` / `applycal` (docs/03) and by
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
public func runCaptured(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register before run() so a concurrent duplicate spawn fails.
captured[id] = process
do {
try process.run()
} catch {
captured.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
async let errData = Task.detached {
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in
process.terminationHandler = { proc in
continuation.resume(returning: proc.terminationStatus)
}
}
let (out, err) = await (outData, errData)
// If kill() already reaped this child, its exit event went out.
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: code))
}
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
}
// MARK: - stdin
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
/// and flushes (docs/03 §stdin protocol).
public func sendStdin(id: String, bytes: Data) throws {
guard let child = children[id] else { throw ProcessError.unknownID(id) }
guard let handle = child.stdin else {
throw ProcessError.stdinFailed("stdin closed for \(id)")
}
do {
try handle.write(contentsOf: bytes)
} catch {
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
}
}
public func sendStdin(id: String, text: String) throws {
try sendStdin(id: id, bytes: Data(text.utf8))
}
// MARK: - Kill
/// Terminates a child. The `exit` event still fires exactly once.
/// stdin is dropped immediately so writers fail fast (docs/03 rule 7).
public func kill(id: String) {
if var child = children[id] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else {
Task { await self.didTerminate(id: id, code: child.process.terminationStatus) }
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
if captured.removeValue(forKey: id) != nil {
emit(.exit(id: id, code: process.terminationStatus))
}
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() -> Int {
let ids = Array(children.keys) + Array(captured.keys)
for id in ids { kill(id: id) }
return ids.count
}
// MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1"
for (key, value) in extra { env[key] = value }
return env
}
private func ingestOutput(
_ data: Data,
id: String,
isStderr: Bool,
handle: FileHandle
) {
guard var child = children[id] else { return }
if data.isEmpty {
// EOF on this pipe.
handle.readabilityHandler = nil
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
children[id] = child
maybeFinalize(id: id)
return
}
let lines: [String] = isStderr
? child.stderrDecoder.feed(data)
: child.stdoutDecoder.feed(data)
children[id] = child
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
private func didTerminate(id: String, code: Int32) {
guard var child = children[id], !child.finalized else { return }
child.pendingExitCode = code
try? child.stdin?.close()
child.stdin = nil
children[id] = child
maybeFinalize(id: id)
}
/// Emits `exit` once the child has terminated *and* both pipes have
/// drained to EOF, so no buffered output is lost.
private func maybeFinalize(id: String) {
guard var child = children[id],
let code = child.pendingExitCode,
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
children.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
emit(.stderr(id: id, line: tail))
}
emit(.exit(id: id, code: code))
}
}
@@ -0,0 +1,251 @@
import Foundation
/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained
/// solely to decode and migrate pre-M2 `settings.json`; new code uses
/// `ProfilingPreset` (docs/22 §ProfilingPreset).
public struct CustomPreset: Codable, Equatable, Sendable {
public var name: String
/// Opaque per-stage form values keyed by field id.
public var values: [String: String]
public init(name: String, values: [String: String] = [:]) {
self.name = name
self.values = values
}
}
/// Where `install_profile` drops finished profiles (docs/22).
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
case user
case system
}
/// `settings.json` model (docs/22). snake_case keys match the v1 file
/// so field names stay identical across rewrites.
///
/// Decoding is tolerant: missing keys take documented defaults and each
/// `custom_presets` element is tried as a typed `ProfilingPreset` first
/// and as a legacy M1 `CustomPreset` second a malformed entry never
/// drops the rest of the array (preset migration, issue #11).
public struct AppSettings: Codable, Equatable, Sendable {
/// User override for Argyll binaries; `nil` bundled sidecars.
public var argyllBinaryDir: String?
/// Stored but **never applied to argv** Stage 2's own instrument
/// select is the live source (docs/04 §0.1).
public var defaultInstrument: String?
/// `nil` `.debug` in debug builds, `.info` in release (#158).
public var logLevel: LogLevel?
public var deltaEGoodMax: Double
public var deltaEWarningMax: Double
public var customPresets: [ProfilingPreset]
public var enableI1Pro2Leds: Bool
public var calibrationStaleDays: Int
public var defaultInstallLocation: InstallLocation
public var askBeforeOverwriteProfile: Bool
public var openColorPanelAfterInstall: Bool
public init(
argyllBinaryDir: String? = nil,
defaultInstrument: String? = nil,
logLevel: LogLevel? = nil,
deltaEGoodMax: Double = 2.0,
deltaEWarningMax: Double = 5.0,
customPresets: [ProfilingPreset] = [],
enableI1Pro2Leds: Bool = false,
calibrationStaleDays: Int = 30,
defaultInstallLocation: InstallLocation = .user,
askBeforeOverwriteProfile: Bool = true,
openColorPanelAfterInstall: Bool = false
) {
self.argyllBinaryDir = argyllBinaryDir
self.defaultInstrument = defaultInstrument
self.logLevel = logLevel
self.deltaEGoodMax = deltaEGoodMax
self.deltaEWarningMax = deltaEWarningMax
self.customPresets = customPresets
self.enableI1Pro2Leds = enableI1Pro2Leds
self.calibrationStaleDays = calibrationStaleDays
self.defaultInstallLocation = defaultInstallLocation
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
self.openColorPanelAfterInstall = openColorPanelAfterInstall
}
public static let `default` = AppSettings()
/// Effective log level runtime state, not just persistence (#158).
public var effectiveLogLevel: LogLevel {
if let logLevel { return logLevel }
#if DEBUG
return .debug
#else
return .info
#endif
}
enum CodingKeys: String, CodingKey {
case argyllBinaryDir = "argyll_binary_dir"
case defaultInstrument = "default_instrument"
case logLevel = "log_level"
case deltaEGoodMax = "delta_e_good_max"
case deltaEWarningMax = "delta_e_warning_max"
case customPresets = "custom_presets"
case enableI1Pro2Leds = "enable_i1pro2_leds"
case calibrationStaleDays = "calibration_stale_days"
case defaultInstallLocation = "default_install_location"
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
case openColorPanelAfterInstall = "open_color_panel_after_install"
}
/// One element of `custom_presets`: typed first, legacy M1 second.
private enum AnyPreset: Decodable {
case typed(ProfilingPreset)
case legacy(CustomPreset)
init(from decoder: Decoder) throws {
if let p = try? ProfilingPreset(from: decoder) {
self = .typed(p)
return
}
self = .legacy(try CustomPreset(from: decoder))
}
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings.default
argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir
defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument
logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel
deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax
deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax
enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds
calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays
defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation
askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile
openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall
// Per-element decode: typed presets win; a legacy M1 shape
// ({"name","values"}) migrates; unconvertible entries are
// skipped so one bad record never drops the array.
let elements = (try? c.decodeIfPresent(
[FailableDecodable<AnyPreset>].self, forKey: .customPresets
)) ?? nil
var migrated: [ProfilingPreset] = []
for (index, element) in (elements ?? []).enumerated() {
switch element.value {
case .typed(let preset):
migrated.append(preset)
case .legacy(let legacy):
if let converted = ProfilingPreset(migrating: legacy, index: index) {
migrated.append(converted)
} else {
AppLogger(category: "settings").warn(
"Skipped unmigratable legacy preset: \(legacy.name)"
)
}
case .none:
AppLogger(category: "settings").warn(
"Skipped malformed preset entry at index \(index)"
)
}
}
customPresets = migrated
}
/// UI-facing validation. Strings are part of the contract (issue #5).
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
public static let errorThresholdOrder =
"Good ΔE threshold must be strictly less than the warning threshold."
/// All validation errors, in declaration order. Empty = valid.
public func validate() -> [String] {
var errors: [String] = []
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
errors.append(Self.errorNegativeDeltaE)
}
if deltaEGoodMax >= deltaEWarningMax {
errors.append(Self.errorThresholdOrder)
}
return errors
}
public var isValid: Bool { validate().isEmpty }
}
extension ProfilingPreset {
/// Migrates a legacy M1 `CustomPreset` (`name` + string values) to
/// the typed schema. Known keys are coerced; anything else is
/// ignored. Returns `nil` only when the name is unusable a
/// deterministic `custom-{index}-{slug}` id is always produced.
init?(migrating legacy: CustomPreset, index: Int) {
let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return nil }
let v = legacy.values
func int(_ key: String) -> Int? {
v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}
func double(_ key: String) -> Double? {
v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) }
}
func bool(_ key: String) -> Bool? {
v[key].flatMap { s in
switch s.trimmingCharacters(in: .whitespaces).lowercased() {
case "true", "1", "yes": return true
case "false", "0", "no": return false
default: return nil
}
}
}
func string(_ key: String) -> String? {
v[key].map { $0.trimmingCharacters(in: .whitespaces) }
.flatMap { $0.isEmpty ? nil : $0 }
}
let slug = trimmedName.lowercased()
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
.reduce(into: "") { $0.append($1) }
self.init(
id: "custom-\(index)-\(slug)",
name: trimmedName,
description: string("description") ?? "",
colourSpace: string("colour_space")?.lowercased() ?? "rgb",
patchCount: int("patch_count") ?? 800,
whitePatches: int("white_patches") ?? 4,
blackPatches: int("black_patches") ?? 4,
greySteps: int("grey_steps"),
singleChannelSteps: int("single_channel_steps"),
neutralSteps: int("neutral_steps"),
neutralConcentration: double("neutral_concentration"),
preconditioningProfile: string("preconditioning_profile"),
ofpsHighQuality: bool("ofps_high_quality"),
ofpsAdaptation: double("ofps_adaptation"),
fullSpreadAlgorithm: string("full_spread_algorithm"),
totalInkLimit: int("total_ink_limit"),
darkEmphasis: double("dark_emphasis"),
devicePower: double("device_power"),
instrument: string("instrument") ?? "i1",
pageSize: string("page_size") ?? "A4",
bitDepth: int("bit_depth") ?? 8,
dpi: int("dpi") ?? 300,
randomSeed: int("random_seed"),
noRandomize: bool("no_randomize"),
calibrationFile: string("calibration_file"),
applyCalibration: bool("apply_calibration"),
colprofAlgorithm: string("colprof_algorithm"),
colprofQuality: string("colprof_quality"),
colprofIntent: string("colprof_intent"),
colprofFwa: string("colprof_fwa"),
colprofIlluminant: string("colprof_illuminant"),
colprofObserver: string("colprof_observer"),
colprofInputViewingCond: string("colprof_input_viewing_cond"),
colprofOutputViewingCond: string("colprof_output_viewing_cond")
)
}
}
@@ -0,0 +1,118 @@
import Foundation
/// Built-in presets shipped with the app (docs/22 §Built-in presets).
/// All four: instrument `i1`, FWA `D50`, random seed `1`,
/// `no_randomize == false`, colprof algorithm `l`.
///
/// Built-ins cannot be deleted; custom presets overlay by `id`.
public enum PresetCatalog {
/// `preset-std-rgb` Standard RGB Photo (800 patches).
public static let standardRGB = ProfilingPreset(
id: "preset-std-rgb",
name: "Standard RGB Photo (800 patches)",
description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.",
colourSpace: "rgb",
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "m",
colprofFwa: "D50"
)
/// `preset-hq-cmyk` High-Gamut CMYK Proofing (1500 patches).
public static let highQualityCMYK = ProfilingPreset(
id: "preset-hq-cmyk",
name: "High-Gamut CMYK Proofing (1500 patches)",
description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.",
colourSpace: "cmyk",
patchCount: 1500,
whitePatches: 4,
blackPatches: 8,
totalInkLimit: 320,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "h",
colprofFwa: "D50"
)
/// `preset-draft-rgb` Fast RGB Draft (400 patches, **150 dpi**).
public static let draftRGB = ProfilingPreset(
id: "preset-draft-rgb",
name: "Fast RGB Draft (400 patches)",
description: "Quick sanity check — 400 patches on A4 at 150 dpi.",
colourSpace: "rgb",
patchCount: 400,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 150,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "l",
colprofFwa: "D50"
)
/// `preset-ultra-rgb` Ultra Precision RGB (2500 patches, `-G`).
public static let ultraRGB = ProfilingPreset(
id: "preset-ultra-rgb",
name: "Ultra Precision RGB (2500 patches)",
description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.",
colourSpace: "rgb",
patchCount: 2500,
whitePatches: 6,
blackPatches: 6,
ofpsHighQuality: true,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "u",
colprofFwa: "D50"
)
public static let builtIns: [ProfilingPreset] = [
standardRGB, highQualityCMYK, draftRGB, ultraRGB,
]
public static let builtInIDs: Set<String> = Set(builtIns.map(\.id))
public static func isBuiltIn(_ id: String) -> Bool {
builtInIDs.contains(id)
}
/// Built-ins plus custom presets, with custom entries overlaying by
/// `id` (a custom preset with a built-in id replaces that entry in
/// place the built-in is still not deletable).
public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] {
var result = builtIns
var seen = builtInIDs
for custom in custom {
if let idx = result.firstIndex(where: { $0.id == custom.id }) {
result[idx] = custom
} else if !seen.contains(custom.id) {
result.append(custom)
seen.insert(custom.id)
}
}
return result
}
}
@@ -0,0 +1,98 @@
import Foundation
/// CRUD + import/export for profiling presets on top of `SettingsStore`
/// (docs/22 §Built-in presets, issue #11).
///
/// - `all()` = built-ins overlaid by custom presets (by `id`).
/// - Built-ins are never written to `settings.json` and cannot be
/// deleted or overwritten by `saveCustom` (a custom id that collides
/// with a built-in still overlays at read time, per spec).
/// - Import/export is single-preset JSON with schema validation.
/// - Imported names/descriptions are untrusted: callers must render
/// them with `Text`, never HTML (#114).
public final class PresetStore: Sendable {
public let settingsStore: SettingsStore
public init(settingsStore: SettingsStore = SettingsStore()) {
self.settingsStore = settingsStore
}
/// All presets: built-ins overlaid by customs, catalog order.
public func all() -> [ProfilingPreset] {
PresetCatalog.all(custom: settingsStore.load().customPresets)
}
/// Custom presets only, as persisted.
public func customs() -> [ProfilingPreset] {
settingsStore.load().customPresets
}
/// Insert or replace a custom preset (matched by `id`). Throws
/// `PresetStoreError.builtIn` when the id belongs to a built-in
/// built-ins are immutable. Validates before persisting.
public func saveCustom(_ preset: ProfilingPreset) throws {
let validated = try preset.validated()
guard !PresetCatalog.isBuiltIn(validated.id) else {
throw PresetStoreError.builtInImmutable(validated.id)
}
var settings = settingsStore.load()
if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) {
settings.customPresets[idx] = validated
} else {
settings.customPresets.append(validated)
}
try settingsStore.save(settings)
}
/// Deletes a custom preset by id. Returns false when the id is a
/// built-in (undeletable) or no custom preset with that id exists.
@discardableResult
public func deleteCustom(id: String) throws -> Bool {
guard !PresetCatalog.isBuiltIn(id) else { return false }
var settings = settingsStore.load()
let before = settings.customPresets.count
settings.customPresets.removeAll { $0.id == id }
guard settings.customPresets.count != before else { return false }
try settingsStore.save(settings)
return true
}
/// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
}
/// Parses + validates a preset from JSON. The preset is assigned a
/// fresh custom id when its id is empty or collides with a built-in.
/// Does **not** persist call `saveCustom` to keep it.
public func `import`(_ data: Data) throws -> ProfilingPreset {
let decoded: ProfilingPreset
do {
decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
} catch {
throw PresetStoreError.invalidJSON(error.localizedDescription)
}
var preset = try decoded.validated()
if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) {
preset.id = "custom-\(UUID().uuidString.lowercased())"
}
return preset
}
public enum PresetStoreError: LocalizedError, Equatable {
case builtInImmutable(String)
case invalidJSON(String)
public var errorDescription: String? {
switch self {
case .builtInImmutable(let id):
return "Built-in preset \"\(id)\" cannot be modified or deleted."
case .invalidJSON(let reason):
return "Not a valid preset file: \(reason)"
}
}
}
}
@@ -0,0 +1,272 @@
import Foundation
/// A profiling preset: a complete snapshot of the Stage 1/2 form plus
/// the Stage 4 fields that are stored now and applied in issue 23
/// (docs/22 §ProfilingPreset).
///
/// snake_case keys match the v1 JSON schema so import/export stays
/// compatible. Identity + Stage 1/2 core fields are required; every
/// other field is optional-defaulted. Unknown keys are ignored on
/// decode; missing required fields fail.
public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable {
// Identity
public var id: String
public var name: String
public var description: String
// Stage 1 (required core)
public var colourSpace: String // "rgb" | "cmyk"
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
// Stage 1 advanced (optional)
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
/// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I".
public var fullSpreadAlgorithm: String?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
// Stage 2 (required core)
public var instrument: String
public var pageSize: String
public var bitDepth: Int
public var dpi: Int
public var randomSeed: Int?
public var noRandomize: Bool?
// Stage 0 / 2 calibration
public var calibrationFile: String?
public var applyCalibration: Bool?
// Stage 4 (stored now, applied by issue 23)
public var colprofAlgorithm: String?
public var colprofQuality: String?
public var colprofIntent: String?
public var colprofFwa: String?
public var colprofIlluminant: String?
public var colprofObserver: String?
public var colprofInputViewingCond: String?
public var colprofOutputViewingCond: String?
public init(
id: String,
name: String,
description: String = "",
colourSpace: String = "rgb",
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: String? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
instrument: String = "i1",
pageSize: String = "A4",
bitDepth: Int = 8,
dpi: Int = 300,
randomSeed: Int? = 1,
noRandomize: Bool? = false,
calibrationFile: String? = nil,
applyCalibration: Bool? = nil,
colprofAlgorithm: String? = nil,
colprofQuality: String? = nil,
colprofIntent: String? = nil,
colprofFwa: String? = nil,
colprofIlluminant: String? = nil,
colprofObserver: String? = nil,
colprofInputViewingCond: String? = nil,
colprofOutputViewingCond: String? = nil
) {
self.id = id
self.name = name
self.description = description
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.instrument = instrument
self.pageSize = pageSize
self.bitDepth = bitDepth
self.dpi = dpi
self.randomSeed = randomSeed
self.noRandomize = noRandomize
self.calibrationFile = calibrationFile
self.applyCalibration = applyCalibration
self.colprofAlgorithm = colprofAlgorithm
self.colprofQuality = colprofQuality
self.colprofIntent = colprofIntent
self.colprofFwa = colprofFwa
self.colprofIlluminant = colprofIlluminant
self.colprofObserver = colprofObserver
self.colprofInputViewingCond = colprofInputViewingCond
self.colprofOutputViewingCond = colprofOutputViewingCond
}
enum CodingKeys: String, CodingKey {
case id, name, description
case colourSpace = "colour_space"
case patchCount = "patch_count"
case whitePatches = "white_patches"
case blackPatches = "black_patches"
case greySteps = "grey_steps"
case singleChannelSteps = "single_channel_steps"
case neutralSteps = "neutral_steps"
case neutralConcentration = "neutral_concentration"
case preconditioningProfile = "preconditioning_profile"
case ofpsHighQuality = "ofps_high_quality"
case ofpsAdaptation = "ofps_adaptation"
case fullSpreadAlgorithm = "full_spread_algorithm"
case totalInkLimit = "total_ink_limit"
case darkEmphasis = "dark_emphasis"
case devicePower = "device_power"
case instrument
case pageSize = "page_size"
case bitDepth = "bit_depth"
case dpi
case randomSeed = "random_seed"
case noRandomize = "no_randomize"
case calibrationFile = "calibration_file"
case applyCalibration = "apply_calibration"
case colprofAlgorithm = "colprof_algorithm"
case colprofQuality = "colprof_quality"
case colprofIntent = "colprof_intent"
case colprofFwa = "colprof_fwa"
case colprofIlluminant = "colprof_illuminant"
case colprofObserver = "colprof_observer"
case colprofInputViewingCond = "colprof_input_viewing_cond"
case colprofOutputViewingCond = "colprof_output_viewing_cond"
}
/// Strict decode: required identity + Stage 1/2 core fields must be
/// present; optionals default to nil. Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
patchCount = try c.decode(Int.self, forKey: .patchCount)
whitePatches = try c.decode(Int.self, forKey: .whitePatches)
blackPatches = try c.decode(Int.self, forKey: .blackPatches)
greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps)
singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps)
neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps)
neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration)
preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile)
ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality)
ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation)
fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm)
totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit)
darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis)
devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower)
instrument = try c.decode(String.self, forKey: .instrument)
pageSize = try c.decode(String.self, forKey: .pageSize)
bitDepth = try c.decode(Int.self, forKey: .bitDepth)
dpi = try c.decode(Int.self, forKey: .dpi)
randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed)
noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize)
calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration)
colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm)
colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality)
colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent)
colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa)
colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant)
colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver)
colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond)
colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond)
}
// MARK: - Validation (import path)
public enum ValidationError: LocalizedError, Equatable {
case emptyID
case emptyName
case invalidColourSpace(String)
case invalidPatchCount(Int)
case invalidBitDepth(Int)
case invalidDPI(Int)
case emptyPageSize
case emptyInstrument
public var errorDescription: String? {
switch self {
case .emptyID: return "Preset is missing an id."
case .emptyName: return "Preset is missing a name."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .invalidPatchCount(let v):
return "patch_count must be positive, got \(v)."
case .invalidBitDepth(let v):
return "bit_depth must be 8 or 16, got \(v)."
case .invalidDPI(let v):
return "dpi must be between 72 and 600, got \(v)."
case .emptyPageSize: return "page_size is empty."
case .emptyInstrument: return "instrument is empty."
}
}
}
/// Validates the required fields for import / catalog use.
/// `colourSpace` is normalized to lowercase before comparison.
@discardableResult
public func validated() throws -> ProfilingPreset {
var p = self
p.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
p.colourSpace = colourSpace.lowercased()
guard !p.id.isEmpty else { throw ValidationError.emptyID }
guard !p.name.isEmpty else { throw ValidationError.emptyName }
guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) }
guard p.bitDepth == 8 || p.bitDepth == 16 else {
throw ValidationError.invalidBitDepth(bitDepth)
}
guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) }
guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyPageSize
}
guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyInstrument
}
return p
}
}
/// Per-element non-throwing decode wrapper one malformed preset entry
/// must not drop the whole `custom_presets` array during migration.
struct FailableDecodable<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws {
value = try? T(from: decoder)
}
}
@@ -0,0 +1,47 @@
import Foundation
/// Persists `AppSettings` to
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
/// Posted on `NotificationCenter.default` after every successful save.
public static let settingsDidChange =
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
}
public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
}
/// Validates before persisting throws `SettingsError` listing
/// every violation; nothing is written on failure.
public func save(_ settings: AppSettings) throws {
let errors = settings.validate()
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
public enum SettingsError: Error, Equatable {
case validationFailed([String])
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
///
/// Navigation is *disk*, not buttons: a stage unlocks only when its
/// predecessor artefacts exist. Forward moves are gated; backward is
/// always allowed. Gating is re-evaluated on window focus and on stage
/// entry (#151 files can disappear in Finder).
public enum WizardGating {
/// Whether `stage` is reachable given the probed artefacts.
///
/// - Stage 0 (calibrate): always it is out-of-band, not gated.
/// - Stage 1: always.
/// - Stage 2: `.ti1` exists.
/// - Stage 3: `.ti1` **and** `.ti2`.
/// - Stage 4: `.ti3` exists (accepted measurement only a `.ti2`
/// alone never unlocks it; #109/#110).
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
public static func isUnlocked(
_ stage: WizardStage,
artefacts: StageArtefacts
) -> Bool {
switch stage {
case .calibrate: return true
case .generate: return true
case .layOutPrint: return artefacts.stage1Complete
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
case .buildProfile: return artefacts.stage3Complete
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
}
}
/// Whether `go(to:)` may proceed. Backward moves and the current
/// stage are always allowed; forward moves must be unlocked.
public static func canNavigate(
to target: WizardStage,
from current: WizardStage,
artefacts: StageArtefacts
) -> Bool {
if target == current { return true }
if target == .calibrate || current == .calibrate {
// Stage 0 is a side-trip, not stepper navigation.
return true
}
if target.rawValue < current.rawValue { return true }
return isUnlocked(target, artefacts: artefacts)
}
/// The deepest unlocked stepper stage used when revalidation
/// locks the current stage (#151).
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
for stage in WizardStage.stepperStages.reversed()
where isUnlocked(stage, artefacts: artefacts) {
return stage
}
return .generate
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The five wizard stages plus Stage 0 (calibration), matching the v1
/// `data-stage` contract in docs/21. Stepper buttons 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)
}
}
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# Mock chartread for bundled/manual testing.
# Supports handheld and XY modes. Writes basename.ti3 on 'd'.
MODE="${MOCK_CHARTREAD_MODE:-strip}"
BASENAME=""
# Basename is the last non-flag argument.
for arg in "$@"; do
case "$arg" in
-*) ;;
*) BASENAME="$arg" ;;
esac
done
read_input() {
IFS= read -r line || return 1
}
emit_row() {
printf 'ROW_COLORS_JSON: %s\n' "$1"
}
write_ti3() {
if [ -n "$BASENAME" ]; then
echo "MOCK_TI3" > "${BASENAME}.ti3"
fi
}
if [ "$MODE" = "xy" ]; then
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read_input
echo "Calibration successful."
echo "Please place sheet 1 of 1 on the table"
echo "hit return to continue, Esc or 'q' to give up"
read_input
echo "locate patch A1 with the sight,"
echo "then hit return to continue"
read_input
echo "Reading sheet 1..."
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 1, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
echo "Sheet 1 of 1 read OK"
echo "Please remove last sheet from table"
echo "'d' if/when done"
while read_input; do
case "$line" in
d*) write_ti3; exit 0 ;;
q*) exit 0 ;;
esac
done
exit 0
fi
# Handheld / strip mode (default)
echo "Place instrument on calibration tile and hit [Space] to calibrate."
read_input
echo "Calibration successful."
echo "Hit [Space] to read strip A"
read_input
echo "Reading strip A..."
emit_row '{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2, "patch_count": 3, "patches": [{"id": "1", "loc": "A1", "is_pad": false, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}}, {"id": "2", "loc": "A2", "is_pad": false, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}}, {"id": "3", "loc": "A3", "is_pad": true, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}}]}'
echo "Hit [Space] to read strip B"
read_input
echo "Reading strip B..."
emit_row '{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2, "patch_count": 2, "patches": [{"id": "4", "loc": "B1", "is_pad": false, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}}, {"id": "5", "loc": "B2", "is_pad": false, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}}]}'
echo "'d' if/when done"
while read_input; do
case "$line" in
d*) write_ti3; exit 0 ;;
q*) exit 0 ;;
esac
done
exit 0
+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>
+120
View File
@@ -0,0 +1,120 @@
import Foundation
import ICCeryCore
/// App dependency container (docs/02). Production builds resolve the
/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI
/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and
/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the
/// developer's settings, wizard state, or real Argyll install.
struct AppEnvironment: Sendable {
let stateStore: WizardStateStore
let settingsStore: SettingsStore
let presetStore: PresetStore
let runner: ArgyllRunner
let cupsService: CupsService
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> AppEnvironment {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
var cupsDir = URL(fileURLWithPath: "/usr/bin")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
settingsStore: settingsStore,
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir)
)
}
}
/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow
/// honours these env-provided paths instead of presenting modal panels
/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are
/// compiled out of release builds.
enum UITestHooks {
private static var env: [String: String] {
ProcessInfo.processInfo.environment
}
static var isEnabled: Bool {
#if DEBUG
return env["ICCERY_UI_TESTING"] == "1"
#else
return false
#endif
}
/// `select_target_file` result (Stage 1 save picker).
static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") }
/// `select_existing_target` result (`.ti1`/`.ti2` resume).
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
// MARK: - Print panel / CUPS stubs (issue 13/17)
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts
/// `CupsService.binaryDir` under UI tests.
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
/// Path the mock `lp` script appends its argv to, for assertions.
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
/// Whether the `NSPrintPanel` should be stubbed under UI testing
/// separate from the stub's *result* so "cancel" (`nil`) does not
/// fall through to the real modal.
static var printPanelStubbed: Bool { isEnabled }
/// Canned `NSPrintPanel` outcome XCUITest cannot drive the
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
/// - `cancel` (or unset while testing) user cancelled `nil`
/// - `ok` `PrintPropertiesResult` with
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for).
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0
}
return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0
} ?? queue,
options: PrintOptions(
mediaType: options.flatMap {
CupsParsers.extractMediaType(fromOptionsString: $0)
},
ppdUncorrectedPassthrough: true,
cupsOptions: options))
default:
return nil
}
}
private static func url(_ key: String) -> URL? {
guard let raw = env[key], !raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw)
}
}
+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
}
}
@@ -0,0 +1,458 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// A single evaluated swatch for the live grid.
struct Swatch: Sendable, Equatable, Identifiable {
let rowId: String
let loc: String
let isPad: Bool
let intended: DisplayRGB
let measured: DisplayRGB
let deltaE: Double?
let classification: SwatchClassification
var id: String { "\(rowId)\(loc)" }
}
/// A row of swatches in display order.
struct SwatchRow: Sendable, Equatable, Identifiable {
let index: Int
let rowId: String
let patches: [Swatch]
var id: String { rowId }
}
/// Stage of the XY-table badge bar.
enum XYStep: Equatable, Sendable {
case place, align, scan, remove
}
/// Stage 3 workflow state and interaction (issues #18#22).
@MainActor
@Observable
final class MeasurementWorkflowViewModel {
// MARK: - Authorities
let wizard: WizardViewModel
let environment: AppEnvironment
// MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
// MARK: - Chartread session
var isChartreadRunning = false
var chartreadState: ChartreadState = .idle
var currentPrompt: String?
var requestedWarningKey: String?
var chartreadLog: [String] = []
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: String?
var finishNoticeIsError = false
var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
loadSettings()
discoverPassSnapshots()
}
// MARK: - Derived state
var basename: String { wizard.basename }
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
var canDetect: Bool { !isDetecting }
var canStartRead: Bool {
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
}
var canMeasureAnotherSheet: Bool {
isFinished && !passSnapshots.isEmpty
}
var canFinish: Bool {
isFinished && !passSnapshots.isEmpty
}
var isFinished: Bool {
chartreadState == .allStripsRead || chartreadState == .finished
}
var xyStep: XYStep {
if showRemoveSheetNotice { return .remove }
switch chartreadState {
case .tablePlaceSheet:
return .place
case .tableAlign:
return .align
case .reading, .awaitingStrip:
return .scan
default:
return .place
}
}
var hasCanonicalTi3: Bool {
guard let cwd = workingDirectory else { return false }
let url = cwd.appendingPathComponent("\(basename).ti3")
return FileManager.default.fileExists(atPath: url.path)
}
// MARK: - Settings
func loadSettings() {
let settings = environment.settingsStore.load()
goodMax = settings.deltaEGoodMax
warningMax = settings.deltaEWarningMax
enableLEDs = settings.enableI1Pro2Leds
}
// MARK: - Instrument detection
func detectInstruments() {
guard !isDetecting else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
if case .device(let selected) = self.selectedInstrument,
!devices.contains(where: { $0.port == selected.port }) {
self.selectedInstrument = .auto
}
} catch {
self.detectionError = error.localizedDescription
}
self.isDetecting = false
}
}
// MARK: - Chartread lifecycle
func startRead() {
guard canStartRead, let cwd = workingDirectory else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
func measureAnotherSheet() {
guard let cwd = workingDirectory, isFinished else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
ChartreadConfig(
basename: basename,
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: enableLEDs,
isXY: selectedInstrument.isXY
)
}
private func startChartread(config: ChartreadConfig) {
guard !isChartreadRunning else { return }
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
lastError = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
if passSnapshots.isEmpty {
rows.removeAll()
swatchRows.removeAll()
}
let stream = environment.runner.runChartread(config: config)
chartreadTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isChartreadRunning = false
}
}
private func handle(event: ChartreadEvent) {
switch event {
case .prompt(let result):
chartreadState = result.state
currentPrompt = promptText(for: result)
requestedWarningKey = result.requestedWarningKey
showRemoveSheetNotice = result.isRemoveSheetNotice
case .row(let row):
upsert(row: row)
if row.isFinalRow {
chartreadState = .allStripsRead
}
case .log(let batch):
chartreadLog.append(contentsOf: batch)
case .removeSheetNotice:
showRemoveSheetNotice = true
case .exit(let code):
if code != 0 {
lastError = "chartread exited with code \(code)"
}
case .completed(let canonicalURL):
chartreadState = .finished
completePass(canonicalURL: canonicalURL)
case .failed(let error):
lastError = error.localizedDescription
chartreadState = .error
isChartreadRunning = false
}
}
private func promptText(for result: ChartreadClassifyResult) -> String {
switch result.state {
case .calibrating:
return "Place instrument on calibration tile and press Calibrate."
case .awaitingStrip:
return "Press a key to read the next strip."
case .allStripsRead:
return "All strips read. Press Done & Save when ready."
case .warning:
if let key = result.requestedWarningKey {
return "Warning — press '\(key.uppercased())' to continue."
}
return "Warning — press Continue."
case .promptContinue:
return "Press Continue."
case .tablePlaceSheet:
if let n = result.sheetNumber, let t = result.sheetTotal {
return "Place sheet \(n) of \(t) on the table."
}
return "Place the sheet on the table."
case .tableAlign:
if let patch = result.alignmentPatch {
return "Locate patch \(patch) with the sight, then continue."
}
return "Align the fiducial, then continue."
case .reading:
return "Reading..."
case .error:
return "Read error — you can Retry or Cancel."
case .finished:
return "Measurement saved."
case .idle:
return "Press Start to begin reading."
}
}
// MARK: - User actions
func calibrate() {
send(.trigger)
}
func accept() {
if let key = requestedWarningKey {
send(.customKey(key))
requestedWarningKey = nil
} else {
send(.accept)
}
}
func retry() {
send(.trigger)
}
func doneAndSave() {
send(.done)
}
func cancelRead() {
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
chartreadTask?.cancel()
isChartreadRunning = false
}
func sendWarningKey(_ key: String) {
send(.customKey(key))
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isChartreadRunning else { return }
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
}
}
// MARK: - Rows and swatches
private func upsert(row: ChartreadRow) {
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
rows[index] = row
} else {
rows.append(row)
}
rows.sort { $0.rowIndex < $1.rowIndex }
recomputeSwatches()
}
func recomputeSwatches() {
var displayRows: [SwatchRow] = []
for (rowIndex, row) in rows.enumerated() {
var swatches: [Swatch] = []
for patch in row.patches {
let skip = shouldSkipPad(patch)
if skip { continue }
let eval = ColorDifference.evaluate(
patch: patch,
goodMax: goodMax,
warningMax: warningMax
)
if let eval {
swatches.append(Swatch(
rowId: row.rowId,
loc: patch.loc,
isPad: patch.isPad,
intended: eval.intended,
measured: eval.measured,
deltaE: eval.deltaE,
classification: eval.classification
))
}
}
if !swatches.isEmpty {
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
}
}
swatchRows = displayRows
}
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
guard patch.isPad else { return false }
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
return measuredEmpty && deviceAllZero
}
// MARK: - Pass management
private func completePass(canonicalURL: URL) {
guard let cwd = workingDirectory else { return }
do {
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
discoverPassSnapshots()
wizard.refreshGating()
} catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)"
}
}
func discoverPassSnapshots() {
guard let cwd = workingDirectory else {
passSnapshots = []
return
}
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
}
// MARK: - Finish / Average
func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
finishNoticeIsError = false
Task { @MainActor [weak self] in
guard let self else { return }
do {
let canonical: URL
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
} else {
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
canonical = try await self.environment.runner.runAverage(
config: config,
onLogBatch: { [weak self] batch in
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
)
}
self.discoverPassSnapshots()
self.wizard.refreshGating()
if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile)
} else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
}
} catch {
// Fallback to pass 1 promotion if averaging failed.
if let pass = self.passSnapshots.first {
do {
_ = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
self.discoverPassSnapshots()
self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass."
self.finishNoticeIsError = true
} catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
} else {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
}
self.isFinishing = false
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import SwiftUI
/// Banner notice model the v2 equivalent of `#wizardNotification`
/// (docs/21 §Banner).
struct Notice: Identifiable, Equatable {
enum Kind: Equatable {
case info, warning, error
var symbolName: String {
switch self {
case .info: return "info.circle"
case .warning: return "exclamationmark.triangle"
case .error: return "xmark.octagon"
}
}
var tint: Color {
switch self {
case .info: return Theme.accent
case .warning: return .orange
case .error: return .red
}
}
}
let id = UUID()
let kind: Kind
let text: String
/// Auto-dismiss interval; `nil` keeps the banner until closed.
var autoHideAfter: TimeInterval? = 6
}
struct NoticeBanner: View {
let notice: Notice
let onClose: () -> Void
var body: some View {
HStack(spacing: 10) {
Image(systemName: notice.kind.symbolName)
.foregroundStyle(notice.kind.tint)
Text(notice.text)
.font(.callout)
.foregroundStyle(Theme.text)
.lineLimit(3)
.accessibilityIdentifier("noticeText")
.accessibilityValue(notice.text)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.panel)
.overlay(
Rectangle()
.frame(height: 1)
.foregroundStyle(Theme.border),
alignment: .bottom
)
}
}
+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")
}
}
@@ -0,0 +1,171 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`.
/// The second argument is the mode string never integer `1` (#188).
typealias ColorMatchingModeFunction =
@convention(c) (PMPrintSession, CFString) -> OSStatus
/// `PMPrintSettingsToOptions` public symbol, resolved via dlsym so a
/// missing SDK declaration can't break the build.
typealias PrintSettingsToOptionsFunction =
@convention(c) (PMPrintSettings, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) -> OSStatus
/// The six-layer unmanaged-printing engine (issue 14, docs/11):
///
/// session binding done by `PrintPanelService` before calling us.
/// private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}`
/// resolved by `dlsym(RTLD_DEFAULT,)`; first `(symbol, mode)`
/// returning `0` wins.
/// `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and
/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked.
/// driver "no colour adjustment" pre-select from `lpoptions -l`
/// keys, unlocked (`detectDriverColorBypass`).
/// mirror + into `NSPrintInfo.printSettings` so the PDE sees them.
/// after "Use Settings": `PMPrintSettingsToOptions`
/// `CupsOptionsFilter` captured `cupsOptions` + `mediaType`.
///
/// All layers degrade gracefully a missing symbol or non-zero status
/// is logged and the next layer still runs.
@MainActor
struct ColorSyncSuppressor {
/// Injected for tests: symbol function. Default resolves via
/// `dlsym(RTLD_DEFAULT, )`.
typealias ModeResolver = (String) -> ColorMatchingModeFunction?
typealias OptionsResolver = () -> PrintSettingsToOptionsFunction?
var modeResolver: ModeResolver = Self.dlsymMode
var optionsResolver: OptionsResolver = Self.dlsymOptions
var log: (String) -> Void = { AppLogger.shared.log(.info, $0) }
// MARK: - Layer SPI
/// Walk `ColorMatchingAttempts.attempts` (Lock plain NoLock ×
/// `AP_ApplicationColorMatching` `ApplicationColorMatching`); the
/// first call returning `0` wins. `false` when nothing worked.
@discardableResult
func applySPIMode(to session: PMPrintSession) -> Bool {
for attempt in ColorMatchingAttempts.attempts {
guard let function = modeResolver(attempt.symbol) else {
continue
}
let status = function(session, attempt.mode as CFString)
if status == 0 {
log("ColorSync: \(attempt.symbol) accepted "
+ "\(attempt.mode)")
return true
}
}
log("ColorSync: no PMSessionSetColorMatchingMode* accepted a "
+ "mode — falling back to PMPrintSettingsSetValue")
return false
}
// MARK: - Layer locked AP_* keys
/// `PMPrintSettingsSetValue` both key spellings, locked.
@discardableResult
func applyLockedKeys(to settings: PMPrintSettings) -> Int {
var applied = 0
for key in ColorMatchingAttempts.printSettingsKeys {
let status = PMPrintSettingsSetValue(
settings,
key as CFString,
ColorMatchingAttempts.applicationMatchingValue as CFString,
true)
if status == 0 { applied += 1 }
}
if applied == 0 {
log("ColorSync: PMPrintSettingsSetValue could not lock "
+ "AP_ColorMatchingMode")
}
return applied
}
// MARK: - Layer driver bypass
/// Pre-select the driver "no colour adjustment" option, unlocked
/// the PDE may override it. Returns the `(key, value)` applied.
@discardableResult
func applyDriverBypass(
to settings: PMPrintSettings,
optionKeys: Set<String>
) -> (key: String, value: String)? {
guard let bypass = CupsParsers.detectDriverColorBypass(
optionKeys: optionKeys)
else { return nil }
let status = PMPrintSettingsSetValue(
settings,
bypass.key as CFString,
bypass.value as CFString,
false)
if status != 0 {
log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) "
+ "rejected (\(status))")
return nil
}
return bypass
}
// MARK: - Layer NSPrintInfo mirror
/// Mirror the applied keys into `printSettings` so the PDE pick
/// sees them.
func mirror(
into printInfo: NSPrintInfo,
driverBypass: (key: String, value: String)?
) {
let settings = printInfo.printSettings
for key in ColorMatchingAttempts.printSettingsKeys {
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
}
if let driverBypass {
settings[driverBypass.key as NSString] = driverBypass.value as NSString
}
}
// MARK: - Layer capture
/// `PMPrintSettingsToOptions` filter `(cupsOptions, mediaType)`.
/// The malloc'd C string is freed after copying.
func captureOptions(
from settings: PMPrintSettings
) -> (cupsOptions: String?, mediaType: String?) {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return (nil, nil)
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered)
return (
filtered.isEmpty ? nil : filtered,
CupsParsers.extractMediaType(fromOptionsString: unfiltered)
)
}
// MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
guard let symbol = dlsym(Self.rtldDefault, name) else { return nil }
return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self)
}
private static func dlsymOptions() -> PrintSettingsToOptionsFunction? {
guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions")
else { return nil }
return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self)
}
/// `RTLD_DEFAULT` `UnsafeMutableRawPointer(bitPattern: -2)`.
private static var rtldDefault: UnsafeMutableRawPointer? {
UnsafeMutableRawPointer(bitPattern: -2)
}
}
@@ -0,0 +1,186 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// Errors raised while preparing the bound print panel.
enum PrintPanelError: LocalizedError {
case sessionBindingFailed(OSStatus)
case noPrinterFound(String)
var errorDescription: String? {
switch self {
case .sessionBindingFailed(let status):
return "Could not bind the print session to the queue (OSStatus \(status))."
case .noPrinterFound(let name):
return "No printer found for '\(name)'."
}
}
}
/// Preferences native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11).
///
/// This is a **settings-capture** dialog the default button is
/// "Use Settings", never "Print". It is never System Settings, the
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
/// and is not an error.
///
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)`
/// `PMSessionSetCurrentPMPrinter` session default settings/page
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
/// label) `printInfo.printer`.
@MainActor
struct PrintPanelService {
/// The suppression engine injectable for tests.
var suppressor = ColorSyncSuppressor()
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
/// the modal panel. Returns `nil` when the user cancels.
func showProperties(
queue: String,
displayName: String?,
cupsService: CupsService
) async throws -> PrintPropertiesResult? {
#if DEBUG
if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue)
}
#endif
// `??` rhs is a non-async @autoclosure fetch first.
let fetched = try? await cupsService.displayName(for: queue)
let display = displayName ?? fetched
// Layer needs the queue's option keys (lpoptions -l) to pick
// the driver colour-bypass before the panel opens.
let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? []
return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys)
}
// MARK: - Panel
private func runNativePanel(
queue: String,
displayName: String?,
optionKeys: Set<String>
) throws -> PrintPropertiesResult? {
let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// Bind the session to the selected CUPS queue (docs/11).
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
pmPrinter = printer
let session = unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self)
let settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let pageFormat = unsafeBitCast(
printInfo.pmPageFormat(), to: PMPageFormat.self)
let status = PMSessionSetCurrentPMPrinter(session, printer)
if status != 0 {
PMRelease(Self.pmObject(printer))
throw PrintPanelError.sessionBindingFailed(status)
}
// Warn-only: defaults keep the panel consistent with the
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings)
_ = PMSessionDefaultPageFormat(session, pageFormat)
boundViaPM = true
} else {
// Fallback: NSPrinter by display name (docs/11 §binding).
guard let displayName,
let nsPrinter = NSPrinter(name: displayName)
else {
throw PrintPanelError.noPrinterFound(
displayName ?? queue)
}
printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues()
}
defer {
if let printer = pmPrinter {
PMRelease(Self.pmObject(printer))
}
}
// ColourSync suppression only on the PM path: the SPI
// and PMPrintSettingsSetValue need a session with a current
// printer to attach to.
var settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self)
suppressor.applySPIMode(to: session) //
suppressor.applyLockedKeys(to: settings) //
driverBypass = suppressor.applyDriverBypass( //
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) //
}
let panel = NSPrintPanel()
panel.options = [
.showsCopies, .showsPageRange, .showsPaperSize,
.showsOrientation, .showsScaling, .showsPrintSelection,
.showsPageSetupAccessory, .showsPreview,
]
panel.setDefaultButtonTitle("Use Settings")
let response = panel.runModal(with: printInfo)
guard response == NSApplication.ModalResponse.OK.rawValue else {
return nil
}
// Capture the user's choices filtered replay options plus
// the media type they picked. Re-fetch the settings handle so
// we read back what the modal wrote.
var cupsOptions: String?
var mediaType: String?
if boundViaPM {
settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let captured = suppressor.captureOptions(from: settings)
cupsOptions = captured.cupsOptions
mediaType = captured.mediaType
}
return PrintPropertiesResult(
selectedPrinter: boundViaPM
? Self.currentPrinterID(
session: unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self),
fallback: queue)
: nil,
options: PrintOptions(
mediaType: mediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: cupsOptions))
}
// MARK: - PM helpers
/// `PMPrinter` `PMObject` for `PMRelease` the Carbon API wants
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
static func pmObject(_ printer: PMPrinter) -> PMObject {
unsafeBitCast(printer, to: PMObject.self)
}
/// `PMSessionGetCurrentPrinter` `PMPrinterGetID` String.
private static func currentPrinterID(
session: PMPrintSession,
fallback: String
) -> String {
var current: PMPrinter?
guard PMSessionGetCurrentPrinter(session, &current) == 0,
let printer = current
else { return fallback }
defer { PMRelease(pmObject(printer)) }
guard let id = PMPrinterGetID(printer)
else { return fallback }
return id.takeUnretainedValue() as String
}
}
+71
View File
@@ -0,0 +1,71 @@
import AppKit
import SwiftUI
import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
private var model: WizardViewModel { workflow.wizard }
var body: some View {
HStack(spacing: 0) {
SidebarView(
workflow: workflow,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true }
)
Rectangle()
.fill(Theme.border)
.frame(width: 1)
VStack(spacing: 0) {
if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice)
}
stageContent
}
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
// #151: re-probe artefacts when the window regains focus
// files deleted in Finder must re-lock stages.
.onReceive(
NotificationCenter.default.publisher(
for: NSWindow.didBecomeKeyNotification
)
) { _ in model.windowDidBecomeKey() }
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $workflow.showingSavePreset) {
SavePresetDialog(workflow: workflow)
}
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
.alert("ICCery 2.0.0", isPresented: $showingAbout) {
Button("OK") {}
} message: {
Text("Native macOS printer profiling workstation.\nFull About dialog lands in issue #31.")
}
}
@ViewBuilder
private var stageContent: some View {
switch model.stage {
case .generate:
Stage1View(workflow: workflow)
case .layOutPrint:
Stage2View(workflow: workflow)
case .measure:
Stage3View(model: workflow.measurement)
default:
StagePlaceholderView(stage: model.stage)
}
}
}
+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")
}
}
+377
View File
@@ -0,0 +1,377 @@
import SwiftUI
import ICCeryCore
/// Stage 2 `#stage-2` Lay Out & Print (`printtarg` `.ti2` + TIFFs,
/// issues #9/#10, docs/09). Print controls are visible but inert
/// real spooling lands in M3.
struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
cmWarning
formSection
labelSection
actionRow
logSection
gallerySection
printPanel
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Theme.background)
.accessibilityIdentifier("stage-2")
}
// MARK: - Colour-management warning (#cmWarningBanner)
private var cmWarning: some View {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Set your printer driver to “No Colour Adjustment” " +
"(Epson) / “Off (No Colour Adjustment)” (Canon) before printing. " +
"Any driver colour management corrupts the target.")
.font(.callout)
.foregroundStyle(Theme.text)
Spacer()
}
.padding(10)
.background(Color.orange.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.overlay(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.stroke(Color.orange.opacity(0.4))
)
.accessibilityIdentifier("cmWarningBanner")
}
// MARK: - Layout form
private var formSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 16) {
Picker("Instrument", selection: $workflow.instrument) {
ForEach(PrintInstrument.allCases, id: \.self) {
Text($0.displayName).tag($0)
}
}
.accessibilityIdentifier("instrumentSelect")
Picker("Page size", selection: $workflow.pageSize) {
ForEach(PageSize.allCases, id: \.self) {
Text($0.rawValue).tag($0)
}
}
.accessibilityIdentifier("pageSizeSelect")
}
if workflow.pageSize == .custom {
HStack(spacing: 8) {
Text("Custom size (mm):")
TextField("W", value: $workflow.customPageW, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 70)
.accessibilityIdentifier("customPageW")
Text("×")
TextField("H", value: $workflow.customPageH, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 70)
.accessibilityIdentifier("customPageH")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("customPageSizeRow")
}
HStack(spacing: 16) {
Picker("Bit depth", selection: $workflow.bitDepth) {
Text("8-bit TIFF").tag(TiffBitDepth.eight)
Text("16-bit TIFF").tag(TiffBitDepth.sixteen)
}
Stepper("DPI: \(workflow.tiffDpi)",
value: $workflow.tiffDpi, in: 72...600, step: 1)
.accessibilityIdentifier("tiffDpi")
}
HStack(spacing: 16) {
Picker("Layout order", selection: $workflow.layoutOrder) {
ForEach(LayoutOrder.allCases, id: \.self) {
Text($0.displayName).tag($0)
}
}
.accessibilityIdentifier("printtargLayoutOrder")
if workflow.layoutOrder == .customSeed {
HStack {
Text("Seed:")
TextField("", value: $workflow.customSeed, format: .number)
.textFieldStyle(.roundedBorder).frame(width: 80)
.accessibilityIdentifier("printtargCustomSeed")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargCustomSeedGroup")
}
}
}
.foregroundStyle(Theme.text)
}
// MARK: - Label (#btnToggleLabelEdit / #targetLabelPreview)
private var labelSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Chart label").font(.headline).foregroundStyle(Theme.text)
Spacer()
Button(workflow.labelIsCustom ? "Use automatic label" : "Edit label…") {
workflow.labelIsCustom.toggle()
}
.accessibilityIdentifier("btnToggleLabelEdit")
}
HStack(spacing: 12) {
TextField("Printer", text: $workflow.metaPrinter)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataPrinter")
TextField("Ink set", text: $workflow.metaInkSet)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataInkSet")
}
HStack(spacing: 12) {
TextField("Driver paper", text: $workflow.metaDriverPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataDriverPaper")
TextField("Actual paper", text: $workflow.metaActualPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetMetadataActualPaper")
}
if workflow.labelIsCustom {
TextField("Custom label", text: $workflow.customLabel)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("targetLabelPreview")
} else {
Text(workflow.automaticLabel)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("targetLabelPreview")
}
}
}
// MARK: - Actions + log
private var actionRow: some View {
HStack {
Button(action: workflow.createLayout) {
Label(workflow.printtargRunning ? "Creating layout…" : "Create Layout",
systemImage: "rectangle.grid.2x2")
}
.controlSize(.large)
.disabled(workflow.printtargRunning || workflow.wizard.basename.isEmpty)
.accessibilityIdentifier("btnCreateLayout")
if workflow.printtargRunning {
ProgressView().controlSize(.small)
}
Spacer()
}
}
private var logSection: some View {
DisclosureGroup("Process log") {
ScrollView {
Text(workflow.printtargLog.joined(separator: "\n"))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Theme.text)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(minHeight: 100, maxHeight: 180)
.accessibilityIdentifier("printtargLog")
}
.foregroundStyle(Theme.text)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("printtargLogContainer")
}
// MARK: - TIFF gallery (#tiffGallery) host-side PNG only (#58)
@ViewBuilder
private var gallerySection: some View {
if let result = workflow.printtargResult {
VStack(alignment: .leading, spacing: 8) {
Text("Target pages — \(result.manifest.pages.count) page(s), " +
"\(result.manifest.pages.reduce(0) { $0 + $1.patches }) patches")
.font(.headline).foregroundStyle(Theme.text)
.accessibilityIdentifier("galleryInfo")
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 220))],
spacing: 12
) {
ForEach(result.pages) { page in
GalleryPageView(page: page, workflow: workflow)
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("galleryGrid")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("tiffGallery")
}
}
// MARK: - Raw print panel (#rawPrintPanel) unmanaged lp path
private var printPanel: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 12) {
Text("Print").font(.headline).foregroundStyle(Theme.text)
if let notice = workflow.printNotice {
Image(systemName: workflow.printNoticeIsError
? "xmark.circle.fill" : "info.circle.fill")
.foregroundStyle(workflow.printNoticeIsError
? .red : .blue)
.accessibilityIdentifier("printNotificationIcon")
Text(notice)
.font(.caption)
.foregroundStyle(workflow.printNoticeIsError
? .red : .secondary)
.accessibilityIdentifier("printNotificationText")
.accessibilityValue(notice)
}
Spacer()
}
// Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) {
Picker("Printer", selection: $workflow.selectedPrinter) {
ForEach(workflow.printers, id: \.name) { printer in
Text(printer.displayName ?? printer.name)
.tag(printer.name)
}
}
.frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect")
.onChange(of: workflow.selectedPrinter) { _, _ in
workflow.selectedTray = nil
workflow.selectedMediaType = nil
Task { @MainActor in await workflow.reloadSelectedCapabilities() }
}
if let selected = workflow.printers
.first(where: { $0.name == workflow.selectedPrinter }) {
Text(selected.status.rawValue)
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Theme.background)
.clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge")
}
Button(action: workflow.refreshPrinters) {
Image(systemName: "arrow.clockwise")
}
.help("Refresh printer list")
.accessibilityIdentifier("btnRefreshPrinters")
Button(action: workflow.openPrinterPreferences) {
Image(systemName: "gearshape")
}
.help("Printer properties — bound NSPrintPanel")
.disabled(workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrinterProperties")
}
// Tray / media / orientation from queue capabilities.
HStack(spacing: 14) {
if !workflow.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.selectedTray) {
ForEach(workflow.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerTraySelect")
}
if !workflow.printerCaps.mediaTypes.isEmpty {
Picker("Media", selection: $workflow.selectedMediaType) {
ForEach(workflow.printerCaps.mediaTypes, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
.frame(maxWidth: 240)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("mediaTypeGroup")
.accessibilityIdentifier("printerMediaTypeSelect")
}
HStack(spacing: 0) {
Button("Portrait") { workflow.printOrientation = "portrait" }
.buttonStyle(.bordered)
.tint(workflow.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
}
Spacer()
}
HStack(spacing: 8) {
Button(action: workflow.printAllPages) {
Label(workflow.isPrinting ? "Printing…" : "Print All",
systemImage: "printer")
}
.controlSize(.large)
.disabled(workflow.isPrinting
|| workflow.printtargResult == nil
|| workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll")
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
.accessibilityIdentifier("btnAdvanceToStage3")
.disabled(workflow.printtargResult == nil
|| !workflow.wizard.isUnlocked(.measure))
}
}
.padding(12)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2).
if workflow.printers.isEmpty, workflow.printtargResult != nil {
workflow.refreshPrinters()
}
}
}
}
/// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View {
let page: GalleryPage
let workflow: TargetWorkflowViewModel
var body: some View {
VStack(spacing: 6) {
if let png = page.previewPNG, let image = NSImage(data: png) {
Image(nsImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 240)
} else {
ZStack {
Rectangle().fill(Theme.panel).frame(height: 160)
Text(page.previewError ?? "No preview")
.font(.caption).foregroundStyle(.secondary)
}
}
Text(page.page.filename)
.font(.caption).foregroundStyle(Theme.text)
Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.printPage(page) }
.disabled(workflow.isPrinting
|| workflow.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
}
.padding(8)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("galleryPage-\(page.index)")
}
}
+392
View File
@@ -0,0 +1,392 @@
import Combine
import SwiftUI
import ICCeryCore
/// Stage 3 measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
instrumentSection
chartreadControlsSection
xyTableSection
swatchGridSection
averagingSection
}
.padding(20)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Theme.background)
.onAppear { model.discoverPassSnapshots() }
.onReceive(NotificationCenter.default.publisher(for: SettingsStore.settingsDidChange)) { _ in
model.loadSettings()
model.recomputeSwatches()
}
}
// MARK: - Header
@ViewBuilder
private var header: some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 4) {
if model.resumedFromTi2 {
Label("Resumed from .ti2", systemImage: "arrow.uturn.right")
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("stage3LoadedTargetBanner")
}
Text(model.basename)
.font(.title3)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBasename")
Text("Measure the printed chart with a spectrophotometer.")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("stage3TargetMeta")
}
Spacer()
if model.isChartreadRunning {
ProgressView()
.scaleEffect(0.8)
.accessibilityIdentifier("readProgress")
}
if let badge = targetBadge {
Text(badge)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("stage3TargetBadge")
}
}
.padding(16)
.background(Theme.panel)
}
private var targetBadge: String? {
if model.isFinished { return "All strips read" }
if model.isChartreadRunning { return "Reading" }
if !model.passSnapshots.isEmpty { return "\(model.passSnapshots.count) pass(es)" }
return nil
}
// MARK: - Instrument detection
@ViewBuilder
private var instrumentSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnDetectInstruments")
}
if model.detectionError != nil {
Text(model.detectionError ?? "")
.font(.caption)
.foregroundStyle(.red)
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("chartreadInstrumentSelect")
if model.selectedInstrument.isXY {
Text("XY table workflow selected.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("xyTableHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Chartread controls
@ViewBuilder
private var chartreadControlsSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.currentPrompt ?? "Press Start to begin reading.")
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadPrompt")
}
if model.showRemoveSheetNotice {
Text("Please remove last sheet from table.")
.font(.caption)
.foregroundStyle(Theme.accent)
}
if let lastError = model.lastError {
Text(lastError)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("chartreadLastError")
.accessibilityValue(lastError)
}
controlButtons
if !model.chartreadLog.isEmpty {
DisclosureGroup("Log") {
VStack(alignment: .leading) {
ForEach(model.chartreadLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
}
}
.foregroundStyle(Theme.text)
.accessibilityIdentifier("chartreadLogContainer")
}
}
.padding(16)
.background(Theme.panel)
}
@ViewBuilder
private var controlButtons: some View {
HStack(spacing: 12) {
if !model.isChartreadRunning {
Button("Start Read") {
model.startRead()
}
.disabled(!model.canStartRead)
.accessibilityIdentifier("btnStartRead")
}
if model.isChartreadRunning {
switch model.chartreadState {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate")
case .awaitingStrip:
Button("Trigger") { model.calibrate() }
.accessibilityIdentifier("btnCalibrate")
case .tablePlaceSheet, .tableAlign, .promptContinue, .warning:
Button(continueTitle) { model.accept() }
.accessibilityIdentifier("btnAccept")
case .error:
Button("Retry") { model.retry() }
.accessibilityIdentifier("btnRetry")
case .allStripsRead:
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneRead")
default:
EmptyView()
}
if model.chartreadState == .awaitingStrip || model.chartreadState == .allStripsRead {
Button("Done & Save") { model.doneAndSave() }
.accessibilityIdentifier("btnDoneRead")
}
if model.chartreadState == .error {
Button("Retry") { model.retry() }
.accessibilityIdentifier("btnRetry")
}
Button("Cancel") { model.cancelRead() }
.accessibilityIdentifier("btnCancel")
}
}
}
private var continueTitle: String {
if let key = model.requestedWarningKey {
return "Continue (send '\(key.uppercased())')"
}
return "Continue"
}
// MARK: - XY table badges
@ViewBuilder
private var xyTableSection: some View {
if model.selectedInstrument.isXY {
HStack(spacing: 8) {
xyStep("Place", active: model.xyStep == .place, id: "xyStepPlace")
xyStep("Align", active: model.xyStep == .align, id: "xyStepAlign")
xyStep("Scan", active: model.xyStep == .scan, id: "xyStepScan")
xyStep("Remove", active: model.xyStep == .remove, id: "xyStepRemove")
}
.padding(12)
.background(Theme.panel)
.accessibilityIdentifier("xyTablePanel")
}
}
private func xyStep(_ label: String, active: Bool, id: String) -> some View {
Text(label)
.font(.caption)
.fontWeight(active ? .bold : .regular)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(active ? Theme.accent : Theme.border)
.foregroundStyle(active ? Color.white : Theme.text)
.cornerRadius(4)
.accessibilityIdentifier(id)
}
// MARK: - Swatch grid
@ViewBuilder
private var swatchGridSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Swatches")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
statsView
}
ScrollView([.horizontal, .vertical]) {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.swatchRows) { row in
HStack(spacing: 2) {
Text(row.rowId)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 24)
ForEach(row.patches) { swatch in
SwatchPatchView(swatch: swatch)
}
}
}
}
.padding(8)
}
.frame(minHeight: 120, maxHeight: 360)
.background(Theme.panel)
.accessibilityIdentifier("swatchGrid")
}
.padding(16)
.background(Theme.background)
}
@ViewBuilder
private var statsView: some View {
let patches = model.swatchRows.flatMap(\.patches)
let valid = patches.compactMap(\.deltaE)
let avg = valid.isEmpty ? nil : valid.reduce(0, +) / Double(valid.count)
let max = valid.max() ?? 0
HStack(spacing: 12) {
if let avg = avg {
Text("avg ΔE \(String(format: "%.2f", avg))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("max ΔE \(String(format: "%.2f", max))")
.font(.caption)
.foregroundStyle(.secondary)
Text("\(patches.count) patches")
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("readStats")
}
// MARK: - Averaging
@ViewBuilder
private var averagingSection: some View {
if !model.passSnapshots.isEmpty || model.isFinished {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Averaging")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text("\(model.passSnapshots.count) pass(es)")
.font(.caption)
.foregroundStyle(Theme.text)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.border)
.cornerRadius(4)
.accessibilityIdentifier("passCounterBadge")
}
ForEach(model.passSnapshots, id: \.lastPathComponent) { url in
Text(url.lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityIdentifier("passesList")
HStack(spacing: 12) {
Button("Measure Another Sheet") {
model.measureAnotherSheet()
}
.disabled(!model.isFinished || model.isChartreadRunning)
.accessibilityIdentifier("btnMeasureAnotherSheet")
Button("Finish & Average") {
model.finishAndAverage()
}
.disabled(!model.isFinished || model.isFinishing)
.accessibilityIdentifier("btnFinishAndAverage")
}
if let notice = model.finishNotice {
Text(notice)
.font(.caption)
.foregroundStyle(model.finishNoticeIsError ? .red : .green)
}
}
.padding(16)
.background(Theme.panel)
.accessibilityIdentifier("chartreadAveragingPanel")
}
}
}
+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)
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftUI
import ICCeryCore
private extension DisplayRGB {
var color: Color {
Color(red: r, green: g, blue: b)
}
}
/// One swatch in the live grid, with a 135° intended/measured diagonal split.
struct SwatchPatchView: View {
let swatch: Swatch
private var indicatorColor: Color {
switch swatch.classification {
case .good: return .green
case .warning: return .yellow
case .bad: return .red
}
}
var body: some View {
ZStack {
// Background: measured
swatch.measured.color
.clipShape(DiagonalClip(side: .bottomRight))
// Foreground: intended
swatch.intended.color
.clipShape(DiagonalClip(side: .topLeft))
// Classification dot
Circle()
.fill(indicatorColor)
.frame(width: 6, height: 6)
.offset(x: 6, y: 6)
}
.frame(width: 32, height: 32)
.overlay(
Rectangle()
.stroke(Color.primary.opacity(0.2), lineWidth: 0.5)
)
.accessibilityIdentifier("swatch-\(swatch.rowId)\(swatch.loc)")
.accessibilityLabel("\(swatch.loc) intended \(String(format: "%.0f", swatch.intended.r * 255)), measured \(String(format: "%.0f", swatch.measured.r * 255))")
}
}
/// 135° diagonal clipping: top-left or bottom-right triangle.
///
/// A 135° line from the top-right corner to the bottom-left corner gives
/// top-left and bottom-right triangles.
private enum DiagonalSide {
case topLeft
case bottomRight
}
private struct DiagonalClip: Shape {
let side: DiagonalSide
func path(in rect: CGRect) -> Path {
var path = Path()
switch side {
case .topLeft:
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
case .bottomRight:
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
}
path.closeSubpath()
return path
}
}
@@ -0,0 +1,644 @@
import Foundation
import Observation
import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
/// application (issues #7#11).
///
/// `wizard` stays authoritative for persisted identity + disk gating;
/// this model owns the editable form, logs, gallery, and preset state.
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back.
@MainActor
@Observable
final class TargetWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb {
didSet {
guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4
}
}
var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` used when `patchPreset == .custom`.
var customPatchCount = 2500
var whitePatches = 4
var blackPatches = 4
// Advanced each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false
var greySteps = 5
var singleChannelEnabled = false
var singleChannelSteps = 5
var neutralStepsEnabled = false
var neutralSteps = 3
var neutralConcEnabled = false
var neutralConcentration = 0.50
var preconditioningProfile: String?
var highQuality = false
var adaptationEnabled = false
var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false
var totalInkLimit = 320
var darkEmphasisEnabled = false
var darkEmphasis = 1.0
var devicePowerEnabled = false
var devicePower = 1.0
/// `#targetBasename` no placeholder is ever invented (#60).
var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4
var customPageW = 210.0
var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` two-way bound; presets can change it (150-DPI draft
/// regression must be visible here).
var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1
var labelIsCustom = false
var customLabel = ""
var metaPrinter = ""
var metaInkSet = ""
var metaDriverPaper = ""
var metaActualPaper = ""
// MARK: - Run state
var targenRunning = false
var targenLog: [String] = []
var printtargRunning = false
var printtargLog: [String] = []
var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false
// MARK: - Print panel (issue 17)
/// CUPS destinations from `lpstat` (#printerSelect).
var printers: [Printer] = []
/// Selected queue name.
var selectedPrinter = ""
/// Capabilities of the selected queue (#printerTraySelect /
/// #printerMediaTypeSelect / PageSize source).
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
/// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape).
var printOrientation = "portrait"
/// Per-queue captured `key=value` strings from Preferences replayed
/// on `lp` (session-only, docs/11 §capturedCupsOptions).
var capturedCupsOptions: [String: String] = [:]
/// In-panel notice (#printNotification) cancel info, not error.
var printNotice: String?
var printNoticeIsError = false
var isPrinting = false
// MARK: - Presets
var presets: [ProfilingPreset] = []
var selectedPresetID = "none"
var showingSavePreset = false
var showingManagePresets = false
var savePresetName = ""
var savePresetDesc = ""
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel
init(environment: AppEnvironment = .live()) {
self.environment = environment
self.wizard = WizardViewModel(stateStore: environment.stateStore)
self.measurement = MeasurementWorkflowViewModel(
wizard: wizard,
environment: environment
)
reloadPresets()
}
// MARK: - Derived
var effectivePatchCount: Int {
patchPreset.patchCount ?? customPatchCount
}
var canGenerate: Bool {
PathSecurity.isValidBasename(targetBasename) && targetDirectory != nil
}
var labelMetadata: TargetLabelMetadata {
TargetLabelMetadata(
printer: metaPrinter, inkSet: metaInkSet,
driverPaper: metaDriverPaper, actualPaper: metaActualPaper)
}
/// `#targetLabelPreview` live preview of the automatic label.
var automaticLabel: String {
PrinttargLabel.automatic(
basename: wizard.basename.isEmpty ? "target" : wizard.basename,
metadata: labelMetadata)
}
var selectedPreset: ProfilingPreset? {
presets.first { $0.id == selectedPresetID }
}
// MARK: - Stage 1: generate
func buildTargenConfig() -> TargenConfig {
TargenConfig(
colourSpace: colourSpace,
patchCount: effectivePatchCount,
whitePatches: whitePatches,
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm == .ofps ? nil : algorithm,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
basename: targetBasename,
workingDirectory: targetDirectory
)
}
func browseForTargetFile() {
let url = UITestHooks.isEnabled
? UITestHooks.saveTargetURL
: fileDialogs.selectTargetFile()
guard let url else { return }
targetBasename = url.deletingPathExtension().lastPathComponent
targetDirectory = url.deletingLastPathComponent()
}
func browseForWorkingDirectory() {
let url = UITestHooks.isEnabled
? UITestHooks.workDirURL
: fileDialogs.selectDirectory()
if let url { targetDirectory = url }
}
func browseForPreconditioningProfile() {
if let url = fileDialogs.selectProfileFile() {
preconditioningProfile = url.path
}
}
func generateTarget() {
guard canGenerate, !targenRunning else { return }
let config = buildTargenConfig()
targenRunning = true
targenLog = []
resumedFromTi2 = false
let runner = environment.runner
Task { @MainActor in
do {
let url = try await runner.runTargen(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.targenLog.append(contentsOf: batch)
}
}
wizard.setTarget(
basename: config.basename,
workingDirectory: config.workingDirectory)
wizard.refreshGating()
wizard.showNotice("Target generated: \(url.lastPathComponent)")
wizard.go(to: .layOutPrint)
} catch {
wizard.showNotice(
"targen failed: \(error.localizedDescription)", kind: .error)
}
targenRunning = false
}
}
// MARK: - Issue 8: resume an existing target
/// `#btnOpenExisting` open `.ti1`/`.ti2` (open dialog, #103).
/// `.ti1` Stage 2; `.ti2` Stage 3 with the resume notice, but
/// only when the sibling `.ti1` exists so the artefact gate holds.
func openExistingTarget() {
let url = UITestHooks.isEnabled
? UITestHooks.existingTargetURL
: fileDialogs.selectExistingTarget()
guard let url else { return }
let stem = url.deletingPathExtension().lastPathComponent
let dir = url.deletingLastPathComponent()
guard PathSecurity.isValidBasename(stem) else {
wizard.showNotice("Invalid target name.", kind: .error)
return
}
switch url.pathExtension.lowercased() {
case "ti1":
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = false
measurement.resumedFromTi2 = false
wizard.go(to: .layOutPrint)
case "ti2":
let header = Ti2Header.parse(url)
guard header.hasSiblingTi1 else {
wizard.showNotice(
"Cannot resume \(stem).ti2 — the sibling \(stem).ti1 is missing.",
kind: .error)
return
}
wizard.setTarget(basename: stem, workingDirectory: dir)
wizard.refreshGating()
resumedFromTi2 = true
measurement.resumedFromTi2 = true
wizard.showNotice("Resumed from .ti2", kind: .info, autoHideAfter: nil)
wizard.go(to: .measure)
default:
wizard.showNotice(
"Not a target file — choose a .ti1 or .ti2.", kind: .error)
}
}
// MARK: - Stage 2: create layout
func buildPrinttargConfig() -> PrinttargConfig {
PrinttargConfig(
instrument: instrument,
pageSize: pageSize,
customPageWidth: customPageW,
customPageHeight: customPageH,
bitDepth: bitDepth,
dpi: tiffDpi,
layoutOrder: layoutOrder,
customSeed: customSeed,
label: PrinttargLabel.resolved(
customLabel: labelIsCustom ? customLabel : nil,
basename: wizard.basename,
metadata: labelMetadata),
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
}
func createLayout() {
guard wizard.isUnlocked(.layOutPrint), !printtargRunning else { return }
let config = buildPrinttargConfig()
printtargRunning = true
printtargLog = []
printtargResult = nil
let runner = environment.runner
Task { @MainActor in
do {
let result = try await runner.runPrinttarg(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.printtargLog.append(contentsOf: batch)
}
}
printtargResult = result
wizard.refreshGating()
wizard.showNotice(
"Layout created — \(result.manifest.pages.count) page(s) ready.")
} catch {
// Stay on Stage 2: non-zero exit, malformed manifest, or
// missing .ti2 must never advance the wizard (#156).
wizard.showNotice(
"printtarg failed: \(error.localizedDescription)", kind: .error)
}
printtargRunning = false
}
}
/// `#btnAdvanceToStage3` manual advance once `.ti2` exists.
func advanceToStage3() {
wizard.refreshGating()
wizard.go(to: .measure)
}
// MARK: - Print panel actions (issue 17)
/// `#btnRefreshPrinters` re-enumerate CUPS destinations and load
/// capabilities for the selection. Auto-runs when the panel first
/// appears with a manifest.
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
} catch {
printNotice = "Could not list printers: \(error.localizedDescription)"
printNoticeIsError = true
}
}
}
/// Capabilities for `selectedPrinter` trays / media / sizes feed
/// the selects.
func reloadSelectedCapabilities() async {
guard !selectedPrinter.isEmpty else {
printerCaps = PrinterCapabilities()
return
}
do {
printerCaps = try await environment.cupsService
.capabilities(for: selectedPrinter)
// Default selections only when the captured options didn't
// already pin them (Preferences round-trip wins).
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
}
} catch {
printerCaps = PrinterCapabilities()
}
}
/// `#btnPrinterProperties` bound NSPrintPanel ("Use Settings").
/// Cancel info notice, never an error, cache untouched. On OK the
/// captured options are stored per-queue; a panel-side queue switch
/// updates `printerSelect` when the returned CUPS id is in the list.
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
else {
printNotice = "Printer properties dialog cancelled."
printNoticeIsError = false
return
}
if let selected = result.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
selectedMediaType = media
}
printNotice = "Settings captured for \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = error.localizedDescription
printNoticeIsError = true
}
}
}
/// `#btnPrintAll` spool every gallery TIFF, sequentially. Stops on
/// the first failure so the user sees which page failed.
func printAllPages() {
guard let result = printtargResult, !isPrinting else { return }
isPrinting = true
Task { @MainActor in
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index)
printed += 1
} catch {
printNotice = "Print failed on \(page.page.filename): "
+ error.localizedDescription
printNoticeIsError = true
isPrinting = false
return
}
}
printNotice = "Sent \(printed) page(s) to \(selectedPrinter)."
printNoticeIsError = false
isPrinting = false
}
}
/// `#btnPrintPage-N` one TIFF.
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
Task { @MainActor in
do {
try await spool(page, index: page.index)
printNotice = "Sent \(page.page.filename) to \(selectedPrinter)."
printNoticeIsError = false
} catch {
printNotice = "Print failed: \(error.localizedDescription)"
printNoticeIsError = true
}
isPrinting = false
}
}
private func spool(_ page: GalleryPage, index: Int) async throws {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
// For Stage 5 history (#95): record which queue printed.
wizard.printerName = selectedPrinter
}
// MARK: - Presets
func reloadPresets() {
presets = environment.presetStore.all()
}
/// Applies every Stage 1/2 field of the preset to the live form
/// (bidirectional the draft preset's dpi=150 must be visible).
func applyPreset(_ preset: ProfilingPreset) {
colourSpace = preset.colourSpace == "cmyk" ? .cmyk : .rgb
patchPreset = PatchCountPreset(rawValue: "\(preset.patchCount)") ?? .custom
customPatchCount = preset.patchCount
whitePatches = preset.whitePatches
blackPatches = preset.blackPatches
greySteps = preset.greySteps ?? 5; greyStepsEnabled = preset.greySteps != nil
singleChannelSteps = preset.singleChannelSteps ?? 5
singleChannelEnabled = preset.singleChannelSteps != nil
neutralSteps = preset.neutralSteps ?? 3
neutralStepsEnabled = preset.neutralSteps != nil
neutralConcentration = preset.neutralConcentration ?? 0.50
neutralConcEnabled = preset.neutralConcentration != nil
preconditioningProfile = preset.preconditioningProfile
highQuality = preset.ofpsHighQuality == true
adaptation = preset.ofpsAdaptation ?? 0.10
adaptationEnabled = preset.ofpsAdaptation != nil
algorithm = preset.fullSpreadAlgorithm
.flatMap { FullSpreadAlgorithm(presetValue: $0) } ?? .ofps
totalInkLimit = preset.totalInkLimit ?? 320
inkLimitEnabled = preset.totalInkLimit != nil
darkEmphasis = preset.darkEmphasis ?? 1.0
darkEmphasisEnabled = preset.darkEmphasis != nil
devicePower = preset.devicePower ?? 1.0
devicePowerEnabled = preset.devicePower != nil
instrument = PrintInstrument(rawValue: preset.instrument) ?? .i1
if let size = PageSize(rawValue: preset.pageSize) {
pageSize = size
} else if let (w, h) = Self.parseCustomPage(preset.pageSize) {
pageSize = .custom; customPageW = w; customPageH = h
} else {
pageSize = .a4
}
bitDepth = preset.bitDepth == 16 ? .sixteen : .eight
tiffDpi = preset.dpi
if preset.noRandomize == true {
layoutOrder = .raster
} else if (preset.randomSeed ?? 1) == 1 {
layoutOrder = .deterministic
} else {
layoutOrder = .customSeed
}
customSeed = preset.randomSeed ?? 1
selectedPresetID = preset.id
}
/// Snapshot of the live Stage 1/2 form as a custom preset.
func saveCurrentAsPreset() {
let name = savePresetName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else {
wizard.showNotice("Preset needs a name.", kind: .warning)
return
}
let preset = ProfilingPreset(
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: savePresetDesc.trimmingCharacters(in: .whitespacesAndNewlines),
colourSpace: colourSpace == .cmyk ? "cmyk" : "rgb",
patchCount: effectivePatchCount,
whitePatches: whitePatches,
blackPatches: blackPatches,
greySteps: greyStepsEnabled ? greySteps : nil,
singleChannelSteps: singleChannelEnabled ? singleChannelSteps : nil,
neutralSteps: neutralStepsEnabled ? neutralSteps : nil,
neutralConcentration: neutralConcEnabled ? neutralConcentration : nil,
preconditioningProfile: preconditioningProfile,
ofpsHighQuality: highQuality ? true : nil,
ofpsAdaptation: adaptationEnabled ? adaptation : nil,
fullSpreadAlgorithm: algorithm.presetValue,
totalInkLimit: inkLimitEnabled ? totalInkLimit : nil,
darkEmphasis: darkEmphasisEnabled ? darkEmphasis : nil,
devicePower: devicePowerEnabled ? devicePower : nil,
instrument: instrument.rawValue,
pageSize: pageSize == .custom
? "\(Int(customPageW))x\(Int(customPageH))"
: pageSize.rawValue,
bitDepth: bitDepth.rawValue,
dpi: tiffDpi,
randomSeed: layoutOrder == .deterministic ? 1 : customSeed,
noRandomize: layoutOrder == .raster
)
do {
try environment.presetStore.saveCustom(preset)
reloadPresets()
selectedPresetID = preset.id
showingSavePreset = false
savePresetName = ""
savePresetDesc = ""
wizard.showNotice("Preset saved: \(preset.name)")
} catch {
wizard.showNotice(
"Could not save preset: \(error.localizedDescription)", kind: .error)
}
}
func deletePreset(_ preset: ProfilingPreset) {
do {
if try environment.presetStore.deleteCustom(id: preset.id) {
if selectedPresetID == preset.id { selectedPresetID = "none" }
reloadPresets()
} else {
wizard.showNotice("Built-in presets cannot be deleted.", kind: .warning)
}
} catch {
wizard.showNotice(
"Could not delete preset: \(error.localizedDescription)", kind: .error)
}
}
func importPreset() {
let url = UITestHooks.isEnabled
? UITestHooks.presetImportURL
: fileDialogs.selectPresetFile()
guard let url else { return }
do {
let data = try Data(contentsOf: url)
let preset = try environment.presetStore.import(data)
try environment.presetStore.saveCustom(preset)
reloadPresets()
selectedPresetID = preset.id
wizard.showNotice("Preset imported: \(preset.name)")
} catch {
wizard.showNotice(
"Import failed: \(error.localizedDescription)", kind: .error)
}
}
func exportPreset(_ preset: ProfilingPreset) {
let url = UITestHooks.isEnabled
? UITestHooks.presetExportURL
: fileDialogs.selectPresetSavePath(name: preset.id)
guard let url else { return }
do {
try environment.presetStore.export(preset)
.write(to: url, options: .atomic)
wizard.showNotice("Preset exported: \(url.lastPathComponent)")
} catch {
wizard.showNotice(
"Export failed: \(error.localizedDescription)", kind: .error)
}
}
static func parseCustomPage(_ raw: String) -> (Double, Double)? {
let parts = raw.lowercased().split(separator: "x")
guard parts.count == 2,
let w = Double(parts[0]), let h = Double(parts[1]),
w >= 50, h >= 50 else { return nil }
return (w, h)
}
}
+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,144 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
import AppKit
import ApplicationServices
/// Issue 14 PMPrintSettingsToOptions capture filter (docs/11 layer ).
@Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
}
@Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw)
}
@Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw)
}
@Test("Drops empty keys and values")
func dropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value dropped; "=noval" empty key.
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
}
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
func extractMedia() {
#expect(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7") == "7")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
}
}
/// Issue 14 the dlsym attempt order and first-success semantics.
/// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are
/// touched.
@Suite("ColorSyncSuppressor")
@MainActor
struct ColorSyncSuppressorTests {
/// Fake PMPrintSession the injected resolver never dereferences it.
private var fakeSession: PMPrintSession {
unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self)
}
/// Call log static since `@convention(c)` can't capture. The
/// resolver sets `currentSymbol` right before each call, so the C
/// function records (symbol, mode) without capturing `name`.
private static var recorded: [(String, String)] = []
private static var currentSymbol = ""
private static var succeeding: (String, String)?
private static var missing: Set<String> = []
private func makeSuppressor() -> ColorSyncSuppressor {
var s = ColorSyncSuppressor()
s.log = { _ in }
s.modeResolver = { name in
if Self.missing.contains(name) { return nil }
Self.currentSymbol = name
return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String))
if let ok = Self.succeeding,
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
return 0
}
return 1
}
}
return s
}
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
Self.recorded = []
Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
// Lock is unresolvable skipped; the rest plays out in order.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" })
}
@Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
])
}
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
#expect(Self.recorded.count == 2)
}
@Test("All symbols missing → false, no calls")
func allMissing() {
Self.recorded = []
Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
#expect(Self.recorded.isEmpty)
}
}
+145
View File
@@ -0,0 +1,145 @@
import Testing
import Foundation
@testable import ICCeryCore
/// Issue 12 CUPS enumeration parsers on recorded fixtures
/// (docs/1011). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers")
struct CupsParsersTests {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
Canon_Pro9500_II_series_XPS
Epson_XP_55_LPD
EPSON_XP_55_Series
"""
private let lpstatP = """
printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026
printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026
printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 -
Paused
"""
private let lpoptionsP = """
device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340
"""
private let lpoptionsL = """
PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT
InputSlot/Media Source: Auto *Main Photo Rear
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
ColorModel/Output Mode: *RGB Gray
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
"""
@Test("lpstat -e: one destination per line; empty = success")
func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
"Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD",
"EPSON_XP_55_Series",
])
#expect(CupsParsers.lpstatDestinations("") == [])
}
@Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
#expect(s["Epson_XP_55_LPD"] == .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped)
}
@Test("lpstat -d: default destination or none")
func defaultDestination() {
#expect(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
}
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
func displayName() {
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
}
@Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6)
let page = listings[0]
#expect(page.key == "PageSize")
#expect(page.label == "Media Size")
#expect(page.defaultChoice == "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4"))
let slot = listings[1]
#expect(slot.key == "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main")
}
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10)
#expect(caps.mediaTypes.map(\.id) == [
"Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope",
])
#expect(caps.supportsOrientation)
}
@Test("PPD enrichment maps id → human label")
func ppdLabels() {
let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper")
#expect(labels["13"] == "Envelope")
}
@Test("detectMediaTypeKey prefers vendor keys in order")
func mediaTypeKey() {
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
}
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" }
}
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil)
}
}
@@ -0,0 +1,129 @@
import Testing
import Foundation
@testable import ICCeryCore
private func tempDir(_ name: String = UUID().uuidString) throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-files-\(name)")
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
}
}
}
@Test func acceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good))
}
}
@Test func resolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
}
@Test func createsParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
@Test func icmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm")
}
@Test func enumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam",
"CAL_t.ti1", "CAL_t.cal",
// must NOT match:
"other.ti1", "t.txt", "CAL_other.ti1",
] { try touch(dir.appendingPathComponent(name)) }
let names = ArtefactProbe.existingArtefacts(basename: "t", cwd: dir)
.map(\.lastPathComponent)
for expected in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
#expect(names.contains(expected), "missing \(expected)")
}
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
+131
View File
@@ -0,0 +1,131 @@
import Testing
import Foundation
@testable import ICCeryCore
/// Issue 15 `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs")
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series"
private func build(
options: PrintOptions = PrintOptions(),
optionKeys: Set<String> = []
) throws -> [String] {
try LpArgs.build(
queue: queue, tiffPath: tiff,
options: options, optionKeys: optionKeys)
}
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7])
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
}
@Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw")
#expect(argv[i + 1] != "raw=true")
}
#expect(!argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo"))
}
@Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst)
}
@Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain"))
}
@Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss"))
}
@Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3"))
let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
}
@Test("Orientation: portrait=3 landscape=4; captured wins")
func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5"))
}
@Test("PageSize emitted unless captured")
func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter"))
}
@Test("Sanitise rejects `;`, newline, and shell metachars")
func sanitise() throws {
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))
}
}
}
@@ -0,0 +1,296 @@
import Foundation
import Testing
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
let json = """
{
"event": "instruments",
"devices": [
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
{"port": 2, "name": "i1Pro 2", "type": "usb"},
{"port": 3, "name": "i1iO Table", "type": "usb"}
]
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"])
}
@Test("LEDs emit -Y l")
func leds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
@Test("Calibration prompt")
func calibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
}
@Test("Done prompt")
func done() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
}
@Test("XY locate patch")
func locatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
@Test("Decodes row JSON")
func decode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
{"id": "1", "loc": "A1", "is_pad": false, "device": [0, 50, 100],
"expected": {"Lab": [50, 0, 0]},
"measured": {"Lab": [51, 1, -1]}}
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
}
}
@Suite("ColourMath")
struct ColourMathTests {
@Test("White XYZ to Lab")
func whiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
@Test("Discovers passes in order")
func discovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
try "A".write(to: cwd.appendingPathComponent("target_pass3.ti3"), atomically: true, encoding: .utf8)
try "B".write(to: cwd.appendingPathComponent("target_pass1.ti3"), atomically: true, encoding: .utf8)
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
let canonical = cwd.appendingPathComponent("target.ti3")
try "v1".write(to: canonical, atomically: true, encoding: .utf8)
_ = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
@Test("Requires at least two pass files")
func passCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [
URL(fileURLWithPath: "/tmp/target_pass1.ti3"),
URL(fileURLWithPath: "/tmp/target_pass2.ti3"),
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
+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)
}
}
@@ -0,0 +1,71 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub")
struct PrintPanelStubTests {
private func withEnv(
_ vars: [String: String?],
_ body: () throws -> Void
) rethrows {
var saved: [String: String?] = [:]
for key in vars.keys {
saved[key] = ProcessInfo.processInfo.environment[key]
}
for (key, value) in vars {
if let value { setenv(key, value, 1) } else { unsetenv(key) }
}
defer {
for (key, value) in saved {
if let value { setenv(key, value, 1) } else { unsetenv(key) }
}
}
try body()
}
@Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel",
]) {
#expect(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
}
}
@Test("OK returns captured options + selected printer")
func okResult() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true)
}
}
@Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": nil,
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue")
#expect(result?.options.cupsOptions == nil)
}
}
}
+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)
}
}
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Mock average for Milestone4UITests.
# Usage: average -v pass1.ti3 pass2.ti3 ... output.ti3
# The canonical output is the last argument.
# Set MOCK_AVERAGE_FAIL=1 to exit with code 1.
if [ "${MOCK_AVERAGE_FAIL:-0}" -ne 0 ]; then
echo "average: could not converge" >&2
exit 1
fi
# Drop leading -v
shift
output="$1"
if [ $# -ge 2 ]; then
output="$2"
fi
# Find last argument
for arg in "$@"; do
output="$arg"
done
# Sanity: the output is the last argument.
# Write a fake canonical .ti3 that identifies the inputs.
{
echo "CTI3"
echo "INPUTS:"
for arg in "$@"; do
if [ "$arg" != "$output" ]; then
echo "$arg"
fi
done
} > "$output"
exit 0
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Mock chartread for Milestone4UITests.
Supports handheld (MOCK_CHARTREAD_MODE=strip) and XY (MOCK_CHARTREAD_MODE=xy).
Writes basename.ti3 on receiving 'd' and exits 0.
Exit 0 and no .ti3 on 'q' before done.
Usage: chartread -v -u [-c port] [-Y l] basename
"""
import json
import os
import sys
def read_line():
try:
return sys.stdin.readline()
except Exception:
return ""
def emit_row(payload: dict):
text = "ROW_COLORS_JSON: " + json.dumps(payload)
print(text, flush=True)
def write_ti3(basename: str):
if basename:
with open(f"{basename}.ti3", "w") as f:
f.write("MOCK_TI3\n")
def main():
mode = os.environ.get("MOCK_CHARTREAD_MODE", "strip")
basename = ""
for arg in sys.argv[1:]:
if arg.startswith("-"):
continue
basename = arg
def read_input():
line = read_line()
if not line:
sys.exit(1)
return line.strip()
if mode == "xy":
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
read_input()
print("Calibration successful.", flush=True)
print("Please place sheet 1 of 1 on the table", flush=True)
print("hit return to continue, Esc or 'q' to give up", flush=True)
read_input()
print("locate patch A1 with the sight,", flush=True)
print("then hit return to continue", flush=True)
read_input()
print("Reading sheet 1...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 1,
"patch_count": 3,
"patches": [
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
]
})
print("Sheet 1 of 1 read OK", flush=True)
print("Please remove last sheet from table", flush=True)
print("'d' if/when done", flush=True)
while True:
line = read_input()
if line.startswith("d"):
write_ti3(basename)
sys.exit(0)
if line.startswith("q"):
sys.exit(0)
# Handheld / strip mode (default)
print("Place instrument on calibration tile and hit [Space] to calibrate.", flush=True)
read_input()
print("Calibration successful.", flush=True)
print("Hit [Space] to read strip A", flush=True)
read_input()
print("Reading strip A...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 2,
"patch_count": 3,
"patches": [
{"id": "1", "loc": "A1", "is_pad": False, "device": [0.0, 50.0, 100.0], "expected": {"XYZ": [18.4210, 20.1234, 15.6789], "Lab": [51.98, -8.45, 12.32]}, "measured": {"XYZ": [18.5120, 20.0451, 15.7100], "Lab": [51.89, -8.31, 12.15]}},
{"id": "2", "loc": "A2", "is_pad": False, "device": [10.0, 60.0, 90.0], "expected": {"Lab": [60.0, 10.0, -20.0]}, "measured": {"Lab": [60.1, 10.5, -19.5]}},
{"id": "3", "loc": "A3", "is_pad": True, "device": [100.0, 100.0, 100.0], "measured": {"Lab": [95.0, 0.0, 0.0]}},
]
})
print("Hit [Space] to read strip B", flush=True)
read_input()
print("Reading strip B...", flush=True)
emit_row({
"event": "row_complete",
"row_id": "B",
"row_index": 1,
"total_rows": 2,
"patch_count": 2,
"patches": [
{"id": "4", "loc": "B1", "is_pad": False, "device": [100.0, 0.0, 0.0], "expected": {"Lab": [40.0, 40.0, 40.0]}, "measured": {"Lab": [38.0, 41.0, 39.0]}},
{"id": "5", "loc": "B2", "is_pad": False, "device": [0.0, 100.0, 0.0], "expected": {"Lab": [80.0, -50.0, 50.0]}, "measured": {"Lab": [79.0, -49.0, 51.0]}},
]
})
print("'d' if/when done", flush=True)
while True:
line = read_input()
if line.startswith("d"):
write_ti3(basename)
sys.exit(0)
if line.startswith("q"):
sys.exit(0)
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# Mock instlist for Milestone4UITests. Emits a pretty JSON device list.
# Override the list with ICCERY_MOCK_INSTLIST_JSON.
if [ -n "${ICCERY_MOCK_INSTLIST_JSON}" ]; then
echo "${ICCERY_MOCK_INSTLIST_JSON}"
exit 0
fi
printf '{
"event": "instruments",
"devices": [
{"port": 1, "name": "X-Rite i1Pro", "type": "usb"},
{"port": 2, "name": "X-Rite i1Pro 2", "type": "usb"},
{"port": 3, "name": "i1iO Table", "type": "usb"}
]
}
'
exit 0

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