Compare commits

...
Author SHA1 Message Date
gronod a73c6c0b97 Merge pull request 'feat/31-about-help-chrome' (#55) from feat/31-about-help-chrome into milestone/m6-gamut-stage0-cgats-release
Reviewed-on: #55
2026-09-09 18:33:26 +01:00
gronod 563f0e5d4a Merge pull request 'Milestone/m6 gamut stage0 cgats release' (#57) from milestone/m6-gamut-stage0-cgats-release into feat/31-about-help-chrome
Reviewed-on: #57
2026-09-09 18:31:47 +01:00
gronod cd4665e7a9 Merge pull request 'feat/30-cgats-interop' (#56) from feat/30-cgats-interop into milestone/m6-gamut-stage0-cgats-release 2026-09-09 17:20:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 153b6194a3 Implement #30: CGATS parser, canonical .ti3 writer, and dataset import.
- Add CGATSParser, CGATSWriter, and CGATSSummary under ICCeryCore/CGATS.
- Parse CTI3, CGATS.17, ISO28178, and CSV; synthesize COLOR_REP/DEVICE_CLASS.
- Scale RGB/CMYK device coordinates from 0-255 to 0-100 when needed.
- Round-trip parse, write, reparse with tab-delimited canonical .ti3.
- Wire `Import Dataset…` in Stage 1 to open-panel import, write .ti3, and
  jump to Stage 4/5 while keeping Stages 1-2 locked without .ti1/.ti2.
- Add CGATSParserTests and Milestone6CGATSUITests.importUsesOpenPanelNotSaveTi1.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:18:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f78da50a59 Implement #31: About dialog and global help overlays.
- Add ArtefactFiles.appInfo() build date from executable mtime.
- Add AboutView with version, build, build date, and close button.
- Add HelpOverlay view modifier for non-reflowing help badges.
- Wire About as a sheet and help toggle in SidebarView/RootView.
- Add AboutHelpUITests coverage.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 17:04:15 +01:00
gronod 629a1fce1d Merge pull request 'Milestone 5 — Profile generation, verification & installation plus M5 bugfixes (#23–#27, #50, #52)' (#54) from milestone/m5-profile into develop 2026-09-09 14:22:10 +01:00
gronod 460b0a1ffa Merge pull request 'M5 bugfixes — Stage 3 prompt stream, ProcessManager, profile/verification fixes (#50, #52)' (#53) from feat/52-m5-bugfixes into milestone/m5-profile 2026-09-09 14:21:42 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ef56cdd7d4 Fix M5 regressions and Stage 3 chartread prompt delivery (#50, #52).
Hardens ProcessManager finalisation, ensures stdout/stderr pipe write-ends
close after spawn, sets termination handlers before run(), and fixes the
runCaptured continuation hand-off for fast exits. Resolves the Stage 3
prompt stream being dropped and the handheld fixture UI not advancing,
unskips and repairs the handheld UI test, and fixes averaging panel
accessibility. Includes the selected #52 profile, verification, and
artefact-gating fixes.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 13:25:39 +01:00
gronod 61c6d62ee2 Merge pull request 'Milestone 5 — Profile generation, verification & installation (#23–#27)' (#51) from feat/23-colprof into milestone/m5-profile 2026-09-09 11:36:35 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f769cf7fec Milestone 5 — Profile generation, verification & installation (#23–#27).
Add the Stage 4/5 native workflow: deterministic colprof/applycal/iccgamut
and profcheck argv builders, verification history with CSV export and drift
analytics, user/system ColorSync profile installation, and the matching
SwiftUI views and fixture-driven tests. All Argyll interaction remains
AGPL-isolated via ProcessManager subprocesses.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 10:43:12 +01:00
gronod da53feec7f Merge milestone/m4-measurement into develop 2026-09-09 09:05:37 +01:00
gronod 64393b7591 Milestone 4 — Measurement workflow (#18–#22) 2026-09-09 09:05:37 +01:00
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
186 changed files with 25708 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,800 @@
import Foundation
/// Errors from `ArgyllRunner` executions.
public enum ArgyllRunnerError: LocalizedError, Equatable, Sendable {
case processFailed(code: Int32, logs: [String])
case missingArtefact(String)
case malformedManifest(String)
case instrumentDetectionFailed(String)
case chartreadFailed(String)
case averageFailed(String)
case colprofFailed(String)
case applycalFailed(String)
case iccgamutFailed(String)
case profcheckFailed(String)
case profcheckUnparseable
public var errorDescription: String? {
switch self {
case .processFailed(let code, _):
return "Process exited with code \(code)"
case .missingArtefact(let path):
return "Expected output file was not created: \(path)"
case .malformedManifest(let reason):
return "Failed to parse printtarg manifest: \(reason)"
case .instrumentDetectionFailed(let reason):
return "Instrument detection failed: \(reason)"
case .chartreadFailed(let reason):
return "Chartread failed: \(reason)"
case .averageFailed(let reason):
return "Averaging failed: \(reason)"
case .colprofFailed(let reason):
return "Profile creation failed: \(reason)"
case .applycalFailed(let reason):
return "Apply calibration failed: \(reason)"
case .iccgamutFailed(let reason):
return "Gamut extraction failed: \(reason)"
case .profcheckFailed(let reason):
return "Profile verification failed: \(reason)"
case .profcheckUnparseable:
return "Profile verification produced unparseable output"
}
}
}
/// Result of a successful `printtarg` run: the `.ti2` artefact plus the
/// validated manifest with per-page PNG previews already decoded.
public struct PrinttargResult: Sendable, Equatable {
public let ti2URL: URL
public let manifest: PrinttargManifest
public let pages: [GalleryPage]
}
/// Service driving Argyll subprocesses off the main actor
/// (docs/03, docs/08, docs/09).
///
/// - Subscribes to the event bus *before* spawning so no stdout or exit
/// is ever lost (subscription is synchronous in `ProcessManager`).
/// - Accumulates stdout/stderr without touching `@MainActor`; the
/// optional `onLogBatch` callback receives coalesced chunks (20 lines
/// or ~100 ms), never one call per line.
/// - Exit code 0 is necessary but not sufficient: the expected artefact
/// (`.ti1` / `.ti2`) must exist on disk, and printtarg must emit a
/// valid `-u` manifest.
public struct ArgyllRunner: Sendable {
public let processManager: ProcessManager
public let binaryResolver: BinaryResolver
public init(
processManager: ProcessManager = .shared,
binaryResolver: BinaryResolver = BinaryResolver()
) {
self.processManager = processManager
self.binaryResolver = binaryResolver
}
// MARK: - targen (Stage 1)
/// Runs `targen` streaming, collecting logs and verifying `.ti1`
/// upon completion. Returns the `.ti1` URL.
public func runTargen(
config: TargenConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try TargenArgs.build(config: config)
let binaryURL = binaryResolver.resolve("targen")
let processId = ProcessID.targen(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti1URL = cwd.appendingPathComponent("\(cleanBasename).ti1")
guard FileManager.default.fileExists(atPath: ti1URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti1URL.path)
}
return ti1URL
}
// MARK: - printtarg (Stage 2)
/// Runs `printtarg` streaming, then parses the `-u` manifest from
/// the complete accumulated stdout and loads each page's PNG
/// preview via `TiffPreview` (host-side, never raw TIFF to the UI).
public func runPrinttarg(
config: PrinttargConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> PrinttargResult {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try PrinttargArgs.build(config: config)
let binaryURL = binaryResolver.resolve("printtarg")
let processId = ProcessID.printtarg(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.processFailed(code: run.exitCode ?? -1, logs: run.lines)
}
let ti2URL = cwd.appendingPathComponent("\(cleanBasename).ti2")
guard FileManager.default.fileExists(atPath: ti2URL.path) else {
throw ArgyllRunnerError.missingArtefact(ti2URL.path)
}
let manifest: PrinttargManifest
do {
manifest = try PrinttargManifestExtractor.manifest(from: run.stdout)
} catch {
throw ArgyllRunnerError.malformedManifest(error.localizedDescription)
}
let pages = manifest.pages.enumerated().map { index, page -> GalleryPage in
let fileURL = cwd.appendingPathComponent(page.filename)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "File not found"
)
}
if let png = TiffPreview.previewPNG(tiff: fileURL) {
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: png, previewError: nil
)
}
return GalleryPage(
index: index, page: page, fileURL: fileURL,
previewPNG: nil, previewError: "Could not decode TIFF"
)
}
return PrinttargResult(ti2URL: ti2URL, manifest: manifest, pages: pages)
}
// MARK: - Shared collection
/// Cancels any previous child with the same id and waits for it to
/// finalize, so `runStreaming` / `runCaptured` never sees a
/// `duplicateID` from a leftover process (#50, #52).
private func ensureNotRunning(id: String) async {
guard await processManager.isRunning(id) else { return }
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
attempts += 1
}
}
private struct CollectedRun {
var exitCode: Int32?
var stdout: String
var stderr: String
var lines: [String]
}
/// Drains the event stream until this child's `exit` event.
/// stdout is accumulated both per-line (logs) and verbatim (for
/// the manifest parse the pretty JSON needs its newlines).
///
/// When `flushPartialLines` is `true`, a background `Task` flushes
/// unterminated output every 500 ms so tools like `colprof` that
/// print dots without newlines still produce log batches.
private func collect(
id processId: String,
events: AsyncStream<ProcessEvent>,
onLogBatch: (@Sendable ([String]) -> Void)?,
flushPartialLines: Bool = false
) async -> CollectedRun {
var lines: [String] = []
var stdout = ""
var stderr = ""
var pendingBatch: [String] = []
var exitCode: Int32?
var lastFlush = Date()
func flush(_ batch: inout [String]) {
guard !batch.isEmpty else { return }
let out = batch
batch.removeAll(keepingCapacity: true)
onLogBatch?(out)
}
var dotFlushTask: Task<Void, Never>?
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
}
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
lines.append(line)
stdout += line + "\n"
pendingBatch.append(line)
case .stderr(_, let line):
lines.append(line)
stderr += line + "\n"
pendingBatch.append(line)
case .error(_, let message):
lines.append("Error: \(message)")
pendingBatch.append("Error: \(message)")
case .jsonRow:
// Only chartread emits these; targen/printtarg never do.
break
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingBatch.count >= 20
|| Date().timeIntervalSince(lastFlush) >= 0.1 {
flush(&pendingBatch)
lastFlush = Date()
}
if exitCode != nil {
flush(&pendingBatch)
break
}
}
dotFlushTask?.cancel()
if let dotFlushTask {
_ = await dotFlushTask.value
}
return CollectedRun(exitCode: exitCode, stdout: stdout, stderr: stderr, lines: lines)
}
// MARK: - instlist (Stage 3 detection)
/// Runs `instlist` and returns the detected devices.
///
/// The fork emits pretty-printed JSON; if that cannot be decoded a regex
/// fallback constrained to known instrument tokens is used.
public func detectInstruments() async throws -> [InstrumentDevice] {
let binaryURL = binaryResolver.resolve("instlist")
let processId = ProcessID.instlist
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: [],
workingDirectory: nil
)
var accumulator = JSONAccumulator()
var stdout = ""
var stderr: [String] = []
var exitCode: Int32?
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
stdout += line + "\n"
_ = accumulator.feed(line: line)
case .stderr(_, let line):
stderr.append(line)
case .exit(_, let code):
exitCode = code
default:
break
}
if exitCode != nil { break }
}
if let data = accumulator.completeData ?? stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8) {
if let devices = try? InstrumentParser.parse(String(data: data, encoding: .utf8) ?? stdout) {
return devices
}
}
if let code = exitCode, code != 0, stderr.isEmpty == false {
throw ArgyllRunnerError.instrumentDetectionFailed(stderr.joined(separator: "\n"))
}
// Final fallback: try to parse the raw stdout as a text document.
if let devices = try? InstrumentParser.parse(stdout) {
return devices
}
throw ArgyllRunnerError.instrumentDetectionFailed("Could not parse instlist output")
}
// MARK: - average (Stage 3 multi-pass finish)
/// Runs `average` to merge two or more pass snapshots into the canonical `.ti3`.
public func runAverage(
config: AverageConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try AverageArgs.build(config: config)
let binaryURL = binaryResolver.resolve("average")
let processId = ProcessID.average(config.basename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.averageFailed("average exited with code \(run.exitCode ?? -1)")
}
let canonical = cwd.appendingPathComponent("\(config.basename).ti3")
guard FileManager.default.fileExists(atPath: canonical.path) else {
throw ArgyllRunnerError.missingArtefact(canonical.path)
}
return canonical
}
// MARK: - colprof (Stage 4)
/// Runs `colprof` streaming, collecting logs and classifying progress
/// until the profile is written.
public func runColprof(
config: ColprofConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = try ColprofArgs.build(config: config)
let binaryURL = binaryResolver.resolve("colprof")
let processId = ProcessID.colprof(cleanBasename)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(
id: processId,
events: events,
onLogBatch: onLogBatch,
flushPartialLines: true
)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.colprofFailed(
"colprof exited with code \(run.exitCode ?? -1)"
)
}
// Argyll may produce `.icm` on Windows, but on macOS we expect `.icc`.
// `resolveProfile` checks `.icm` first, then `.icc`, matching #69.
guard let profileURL = ArtefactProbe.resolveProfile(
basename: cleanBasename,
cwd: cwd
) else {
let defaultURL = cwd.appendingPathComponent("\(cleanBasename).icc")
throw ArgyllRunnerError.missingArtefact(defaultURL.path)
}
return profileURL
}
// MARK: - applycal (post-colprof calibration curve)
/// Embeds a `.cal` curve into an `.icc`/`.icm` profile.
///
/// Runs `applycal` captured and performs an in-place replace via
/// `{input}.applycal.tmp` then `replaceItemAt`. On failure the tmp
/// file is removed and the original is left untouched. The UI must
/// never request `unapply` (#52).
public func runApplycal(
config: ApplycalConfig
) async throws -> URL {
assert(!config.unapply, "runApplycal does not support unapply")
let inputURL = config.inputProfileURL
let cwd = inputURL.deletingLastPathComponent()
let binaryURL = binaryResolver.resolve("applycal")
let processId = ProcessID.applycal(inputURL.lastPathComponent)
let tmpURL = inputURL.appendingPathExtension("applycal.tmp")
let fm = FileManager.default
// Remove any stale tmp from a previous crash.
try? fm.removeItem(at: tmpURL)
await ensureNotRunning(id: processId)
let outputConfig = ApplycalConfig(
calibrationPath: config.calibrationPath,
inputProfileURL: inputURL,
outputProfileURL: tmpURL,
unapply: false
)
let outputArgs = try ApplycalArgs.build(config: outputConfig)
let result = try await processManager.runCaptured(
id: processId,
binary: binaryURL,
arguments: outputArgs,
workingDirectory: cwd
)
guard result.exitCode == 0, !Task.isCancelled else {
try? fm.removeItem(at: tmpURL)
if Task.isCancelled {
throw CancellationError()
}
throw ArgyllRunnerError.applycalFailed(
result.stderr.isEmpty
? "applycal exited with code \(result.exitCode)"
: result.stderr
)
}
guard fm.fileExists(atPath: tmpURL.path) else {
throw ArgyllRunnerError.applycalFailed(
"applycal did not create temp profile"
)
}
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(
"calibrated profile is too small (\(size) bytes)"
)
}
do {
if fm.fileExists(atPath: inputURL.path) {
_ = try fm.replaceItemAt(inputURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: inputURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
throw ArgyllRunnerError.applycalFailed(error.localizedDescription)
}
return inputURL
}
// MARK: - iccgamut (post-colprof gamut mesh)
/// Extracts a `.gam` mesh from the finished profile.
public func runIccgamut(
config: IccgamutConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> URL {
let profileURL = config.profileURL
let cwd = profileURL.deletingLastPathComponent()
let stem = profileURL.deletingPathExtension().lastPathComponent
let args = try IccgamutArgs.build(config: config)
let binaryURL = binaryResolver.resolve("iccgamut")
let processId = ProcessID.iccgamut(stem: stem)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.iccgamutFailed(
"iccgamut exited with code \(run.exitCode ?? -1)"
)
}
let gamURL = cwd.appendingPathComponent("\(stem).gam")
guard FileManager.default.fileExists(atPath: gamURL.path) else {
throw ArgyllRunnerError.missingArtefact(gamURL.path)
}
return gamURL
}
// MARK: - profcheck (Stage 5 verification)
/// Verifies a profile against the canonical `.ti3`.
public func runProfcheck(
config: ProfcheckConfig,
onLogBatch: (@Sendable ([String]) -> Void)? = nil
) async throws -> ProfcheckReport {
let cwd = config.ti3URL.deletingLastPathComponent()
let ti3Path = config.ti3URL.path
let iccURL = Self.resolveProfileForVerification(config.iccURL)
let config = ProfcheckConfig(ti3URL: config.ti3URL, iccURL: iccURL)
let args = try ProfcheckArgs.build(config: config)
let binaryURL = binaryResolver.resolve("profcheck")
let processId = ProcessID.profcheck(ti3Path: ti3Path)
await ensureNotRunning(id: processId)
let events = processManager.events()
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
let run = await collect(id: processId, events: events, onLogBatch: onLogBatch)
guard run.exitCode == 0 else {
throw ArgyllRunnerError.profcheckFailed(
run.stderr.isEmpty
? "profcheck exited with code \(run.exitCode ?? -1)"
: run.stderr
)
}
let output = (run.stdout + "\n" + run.stderr).trimmingCharacters(in: .whitespacesAndNewlines)
let report = ProfcheckParser.parse(output)
guard report.isValid else {
throw ArgyllRunnerError.profcheckUnparseable
}
return report
}
private static func resolveProfileForVerification(_ url: URL) -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { return url }
let alt = url.pathExtension.lowercased() == "icc"
? url.deletingPathExtension().appendingPathExtension("icm")
: url.deletingPathExtension().appendingPathExtension("icc")
return fm.fileExists(atPath: alt.path) ? alt : url
}
// MARK: - chartread (Stage 3 interactive)
/// Runs `chartread` and returns an `AsyncStream` of typed events.
///
/// Subscribe-before-spawn, prompt/row/log forwarding, and exit verification
/// are all handled here. Use `sendChartreadInput` to drive the child and
/// `cancelChartread` to terminate it.
public func runChartread(config: ChartreadConfig) -> AsyncStream<ChartreadEvent> {
let cleanBasename: String
let cwd: URL
do {
cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let args: [String]
do {
args = try ChartreadArgs.build(config: config)
} catch {
return AsyncStream { continuation in
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
}
}
let binaryURL = binaryResolver.resolve("chartread")
let processId = ProcessID.chartread(cleanBasename)
let processManager = self.processManager
let isXY = config.isXY
return AsyncStream { continuation in
let task = Task {
await ensureNotRunning(id: processId)
let events = processManager.events()
// Register the XY parking hook before spawning.
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(for: .milliseconds(500))
}
}
do {
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed(error.localizedDescription)))
continuation.finish()
return
}
var state: ChartreadState = .idle
var pendingLogs: [String] = []
var lastFlush = Date()
var exitCode: Int32?
func flushLogs() {
guard !pendingLogs.isEmpty else { return }
let batch = pendingLogs
pendingLogs.removeAll(keepingCapacity: true)
continuation.yield(.log(batch))
}
for await event in events {
guard event.id == processId else { continue }
switch event {
case .stdout(_, let line):
let previous = state
let classified = ChartreadClassifier.classify(line: line, previousState: previous)
state = classified.state
if classified.isRemoveSheetNotice {
continuation.yield(.removeSheetNotice)
}
let shouldPrompt =
classified.sheetNumber != nil
|| classified.alignmentPatch != nil
|| classified.requestedWarningKey != nil
|| classified.state != previous
|| classified.isTableContinuation
if shouldPrompt {
continuation.yield(.prompt(classified))
}
pendingLogs.append(line)
case .stderr(_, let line):
pendingLogs.append(line)
case .jsonRow(_, let payload):
do {
let row = try JSONDecoder().decode(ChartreadRow.self, from: payload)
state = row.isFinalRow ? .allStripsRead : state
continuation.yield(.row(row))
} catch {
pendingLogs.append("Malformed row JSON: \(error.localizedDescription)")
}
case .error(_, let message):
pendingLogs.append("Error: \(message)")
case .exit(_, let code):
exitCode = code
}
if exitCode == nil,
pendingLogs.count >= 20 || Date().timeIntervalSince(lastFlush) >= 0.1 {
flushLogs()
lastFlush = Date()
}
if exitCode != nil {
flushLogs()
break
}
}
if Task.isCancelled {
continuation.finish()
return
}
let canonical = cwd.appendingPathComponent("\(cleanBasename).ti3")
if let code = exitCode, code == 0 {
if FileManager.default.fileExists(atPath: canonical.path) {
continuation.yield(.completed(canonical))
} else {
continuation.yield(.failed(ArgyllRunnerError.missingArtefact(canonical.path)))
}
} else {
continuation.yield(.failed(ArgyllRunnerError.chartreadFailed("chartread exited with code \(exitCode ?? -1)")))
}
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
Task {
await processManager.kill(id: processId)
}
}
}
}
/// Send an exact input sequence to the running `chartread` child.
public func sendChartreadInput(basename: String, input: ChartreadInput) async throws {
let cleanBasename = try PathSecurity.sanitizeBasename(basename)
let processId = ProcessID.chartread(cleanBasename)
try await processManager.sendStdin(id: processId, bytes: input.bytes)
}
/// Terminate a running `chartread` child.
///
/// The actual XY parking is handled by the pre-kill hook registered in
/// `runChartread`.
public func cancelChartread(basename: String, isXY: Bool = false) {
let cleanBasename = try? PathSecurity.sanitizeBasename(basename)
guard let cleanBasename else { return }
let processId = ProcessID.chartread(cleanBasename)
Task {
await processManager.kill(id: processId)
}
}
}
/// Events emitted by a running `chartread` session.
public enum ChartreadEvent: Sendable {
/// Classified prompt / state update.
case prompt(ChartreadClassifyResult)
/// A decoded `ROW_COLORS_JSON` row.
case row(ChartreadRow)
/// A batched log chunk (stdout + stderr lines).
case log([String])
/// Informational "remove last sheet" notice.
case removeSheetNotice
/// Process exited with the given code.
case exit(Int32)
/// Successful completion with the canonical `.ti3` URL.
case completed(URL)
/// Failure (non-zero exit, missing artefact, spawn/parse error).
case failed(ArgyllRunnerError)
}
/// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable {
case trigger // " \n"
case accept // "\n"
case done // "d\n"
case quit // "q\n"
case customKey(String)
public var bytes: Data {
switch self {
case .trigger:
return Data(" \n".utf8)
case .accept:
return Data("\n".utf8)
case .done:
return Data("d\n".utf8)
case .quit:
return Data("q\n".utf8)
case .customKey(let key):
return Data("\(key)\n".utf8)
}
}
}
@@ -0,0 +1,100 @@
import Foundation
/// Resolves Argyll sidecar binaries (docs/04 §0.1 `resolve_binary`).
///
/// Order:
/// 1. Settings `argyll_binary_dir` override only if `<dir>/<name>`
/// exists there.
/// 2. Bundled `<bundle>/Resources/Argyll/<platform>/<name>`.
/// On macOS, `macos-universal` wins whenever it contains the `instlist`
/// marker; otherwise `macos-arm64` / `macos-x86_64` by host arch.
/// 3. If nothing exists the *constructed* bundled path is still returned
/// a missing binary surfaces later as `process:error` on spawn,
/// matching v1 semantics.
public struct BinaryResolver: Sendable {
/// Root that contains the platform dirs `Bundle.resource/Argyll` in
/// the app, a fixture dir in tests.
public let bundledRoot: URL
/// `settings.argyll_binary_dir`, already expanded to a URL.
public let overrideDir: URL?
/// Host architecture directory names, universal preferred.
public let archDirs: [String]
public init(
bundledRoot: URL = AppPaths.bundledArgyllDir,
overrideDir: URL? = nil,
archDirs: [String]? = nil
) {
self.bundledRoot = bundledRoot
self.overrideDir = overrideDir
#if arch(arm64)
let fallback = ["macos-arm64", "macos-aarch64"]
#else
let fallback = ["macos-x86_64"]
#endif
self.archDirs = archDirs ?? ["macos-universal"] + fallback
}
/// Marker used to decide whether `macos-universal` is usable.
public static let markerBinary = "instlist"
/// Resolves a tool name to an absolute URL (never throws see type
/// docs). `name` is the bare tool name, e.g. `"targen"`.
public func resolve(_ name: String) -> URL {
let fm = FileManager.default
if let dir = overrideDir {
let candidate = dir.appendingPathComponent(name)
if fm.fileExists(atPath: candidate.path) {
return candidate
}
}
return bundledRoot
.appendingPathComponent(platformDir(), isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// The bundled platform directory that resolution will use.
public func platformDir() -> String {
let fm = FileManager.default
let universal = bundledRoot.appendingPathComponent("macos-universal")
if fm.fileExists(
atPath: universal.appendingPathComponent(Self.markerBinary).path
) {
return "macos-universal"
}
for dir in archDirs where dir != "macos-universal" {
if fm.fileExists(
atPath: bundledRoot
.appendingPathComponent(dir)
.appendingPathComponent(Self.markerBinary).path
) {
return dir
}
}
// Nothing present still return the preferred dir so the error
// message points at where the user should drop binaries.
return archDirs.first ?? "macos-universal"
}
/// Bundled mock tool (tracked in git under `Resources/Argyll/mocks/`).
public func mock(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("mocks", isDirectory: true)
.appendingPathComponent("\(name).mock", isDirectory: false)
}
/// Bundled reference gamut (`Resources/Argyll/reference_gamuts/`).
public func referenceGamut(_ name: String) -> URL {
bundledRoot
.appendingPathComponent("reference_gamuts", isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
}
/// Whether the resolved path exists and is executable.
public func exists(_ url: URL) -> Bool {
FileManager.default.isExecutableFile(atPath: url.path)
}
}
@@ -0,0 +1,99 @@
import Foundation
/// Errors during `buildPrinttargArgs` validation (docs/04 §2.2).
public enum PrinttargArgError: LocalizedError, Equatable {
case invalidCustomPageDimension(Double)
case invalidDPI(Int)
case invalidSeed(Int)
public var errorDescription: String? {
switch self {
case .invalidCustomPageDimension(let mm):
return "Custom page dimensions must be at least 50 mm, got: \(mm)"
case .invalidDPI(let dpi):
return "TIFF DPI must be between 72 and 600, got: \(dpi)"
case .invalidSeed(let seed):
return "Custom layout seed must be ≥ 1, got: \(seed)"
}
}
}
/// Pure argv builder for Argyll's `printtarg` tool (docs/09, docs/04 §2.2).
///
/// Contract:
/// ```
/// -v -u -i {instrument} -p {page} [-r | -R seed] [-d label] {-t|-T} {dpi} [-K|-I cal] basename
/// ```
public enum PrinttargArgs {
/// Builds the exact command-line arguments for `printtarg`.
///
/// Invariants:
/// - Always `-v -u` (the fork's `-u` emits the JSON page manifest).
/// - Default layout is deterministic `-R 1` (#163) a missing seed
/// reshuffles patches on every re-run and desyncs print vs `.ti2`.
/// - `.raster` emits `-r` and supersedes any seed. This is NOT
/// targen's `-r` full-spread algorithm (docs/25).
/// - `-d` is the chart **label** string, not colour space.
/// - `-K`/`-I` are never emitted for `CAL_` basenames the
/// calibration chart must not embed its own curves.
/// - Basename is the last positional argument.
public static func build(config: PrinttargConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = [
"-v", "-u",
"-i", config.instrument.rawValue,
"-p", try pageSizeValue(config),
]
switch config.layoutOrder {
case .deterministic:
args.append(contentsOf: ["-R", "1"])
case .customSeed:
guard config.customSeed >= 1 else {
throw PrinttargArgError.invalidSeed(config.customSeed)
}
args.append(contentsOf: ["-R", "\(config.customSeed)"])
case .raster:
args.append("-r")
}
if let label = config.label?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
args.append(contentsOf: ["-d", label])
}
guard (72...600).contains(config.dpi) else {
throw PrinttargArgError.invalidDPI(config.dpi)
}
args.append(contentsOf: [config.bitDepth.flag, "\(config.dpi)"])
if !cleanBasename.hasPrefix("CAL_"),
let cal = config.calibrationFile?.trimmingCharacters(in: .whitespacesAndNewlines),
!cal.isEmpty {
args.append(contentsOf: [config.calibrationEmbedOnly ? "-I" : "-K", cal])
}
args.append(cleanBasename)
return args
}
private static func pageSizeValue(_ config: PrinttargConfig) throws -> String {
guard config.pageSize == .custom else { return config.pageSize.rawValue }
for dim in [config.customPageWidth, config.customPageHeight] {
guard dim >= 50 else {
throw PrinttargArgError.invalidCustomPageDimension(dim)
}
}
return "\(formatMM(config.customPageWidth))x\(formatMM(config.customPageHeight))"
}
/// Formats millimetres as an integer when exact, else decimal.
private static func formatMM(_ value: Double) -> String {
if value == value.rounded(), abs(value) < 1e15 {
return "\(Int(value))"
}
return String(format: "%.1f", locale: Locale(identifier: "en_US_POSIX"), value)
}
}
@@ -0,0 +1,208 @@
import Foundation
/// Measurement instrument for `printtarg -i` chart geometry
/// (docs/09, docs/04 §2.2). Raw values are the Argyll codes.
public enum PrintInstrument: String, Codable, Sendable, CaseIterable {
case i1
case p3
case cm = "CM"
case ss = "SS"
case dtp20 = "20"
case dtp22 = "22"
case dtp41 = "41"
case dtp51 = "51"
public var displayName: String {
switch self {
case .i1: return "X-Rite i1Pro / i1Pro 2"
case .p3: return "X-Rite i1Pro 3 / 3 Plus"
case .cm: return "ColorMunki"
case .ss: return "Specbos / Spectraval (XY table)"
case .dtp20: return "Gretag i1Display 2"
case .dtp22: return "X-Rite i1Display Pro / ColorMunki Display"
case .dtp41: return "Datacolor Spyder 4/5"
case .dtp51: return "Spyder X"
}
}
}
/// Page size for `printtarg -p` (docs/09). `.custom` emits `{W}x{H}` mm.
public enum PageSize: String, Codable, Sendable, CaseIterable {
case a4 = "A4"
case a4r = "A4R"
case a3 = "A3"
case a2 = "A2"
case letter = "Letter"
case letterR = "LetterR"
case legal = "Legal"
case fourBySix = "4x6"
case elevenBySeventeen = "11x17"
case custom = "custom"
public var isCustom: Bool { self == .custom }
}
/// TIFF bit depth: `-t` (8-bit) or `-T` (16-bit).
public enum TiffBitDepth: Int, Codable, Sendable, CaseIterable {
case eight = 8
case sixteen = 16
public var flag: String {
switch self {
case .eight: return "-t"
case .sixteen: return "-T"
}
}
}
/// Patch layout order (docs/09 §Randomisation, #163).
/// `.deterministic` is the default (`-R 1`); `.raster` emits `-r` and
/// supersedes any seed never confuse with targen's `-r` algorithm.
public enum LayoutOrder: String, Codable, Sendable, CaseIterable {
case deterministic
case customSeed = "custom_seed"
case raster
public var displayName: String {
switch self {
case .deterministic: return "Deterministic (seed 1)"
case .customSeed: return "Custom seed"
case .raster: return "Raster order (no shuffle)"
}
}
}
/// Chart label metadata used to assemble the automatic `printtarg -d`
/// label. Any empty/missing component becomes `Unspecified` until real
/// printer metadata lands in M3.
public struct TargetLabelMetadata: Codable, Equatable, Sendable {
public var printer: String
public var inkSet: String
public var driverPaper: String
public var actualPaper: String
public init(
printer: String = "",
inkSet: String = "",
driverPaper: String = "",
actualPaper: String = ""
) {
self.printer = printer
self.inkSet = inkSet
self.driverPaper = driverPaper
self.actualPaper = actualPaper
}
}
/// Builds the chart legend for `printtarg -d` (fork argyllcms#19,
/// ICCery #119). `-d` here is a **label string** not targen's colour
/// space, not iccgamut's density (docs/25).
public enum PrinttargLabel {
public static let unspecified = "Unspecified"
/// `ICCery - {basename} - {printer} - {ink} - {driverPaper} -
/// {actualPaper} - DD/MM/YYYY HH:MM`
///
/// `date` is injected for deterministic tests; production passes
/// the current local time. A fixed POSIX locale keeps the format
/// stable regardless of user locale.
public static func automatic(
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "dd/MM/yyyy HH:mm"
return [
"ICCery",
basename,
field(metadata.printer),
field(metadata.inkSet),
field(metadata.driverPaper),
field(metadata.actualPaper),
formatter.string(from: date),
].joined(separator: " - ")
}
/// Resolves the label to emit: an explicit non-empty manual label
/// wins; otherwise the assembled automatic label.
public static func resolved(
customLabel: String?,
basename: String,
metadata: TargetLabelMetadata,
date: Date = Date(),
timeZone: TimeZone = .current
) -> String {
if let label = customLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!label.isEmpty {
return label
}
return automatic(basename: basename, metadata: metadata, date: date, timeZone: timeZone)
}
private static func field(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? unspecified : trimmed
}
}
/// Configuration model for `printtarg` invocation (docs/09, docs/04 §2.2).
public struct PrinttargConfig: Codable, Equatable, Sendable {
public var instrument: PrintInstrument
public var pageSize: PageSize
/// Custom page dimensions in millimetres; each must be 50 when
/// `pageSize == .custom`.
public var customPageWidth: Double
public var customPageHeight: Double
public var bitDepth: TiffBitDepth
public var dpi: Int
public var layoutOrder: LayoutOrder
/// Seed for `.customSeed` layout (`-R N`, N 1). Ignored for
/// `.deterministic` (fixed `-R 1`) and `.raster` (`-r`).
public var customSeed: Int
/// Resolved `-d` label. Callers usually compute this via
/// `PrinttargLabel.resolved` so tests can inject the clock.
public var label: String?
/// `.cal` file applied to printed patches (`-K`), or embedded
/// without applying (`-I` when `calibrationEmbedOnly`). Never
/// emitted for `CAL_` basenames (Stage 0 protection).
public var calibrationFile: String?
public var calibrationEmbedOnly: Bool
public var basename: String
public var workingDirectory: URL?
public init(
instrument: PrintInstrument = .i1,
pageSize: PageSize = .a4,
customPageWidth: Double = 210,
customPageHeight: Double = 297,
bitDepth: TiffBitDepth = .eight,
dpi: Int = 300,
layoutOrder: LayoutOrder = .deterministic,
customSeed: Int = 1,
label: String? = nil,
calibrationFile: String? = nil,
calibrationEmbedOnly: Bool = false,
basename: String = "",
workingDirectory: URL? = nil
) {
self.instrument = instrument
self.pageSize = pageSize
self.customPageWidth = customPageWidth
self.customPageHeight = customPageHeight
self.bitDepth = bitDepth
self.dpi = dpi
self.layoutOrder = layoutOrder
self.customSeed = customSeed
self.label = label
self.calibrationFile = calibrationFile
self.calibrationEmbedOnly = calibrationEmbedOnly
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,180 @@
import Foundation
/// One page of a `printtarg -u` manifest (docs/05 §2.3).
/// `patches` is a page-assigned count including TID/padding cells,
/// not strictly user patches.
public struct PrinttargPage: Codable, Equatable, Sendable {
public var filename: String
public var patches: Int
public var widthMm: Double
public var heightMm: Double
public init(filename: String, patches: Int, widthMm: Double, heightMm: Double) {
self.filename = filename
self.patches = patches
self.widthMm = widthMm
self.heightMm = heightMm
}
enum CodingKeys: String, CodingKey {
case filename, patches
case widthMm = "width_mm"
case heightMm = "height_mm"
}
}
/// The final-only, pretty-printed, **unprefixed** JSON object emitted
/// by fork `printtarg -u` after all pages are written (docs/05 §2.3).
public struct PrinttargManifest: Codable, Equatable, Sendable {
public var event: String
public var pages: [PrinttargPage]
public init(event: String = "manifest", pages: [PrinttargPage]) {
self.event = event
self.pages = pages
}
}
/// A manifest page resolved against the working directory, with its
/// host-side PNG preview (#58 TIFF is never fed to the UI directly).
public struct GalleryPage: Equatable, Sendable, Identifiable {
public var id: Int { index }
public let index: Int
public let page: PrinttargPage
public let fileURL: URL
public let previewPNG: Data?
public let previewError: String?
public init(index: Int, page: PrinttargPage, fileURL: URL, previewPNG: Data?, previewError: String?) {
self.index = index
self.page = page
self.fileURL = fileURL
self.previewPNG = previewPNG
self.previewError = previewError
}
}
public enum ManifestError: LocalizedError, Equatable {
case noJSONDocument
case wrongEvent(String)
case decodeFailed(String)
case invalidPage(String)
public var errorDescription: String? {
switch self {
case .noJSONDocument:
return "No JSON document found in printtarg stdout."
case .wrongEvent(let event):
return "Unexpected JSON event \"\(event)\" — expected \"manifest\"."
case .decodeFailed(let reason):
return "printtarg manifest JSON failed to decode: \(reason)"
case .invalidPage(let reason):
return "printtarg manifest page is invalid: \(reason)"
}
}
}
/// Extracts and decodes the `printtarg -u` manifest from the complete
/// accumulated stdout (docs/04 §2.3, docs/09 §JSON manifest).
///
/// #68 invariant: the JSON is a structured document, not a brace-hunt.
/// Extraction is string/escape-aware a `{` or `}` inside a quoted
/// filename can never corrupt the scan and starts only at a `{` that
/// begins a trimmed stdout line.
public enum PrinttargManifestExtractor {
/// Finds the manifest object in accumulated stdout.
public static func manifest(from stdout: String) throws -> PrinttargManifest {
for block in jsonObjects(in: stdout) {
let data = Data(block.utf8)
guard let manifest = try? JSONDecoder().decode(PrinttargManifest.self, from: data) else {
continue
}
guard manifest.event == "manifest" else {
throw ManifestError.wrongEvent(manifest.event)
}
try validate(manifest)
return manifest
}
if let first = jsonObjects(in: stdout).first,
let obj = try? JSONSerialization.jsonObject(with: Data(first.utf8)) as? [String: Any],
let event = obj["event"] as? String {
throw ManifestError.wrongEvent(event)
}
throw ManifestError.noJSONDocument
}
private static func validate(_ manifest: PrinttargManifest) throws {
for page in manifest.pages {
guard page.patches >= 0 else {
throw ManifestError.invalidPage("negative patch count \(page.patches)")
}
guard page.widthMm > 0, page.heightMm > 0 else {
throw ManifestError.invalidPage("non-positive page size \(page.widthMm)x\(page.heightMm)")
}
let name = page.filename
guard !name.isEmpty,
!name.hasPrefix("/"),
!name.contains("/"),
!name.contains("\\"),
!name.contains("..") else {
throw ManifestError.invalidPage("unsafe filename \"\(name)\"")
}
let ext = (name as NSString).pathExtension.lowercased()
guard ext == "tif" || ext == "tiff" else {
throw ManifestError.invalidPage("non-TIFF filename \"\(name)\"")
}
}
}
/// Yields every complete top-level JSON object `{...}` found at a
/// trimmed line boundary, in document order. Depth tracking respects
/// quoted strings and backslash escapes.
static func jsonObjects(in text: String) -> [String] {
var out: [String] = []
let scalars = Array(text.unicodeScalars)
var i = 0
func isLineStart(_ idx: Int) -> Bool {
var j = idx - 1
while j >= 0 && scalars[j] != "\n" {
if scalars[j] != " " && scalars[j] != "\t" && scalars[j] != "\r" {
return false
}
j -= 1
}
return true
}
while i < scalars.count {
if scalars[i] == "{", isLineStart(i) {
var depth = 0
var inString = false
var escaped = false
var j = i
while j < scalars.count {
let c = scalars[j]
if inString {
if escaped { escaped = false }
else if c == "\\" { escaped = true }
else if c == "\"" { inString = false }
} else {
if c == "\"" { inString = true }
else if c == "{" { depth += 1 }
else if c == "}" {
depth -= 1
if depth == 0 {
out.append(String(String.UnicodeScalarView(scalars[i...j])))
i = j
break
}
}
}
j += 1
}
}
i += 1
}
return out
}
}
@@ -0,0 +1,104 @@
import Foundation
/// Errors during `buildTargenArgs` validation (docs/04 §1.2).
public enum TargenArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPatchCount(Int)
case invalidWhitePatches(Int)
case invalidBlackPatches(Int)
case invalidInkLimit(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid target basename: \(name)"
case .invalidPatchCount(let count):
return "Patch count must be positive, got: \(count)"
case .invalidWhitePatches(let count):
return "White patches cannot be negative, got: \(count)"
case .invalidBlackPatches(let count):
return "Black patches cannot be negative, got: \(count)"
case .invalidInkLimit(let limit):
return "Ink limit must be between 1 and 400, got: \(limit)"
}
}
}
/// Pure argv builder for Argyll's `targen` tool (docs/08, docs/04 §1.2).
public enum TargenArgs {
/// Builds the exact command-line arguments for `targen`.
///
/// Invariants:
/// - Always starts `-v -d {2|4}` (RGB=2, CMYK=4).
/// - Never emits `-u` (Argyll fork progress is not enabled for targen).
/// - Always emits `-f N` when patchCount > 0 (#44).
/// - White `-e`, Black `-B`.
/// - `-N` omitted when approximately 0.50.
/// - `-A` is emitted even at 0.10 (no default-skip).
/// - `-l` is CMYK only (1...400).
/// - `-V` omitted when approximately 1.0.
/// - `-p` omitted when non-positive or approximately 1.0.
/// - Basename is the last positional argument.
public static func build(config: TargenConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.patchCount > 0 else {
throw TargenArgError.invalidPatchCount(config.patchCount)
}
guard config.whitePatches >= 0 else {
throw TargenArgError.invalidWhitePatches(config.whitePatches)
}
guard config.blackPatches >= 0 else {
throw TargenArgError.invalidBlackPatches(config.blackPatches)
}
var args: [String] = [
"-v",
"-d", config.colourSpace.dFlagValue,
"-f", "\(config.patchCount)",
"-e", "\(config.whitePatches)",
"-B", "\(config.blackPatches)"
]
if let g = config.greySteps, g > 0 {
args.append(contentsOf: ["-g", "\(g)"])
}
if let s = config.singleChannelSteps, s > 0 {
args.append(contentsOf: ["-s", "\(s)"])
}
if let n = config.neutralSteps, n > 0 {
args.append(contentsOf: ["-n", "\(n)"])
}
if let nConc = config.neutralConcentration, abs(nConc - 0.50) >= 0.001 {
args.append(contentsOf: ["-N", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), nConc)])
}
if let c = config.preconditioningProfile?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
args.append(contentsOf: ["-c", c])
}
if config.ofpsHighQuality == true {
args.append("-G")
}
if let a = config.ofpsAdaptation {
args.append(contentsOf: ["-A", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), a)])
}
if let algFlag = config.fullSpreadAlgorithm?.flag {
args.append(algFlag)
}
if config.colourSpace == .cmyk, let inkLimit = config.totalInkLimit {
guard (1...400).contains(inkLimit) else {
throw TargenArgError.invalidInkLimit(inkLimit)
}
args.append(contentsOf: ["-l", "\(inkLimit)"])
}
if let v = config.darkEmphasis, abs(v - 1.0) >= 0.001 {
args.append(contentsOf: ["-V", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), v)])
}
if let p = config.devicePower, p > 0, abs(p - 1.0) >= 0.001 {
args.append(contentsOf: ["-p", String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), p)])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,150 @@
import Foundation
/// Colour space for patch generation (docs/08, docs/04 §1.2).
public enum ColourSpace: String, Codable, Sendable, CaseIterable {
case rgb
case cmyk
/// Argyll targen `-d` flag argument: 2 for RGB, 4 for CMYK.
public var dFlagValue: String {
switch self {
case .rgb: return "2"
case .cmyk: return "4"
}
}
}
/// Patch count preset for Stage 1.
public enum PatchCountPreset: String, Codable, Sendable, CaseIterable {
case draft400 = "400"
case standard800 = "800"
case photo1500 = "1500"
case custom = "custom"
public var patchCount: Int? {
switch self {
case .draft400: return 400
case .standard800: return 800
case .photo1500: return 1500
case .custom: return nil
}
}
public var title: String {
switch self {
case .draft400: return "Draft (400)"
case .standard800: return "Standard (800)"
case .photo1500: return "Photo (1500)"
case .custom: return "Custom"
}
}
}
/// Full spread patch distribution algorithm (docs/08).
/// Default is "ofps" (no flag emitted).
public enum FullSpreadAlgorithm: String, Codable, Sendable, CaseIterable {
case ofps = "ofps"
case target = "-t"
case random = "-r"
case uniformRandom = "-R"
case quasiRandom = "-q"
case uniformQuasiRandom = "-Q"
case invertedQuasiRandom = "-i"
case invertedUniformQuasiRandom = "-I"
public var displayName: String {
switch self {
case .ofps: return "OFPS (Default)"
case .target: return "Target (-t)"
case .random: return "Random (-r)"
case .uniformRandom: return "Uniform Random (-R)"
case .quasiRandom: return "Quasi-random (-q)"
case .uniformQuasiRandom: return "Uniform Quasi-random (-Q)"
case .invertedQuasiRandom: return "Inverted Quasi-random (-i)"
case .invertedUniformQuasiRandom: return "Inverted Uniform Quasi-random (-I)"
}
}
public var flag: String? {
switch self {
case .ofps: return nil
default: return rawValue
}
}
/// Preset JSON value: `"ofps"` or the bare flag letter
/// (`t`, `r`, `R`, `q`, `Q`, `i`, `I`) docs/22.
public var presetValue: String {
switch self {
case .ofps: return "ofps"
default: return String(rawValue.dropFirst())
}
}
public init?(presetValue: String) {
if presetValue == "ofps" {
self = .ofps
} else {
self.init(rawValue: "-" + presetValue)
}
}
}
/// Configuration model for `targen` invocation (docs/08, docs/04 §1.2).
public struct TargenConfig: Codable, Equatable, Sendable {
public var colourSpace: ColourSpace
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
public var fullSpreadAlgorithm: FullSpreadAlgorithm?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
public var basename: String
public var workingDirectory: URL?
public init(
colourSpace: ColourSpace = .rgb,
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: FullSpreadAlgorithm? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
basename: String = "",
workingDirectory: URL? = nil
) {
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,372 @@
import Foundation
/// Errors that can occur while parsing CGATS-like data.
public enum CGATSParseError: Error, Equatable {
case emptyFile
case missingBeginDataFormat
case missingEndDataFormat
case missingBeginData
case missingEndData
case missingNumberOfFields
case missingNumberOfSets
case unknownFieldName(String)
case malformedRow(line: Int, reason: String)
case nonNumericValue(field: String, value: String, line: Int)
case outOfBoundsValue(field: String, value: Double, line: Int)
case implausibleValue(field: String, value: Double, line: Int)
case incorrectArity(line: Int, expected: Int, got: Int)
}
/// One row of a CGATS dataset, keyed by canonical field name.
public struct CGATSSample: Sendable, Equatable {
public var id: String
public var loc: String?
public var values: [String: String]
public init(id: String, loc: String? = nil, values: [String: String] = [:]) {
self.id = id
self.loc = loc
self.values = values
}
}
/// A parsed CGATS / CTI3 / CSV dataset.
public struct CGATSDataset: Sendable, Equatable {
public var format: CGATSFormat
public var keywords: [String: String]
public var fieldNames: [String]
public var samples: [CGATSSample]
public var colorRep: String?
public var deviceClass: String?
public var targetInstrument: String?
public init(
format: CGATSFormat,
keywords: [String: String] = [:],
fieldNames: [String] = [],
samples: [CGATSSample] = [],
colorRep: String? = nil,
deviceClass: String? = nil,
targetInstrument: String? = nil
) {
self.format = format
self.keywords = keywords
self.fieldNames = fieldNames
self.samples = samples
self.colorRep = colorRep
self.deviceClass = deviceClass
self.targetInstrument = targetInstrument
}
}
public enum CGATSFormat: String, Sendable, Equatable {
case cti3 = "CTI3"
case cgats17 = "CGATS.17"
case csv = "CSV"
}
/// Parser for CGATS.17, CTI3, ISO28178, and simple CSV datasets.
public enum CGATSParser {
/// Parse the contents of a CGATS-like file.
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix("SAMPLE_ID,")
let (format, lines) = try preprocess(contents, isCSV: isCSV)
var formatStart: Int?
var formatEnd: Int?
var dataStart: Int?
var dataEnd: Int?
var keywords = [String: String]()
for (index, line) in lines.enumerated() {
switch Self.normalizedKeyword(line) {
case "BEGIN_DATA_FORMAT": formatStart = index
case "END_DATA_FORMAT": formatEnd = index
case "BEGIN_DATA": dataStart = index
case "END_DATA": dataEnd = index
default:
if let (key, value) = parseKeyword(line) {
keywords[key] = value
}
}
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
let fieldNames = rawFieldNames.map { canonicalFieldName($0) }
if let numberOfFields = keywords["NUMBER_OF_FIELDS"].flatMap(Int.init),
numberOfFields != fieldNames.count {
// Warn only; the data format line is the source of truth.
} else if keywords["NUMBER_OF_FIELDS"] == nil {
// Optional header; do not fail.
}
if let numberOfSets = keywords["NUMBER_OF_SETS"].flatMap(Int.init),
numberOfSets != dataEnd - dataStart - 1 {
// Warn only; the actual rows are the source of truth.
} else if keywords["NUMBER_OF_SETS"] == nil {
// Optional header; do not fail.
}
struct RawSample {
var id: String
var loc: String?
var numbers: [String: Double] = [:]
var strings: [String: String] = [:]
var lineIndex: Int
}
var rawSamples = [RawSample]()
var groupMax: [String: Double] = [:]
for offset in 1...(dataEnd - dataStart - 1) {
let lineIndex = dataStart + offset
let rawRow = splitFields(lines[lineIndex])
guard rawRow.count == fieldNames.count else {
throw .incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
for (i, name) in fieldNames.enumerated() {
let raw = stripInlineComment(rawRow[i])
if isNumericField(name) {
let cleaned = raw.trimmingCharacters(in: .whitespaces)
if let number = parseNumber(cleaned) {
sample.numbers[name] = number
if let group = deviceGroup(name) {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
}
}
sample.id = sample.strings["SAMPLE_ID"] ?? sample.numbers["SAMPLE_ID"].map { String(format: "%.0f", $0) } ?? String(offset)
sample.loc = sample.strings["SAMPLE_LOC"]
rawSamples.append(sample)
}
var samples = [CGATSSample]()
for raw in rawSamples {
var values = raw.strings
for (name, number) in raw.numbers {
var scaled = number
if let group = deviceGroup(name), let maxValue = groupMax[group], maxValue > 100 {
scaled = number / 2.55
}
values[name] = validateValue(scaled, field: name, line: raw.lineIndex + 1)
}
var sample = CGATSSample(id: raw.id, loc: raw.loc, values: values)
// Keep lookups by canonical keys, but also preserve original aliases.
let rawRow = splitFields(lines[raw.lineIndex])
for (i, rawName) in rawFieldNames.enumerated() {
let canonical = canonicalFieldName(rawName)
if canonical != rawName {
sample.values[rawName] = rawRow[i]
}
}
samples.append(sample)
}
let colorRep = keywords["COLOR_REP"] ?? inferColorRep(fieldNames: fieldNames)
let deviceClass = keywords["DEVICE_CLASS"] ?? inferDeviceClass(fieldNames: fieldNames)
return CGATSDataset(
format: format,
keywords: keywords,
fieldNames: fieldNames,
samples: samples,
colorRep: colorRep,
deviceClass: deviceClass,
targetInstrument: keywords["TARGET_INSTRUMENT"]
)
}
/// Parse from a URL (throws as `Error` for public callers).
public static func parse(url: URL) throws -> CGATSDataset {
let contents = try String(contentsOf: url)
return try parse(contents, sourceURL: url)
}
// MARK: - Internals
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
var format: CGATSFormat?
for var line in allLines {
line = stripComment(line)
line = line.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty else { continue }
if format == nil {
if line.hasPrefix("CTI3") { format = .cti3 }
else if line.hasPrefix("CGATS.17") { format = .cgats17 }
else if isCSV { format = .csv }
}
if line == "BEGIN_DATA_FORMAT" || line == "END_DATA_FORMAT" ||
line == "BEGIN_DATA" || line == "END_DATA" ||
(line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("END_DATA_FORMAT") ||
line.hasPrefix("BEGIN_DATA") || line.hasPrefix("END_DATA")) {
// These are exact keywords; keep them intact.
}
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
if let format, format == .csv,
!lines.contains(where: { Self.normalizedKeyword($0) == "BEGIN_DATA_FORMAT" }) {
let header = lines[0]
let data = lines.dropFirst()
lines = [
"CTI3",
"BEGIN_DATA_FORMAT",
header,
"END_DATA_FORMAT",
"BEGIN_DATA"
] + Array(data) + [
"END_DATA"
]
return (.csv, lines)
}
return (format ?? .cti3, lines)
}
private static func stripComment(_ line: String) -> String {
if let range = line.range(of: "#") {
return String(line[..<range.lowerBound])
}
return line
}
private static func stripInlineComment(_ token: String) -> String {
if let range = token.range(of: "#") {
return String(token[..<range.lowerBound]).trimmingCharacters(in: .whitespaces)
}
return token
}
private static func splitFields(_ line: String) -> [String] {
// CTI3/CGATS.17 use whitespace/tabs; CSV uses commas.
if line.contains(",") {
return line.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }
}
return line.components(separatedBy: .whitespaces).filter { !$0.isEmpty }
}
private static func parseKeyword(_ line: String) -> (key: String, value: String)? {
// KEYWORD value or KEYWORD "value"
let tokens = splitFields(line)
guard let key = tokens.first else { return nil }
// Data-boundary keywords are not value keywords.
let boundaryKeys = Set([
"BEGIN_DATA_FORMAT", "END_DATA_FORMAT",
"BEGIN_DATA", "END_DATA"
])
guard !boundaryKeys.contains(key) else { return nil }
let rawValue = tokens.dropFirst().joined(separator: " ")
let value = rawValue.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return (key, value)
}
private static func normalizedKeyword(_ line: String) -> String {
line.uppercased().trimmingCharacters(in: .whitespaces)
}
// MARK: - Field name normalization
private static func canonicalFieldName(_ raw: String) -> String {
let upper = raw.uppercased()
.replacingOccurrences(of: " ", with: "_")
.replacingOccurrences(of: "-", with: "_")
switch upper {
case "SAMPLE_ID", "ID": return "SAMPLE_ID"
case "SAMPLE_LOC", "LOC": return "SAMPLE_LOC"
case "SAMPLE_NAME": return "SAMPLE_ID"
case "LAB_L", "L*", "L_AB": return "LAB_L"
case "LAB_A", "A*", "A_AB": return "LAB_A"
case "LAB_B", "B*", "B_AB": return "LAB_B"
case "XYZ_X", "X": return "XYZ_X"
case "XYZ_Y", "Y": return "XYZ_Y"
case "XYZ_Z", "Z": return "XYZ_Z"
default: return upper
}
}
private static func isNumericField(_ name: String) -> Bool {
let numericNames: Set = [
"SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"
]
return !numericNames.contains(name)
}
private static func parseNumber(_ raw: String) -> Double? {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.number(from: raw)?.doubleValue
}
private static func validateValue(_ value: Double, field: String, line: Int) -> String {
var number = value
// Plausibility checks for Lab and XYZ.
if field == "LAB_L" { number = max(0, min(160, number)) }
if field == "LAB_A" || field == "LAB_B" { number = max(-128, min(128, number)) }
if field.hasPrefix("XYZ_") { number = max(0, min(200, number)) }
return String(format: "%.4f", number)
}
private static func deviceGroup(_ name: String) -> String? {
if name.hasPrefix("RGB_") { return "RGB" }
if name.hasPrefix("CMYK_") { return "CMYK" }
if name.hasPrefix("DEVICE_") { return "DEVICE" }
return nil
}
private static func inferColorRep(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "CMYK" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "RGB" }
if fieldNames.contains(where: { $0.hasPrefix("LAB_") }) { return "LAB" }
if fieldNames.contains(where: { $0.hasPrefix("XYZ_") }) { return "XYZ" }
return nil
}
private static func inferDeviceClass(fieldNames: [String]) -> String? {
if fieldNames.contains(where: { $0.hasPrefix("CMYK_") }) { return "PRINTER" }
if fieldNames.contains(where: { $0.hasPrefix("RGB_") }) { return "DISPLAY" }
return "OUTPUT"
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Human-readable summary of an imported CGATS dataset.
public struct CGATSSummary: Sendable, Equatable {
public let patchCount: Int
public let colorSpace: String?
public let deviceClass: String?
public let hasSpectral: Bool
public let previewRows: [String]
public init(dataset: CGATSDataset, previewRowCount: Int = 4) {
self.patchCount = dataset.samples.count
self.colorSpace = dataset.colorRep
self.deviceClass = dataset.deviceClass
self.hasSpectral = dataset.fieldNames.contains { $0.hasPrefix("SPECTRAL_") }
self.previewRows = Array(dataset.samples.prefix(previewRowCount).map { sample in
"\(sample.id)" + (sample.loc.map { " \($0)" } ?? "")
})
}
}
@@ -0,0 +1,82 @@
import Foundation
/// Errors from writing a canonical `.ti3` dataset.
public enum CGATSWriterError: Error, Equatable {
case noSamples
case missingRequiredField(String)
case invalidValue(field: String, value: String)
}
/// Write a `CGATSDataset` to Argyll-consumable `.ti3` text.
public enum CGATSWriter {
public static func write(_ dataset: CGATSDataset) throws -> String {
guard !dataset.samples.isEmpty, !dataset.fieldNames.isEmpty else {
throw CGATSWriterError.noSamples
}
var lines = [String]()
// Header
lines.append(dataset.format.rawValue)
lines.append("")
lines.append("DESCRIPTOR \"ICCery CGATS export\"")
if let colorRep = dataset.colorRep {
lines.append("COLOR_REP \"\(colorRep)\"")
}
if let deviceClass = dataset.deviceClass {
lines.append("DEVICE_CLASS \"\(deviceClass)\"")
}
if let instrument = dataset.targetInstrument {
lines.append("TARGET_INSTRUMENT \"\(instrument)\"")
}
lines.append("NUMBER_OF_FIELDS \(dataset.fieldNames.count)")
lines.append("NUMBER_OF_SETS \(dataset.samples.count)")
lines.append("")
lines.append("BEGIN_DATA_FORMAT")
lines.append(dataset.fieldNames.joined(separator: "\t"))
lines.append("END_DATA_FORMAT")
lines.append("")
lines.append("BEGIN_DATA")
for sample in dataset.samples {
let row = try dataset.fieldNames.map { field in
guard let raw = sample.values[field], !raw.isEmpty else {
throw CGATSWriterError.missingRequiredField(field)
}
// Normalize numeric fields to a compact decimal.
if isNumeric(field) {
return normalizedNumber(raw)
}
return raw
}
lines.append(row.joined(separator: "\t"))
}
lines.append("END_DATA")
return lines.joined(separator: "\n") + "\n"
}
public static func write(_ dataset: CGATSDataset, to url: URL) throws {
let text = try write(dataset)
try text.write(to: url, atomically: true, encoding: .utf8)
}
// MARK: - Internals
private static func isNumeric(_ field: String) -> Bool {
let nonNumeric: Set = ["SAMPLE_ID", "SAMPLE_LOC", "SAMPLE_NAME"]
return !nonNumeric.contains(field)
}
private static func normalizedNumber(_ raw: String) -> String {
guard let number = Double(raw) else { return raw }
if number == floor(number) {
return String(format: "%.0f", number)
}
return String(format: "%.4f", number)
}
}
@@ -0,0 +1,40 @@
import Foundation
/// Small host-side file helpers (issue #6).
public enum ArtefactFiles {
/// `get_default_working_dir` `resolveSafeCwd(nil)`.
public static func defaultWorkingDirectory() -> URL {
PathSecurity.resolveSafeCwd(nil)
}
/// `read_file_base64` for **text artefacts** the UI needs verbatim
/// (ti1/ti2 previews, CGATS datasets, logs). Binary payloads (TIFF)
/// go through `TiffPreview` instead.
public static func readBase64(_ url: URL) throws -> String {
try Data(contentsOf: url).base64EncodedString()
}
/// `get_app_info` version, build, and build date for the About dialog.
public static func appInfo(
bundle: Bundle = .main
) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:]
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
let build = info["CFBundleVersion"] as? String ?? "0"
let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
if let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]),
let date = values.contentModificationDate {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
buildDate = formatter.string(from: date)
} else {
buildDate = "Unknown"
}
return (version, build, buildDate)
}
}
@@ -0,0 +1,112 @@
import Foundation
/// Result of `verify_stage_artefacts(cwd, basename)` (docs/06).
public struct StageArtefacts: Sendable, Equatable {
/// `<basename>.ti1` exists (Stage 1 done unlocks Stage 2).
public var stage1Complete = false
/// `<basename>.ti2` exists (Stage 2 done with ti1, unlocks Stage 3).
public var stage2Complete = false
/// `<basename>.ti3` exists (Stage 3 done unlocks Stage 4).
public var stage3Complete = false
/// `.icc`/`.icm` exists (Stage 4 done with ti3, unlocks Stage 5).
public var stage4Complete = false
/// Absolute path of the profile file when present.
public var profilePath: URL?
public init(
stage1Complete: Bool = false,
stage2Complete: Bool = false,
stage3Complete: Bool = false,
stage4Complete: Bool = false,
profilePath: URL? = nil
) {
self.stage1Complete = stage1Complete
self.stage2Complete = stage2Complete
self.stage3Complete = stage3Complete
self.stage4Complete = stage4Complete
self.profilePath = profilePath
}
}
/// Filesystem probing for wizard artefacts (docs/02 §Working directory,
/// docs/06 §Stages). All artefacts live next to each other in `cwd`.
public enum ArtefactProbe {
/// `verify_stage_artefacts` the gating truth source.
public static func verify(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> StageArtefacts {
var out = StageArtefacts()
out.stage1Complete = exists(artefact(basename, "ti1", cwd), fm: fileManager)
out.stage2Complete = exists(artefact(basename, "ti2", cwd), fm: fileManager)
out.stage3Complete = exists(artefact(basename, "ti3", cwd), fm: fileManager)
if let profile = resolveProfile(basename: basename, cwd: cwd, fileManager: fileManager) {
out.stage4Complete = true
out.profilePath = profile
}
return out
}
/// `<cwd>/<basename>.<ext>` the canonical artefact URL.
public static func artefact(_ basename: String, _ ext: String, _ cwd: URL) -> URL {
cwd.appendingPathComponent("\(basename).\(ext)", isDirectory: false)
}
/// Profile extension resolution (#69): existing `.icm` wins over
/// `.icc`; when neither exists the macOS default is `.icc`.
/// (`profcheck`/`iccgamut` swap extension when the requested path is
/// missing.)
public static func resolveProfile(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> URL? {
let icm = artefact(basename, "icm", cwd)
if exists(icm, fm: fileManager) { return icm }
let icc = artefact(basename, "icc", cwd)
if exists(icc, fm: fileManager) { return icc }
return nil
}
/// Default extension for a *new* profile on macOS (#69).
public static let defaultProfileExtension = "icc"
/// Every artefact path for a basename: `.ti1 .ti2 .tif .N.tif
/// .ti3 _passN.ti3 .icc .icm .gam` plus the `CAL_<basename>` namespace.
/// Multi-page TIFFs match `<basename>.tif`, `<basename>.1.tif` and
/// `<basename>_NN.tif` (manifest naming).
public static func existingArtefacts(
basename: String,
cwd: URL,
fileManager: FileManager = .default
) -> [URL] {
guard let entries = try? fileManager.contentsOfDirectory(
at: cwd,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
) else { return [] }
let prefixes = [basename + ".", "CAL_" + basename + "."]
let suffixes: Set<String> = ["ti1", "ti2", "tif", "ti3", "icc", "icm", "gam", "cal"]
let passPrefix = basename + "_pass"
let tifStemPrefix = basename + "_"
let calPrefix = "CAL_" + basename
return entries.filter { url in
let name = url.lastPathComponent
let ext = url.pathExtension.lowercased()
guard suffixes.contains(ext) else { return false }
if prefixes.contains(where: { name.hasPrefix($0) }) { return true }
if name.hasPrefix(passPrefix), ext == "ti3" { return true }
if name.hasPrefix(tifStemPrefix), ext == "tif" { return true }
if name.hasPrefix(calPrefix) { return true }
return false
}.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
private static func exists(_ url: URL, fm: FileManager) -> Bool {
fm.fileExists(atPath: url.path)
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Atomic `.tmp`-then-rename file writes the convention used by
/// settings.json, verification_history.json and wizard_state.json
/// (docs/02 §Persistence, #213).
public enum AtomicFileWriter {
/// Writes `data` to `url` atomically: sibling `<name>.tmp`, then a
/// rename (which is atomic on APFS/HFS+). Parent dirs are created.
public static func write(_ data: Data, to url: URL) throws {
let fm = FileManager.default
let dir = url.deletingLastPathComponent()
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
let tmp = url.appendingPathExtension("tmp")
do {
try data.write(to: tmp, options: [])
// replaceItemAt handles same-volume atomic swap and removes
// the destination cleanly; fall back to remove+move.
if fm.fileExists(atPath: url.path) {
_ = try fm.replaceItemAt(url, withItemAt: tmp)
} else {
try fm.moveItem(at: tmp, to: url)
}
} catch {
try? fm.removeItem(at: tmp)
throw error
}
}
public static func write(_ text: String, to url: URL) throws {
try write(Data(text.utf8), to: url)
}
}
@@ -0,0 +1,54 @@
import Foundation
/// Basename sanitisation and safe working-directory resolution
/// (docs/02 §Working directory, docs/06 §Empty cwd).
public enum PathSecurity {
public enum Error: Swift.Error, Equatable, Sendable {
case invalidBasename(String)
}
/// Basenames must not contain `/`, `\`, or `..` and must be
/// non-empty. Never invent a default basename (#60).
public static func isValidBasename(_ name: String) -> Bool {
guard !name.isEmpty else { return false }
return !name.contains("/") && !name.contains("\\") && !name.contains("..")
}
@discardableResult
public static func sanitizeBasename(_ name: String) throws -> String {
guard isValidBasename(name) else {
throw Error.invalidBasename(name)
}
return name
}
/// `resolve_safe_cwd` (docs/04 §0.2): explicit real directory
/// Documents Home app-data. Never returns an empty/nil cwd.
public static func resolveSafeCwd(
_ explicit: URL?,
fileManager: FileManager = .default
) -> URL {
if let explicit,
fileManager.fileExists(atPath: explicit.path, isDirectory: nil) {
return explicit
}
let candidates: [URL?] = [
fileManager.urls(for: .documentDirectory, in: .userDomainMask).first,
fileManager.homeDirectoryForCurrentUser,
AppPaths.appDataDir,
]
for candidate in candidates {
guard let url = candidate else { continue }
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
}
if fileManager.fileExists(atPath: url.path, isDirectory: nil) {
return url
}
}
// Last resort: app-data, created unconditionally.
try? fileManager.createDirectory(at: AppPaths.appDataDir, withIntermediateDirectories: true)
return AppPaths.appDataDir
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Parsed header of a `.ti2` chart-layout file (docs/06 §Resume).
/// `parse_ti2_header` reads only CGATS keyword lines the data grid
/// itself belongs to issue #30.
public struct Ti2Header: Sendable, Equatable {
/// `TARGET_INSTRUMENT` (e.g. `i1`, `i1iO`, `CM`).
public var instrument: String?
/// `NUMBER_OF_SETS` the patch count. Note: `NUMBER_OF_FIELDS` is
/// the CGATS column count, *not* the patch count.
public var patchCount: Int?
/// `NUMBER_OF_PAGES`.
public var pageCount: Int?
/// A sibling `<stem>.ti1` exists next to the parsed file.
public var hasSiblingTi1 = false
public static func parse(
_ url: URL,
fileManager: FileManager = .default
) -> Ti2Header {
var header = Ti2Header()
guard let text = try? String(contentsOf: url, encoding: .utf8) else {
return header
}
for rawLine in text.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("BEGIN_DATA_FORMAT") || line.hasPrefix("BEGIN_DATA") {
break
}
// CGATS keyword lines: `KEYWORD "value"` or `KEYWORD value`.
guard let space = line.firstIndex(of: " ") else { continue }
let key = String(line[..<space])
let value = String(line[line.index(after: space)...])
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
switch key {
case "TARGET_INSTRUMENT":
header.instrument = value
case "NUMBER_OF_SETS":
header.patchCount = Int(value)
case "NUMBER_OF_PAGES":
header.pageCount = Int(value)
default:
continue
}
}
let stem = url.deletingPathExtension()
header.hasSiblingTi1 = fileManager.fileExists(
atPath: stem.appendingPathExtension("ti1").path
)
return header
}
}
@@ -0,0 +1,38 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// TIFF PNG preview for the Stage 2 gallery (#58): decode on the host
/// side, cap the long edge at 1200 px, emit PNG. Never hand raw TIFF
/// bytes to the UI.
public enum TiffPreview {
public static let maxEdge: Int = 1200
/// Returns PNG data for the first page of a TIFF, or `nil` when the
/// file cannot be decoded.
public static func previewPNG(
tiff url: URL,
maxEdge: Int = Self.maxEdge
) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxEdge,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source, 0, options as CFDictionary
) else { return nil }
let out = NSMutableData()
guard let dest = CGImageDestinationCreateWithData(
out, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else { return nil }
return out as Data
}
}
@@ -0,0 +1,62 @@
import Foundation
import OSLog
/// Severity levels, matching the v1 `log_level` setting values.
public enum LogLevel: String, Codable, Sendable, CaseIterable {
case error, warn, info, debug, trace
var osType: OSLogType {
switch self {
case .error: return .error
case .warn: return .default
case .info: return .info
case .debug: return .debug
case .trace: return .debug
}
}
/// Lower rank = more severe. `shouldLog` keeps `rank <= min`.
var rank: Int {
switch self {
case .error: return 0
case .warn: return 1
case .info: return 2
case .debug: return 3
case .trace: return 4
}
}
}
/// Central logger: `os.Logger` + rolling file sink (`LogSink`), level
/// gated at write time so a settings save takes effect immediately
/// (#158).
public struct AppLogger: Sendable {
public static let shared = AppLogger(category: "app")
private let osLog: Logger
private let sink: LogSink
public let category: String
public init(category: String, sink: LogSink = .shared) {
self.category = category
self.sink = sink
self.osLog = Logger(
subsystem: AppPaths.bundleIdentifier,
category: category
)
}
public func log(_ level: LogLevel, _ message: @autoclosure () -> String) {
let text = LogSanitizer.sanitize(message())
if level.rank <= sink.level.rank {
osLog.log(level: level.osType, "\(text, privacy: .public)")
}
sink.write(level: level, category: category, message: text)
}
public func error(_ message: @autoclosure () -> String) { log(.error, message()) }
public func warn(_ message: @autoclosure () -> String) { log(.warn, message()) }
public func info(_ message: @autoclosure () -> String) { log(.info, message()) }
public func debug(_ message: @autoclosure () -> String) { log(.debug, message()) }
public func trace(_ message: @autoclosure () -> String) { log(.trace, message()) }
}
@@ -0,0 +1,17 @@
import Foundation
/// Rewrites the user's home directory to `~` in log output
/// (docs/03 §Logging hygiene `sanitize_arg_for_logging`).
public enum LogSanitizer {
/// Replaces every occurrence of the current user's home path with `~`.
public static func sanitize(_ text: String) -> String {
let home = NSHomeDirectory()
guard !home.isEmpty else { return text }
return text.replacingOccurrences(of: home, with: "~")
}
/// Sanitizes an argv list for display.
public static func sanitizeArgs(_ args: [String]) -> String {
args.map(sanitize).joined(separator: " ")
}
}
@@ -0,0 +1,127 @@
import Foundation
import OSLog
/// Rolling file sink for `AppLogger` `~/Library/Logs/<bundle>/
/// iccery.log`, rotated at 5 MiB, keeping 5 historical segments
/// (`iccery.log.1` `iccery.log.5`).
///
/// The minimum level is **runtime state** (#158): `setLevel` takes
/// effect immediately at startup and on every settings save.
public final class LogSink: @unchecked Sendable {
public static let shared = LogSink(fileURL: AppPaths.logFile)
private let lock = NSLock()
private let fileURL: URL
private var minimumLevel: LogLevel
private var handle: FileHandle?
/// 5 MiB per segment, 5 historical segments kept.
public static let maxSegmentBytes: UInt64 = 5 * 1024 * 1024
public static let keptSegments = 5
public init(
fileURL: URL = AppPaths.logFile,
minimumLevel: LogLevel? = nil
) {
self.fileURL = fileURL
#if DEBUG
self.minimumLevel = minimumLevel ?? .debug
#else
self.minimumLevel = minimumLevel ?? .info
#endif
}
public var level: LogLevel {
lock.lock()
defer { lock.unlock() }
return minimumLevel
}
/// Applied at startup AND on every settings save (issue #5, #158).
public func setLevel(_ level: LogLevel) {
lock.lock()
minimumLevel = level
lock.unlock()
}
/// `nil` DEBUG-build default (.debug) / release (.info).
public func applySettings(_ settings: AppSettings) {
setLevel(settings.effectiveLogLevel)
}
public func shouldLog(_ level: LogLevel) -> Bool {
level.rank <= { lock.lock(); defer { lock.unlock() }; return minimumLevel }().rank
}
// MARK: - Writing
/// Appends a `YYYY-MM-DD HH:mm:ss.SSS [LEVEL] category: msg` line,
/// rotating first when the active segment exceeds 5 MiB.
public func write(level: LogLevel, category: String, message: String) {
guard shouldLog(level) else { return }
lock.lock()
defer { lock.unlock() }
rotateIfNeeded()
openIfNeeded()
let stamp = Self.timestamp()
let line = "\(stamp) [\(level.rawValue.uppercased())] \(category): \(message)\n"
if let data = line.data(using: .utf8) {
handle?.write(data)
}
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
f.locale = Locale(identifier: "en_US_POSIX")
return f
}()
private static func timestamp() -> String {
formatter.string(from: Date())
}
private func openIfNeeded() {
guard handle == nil else { return }
try? FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true
)
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
handle = try? FileHandle(forWritingTo: fileURL)
try? handle?.seekToEnd()
}
/// Shifts `iccery.log.4.5`, `.3.4`, , `.log.1` and resets the
/// writer. Oldest segment is deleted.
private func rotateIfNeeded() {
guard FileManager.default.fileExists(atPath: fileURL.path),
let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let size = attrs[.size] as? UInt64,
size >= Self.maxSegmentBytes
else { return }
try? handle?.close()
handle = nil
let fm = FileManager.default
let oldest = fileURL.appendingPathExtension("\(Self.keptSegments)")
try? fm.removeItem(at: oldest)
for i in stride(from: Self.keptSegments - 1, through: 1, by: -1) {
let src = fileURL.appendingPathExtension("\(i)")
let dst = fileURL.appendingPathExtension("\(i + 1)")
if fm.fileExists(atPath: src.path) {
try? fm.moveItem(at: src, to: dst)
}
}
try? fm.moveItem(at: fileURL, to: fileURL.appendingPathExtension("1"))
}
/// Tail of the active log for the settings dialog's "copy excerpt".
public func tailExcerpt(maxBytes: Int = 32 * 1024) -> String {
guard let data = try? Data(contentsOf: fileURL) else { return "" }
let slice = data.suffix(maxBytes)
return String(decoding: slice, as: UTF8.self)
}
}
@@ -0,0 +1,78 @@
import Foundation
/// Errors during `average` argv construction.
public enum AverageArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
case invalidPassCount(Int)
case outputCollidesWithInput
case pathOutsideCwd(URL)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid basename for average: \(name)"
case .invalidPassCount(let count):
return "Average requires at least 2 pass files, got \(count)"
case .outputCollidesWithInput:
return "Average output filename collides with one of the inputs"
case .pathOutsideCwd(let url):
return "Pass or output file is outside the working directory: \(url.path)"
}
}
}
/// Configuration for an `average` run.
public struct AverageConfig: Sendable, Equatable {
public let workingDirectory: URL
public let basename: String
public let passFiles: [URL]
public init(
workingDirectory: URL,
basename: String,
passFiles: [URL]
) {
self.workingDirectory = workingDirectory
self.basename = basename
self.passFiles = passFiles
}
}
/// Pure argv builder for Argyll's `average` tool.
public enum AverageArgs {
/// Builds `average -v pass1 pass2 ... basename.ti3` with relative names.
public static func build(config: AverageConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
guard config.passFiles.count >= 2 else {
throw AverageArgError.invalidPassCount(config.passFiles.count)
}
let output = config.workingDirectory
.appendingPathComponent("\(cleanBasename).ti3")
var inputNames: [String] = []
for url in config.passFiles {
try validate(url, isIn: config.workingDirectory)
inputNames.append(url.lastPathComponent)
}
try validate(output, isIn: config.workingDirectory)
let outputName = output.lastPathComponent
guard !inputNames.contains(outputName) else {
throw AverageArgError.outputCollidesWithInput
}
return ["-v"] + inputNames + [outputName]
}
private static func validate(_ url: URL, isIn cwd: URL) throws {
let cwdPath = cwd.standardizedFileURL.path
let urlPath = url.deletingLastPathComponent().standardizedFileURL.path
guard urlPath == cwdPath else {
throw AverageArgError.pathOutsideCwd(url)
}
}
}
@@ -0,0 +1,28 @@
import Foundation
/// Pure argv builder for Argyll's `chartread` tool.
public enum ChartreadArgs {
/// Builds `chartread` argv per the Gronod fork protocol.
///
/// - Always `-v -u`.
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`.
/// - `-Y l` is emitted only when `enableLEDs` is `true`.
/// - Basename is the last positional argument and is sanitized.
public static func build(config: ChartreadConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v", "-u"]
if let port = config.selectedPort, port > 1 {
args.append(contentsOf: ["-c", "\(port)"])
}
if config.enableLEDs {
args.append(contentsOf: ["-Y", "l"])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,302 @@
import Foundation
/// Discrete states for the `chartread` interaction.
public enum ChartreadState: String, Codable, Sendable, Equatable, CaseIterable {
case idle
case calibrating
case awaitingStrip
case reading
case allStripsRead
case warning
case promptContinue
case tablePlaceSheet
case tableAlign
case error
case finished
}
/// Extra metadata produced by classifying a single `chartread` stdout line.
public struct ChartreadClassifyResult: Sendable, Equatable {
public let state: ChartreadState
/// Whether this line is an informational "remove last sheet" notice.
public let isRemoveSheetNotice: Bool
/// Parsed sheet index and total from "sheet N of M read ok" or "place sheet N of M".
public let sheetNumber: Int?
public let sheetTotal: Int?
/// Fiducial patch name from XY "locate patch X with the sight".
public let alignmentPatch: String?
/// When a warning asks for a specific key (e.g. `y` or `n`), the caller should send that key.
public let requestedWarningKey: String?
/// Whether the line is a continuation of a multi-line XY prompt.
public let isTableContinuation: Bool
public init(
state: ChartreadState,
isRemoveSheetNotice: Bool = false,
sheetNumber: Int? = nil,
sheetTotal: Int? = nil,
alignmentPatch: String? = nil,
requestedWarningKey: String? = nil,
isTableContinuation: Bool = false
) {
self.state = state
self.isRemoveSheetNotice = isRemoveSheetNotice
self.sheetNumber = sheetNumber
self.sheetTotal = sheetTotal
self.alignmentPatch = alignmentPatch
self.requestedWarningKey = requestedWarningKey
self.isTableContinuation = isTableContinuation
}
}
/// Pure line classifier for `chartread` stdout.
///
/// Matchers are evaluated in strict priority order (docs/04 §3.5, docs/05 §12.6).
/// XY table continuation lines stay sticky in `TABLE_PLACE_SHEET` / `TABLE_ALIGN`.
public enum ChartreadClassifier {
private typealias Matcher = (String, ChartreadState) -> ChartreadClassifyResult?
public static func classify(
line: String,
previousState: ChartreadState
) -> ChartreadClassifyResult {
let text = line.lowercased()
for matcher in matchers(previousState) {
if let result = matcher(text, previousState) {
return result
}
}
return ChartreadClassifyResult(state: previousState)
}
private static func matchers(_ previous: ChartreadState) -> [Matcher] {
[
removeSheetNotice,
sheetReadOk,
locatePatch,
placeSheet,
continuation(previous),
done,
warning,
calibration,
awaitingStrip,
reading,
error
]
}
// 1. "Please remove last sheet from table" info only.
private static func removeSheetNotice(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard text.contains("remove") && text.contains("last") && text.contains("sheet") else { return nil }
return ChartreadClassifyResult(state: previous, isRemoveSheetNotice: true)
}
// 2. "Sheet N of M read OK".
private static func sheetReadOk(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"sheet\s+(\d+)\s+of\s+(\d+)\s+read\s+ok"#) else { return nil }
return ChartreadClassifyResult(
state: previous,
sheetNumber: match.1,
sheetTotal: match.2
)
}
// 3. "locate patch X with the sight".
private static func locatePatch(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
guard let match = text.firstMatch(pattern: #"locate\s+patch\s+([a-z0-9_]+)\s+with"#),
!match.0.isEmpty else { return nil }
return ChartreadClassifyResult(
state: .tableAlign,
alignmentPatch: match.0.uppercased()
)
}
// 4. "place sheet N of M" or "remove previous sheet".
private static func placeSheet(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
if let match = text.firstMatch(pattern: #"place\s+sheet\s+(\d+)\s+of\s+(\d+)"#) {
return ChartreadClassifyResult(
state: .tablePlaceSheet,
sheetNumber: match.1,
sheetTotal: match.2
)
}
if text.contains("remove previous sheet") || text.contains("place sheet") {
return ChartreadClassifyResult(state: .tablePlaceSheet)
}
return nil
}
// 5. "hit return to continue" sticky if already in a table state.
private static func continuation(_ previous: ChartreadState) -> Matcher {
return { text, _ in
guard text.contains("hit return to continue")
|| text.contains("hit any key to continue")
|| text.contains("hit space to continue")
else { return nil }
if case .tablePlaceSheet = previous {
return ChartreadClassifyResult(state: .tablePlaceSheet, isTableContinuation: true)
}
if case .tableAlign = previous {
return ChartreadClassifyResult(state: .tableAlign, isTableContinuation: true)
}
return ChartreadClassifyResult(state: .promptContinue, isTableContinuation: true)
}
}
// 6. Done / all read.
private static func done(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let phrases = [
"'d' if/when done", "d to finish/save", "all strips/patches read",
"all strips read", "all patches read", "done reading",
"'d' to save", "press d to", "hit 'd'", "d to finish", "d to save"
]
if phrases.contains(where: { text.contains($0) }) {
return ChartreadClassifyResult(state: .allStripsRead)
}
return nil
}
// 7. Warnings / prompts needing a key.
private static func warning(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text
let warningSignals = [
"(warning)", "use it anyway", "seem to have read strip",
"unexpected response", "try again", "do you want to",
"abort ? - are you sure", "are you sure"
]
let isWarningPrompt =
warningSignals.contains(where: { lower.contains($0) })
|| lower.contains("(y/n)")
|| lower.contains("'y' or 'n'")
|| lower.contains("?")
guard isWarningPrompt else { return nil }
var key: String?
if lower.contains("(y/n)") || lower.contains("'y' or 'n'") {
// Default to asking the user; no automatic key.
key = nil
} else if lower.contains("'y'") || lower.contains("press y") || lower.contains("hit 'y'") {
key = "y"
} else if lower.contains("'n'") || lower.contains("press n") || lower.contains("hit 'n'") {
key = "n"
}
return ChartreadClassifyResult(state: .warning, requestedWarningKey: key)
}
// 8. Calibration / place reference / white / standard tile.
private static func calibration(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let placeTokens = ["place", "reference", "white", "calibrat", "standard"]
let hasPlaceSheet = lowercased.contains("place sheet") || lowercased.contains("remove previous sheet")
let hasLocate = lowercased.contains("locate patch")
guard placeTokens.contains(where: { lowercased.contains($0) }),
!hasPlaceSheet,
!hasLocate
else { return nil }
if lowercased.contains("calibrat")
|| lowercased.contains("white reference")
|| lowercased.contains("white tile")
|| lowercased.contains("standard tile")
|| lowercased.contains("reference")
|| lowercased.contains("tile")
|| lowercased.contains("hit any key to continue")
|| lowercased.contains("hit space to continue") {
return ChartreadClassifyResult(state: .calibrating)
}
return nil
}
// 9. Awaiting strip.
private static func awaitingStrip(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
// These are explicit, multi-word prompts; we deliberately do NOT
// match bare "read strip" so that error lines like
// "failed to read strip" or "error reading strip" fall through to
// the error matcher.
let phrases = [
"ready to read",
"hit any key to read",
"hit a key to read",
"hit space to read",
"hit [space] to read",
"press any key to read",
"press space to read",
"trigger instrument",
"start reading",
"read next strip"
]
// Also permit "hit X to read strip Y" or "ready to read strip Z".
if lowercased.range(of: #"(hit|press).+to\s+read\s+strip"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .awaitingStrip)
}
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .awaitingStrip)
}
// 10. Reading.
private static func reading(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lowercased = text.lowercased()
let phrases = ["reading strip", "reading sheet", "processing", "scanning", "reading..."]
guard phrases.contains(where: { lowercased.contains($0) }) else { return nil }
return ChartreadClassifyResult(state: .reading)
}
// 11. Error.
private static func error(text: String, previous: ChartreadState) -> ChartreadClassifyResult? {
let lower = text.lowercased()
// Avoid false positives from confirmation prompts and "no error" status.
guard !lower.contains("no error") else { return nil }
guard !lower.contains("(y/n)")
&& !lower.contains("'y' or 'n'")
&& !lower.contains("?")
else { return nil }
let phraseMatches = ["failed to read", "error reading", "too fast", "too slow", "misread"]
for phrase in phraseMatches {
if lower.contains(phrase) {
return ChartreadClassifyResult(state: .error)
}
}
// Whole-word "error" only bare "failed" alone is not enough.
if lower.range(of: #"\berror\b"#, options: .regularExpression) != nil {
return ChartreadClassifyResult(state: .error)
}
return nil
}
}
private extension String {
func firstMatch(pattern: String) -> (String, Int, Int)? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: self, options: [], range: NSRange(self.startIndex..., in: self))
else { return nil }
let groups: [String] = (1..<match.numberOfRanges).compactMap { i in
let r = match.range(at: i)
guard r.location != NSNotFound, let range = Range(r, in: self) else { return nil }
return String(self[range])
}
guard let first = groups.first else { return nil }
let ints = groups.compactMap { Int($0) }
let a = ints.count > 0 ? ints[0] : 0
let b = ints.count > 1 ? ints[1] : 0
return (first, a, b)
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Errors during `ChartreadArgs` validation.
public enum ChartreadArgError: LocalizedError, Equatable {
case invalidBasename(String)
case invalidPort(Int)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid chart basename: \(name)"
case .invalidPort(let port):
return "Invalid chartread port: \(port)"
}
}
}
/// Configuration for a `chartread` invocation.
public struct ChartreadConfig: Codable, Equatable, Sendable {
public var basename: String
public var workingDirectory: URL?
/// Communication port to pass to `chartread -c`.
/// `nil` means omit `-c` (Auto or port 1).
public var selectedPort: Int?
/// Enable i1Pro 2 visual LEDs (`-Y l`).
public var enableLEDs: Bool
public init(
basename: String,
workingDirectory: URL? = nil,
selectedPort: Int? = nil,
enableLEDs: Bool = false,
isXY: Bool = false
) {
self.basename = basename
self.workingDirectory = workingDirectory
self.selectedPort = selectedPort
self.enableLEDs = enableLEDs
self.isXY = isXY
}
/// Whether the current config implies an XY-table workflow.
/// This is normally supplied by the view model from the selected instrument.
public var isXY: Bool = false
}
@@ -0,0 +1,143 @@
import Foundation
/// A single patch read by `chartread`.
public struct ChartreadPatch: Codable, Sendable, Equatable {
public let id: String
public let loc: String
public let isPad: Bool
public let device: [Double]
public let expected: PatchColor?
public let measured: PatchColor
public init(
id: String,
loc: String,
isPad: Bool,
device: [Double],
expected: PatchColor?,
measured: PatchColor
) {
self.id = id
self.loc = loc
self.isPad = isPad
self.device = device
self.expected = expected
self.measured = measured
}
enum CodingKeys: String, CodingKey {
case id, loc
case isPad = "is_pad"
case device, expected, measured
}
}
/// Colour payload carried by `expected` or `measured`.
public struct PatchColor: Codable, Sendable, Equatable {
public let xyz: CIEXYZ?
public let lab: CIELab?
public let spectral: SpectralData?
public init(xyz: CIEXYZ? = nil, lab: CIELab? = nil, spectral: SpectralData? = nil) {
self.xyz = xyz
self.lab = lab
self.spectral = spectral
}
enum CodingKeys: String, CodingKey {
case xyz = "XYZ"
case lab = "Lab"
case spectral = "spectral"
}
}
public struct CIEXYZ: Codable, Sendable, Equatable {
public let x: Double
public let y: Double
public let z: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.x = try container.decode(Double.self)
self.y = try container.decode(Double.self)
self.z = try container.decode(Double.self)
}
public init(x: Double, y: Double, z: Double) {
self.x = x
self.y = y
self.z = z
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(x)
try container.encode(y)
try container.encode(z)
}
}
public struct CIELab: Codable, Sendable, Equatable {
public let l: Double
public let a: Double
public let b: Double
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
self.l = try container.decode(Double.self)
self.a = try container.decode(Double.self)
self.b = try container.decode(Double.self)
}
public init(l: Double, a: Double, b: Double) {
self.l = l
self.a = a
self.b = b
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(l)
try container.encode(a)
try container.encode(b)
}
}
public struct SpectralData: Codable, Sendable, Equatable {
public let bands: Int
public let startNM: Double
public let endNM: Double
public let norm: Double
public let values: [Double]
enum CodingKeys: String, CodingKey {
case bands
case startNM = "start_nm"
case endNM = "end_nm"
case norm
case values
}
}
/// A complete row emitted by `chartread -u`.
public struct ChartreadRow: Codable, Sendable, Equatable {
public let event: String
public let rowId: String
public let rowIndex: Int
public let totalRows: Int
public let patchCount: Int
public let patches: [ChartreadPatch]
enum CodingKeys: String, CodingKey {
case event
case rowId = "row_id"
case rowIndex = "row_index"
case totalRows = "total_rows"
case patchCount = "patch_count"
case patches
}
public var isFinalRow: Bool {
rowIndex + 1 >= totalRows
}
}
@@ -0,0 +1,174 @@
import Foundation
/// Result of evaluating one measured patch.
public struct SwatchEvaluation: Sendable, Equatable {
public let intended: DisplayRGB
public let measured: DisplayRGB
public let deltaE: Double?
public let classification: SwatchClassification
public init(
intended: DisplayRGB,
measured: DisplayRGB,
deltaE: Double?,
classification: SwatchClassification
) {
self.intended = intended
self.measured = measured
self.deltaE = deltaE
self.classification = classification
}
}
public enum SwatchClassification: String, Sendable, Equatable, CaseIterable {
case good
case warning
case bad
}
/// CIEDE2000 ΔE between two D50 Lab values.
public enum ColorDifference {
/// Compute ΔE using the full CIEDE2000 formula.
public static func deltaE00(_ lab1: LabColor, _ lab2: LabColor) -> Double {
let kL: Double = 1
let kC: Double = 1
let kH: Double = 1
let c1 = sqrt(lab1.a * lab1.a + lab1.b * lab1.b)
let c2 = sqrt(lab2.a * lab2.a + lab2.b * lab2.b)
let cBar = (c1 + c2) / 2.0
let cBar7 = pow(cBar, 7)
let g = 0.5 * (1 - sqrt(cBar7 / (cBar7 + pow(25, 7))))
let a1p = (1 + g) * lab1.a
let a2p = (1 + g) * lab2.a
let c1p = sqrt(a1p * a1p + lab1.b * lab1.b)
let c2p = sqrt(a2p * a2p + lab2.b * lab2.b)
let h1p = atan2ToDegrees(lab1.b, a1p)
let h2p = atan2ToDegrees(lab2.b, a2p)
let deltaLp = lab2.l - lab1.l
let deltaCp = c2p - c1p
var deltaHp: Double = 0
if c1p * c2p == 0 {
deltaHp = 0
} else {
let diff = h2p - h1p
if abs(diff) <= 180 {
deltaHp = diff
} else if diff > 180 {
deltaHp = diff - 360
} else {
deltaHp = diff + 360
}
}
let deltaHp2 = 2 * sqrt(c1p * c2p) * sin(deltaHp * .pi / 360.0)
let lBarp = (lab1.l + lab2.l) / 2.0
let cBarp = (c1p + c2p) / 2.0
var hBarp: Double
if c1p * c2p == 0 {
hBarp = h1p + h2p
} else {
if abs(h1p - h2p) <= 180 {
hBarp = (h1p + h2p) / 2.0
} else if h1p + h2p < 360 {
hBarp = (h1p + h2p + 360) / 2.0
} else {
hBarp = (h1p + h2p - 360) / 2.0
}
}
let t = 1
- 0.17 * cos(deg2rad(hBarp - 30))
+ 0.24 * cos(deg2rad(2 * hBarp))
+ 0.32 * cos(deg2rad(3 * hBarp + 6))
- 0.20 * cos(deg2rad(4 * hBarp - 63))
let dTheta = 30 * exp(-pow((hBarp - 275) / 25, 2))
let cBarp7 = pow(cBarp, 7)
let rc = 2 * sqrt(cBarp7 / (cBarp7 + pow(25, 7)))
let sl = 1 + (0.015 * pow(lBarp - 50, 2)) / sqrt(20 + pow(lBarp - 50, 2))
let sc = 1 + 0.045 * cBarp
let sh = 1 + 0.015 * cBarp * t
let rt = -sin(deg2rad(2 * dTheta)) * rc
let lTerm = deltaLp / (kL * sl)
let cTerm = deltaCp / (kC * sc)
let hTerm = deltaHp2 / (kH * sh)
return sqrt(
lTerm * lTerm
+ cTerm * cTerm
+ hTerm * hTerm
+ rt * cTerm * hTerm
)
}
/// Classify a ΔE value against user thresholds.
public static func classify(deltaE: Double, goodMax: Double, warningMax: Double) -> SwatchClassification {
if deltaE < goodMax { return .good }
if deltaE < warningMax { return .warning }
return .bad
}
/// Evaluate a patch: compute intended/measured sRGB and ΔE if both Lab values are present.
public static func evaluate(
patch: ChartreadPatch,
goodMax: Double,
warningMax: Double
) -> SwatchEvaluation? {
guard let measured = resolveLab(patch.measured) else { return nil }
let measuredRGB = LabColorMath.labToSRGB(measured)
if let expectedColor = patch.expected,
let expectedLab = resolveLab(expectedColor) {
let de = deltaE00(expectedLab, measured)
let intendedRGB = LabColorMath.labToSRGB(expectedLab)
return SwatchEvaluation(
intended: intendedRGB,
measured: measuredRGB,
deltaE: de,
classification: classify(deltaE: de, goodMax: goodMax, warningMax: warningMax)
)
} else {
// No reference: still render measured colour, no ΔE.
return SwatchEvaluation(
intended: measuredRGB,
measured: measuredRGB,
deltaE: nil,
classification: .good
)
}
}
/// Resolve a Lab from a `PatchColor`, computing it from XYZ when Lab is absent.
public static func resolveLab(_ color: PatchColor) -> LabColor? {
if let lab = color.lab {
return LabColor(l: lab.l, a: lab.a, b: lab.b)
}
guard let xyz = color.xyz else { return nil }
return LabColorMath.xyzToLab(XYZColor(x: xyz.x, y: xyz.y, z: xyz.z))
}
private static func atan2ToDegrees(_ y: Double, _ x: Double) -> Double {
let radians = atan2(y, x)
var degrees = radians * 180.0 / .pi
if degrees < 0 { degrees += 360 }
return degrees
}
private static func deg2rad(_ degrees: Double) -> Double {
degrees * .pi / 180.0
}
}
@@ -0,0 +1,58 @@
import Foundation
/// A device discovered by the Argyll `instlist` fork.
public struct InstrumentDevice: Codable, Sendable, Equatable, Identifiable {
public let port: Int
public let name: String
public let type: String
public init(port: Int, name: String, type: String) {
self.port = port
self.name = name
self.type = type
}
public var id: Int { port }
/// XY tables are identified by name or type matching the fork pattern.
public var isXY: Bool {
let combined = "\(name) \(type)".lowercased()
let pattern = #"/spectro\s?scan|i1io/"#
return combined.range(of: pattern, options: .regularExpression) != nil
}
/// Human-readable label shown in the picker.
public var displayName: String {
let xyTag = isXY ? " · XY Table" : ""
return "\(name) [\(type)]\(xyTag)"
}
}
/// The 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,53 @@
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)
}
/// Emits the current unterminated tail as a single line and clears it.
/// Used by `ProcessManager.flushPartialLine` for tools that emit
/// progress dots without newlines.
public mutating func flushPartial() -> String? {
guard !pending.isEmpty else { return nil }
var rest = pending
pending.removeAll(keepingCapacity: false)
if rest.last == 0x0D { rest = rest.dropLast() }
let text = Self.decode(rest)
return text.isEmpty ? nil : text
}
private static func decode(_ bytes: Data.SubSequence) -> String {
String(decoding: bytes, as: UTF8.self)
}
}
@@ -0,0 +1,543 @@
import Foundation
/// Captured output from `runCaptured` one-shot tools whose results
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
public struct CapturedResult: Sendable, Equatable {
public let stdout: String
public let stderr: String
public let exitCode: Int32
}
/// Spawn / stdin / kill / event bus for Argyll sidecar children
/// (docs/02 §Event bus, docs/03 §Process manager).
///
/// Invariants:
/// - Duplicate `id` while a child runs is rejected (#116).
/// - The stdin handle lives in its own map, independent of wait, so
/// `sendStdin` never blocks on process exit (#84).
/// - `ARGYLL_NOT_INTERACTIVE=1` is set on every child.
/// - stdout lines beginning `ROW_COLORS_JSON: ` become `jsonRow` events
/// with the prefix stripped; all other stdout is `stdout` events.
/// - `exit` is emitted exactly once per child, and only after both
/// output pipes reach EOF so no buffered output is lost on fast
/// exits or kills. If EOFs never arrive, a watchdog finalizes.
/// - `kill` runs a pre-kill hook (e.g. XY `q\n` + 500 ms park) before
/// terminating. Hooks are removed once the child finalizes.
/// - `killAll` on `NSApplication.willTerminate` and last-window close
/// runs all hooks and terminates every child (#147, #149).
public actor ProcessManager {
public static let rowColorsPrefix = "ROW_COLORS_JSON: "
public static let shared = ProcessManager()
// MARK: - Event bus (multicast)
/// Lock-protected subscriber table. Registration is *synchronous*
/// inside `events()` so a caller can subscribe, then spawn, without
/// racing the child's first output or exit event.
private final class SubscriberBox: @unchecked Sendable {
private let lock = NSLock()
private var map: [UUID: AsyncStream<ProcessEvent>.Continuation] = [:]
func add(_ continuation: AsyncStream<ProcessEvent>.Continuation, token: UUID) {
lock.lock()
map[token] = continuation
lock.unlock()
}
func remove(_ token: UUID) {
lock.lock()
map.removeValue(forKey: token)
lock.unlock()
}
func yield(_ event: ProcessEvent) {
lock.lock()
let continuations = Array(map.values)
lock.unlock()
for continuation in continuations {
continuation.yield(event)
}
}
}
private nonisolated let subscriberBox = SubscriberBox()
/// Subscribe to the event bus. Each call returns an independent
/// stream; every event is delivered to every live subscriber.
/// The subscriber is registered before `events()` returns callers
/// may spawn immediately after subscribing without losing events.
public nonisolated func events() -> AsyncStream<ProcessEvent> {
let box = subscriberBox
let token = UUID()
return AsyncStream { continuation in
box.add(continuation, token: token)
continuation.onTermination = { _ in box.remove(token) }
}
}
private nonisolated func emit(_ event: ProcessEvent) {
subscriberBox.yield(event)
}
// MARK: - Child registry
private struct RunningChild {
let process: Process
/// stdin lives in its own slot, independent of process wait (#84).
var stdin: FileHandle?
var stdoutDecoder: ProcessLineDecoder
var stderrDecoder: ProcessLineDecoder
var stdoutEOF = false
var stderrEOF = false
/// Set by the termination handler; `exit` is emitted once both
/// pipes have also reached EOF.
var pendingExitCode: Int32?
var finalized = false
/// Watchdog that forces finalization if EOFs never arrive.
var finalizeTask: Task<Void, Never>?
}
private var children: [String: RunningChild] = [:]
/// Processes owned by `runCaptured` (dup detection + kill support).
private var captured: [String: Process] = [:]
/// Hooks run by `kill` before terminating the child.
/// Used by `chartread` to park an XY head with `q\n`.
private var preKillHooks: [String: @Sendable () async -> Void] = [:]
/// Ids of currently-running children.
public var runningIDs: [String] { Array(children.keys) + captured.keys }
public func isRunning(_ id: String) -> Bool {
children[id] != nil || captured[id] != nil
}
// MARK: - Pre-kill hooks
/// Register a hook to run before `kill(id:)` terminates the child.
/// The hook is removed once the child finalizes.
public func setPreKillHook(id: String, hook: @escaping @Sendable () async -> Void) {
preKillHooks[id] = hook
}
// MARK: - Spawn (streaming)
/// Spawns a streaming child. Returns after spawn; callers wait for
/// `exit(id:)` events never assume the return means the tool
/// finished (docs/03).
public func runStreaming(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) throws {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
children[id] = RunningChild(
process: process,
stdin: stdinPipe.fileHandleForWriting,
stdoutDecoder: ProcessLineDecoder(),
stderrDecoder: ProcessLineDecoder()
)
let stdoutHandle = stdoutPipe.fileHandleForReading
let stderrHandle = stderrPipe.fileHandleForReading
stdoutHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: false, handle: handle) }
}
stderrHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard let self else { return }
Task { await self.ingestOutput(data, id: id, isStderr: true, handle: handle) }
}
process.terminationHandler = { [weak self] proc in
guard let self else { return }
Task { await self.didTerminate(id: id, code: proc.terminationStatus) }
}
do {
try process.run()
} catch {
preKillHooks.removeValue(forKey: id)
children.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
}
// MARK: - Spawn (captured)
/// Runs a child to completion and returns all output. Reads stdout
/// and stderr concurrently so a full pipe buffer can never deadlock
/// the child. Used by `printcal` / `applycal` (docs/03) and by
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
public func runCaptured(
id: String,
binary: URL,
arguments: [String],
workingDirectory: URL? = nil,
environment: [String: String] = [:]
) async throws -> CapturedResult {
guard !isRunning(id) else { throw ProcessError.duplicateID(id) }
let process = Process()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.executableURL = binary
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
process.environment = childEnvironment(extra: environment)
AppLogger(category: "process").debug(
"spawn(captured) \(id): \(binary.path) \(LogSanitizer.sanitizeArgs(arguments))"
)
// Register and set up the termination hand-off before run() so
// a very fast exit is never missed (#50, #52).
captured[id] = process
let capturedProcess = process
// Box is local and synchronised with an NSLock; the @unchecked
// Sendable annotation is safe because all access is under the lock.
final class Box: @unchecked Sendable {
private let lock = NSLock()
private var status: Int32?
private var continuation: CheckedContinuation<Int32, Never>?
/// Try to resume an already-stored continuation with the exit
/// status. Returns true if a continuation was resumed.
func resume(with status: Int32) -> Bool {
lock.lock()
if let cont = continuation {
continuation = nil
lock.unlock()
cont.resume(returning: status)
return true
}
self.status = status
lock.unlock()
return false
}
/// Store a continuation, returning any status that arrived
/// before it. The caller must resume with the returned status.
func store(_ continuation: CheckedContinuation<Int32, Never>) -> Int32? {
lock.lock()
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
self.continuation = continuation
// A fast exit may have raced past the first nil-check.
if let status = status {
self.status = nil
self.continuation = nil
lock.unlock()
return status
}
lock.unlock()
return nil
}
}
let box = Box()
capturedProcess.terminationHandler = { proc in
_ = box.resume(with: proc.terminationStatus)
}
do {
try process.run()
} catch {
_ = box.resume(with: -1)
captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.error(id: id, message: error.localizedDescription))
throw ProcessError.spawnFailed("\(binary.path): \(error.localizedDescription)")
}
// Close the parent write ends so readDataToEndOfFile() gets EOF
// as soon as the child exits; the child still has its own copies.
try? stdoutPipe.fileHandleForWriting.close()
try? stderrPipe.fileHandleForWriting.close()
return await withTaskCancellationHandler {
async let outData = Task.detached {
stdoutPipe.fileHandleForReading.readDataToEndOfFile()
}.value
async let errData = Task.detached {
stderrPipe.fileHandleForReading.readDataToEndOfFile()
}.value
let code = await withCheckedContinuation { continuation in
if let status = box.store(continuation) {
continuation.resume(returning: status)
}
}
let (out, err) = await (outData, errData)
// Emit the real exit code once, regardless of whether kill()
// already removed the id from `captured`.
_ = captured.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
emit(.exit(id: id, code: code))
return CapturedResult(
stdout: String(decoding: out, as: UTF8.self),
stderr: String(decoding: err, as: UTF8.self),
exitCode: code
)
} onCancel: { [weak self] in
// If the awaiting Task is cancelled, terminate the child so
// callers like runApplycal never replace a good profile with
// a truncated tmp.
if capturedProcess.isRunning {
capturedProcess.terminate()
}
Task { [weak self] in
await self?.kill(id: id)
}
}
}
// MARK: - stdin
/// Writes the exact bytes (caller includes `\n`) to a child's stdin
/// and flushes (docs/03 §stdin protocol).
public func sendStdin(id: String, bytes: Data) throws {
guard let child = children[id] else { throw ProcessError.unknownID(id) }
guard let handle = child.stdin else {
throw ProcessError.stdinFailed("stdin closed for \(id)")
}
do {
try handle.write(contentsOf: bytes)
} catch {
throw ProcessError.stdinFailed("\(id): \(error.localizedDescription)")
}
}
public func sendStdin(id: String, text: String) throws {
try sendStdin(id: id, bytes: Data(text.utf8))
}
// MARK: - Partial-line flush
/// Emits the current unterminated tail of a streaming child's stdout
/// and stderr as ordinary lines. Callers (e.g. `colprof`) use this
/// to flush progress dots without waiting for a newline.
public func flushPartialLine(id: String) {
guard var child = children[id], !child.finalized else { return }
if let tail = child.stdoutDecoder.flushPartial() {
if tail.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else {
emit(.stdout(id: id, line: tail))
}
}
if let tail = child.stderrDecoder.flushPartial() {
emit(.stderr(id: id, line: tail))
}
children[id] = child
}
// MARK: - Kill
/// Terminates a child. First runs any registered pre-kill hook, then
/// drops stdin and signals the process. For streaming children the
/// `exit` event is emitted once both stdout and stderr EOFs have been
/// seen (or the watchdog finalizes). For captured children the real
/// exit code is emitted by `runCaptured` itself.
public func kill(id: String) async {
if let hook = preKillHooks.removeValue(forKey: id) {
await hook()
}
if var child = children[id] {
try? child.stdin?.close()
child.stdin = nil
children[id] = child
if child.process.isRunning {
child.process.terminate()
} else if child.pendingExitCode == nil {
// The process already exited but `didTerminate` has not
// run; synthesize it so `maybeFinalize` can fire.
didTerminate(id: id, code: child.process.terminationStatus)
}
return
}
if let process = captured[id] {
if process.isRunning { process.terminate() }
// Do not emit `.exit` here; `runCaptured` emits the real code
// after the process reaps.
return
}
}
/// Terminates every running child; returns how many were signaled
/// (`kill_all_processes`, docs/03). Mandatory on app exit (#147/#149).
@discardableResult
public func killAll() async -> Int {
let ids = runningIDs
for id in ids { await kill(id: id) }
return ids.count
}
// MARK: - Force kill (SIGKILL fallback)
/// Sends `SIGKILL` to a streaming child if it is still running.
/// Used by the finalization watchdog when a graceful `terminate()`
/// does not cause the process to exit.
public func forceKill(id: String) {
guard let child = children[id],
!child.finalized,
child.process.isRunning
else { return }
let pid = child.process.processIdentifier
guard pid > 0 else { return }
_ = Darwin.kill(pid, SIGKILL)
}
// MARK: - Internals
private func childEnvironment(extra: [String: String]) -> [String: String] {
var env = ProcessInfo.processInfo.environment
env["ARGYLL_NOT_INTERACTIVE"] = "1"
for (key, value) in extra { env[key] = value }
return env
}
private func ingestOutput(
_ data: Data,
id: String,
isStderr: Bool,
handle: FileHandle
) {
guard var child = children[id] else { return }
if data.isEmpty {
// EOF on this pipe.
handle.readabilityHandler = nil
if isStderr { child.stderrEOF = true } else { child.stdoutEOF = true }
children[id] = child
maybeFinalize(id: id)
return
}
let lines: [String] = isStderr
? child.stderrDecoder.feed(data)
: child.stdoutDecoder.feed(data)
children[id] = child
let log = AppLogger(category: "subprocess")
for line in lines {
if !isStderr, line.hasPrefix(Self.rowColorsPrefix) {
let payload = Data(line.dropFirst(Self.rowColorsPrefix.count).utf8)
emit(.jsonRow(id: id, payload: payload))
} else if isStderr {
log.warn("[\(id)] \(line)")
emit(.stderr(id: id, line: line))
} else {
log.info("[\(id)] \(line)")
emit(.stdout(id: id, line: line))
}
}
}
private func didTerminate(id: String, code: Int32) {
guard var child = children[id], !child.finalized else { return }
child.pendingExitCode = code
try? child.stdin?.close()
child.stdin = nil
// Start a watchdog in case the `readabilityHandler` EOFs never
// arrive after the process exits (e.g. a hung pipe).
child.finalizeTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
}
children[id] = child
maybeFinalize(id: id)
}
/// Emits `exit` once the child has terminated *and* both pipes have
/// drained to EOF, so no buffered output is lost.
private func maybeFinalize(id: String) {
guard var child = children[id],
let code = child.pendingExitCode,
child.stdoutEOF, child.stderrEOF,
!child.finalized
else { return }
child.finalized = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children.removeValue(forKey: id)
preKillHooks.removeValue(forKey: id)
// Flush unterminated tail lines.
if var decoder = Optional(child.stdoutDecoder),
let tail = decoder.finish() {
if tail.hasPrefix(Self.rowColorsPrefix) {
emit(.jsonRow(id: id, payload: Data(tail.dropFirst(Self.rowColorsPrefix.count).utf8)))
} else {
emit(.stdout(id: id, line: tail))
}
}
if var decoder = Optional(child.stderrDecoder),
let tail = decoder.finish() {
emit(.stderr(id: id, line: tail))
}
emit(.exit(id: id, code: code))
}
/// Forces finalization even when one or both EOFs are missing.
/// Used by the `didTerminate` watchdog.
private func forceFinalize(id: String) {
guard var child = children[id], !child.finalized else { return }
if child.pendingExitCode == nil {
child.pendingExitCode = -9
}
child.stdoutEOF = true
child.stderrEOF = true
child.finalizeTask?.cancel()
child.finalizeTask = nil
children[id] = child
maybeFinalize(id: id)
}
}
@@ -0,0 +1,50 @@
import Foundation
/// Errors during `applycal` argv construction.
public enum ApplycalArgError: LocalizedError, Equatable, Sendable {
case invalidCalibrationPath
case invalidInputProfileURL
public var errorDescription: String? {
switch self {
case .invalidCalibrationPath:
return "Calibration path is invalid or empty"
case .invalidInputProfileURL:
return "Input profile path is invalid or empty"
}
}
}
/// Pure argv builder for Argyll's `applycal` tool.
///
/// `applycal` is always run captured, never streamed.
public enum ApplycalArgs {
/// Builds `applycal -v -a {cal} {input} [{output}]`.
///
/// `-u` (unapply) is rejected at the builder level the UI never
/// sends it (docs/04 §7.2).
public static func build(config: ApplycalConfig) throws -> [String] {
let cal = config.calibrationPath.trimmingCharacters(in: .whitespaces)
guard !cal.isEmpty else { throw ApplycalArgError.invalidCalibrationPath }
let input = config.inputProfileURL.path
guard !input.isEmpty else { throw ApplycalArgError.invalidInputProfileURL }
var args: [String] = ["-v"]
if config.unapply {
// Defensive: should never be called from the UI.
args.append("-u")
} else {
args.append("-a")
}
args.append(contentsOf: [cal, input])
if let output = config.outputProfileURL?.path, !output.isEmpty {
args.append(output)
}
return args
}
}
@@ -0,0 +1,22 @@
import Foundation
/// Configuration for an Argyll `applycal` run.
public struct ApplycalConfig: Sendable, Equatable {
public var calibrationPath: String
public var inputProfileURL: URL
public var outputProfileURL: URL?
public var unapply: Bool
/// In-place when `outputProfileURL` is `nil`.
public init(
calibrationPath: String,
inputProfileURL: URL,
outputProfileURL: URL? = nil,
unapply: Bool = false
) {
self.calibrationPath = calibrationPath
self.inputProfileURL = inputProfileURL
self.outputProfileURL = outputProfileURL
self.unapply = unapply
}
}
@@ -0,0 +1,79 @@
import Foundation
/// Errors during `colprof` argv construction.
public enum ColprofArgError: LocalizedError, Equatable, Sendable {
case invalidBasename(String)
public var errorDescription: String? {
switch self {
case .invalidBasename(let name):
return "Invalid colprof basename: \(name)"
}
}
}
/// Pure argv builder for Argyll's `colprof` tool.
public enum ColprofArgs {
/// Builds `colprof` argv per the Gronod fork protocol.
///
/// Always `-v -a {algorithm} -q {quality}`. Optional flags are added
/// only when their fields are non-empty and meaningful. `-f` has
/// special handling for "none" (omit), "" (bare flag), and a custom
/// `.sp` path (passed through). `-c`/`-d` viewing conditions are
/// skipped when set to "none".
public static func build(config: ColprofConfig) throws -> [String] {
let cleanBasename = try PathSecurity.sanitizeBasename(config.basename)
var args: [String] = ["-v"]
args.append(contentsOf: ["-a", config.algorithm])
args.append(contentsOf: ["-q", config.quality])
if let intent = config.intent?.trimmingCharacters(in: .whitespaces), !intent.isEmpty {
args.append(contentsOf: ["-t", intent])
}
if let fwa = config.fwa?.trimmingCharacters(in: .whitespaces) {
switch fwa.lowercased() {
case "none", "":
// "none" omits the flag; an explicit empty string means bare -f.
if fwa.isEmpty {
args.append("-f")
}
default:
args.append(contentsOf: ["-f", fwa])
}
}
if let illuminant = config.illuminant?.trimmingCharacters(in: .whitespaces), !illuminant.isEmpty {
args.append(contentsOf: ["-i", illuminant])
}
if let observer = config.observer?.trimmingCharacters(in: .whitespaces), !observer.isEmpty {
args.append(contentsOf: ["-o", observer])
}
if let inputCond = config.inputViewingCond?.trimmingCharacters(in: .whitespaces),
!inputCond.isEmpty, inputCond.lowercased() != "none" {
args.append(contentsOf: ["-c", inputCond])
}
if let outputCond = config.outputViewingCond?.trimmingCharacters(in: .whitespaces),
!outputCond.isEmpty, outputCond.lowercased() != "none" {
args.append(contentsOf: ["-d", outputCond])
}
let profileDescription = config.description?.trimmingCharacters(in: .whitespaces)
if let description = profileDescription, !description.isEmpty {
args.append(contentsOf: ["-D", description])
}
if let copyright = config.copyright?.trimmingCharacters(in: .whitespaces), !copyright.isEmpty {
args.append(contentsOf: ["-C", copyright])
}
args.append(cleanBasename)
return args
}
}
@@ -0,0 +1,45 @@
import Foundation
/// Configuration for an Argyll `colprof` run (issue #23, docs/16).
public struct ColprofConfig: Sendable, Equatable {
public var algorithm: String
public var quality: String
public var intent: String?
public var fwa: String?
public var illuminant: String?
public var observer: String?
public var inputViewingCond: String?
public var outputViewingCond: String?
public var description: String?
public var copyright: String?
public var basename: String
public var workingDirectory: URL?
public init(
algorithm: String = "l",
quality: String = "m",
intent: String? = nil,
fwa: String? = nil,
illuminant: String? = nil,
observer: String? = nil,
inputViewingCond: String? = nil,
outputViewingCond: String? = nil,
description: String? = nil,
copyright: String? = nil,
basename: String,
workingDirectory: URL? = nil
) {
self.algorithm = algorithm
self.quality = quality
self.intent = intent
self.fwa = fwa
self.illuminant = illuminant
self.observer = observer
self.inputViewingCond = inputViewingCond
self.outputViewingCond = outputViewingCond
self.description = description
self.copyright = copyright
self.basename = basename
self.workingDirectory = workingDirectory
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Classified `colprof` stdout progress milestone.
public enum ColprofProgress: Sendable, Equatable {
case gamutMapping
case fittingClut
case writingIcc
case unknown
}
/// Parses `colprof` plaintext progress (docs/16 §6.3).
///
/// The Gronod fork supports `-u` JSON, but ICCery v2.0 does not pass it.
/// Progress is therefore inferred from case-insensitive substring matches.
public enum ColprofProgressClassifier {
public static func classify(line: String) -> ColprofProgress {
let lower = line.lowercased()
if lower.contains("gamut mapping") {
return .gamutMapping
}
if lower.contains("fitting") || lower.contains("clut") {
return .fittingClut
}
if lower.contains("writing") || lower.contains("icc profile") {
return .writingIcc
}
return .unknown
}
}
@@ -0,0 +1,49 @@
import Foundation
/// Computes a consecutive-breach warning from verification history.
///
/// A drift alert triggers when the most recent chronologically consecutive
/// poor records form a run of at least two, and the first and last of that
/// run are on distinct UTC days or at least one hour apart.
public enum DriftAlert {
/// Returns an alert message, or `nil` when no consecutive breach exists.
public static func compute(from records: [VerificationRecord]) -> String? {
// Work in chronological order.
let chronological = records.sorted { $0.timestamp < $1.timestamp }
// Build the longest suffix of consecutive `.poor` records.
// Non-poor records break the run, so we stop at the first non-poor
// encountered from the end.
var run: [VerificationRecord] = []
for record in chronological.reversed() {
if record.status == .poor {
run.insert(record, at: 0)
} else {
break
}
}
guard run.count >= 2 else { return nil }
let first = run.first!
let last = run.last!
let sameDay = Calendar.utc.isDate(first.timestamp, inSameDayAs: last.timestamp)
let oneHour = last.timestamp.timeIntervalSince(first.timestamp) >= 3600
if !sameDay || oneHour {
return "Drift alert: poor results between \(first.id) and \(last.id)."
}
return nil
}
}
private extension Calendar {
static let utc: Calendar = {
var c = Calendar(identifier: .iso8601)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()
}
@@ -0,0 +1,29 @@
import Foundation
/// Errors during `iccgamut` argv construction.
public enum IccgamutArgError: LocalizedError, Equatable, Sendable {
case invalidProfileURL
public var errorDescription: String? {
switch self {
case .invalidProfileURL:
return "iccgamut requires a valid profile path"
}
}
}
/// Pure argv builder for Argyll's `iccgamut` tool.
public enum IccgamutArgs {
/// Builds `iccgamut -v -d {density} {profilePath}`.
///
/// The caller is responsible for ensuring `density` is a positive
/// integer. `-d` here is surface **density**, not a directory.
public static func build(config: IccgamutConfig) throws -> [String] {
let path = config.profileURL.path
guard !path.isEmpty else { throw IccgamutArgError.invalidProfileURL }
let density = max(1, config.density)
return ["-v", "-d", "\(density)", path]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `iccgamut` run.
public struct IccgamutConfig: Sendable, Equatable {
public var profileURL: URL
public var density: Int
public init(profileURL: URL, density: Int = 10) {
self.profileURL = profileURL
self.density = density
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for a profile installation.
public struct InstallProfileConfig: Sendable, Equatable {
public var sourceURL: URL
public var options: InstallProfileOptions
public init(sourceURL: URL, options: InstallProfileOptions = InstallProfileOptions()) {
self.sourceURL = sourceURL
self.options = options
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Collision policy for profile installation.
public enum ProfileCollisionPolicy: String, Sendable, Equatable, Codable, CaseIterable {
case overwrite
case rename
case cancel
}
/// Options for installing a finished profile into the OS colour store.
public struct InstallProfileOptions: Sendable, Equatable, Codable {
public var forceOverwrite: Bool
public var preferSystem: Bool
public var collisionPolicy: ProfileCollisionPolicy
public var openColorPanel: Bool
public var calibrationNote: String?
public init(
forceOverwrite: Bool = false,
preferSystem: Bool = false,
collisionPolicy: ProfileCollisionPolicy = .cancel,
openColorPanel: Bool = false,
calibrationNote: String? = nil
) {
self.forceOverwrite = forceOverwrite
self.preferSystem = preferSystem
self.collisionPolicy = collisionPolicy
self.openColorPanel = openColorPanel
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Result of installing a profile into the OS colour store.
public struct InstallProfileResult: Sendable, Equatable, Codable {
public var destPath: String
public var registered: Bool
public var overwritten: Bool
public var renamed: Bool
public var openedPanel: Bool
public var message: String
public var calibrationNote: String?
public init(
destPath: String,
registered: Bool,
overwritten: Bool,
renamed: Bool,
openedPanel: Bool,
message: String,
calibrationNote: String? = nil
) {
self.destPath = destPath
self.registered = registered
self.overwritten = overwritten
self.renamed = renamed
self.openedPanel = openedPanel
self.message = message
self.calibrationNote = calibrationNote
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Errors during `profcheck` argv construction.
public enum ProfcheckArgError: LocalizedError, Equatable, Sendable {
case missingTi3
case missingIcc
case invalidTi3Path
public var errorDescription: String? {
switch self {
case .missingTi3:
return "profcheck requires a .ti3 file"
case .missingIcc:
return "profcheck requires a profile (.icc/.icm)"
case .invalidTi3Path:
return "profcheck .ti3 path is invalid"
}
}
}
/// Pure argv builder for Argyll's `profcheck` tool.
public enum ProfcheckArgs {
/// Builds `profcheck -v -k -s -u {ti3Path} {iccPath}`.
///
/// The `-u` here is the Gronod fork JSON report flag, not the
/// generic `-u` auto-fix that some Argyll builds use.
public static func build(config: ProfcheckConfig) throws -> [String] {
let ti3Path = config.ti3URL.path
let iccPath = config.iccURL.path
guard !ti3Path.isEmpty else { throw ProfcheckArgError.missingTi3 }
guard !iccPath.isEmpty else { throw ProfcheckArgError.missingIcc }
return ["-v", "-k", "-s", "-u", ti3Path, iccPath]
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Configuration for an Argyll `profcheck` run.
public struct ProfcheckConfig: Sendable, Equatable {
public var ti3URL: URL
public var iccURL: URL
public init(ti3URL: URL, iccURL: URL) {
self.ti3URL = ti3URL
self.iccURL = iccURL
}
}
@@ -0,0 +1,217 @@
import Foundation
/// Errors returned when `profcheck` output cannot be parsed.
public enum ProfcheckParserError: LocalizedError, Equatable, Sendable {
case unparseable
case jsonDecodingFailed
public var errorDescription: String? {
switch self {
case .unparseable:
return "Could not parse profcheck report"
case .jsonDecodingFailed:
return "profcheck JSON report could not be decoded"
}
}
}
/// Parses the mixed JSON/text output from `profcheck -v -k -s -u`.
public enum ProfcheckParser {
/// Parsing order (issue #25):
/// 1. Patch count from `No of test patches = N`.
/// 2. JSON objects; prefer one with `event == "report"` or `*_de2000` keys.
/// 3. Legacy text: `Profile check complete, errors(CIEDE2000): max. = X, avg. = Y, RMS = Z`.
/// 4. Broad regex fallback.
/// 5. If no metrics found, return a report whose `warning` is set.
public static func parse(_ output: String) -> ProfcheckReport {
var report = ProfcheckReport()
// 1. Patch count.
let patchRegex = try? NSRegularExpression(
pattern: #"No of test patches\s*=\s*(\d+)"#,
options: [.caseInsensitive]
)
if let match = patchRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
), let range = Range(match.range(at: 1), in: output) {
let count = Int(output[range])
report.patchCount = count
}
// 2. JSON objects.
let jsonObjects = extractJSONObjects(from: output)
for object in jsonObjects {
if let event = object["event"] as? String, event == "report" {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
if hasMetricKeys(object) {
if let parsed = metrics(from: object) {
apply(metrics: parsed, to: &report)
return report
}
}
}
// 3. Legacy text.
let textRegex = try? NSRegularExpression(
pattern: #"Profile check complete, errors\(CIEDE2000\): max\.\s*=\s*([0-9.]+),\s*avg\.\s*=\s*([0-9.]+),\s*RMS\s*=\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
if let match = textRegex?.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
) {
let numbers = (1...3).compactMap { i -> Double? in
guard let range = Range(match.range(at: i), in: output) else { return nil }
return Double(output[range])
}
if numbers.count == 3 {
report.maxDE = numbers[0]
report.avgDE = numbers[1]
report.rmsDE = numbers[2]
report.status = report.avgDE.map { VerificationStatus.from(avgDE: $0) }
return report
}
}
// 4. Broad regex fallback.
if let fallback = parseRegexFallback(output) {
var merged = fallback
merged.patchCount = report.patchCount
return merged
}
// 5. Unparseable.
report.warning = "profcheck output did not contain a recognisable report."
return report
}
// MARK: - JSON extraction
private static func extractJSONObjects(from output: String) -> [[String: Any]] {
var objects: [[String: Any]] = []
var start: String.Index?
var depth = 0
for index in output.indices {
let char = output[index]
if char == "{" {
if depth == 0 {
start = index
}
depth += 1
} else if char == "}" {
if depth > 0 {
depth -= 1
if depth == 0, let start = start {
let jsonString = String(output[start...index])
if let data = jsonString.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
objects.append(object)
}
}
}
}
}
return objects
}
private static func hasMetricKeys(_ object: [String: Any]) -> Bool {
let keys = [
"avg_de", "avg_de2000",
"peak_de", "peak_de2000", "max_de",
"rms", "rms_de"
]
return keys.contains { object[$0] != nil }
}
private struct Metrics {
var avg: Double?
var max: Double?
var rms: Double?
}
private static func metrics(from object: [String: Any]) -> Metrics? {
var m = Metrics()
m.avg = doubleValue(for: "avg_de2000", in: object)
?? doubleValue(for: "avg_de", in: object)
m.max = doubleValue(for: "peak_de2000", in: object)
?? doubleValue(for: "peak_de", in: object)
?? doubleValue(for: "max_de", in: object)
?? doubleValue(for: "max_de2000", in: object)
m.rms = doubleValue(for: "rms", in: object)
?? doubleValue(for: "rms_de", in: object)
?? doubleValue(for: "rms_de2000", in: object)
guard m.avg != nil || m.max != nil || m.rms != nil else { return nil }
return m
}
private static func doubleValue(for key: String, in object: [String: Any]) -> Double? {
if let number = object[key] as? Double { return number }
if let number = object[key] as? NSNumber { return number.doubleValue }
if let string = object[key] as? String { return Double(string) }
return nil
}
private static func apply(metrics: Metrics, to report: inout ProfcheckReport) {
report.avgDE = metrics.avg
report.maxDE = metrics.max
report.rmsDE = metrics.rms
if let avg = metrics.avg {
report.status = VerificationStatus.from(avgDE: avg)
}
}
// MARK: - Regex fallback
private static func parseRegexFallback(_ output: String) -> ProfcheckReport? {
var report = ProfcheckReport()
let avgRegex = try? NSRegularExpression(
pattern: #"(?:avg\.?|average)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let maxRegex = try? NSRegularExpression(
pattern: #"(?:max\.?|peak|maximum)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
let rmsRegex = try? NSRegularExpression(
pattern: #"(?:rms)\s*(?:=|:)\s*([0-9.]+)"#,
options: [.caseInsensitive]
)
report.avgDE = firstDouble(from: output, regex: avgRegex)
report.maxDE = firstDouble(from: output, regex: maxRegex)
report.rmsDE = firstDouble(from: output, regex: rmsRegex)
guard report.avgDE != nil || report.maxDE != nil || report.rmsDE != nil else {
return nil
}
if let avg = report.avgDE {
report.status = VerificationStatus.from(avgDE: avg)
}
return report
}
private static func firstDouble(from output: String, regex: NSRegularExpression?) -> Double? {
guard let regex = regex,
let match = regex.firstMatch(
in: output,
options: [],
range: NSRange(output.startIndex..., in: output)
),
let range = Range(match.range(at: 1), in: output) else { return nil }
return Double(output[range])
}
}
@@ -0,0 +1,31 @@
import Foundation
/// Parsed result from a `profcheck -u` run.
public struct ProfcheckReport: Sendable, Equatable, Codable {
public var patchCount: Int?
public var avgDE: Double?
public var maxDE: Double?
public var rmsDE: Double?
public var status: VerificationStatus?
public var warning: String?
public var isValid: Bool {
avgDE != nil && maxDE != nil && rmsDE != nil
}
public init(
patchCount: Int? = nil,
avgDE: Double? = nil,
maxDE: Double? = nil,
rmsDE: Double? = nil,
status: VerificationStatus? = nil,
warning: String? = nil
) {
self.patchCount = patchCount
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.status = status
self.warning = warning
}
}
@@ -0,0 +1,238 @@
import Foundation
/// Errors thrown by `ProfileInstaller`.
public enum ProfileInstallError: LocalizedError, Equatable, Sendable {
case unsafeStem(String)
case sourceMissing
case sourceNotProfile
case sourceTooSmall
case systemRequiresAdminRights
case copyFailed(String)
case cancelled
public var errorDescription: String? {
switch self {
case .unsafeStem(let stem):
return "Profile name contains unsafe characters: \(stem)"
case .sourceMissing:
return "Source profile does not exist"
case .sourceNotProfile:
return "Source must be a .icc or .icm file"
case .sourceTooSmall:
return "Source file is too small to be a valid profile"
case .systemRequiresAdminRights:
return "Installing to /Library/ColorSync/Profiles requires administrator rights"
case .copyFailed(let reason):
return "Could not install profile: \(reason)"
case .cancelled:
return "Install cancelled"
}
}
}
/// Installs an ICC/ICM profile into the OS colour store.
public enum ProfileInstaller {
/// Resolves the destination URL that `install` would write to for the
/// given source and options, without copying anything. Useful for
/// collision previews in the UI.
public static func resolveDestinationURL(
for config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> URL {
let sourceURL = config.sourceURL
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
try validateSourceURL(sourceURL)
let destDir = destinationDirectory(for: config.options, fileManager: fileManager)
return destDir.appendingPathComponent(sourceURL.lastPathComponent)
}
/// Installs `sourceURL` into `~/Library/ColorSync/Profiles` or
/// `/Library/ColorSync/Profiles`. Always copies, never moves.
public static func install(
config: InstallProfileConfig,
fileManager: FileManager = .default
) throws -> InstallProfileResult {
let fm = fileManager
// Source validation.
let sourceURL = config.sourceURL
guard fm.fileExists(atPath: sourceURL.path) else {
throw ProfileInstallError.sourceMissing
}
let ext = sourceURL.pathExtension.lowercased()
guard ext == "icc" || ext == "icm" else {
throw ProfileInstallError.sourceNotProfile
}
let attrs = try? fm.attributesOfItem(atPath: sourceURL.path)
let size = attrs?[.size] as? UInt64 ?? 0
guard size >= 128 else {
throw ProfileInstallError.sourceTooSmall
}
try validateSourceURL(sourceURL)
// Destination directory.
let destURL = try resolveDestinationURL(for: config, fileManager: fm)
try? fm.createDirectory(
at: destURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
// Collision resolution.
let destExists = fm.fileExists(atPath: destURL.path)
if destExists {
if config.options.forceOverwrite {
return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
fileManager: fm,
overwritten: true,
renamed: false
)
} else if config.options.collisionPolicy == .rename {
let epoch = Int(Date().timeIntervalSince1970)
let stem = sourceURL.deletingPathExtension().lastPathComponent
let renamedURL = destURL.deletingLastPathComponent()
.appendingPathComponent("\(stem)-\(epoch).\(ext)")
return try performInstall(
from: sourceURL,
to: renamedURL,
options: config.options,
fileManager: fm,
overwritten: false,
renamed: true
)
} else if config.options.collisionPolicy == .cancel {
throw ProfileInstallError.cancelled
} else {
// Default with askBeforeOverwrite the app must decide.
throw ProfileInstallError.copyFailed("destination already exists")
}
}
return try performInstall(
from: sourceURL,
to: destURL,
options: config.options,
fileManager: fm,
overwritten: false,
renamed: false
)
}
// MARK: - Private helpers
private static func validateSourceURL(_ sourceURL: URL) throws {
let path = sourceURL.path
let stem = sourceURL.deletingPathExtension().lastPathComponent
// Reject backslashes anywhere in the path.
guard !path.contains("\\") else {
throw ProfileInstallError.unsafeStem(stem)
}
// Reject any path component that is literally "." or "..".
// This allows names like "foo..bar" while blocking real traversal.
for component in sourceURL.pathComponents {
if component == "." || component == ".." {
throw ProfileInstallError.unsafeStem(stem)
}
}
}
private static func destinationDirectory(
for options: InstallProfileOptions,
fileManager: FileManager
) -> URL {
if options.preferSystem {
return URL(fileURLWithPath: "/Library/ColorSync/Profiles")
} else {
return fileManager.homeDirectoryForCurrentUser
.appendingPathComponent("Library/ColorSync/Profiles")
}
}
private static func performInstall(
from sourceURL: URL,
to destURL: URL,
options: InstallProfileOptions,
fileManager: FileManager,
overwritten: Bool,
renamed: Bool
) throws -> InstallProfileResult {
let fm = fileManager
let tmpURL = destURL.appendingPathExtension("iccery-install.tmp")
// Remove stale tmp.
try? fm.removeItem(at: tmpURL)
do {
try fm.copyItem(at: sourceURL, to: tmpURL)
let attrs = try? fm.attributesOfItem(atPath: tmpURL.path)
let tmpSize = attrs?[.size] as? UInt64 ?? 0
guard tmpSize >= 128 else {
try? fm.removeItem(at: tmpURL)
throw ProfileInstallError.sourceTooSmall
}
if fm.fileExists(atPath: destURL.path) {
_ = try fm.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
try fm.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? fm.removeItem(at: tmpURL)
// Surface a clear admin-rights hint when writing to system.
if destURL.path.hasPrefix("/Library/") && !fm.fileExists(atPath: destURL.path) {
throw ProfileInstallError.systemRequiresAdminRights
}
if let installError = error as? ProfileInstallError {
throw installError
}
throw ProfileInstallError.copyFailed(error.localizedDescription)
}
let registered = fm.fileExists(atPath: destURL.path)
var openedPanel = false
if options.openColorPanel {
openedPanel = openColorSyncUtility()
}
return InstallProfileResult(
destPath: destURL.path,
registered: registered,
overwritten: overwritten,
renamed: renamed,
openedPanel: openedPanel,
message: "Profile installed to \(destURL.path)",
calibrationNote: options.calibrationNote
)
}
private static func openColorSyncUtility() -> Bool {
let task = Process()
task.launchPath = "/usr/bin/open"
task.arguments = ["-a", "ColorSync Utility"]
task.environment = ["ARGYLL_NOT_INTERACTIVE": "1"]
do {
try task.run()
task.waitUntilExit()
} catch {
return false
}
return true
}
}
@@ -0,0 +1,122 @@
import Foundation
/// Persistence for `VerificationRecord` entries.
public actor VerificationHistoryStore {
/// Default cap.
public static let defaultCapacity = 1000
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var records: [VerificationRecord] = []
private let capacity: Int
private let encoder: JSONEncoder
private let decoder: JSONDecoder
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("verification_history.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
}
/// Loads records from disk. Returns the existing cache if already loaded.
///
/// Throws when the file exists but cannot be parsed; the existing file
/// is never overwritten in that case.
public func load() throws -> [VerificationRecord] {
guard records.isEmpty else { return records }
let fm = FileManager.default
guard fm.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url) else { return [] }
records = try decoder.decode([VerificationRecord].self, from: data)
return records
}
/// Returns all records.
public func all() -> [VerificationRecord] {
records
}
/// Records matching the optional printer filter.
public func filtered(by printer: String?) -> [VerificationRecord] {
guard let printer = printer, !printer.isEmpty else { return records }
return records.filter { $0.printerName == printer }
}
/// Appends a record, trims to capacity, and writes atomically.
///
/// Loads the existing history first and propagates any load error so an
/// unparseable file is never overwritten.
@discardableResult
public func append(_ record: VerificationRecord) throws -> [VerificationRecord] {
try load()
var updated = records
updated.append(record)
if updated.count > capacity {
updated.sort { $0.timestamp < $1.timestamp }
updated = Array(updated.suffix(capacity))
}
try write(updated)
records = updated
return updated
}
/// Removes all history and updates disk.
public func clear() throws {
try write([])
records = []
}
/// RFC-4180 CSV export.
public func exportCSV() -> String {
var lines: [String] = [
csvRow(["id", "profile_name", "printer_name", "avg_de", "max_de", "rms_de", "patch_count", "status", "timestamp"])
]
for record in records {
lines.append(csvRow([
record.id,
record.profileName,
record.printerName,
String(record.avgDE),
String(record.maxDE),
String(record.rmsDE),
String(record.patchCount),
record.status.rawValue,
ISO8601DateFormatter().string(from: record.timestamp)
]))
}
return lines.joined(separator: "\n") + "\n"
}
/// Writes `records` through a temp file and rename.
private func write(_ records: [VerificationRecord]) throws {
let data = try encoder.encode(records)
try AtomicFileWriter.write(data, to: url)
}
private func csvRow(_ fields: [String]) -> String {
fields.map { field in
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
return "\"\(escaped)\""
}
return escaped
}.joined(separator: ",")
}
}
@@ -0,0 +1,36 @@
import Foundation
/// A single entry in the verification history store.
public struct VerificationRecord: Sendable, Equatable, Codable, Identifiable {
public var id: String
public var profileName: String
public var printerName: String
public var avgDE: Double
public var maxDE: Double
public var rmsDE: Double
public var patchCount: Int
public var status: VerificationStatus
public var timestamp: Date
public init(
id: String,
profileName: String,
printerName: String,
avgDE: Double,
maxDE: Double,
rmsDE: Double,
patchCount: Int,
status: VerificationStatus,
timestamp: Date
) {
self.id = id
self.profileName = profileName
self.printerName = printerName
self.avgDE = avgDE
self.maxDE = maxDE
self.rmsDE = rmsDE
self.patchCount = patchCount
self.status = status
self.timestamp = timestamp
}
}
@@ -0,0 +1,32 @@
import Foundation
/// ICCery quality band for a verification run.
///
/// Bands are on the **average** ΔE:
/// - < 1.0 Excellent
/// - < 2.0 Good
/// - < 3.5 Acceptable
/// - 3.5 Warning
public enum VerificationStatus: String, Sendable, Equatable, Codable, CaseIterable {
case excellent
case good
case acceptable
case poor
public var displayName: String {
switch self {
case .excellent: return "Excellent"
case .good: return "Good"
case .acceptable: return "Acceptable"
case .poor: return "Warning"
}
}
/// Returns the quality band for the given average ΔE.
public static func from(avgDE: Double) -> VerificationStatus {
if avgDE < 1.0 { return .excellent }
if avgDE < 2.0 { return .good }
if avgDE < 3.5 { return .acceptable }
return .poor
}
}
@@ -0,0 +1,251 @@
import Foundation
/// Legacy M1 preset shape (`name` + opaque string dictionary). Retained
/// solely to decode and migrate pre-M2 `settings.json`; new code uses
/// `ProfilingPreset` (docs/22 §ProfilingPreset).
public struct CustomPreset: Codable, Equatable, Sendable {
public var name: String
/// Opaque per-stage form values keyed by field id.
public var values: [String: String]
public init(name: String, values: [String: String] = [:]) {
self.name = name
self.values = values
}
}
/// Where `install_profile` drops finished profiles (docs/22).
public enum InstallLocation: String, Codable, Sendable, CaseIterable {
case user
case system
}
/// `settings.json` model (docs/22). snake_case keys match the v1 file
/// so field names stay identical across rewrites.
///
/// Decoding is tolerant: missing keys take documented defaults and each
/// `custom_presets` element is tried as a typed `ProfilingPreset` first
/// and as a legacy M1 `CustomPreset` second a malformed entry never
/// drops the rest of the array (preset migration, issue #11).
public struct AppSettings: Codable, Equatable, Sendable {
/// User override for Argyll binaries; `nil` bundled sidecars.
public var argyllBinaryDir: String?
/// Stored but **never applied to argv** Stage 2's own instrument
/// select is the live source (docs/04 §0.1).
public var defaultInstrument: String?
/// `nil` `.debug` in debug builds, `.info` in release (#158).
public var logLevel: LogLevel?
public var deltaEGoodMax: Double
public var deltaEWarningMax: Double
public var customPresets: [ProfilingPreset]
public var enableI1Pro2Leds: Bool
public var calibrationStaleDays: Int
public var defaultInstallLocation: InstallLocation
public var askBeforeOverwriteProfile: Bool
public var openColorPanelAfterInstall: Bool
public init(
argyllBinaryDir: String? = nil,
defaultInstrument: String? = nil,
logLevel: LogLevel? = nil,
deltaEGoodMax: Double = 2.0,
deltaEWarningMax: Double = 5.0,
customPresets: [ProfilingPreset] = [],
enableI1Pro2Leds: Bool = false,
calibrationStaleDays: Int = 30,
defaultInstallLocation: InstallLocation = .user,
askBeforeOverwriteProfile: Bool = true,
openColorPanelAfterInstall: Bool = false
) {
self.argyllBinaryDir = argyllBinaryDir
self.defaultInstrument = defaultInstrument
self.logLevel = logLevel
self.deltaEGoodMax = deltaEGoodMax
self.deltaEWarningMax = deltaEWarningMax
self.customPresets = customPresets
self.enableI1Pro2Leds = enableI1Pro2Leds
self.calibrationStaleDays = calibrationStaleDays
self.defaultInstallLocation = defaultInstallLocation
self.askBeforeOverwriteProfile = askBeforeOverwriteProfile
self.openColorPanelAfterInstall = openColorPanelAfterInstall
}
public static let `default` = AppSettings()
/// Effective log level runtime state, not just persistence (#158).
public var effectiveLogLevel: LogLevel {
if let logLevel { return logLevel }
#if DEBUG
return .debug
#else
return .info
#endif
}
enum CodingKeys: String, CodingKey {
case argyllBinaryDir = "argyll_binary_dir"
case defaultInstrument = "default_instrument"
case logLevel = "log_level"
case deltaEGoodMax = "delta_e_good_max"
case deltaEWarningMax = "delta_e_warning_max"
case customPresets = "custom_presets"
case enableI1Pro2Leds = "enable_i1pro2_leds"
case calibrationStaleDays = "calibration_stale_days"
case defaultInstallLocation = "default_install_location"
case askBeforeOverwriteProfile = "ask_before_overwrite_profile"
case openColorPanelAfterInstall = "open_color_panel_after_install"
}
/// One element of `custom_presets`: typed first, legacy M1 second.
private enum AnyPreset: Decodable {
case typed(ProfilingPreset)
case legacy(CustomPreset)
init(from decoder: Decoder) throws {
if let p = try? ProfilingPreset(from: decoder) {
self = .typed(p)
return
}
self = .legacy(try CustomPreset(from: decoder))
}
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings.default
argyllBinaryDir = try c.decodeIfPresent(String.self, forKey: .argyllBinaryDir) ?? d.argyllBinaryDir
defaultInstrument = try c.decodeIfPresent(String.self, forKey: .defaultInstrument) ?? d.defaultInstrument
logLevel = try c.decodeIfPresent(LogLevel.self, forKey: .logLevel) ?? d.logLevel
deltaEGoodMax = try c.decodeIfPresent(Double.self, forKey: .deltaEGoodMax) ?? d.deltaEGoodMax
deltaEWarningMax = try c.decodeIfPresent(Double.self, forKey: .deltaEWarningMax) ?? d.deltaEWarningMax
enableI1Pro2Leds = try c.decodeIfPresent(Bool.self, forKey: .enableI1Pro2Leds) ?? d.enableI1Pro2Leds
calibrationStaleDays = try c.decodeIfPresent(Int.self, forKey: .calibrationStaleDays) ?? d.calibrationStaleDays
defaultInstallLocation = try c.decodeIfPresent(InstallLocation.self, forKey: .defaultInstallLocation) ?? d.defaultInstallLocation
askBeforeOverwriteProfile = try c.decodeIfPresent(Bool.self, forKey: .askBeforeOverwriteProfile) ?? d.askBeforeOverwriteProfile
openColorPanelAfterInstall = try c.decodeIfPresent(Bool.self, forKey: .openColorPanelAfterInstall) ?? d.openColorPanelAfterInstall
// Per-element decode: typed presets win; a legacy M1 shape
// ({"name","values"}) migrates; unconvertible entries are
// skipped so one bad record never drops the array.
let elements = (try? c.decodeIfPresent(
[FailableDecodable<AnyPreset>].self, forKey: .customPresets
)) ?? nil
var migrated: [ProfilingPreset] = []
for (index, element) in (elements ?? []).enumerated() {
switch element.value {
case .typed(let preset):
migrated.append(preset)
case .legacy(let legacy):
if let converted = ProfilingPreset(migrating: legacy, index: index) {
migrated.append(converted)
} else {
AppLogger(category: "settings").warn(
"Skipped unmigratable legacy preset: \(legacy.name)"
)
}
case .none:
AppLogger(category: "settings").warn(
"Skipped malformed preset entry at index \(index)"
)
}
}
customPresets = migrated
}
/// UI-facing validation. Strings are part of the contract (issue #5).
public static let errorNegativeDeltaE = "ΔE thresholds cannot be negative."
public static let errorThresholdOrder =
"Good ΔE threshold must be strictly less than the warning threshold."
/// All validation errors, in declaration order. Empty = valid.
public func validate() -> [String] {
var errors: [String] = []
if deltaEGoodMax < 0 || deltaEWarningMax < 0 {
errors.append(Self.errorNegativeDeltaE)
}
if deltaEGoodMax >= deltaEWarningMax {
errors.append(Self.errorThresholdOrder)
}
return errors
}
public var isValid: Bool { validate().isEmpty }
}
extension ProfilingPreset {
/// Migrates a legacy M1 `CustomPreset` (`name` + string values) to
/// the typed schema. Known keys are coerced; anything else is
/// ignored. Returns `nil` only when the name is unusable a
/// deterministic `custom-{index}-{slug}` id is always produced.
init?(migrating legacy: CustomPreset, index: Int) {
let trimmedName = legacy.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { return nil }
let v = legacy.values
func int(_ key: String) -> Int? {
v[key].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}
func double(_ key: String) -> Double? {
v[key].flatMap { Double($0.trimmingCharacters(in: .whitespaces)) }
}
func bool(_ key: String) -> Bool? {
v[key].flatMap { s in
switch s.trimmingCharacters(in: .whitespaces).lowercased() {
case "true", "1", "yes": return true
case "false", "0", "no": return false
default: return nil
}
}
}
func string(_ key: String) -> String? {
v[key].map { $0.trimmingCharacters(in: .whitespaces) }
.flatMap { $0.isEmpty ? nil : $0 }
}
let slug = trimmedName.lowercased()
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
.reduce(into: "") { $0.append($1) }
self.init(
id: "custom-\(index)-\(slug)",
name: trimmedName,
description: string("description") ?? "",
colourSpace: string("colour_space")?.lowercased() ?? "rgb",
patchCount: int("patch_count") ?? 800,
whitePatches: int("white_patches") ?? 4,
blackPatches: int("black_patches") ?? 4,
greySteps: int("grey_steps"),
singleChannelSteps: int("single_channel_steps"),
neutralSteps: int("neutral_steps"),
neutralConcentration: double("neutral_concentration"),
preconditioningProfile: string("preconditioning_profile"),
ofpsHighQuality: bool("ofps_high_quality"),
ofpsAdaptation: double("ofps_adaptation"),
fullSpreadAlgorithm: string("full_spread_algorithm"),
totalInkLimit: int("total_ink_limit"),
darkEmphasis: double("dark_emphasis"),
devicePower: double("device_power"),
instrument: string("instrument") ?? "i1",
pageSize: string("page_size") ?? "A4",
bitDepth: int("bit_depth") ?? 8,
dpi: int("dpi") ?? 300,
randomSeed: int("random_seed"),
noRandomize: bool("no_randomize"),
calibrationFile: string("calibration_file"),
applyCalibration: bool("apply_calibration"),
colprofAlgorithm: string("colprof_algorithm"),
colprofQuality: string("colprof_quality"),
colprofIntent: string("colprof_intent"),
colprofFwa: string("colprof_fwa"),
colprofIlluminant: string("colprof_illuminant"),
colprofObserver: string("colprof_observer"),
colprofInputViewingCond: string("colprof_input_viewing_cond"),
colprofOutputViewingCond: string("colprof_output_viewing_cond")
)
}
}
@@ -0,0 +1,118 @@
import Foundation
/// Built-in presets shipped with the app (docs/22 §Built-in presets).
/// All four: instrument `i1`, FWA `D50`, random seed `1`,
/// `no_randomize == false`, colprof algorithm `l`.
///
/// Built-ins cannot be deleted; custom presets overlay by `id`.
public enum PresetCatalog {
/// `preset-std-rgb` Standard RGB Photo (800 patches).
public static let standardRGB = ProfilingPreset(
id: "preset-std-rgb",
name: "Standard RGB Photo (800 patches)",
description: "Everyday RGB driver printing — 800 patches on A4 at 300 dpi.",
colourSpace: "rgb",
patchCount: 800,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "m",
colprofFwa: "D50"
)
/// `preset-hq-cmyk` High-Gamut CMYK Proofing (1500 patches).
public static let highQualityCMYK = ProfilingPreset(
id: "preset-hq-cmyk",
name: "High-Gamut CMYK Proofing (1500 patches)",
description: "RIP-driven CMYK output — 1500 patches on A3, 16-bit, 320% ink limit.",
colourSpace: "cmyk",
patchCount: 1500,
whitePatches: 4,
blackPatches: 8,
totalInkLimit: 320,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "h",
colprofFwa: "D50"
)
/// `preset-draft-rgb` Fast RGB Draft (400 patches, **150 dpi**).
public static let draftRGB = ProfilingPreset(
id: "preset-draft-rgb",
name: "Fast RGB Draft (400 patches)",
description: "Quick sanity check — 400 patches on A4 at 150 dpi.",
colourSpace: "rgb",
patchCount: 400,
whitePatches: 4,
blackPatches: 4,
instrument: "i1",
pageSize: "A4",
bitDepth: 8,
dpi: 150,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "l",
colprofFwa: "D50"
)
/// `preset-ultra-rgb` Ultra Precision RGB (2500 patches, `-G`).
public static let ultraRGB = ProfilingPreset(
id: "preset-ultra-rgb",
name: "Ultra Precision RGB (2500 patches)",
description: "Maximum coverage — 2500 patches on A3, 16-bit, OFPS high quality.",
colourSpace: "rgb",
patchCount: 2500,
whitePatches: 6,
blackPatches: 6,
ofpsHighQuality: true,
instrument: "i1",
pageSize: "A3",
bitDepth: 16,
dpi: 300,
randomSeed: 1,
noRandomize: false,
colprofAlgorithm: "l",
colprofQuality: "u",
colprofFwa: "D50"
)
public static let builtIns: [ProfilingPreset] = [
standardRGB, highQualityCMYK, draftRGB, ultraRGB,
]
public static let builtInIDs: Set<String> = Set(builtIns.map(\.id))
public static func isBuiltIn(_ id: String) -> Bool {
builtInIDs.contains(id)
}
/// Built-ins plus custom presets, with custom entries overlaying by
/// `id` (a custom preset with a built-in id replaces that entry in
/// place the built-in is still not deletable).
public static func all(custom: [ProfilingPreset]) -> [ProfilingPreset] {
var result = builtIns
var seen = builtInIDs
for custom in custom {
if let idx = result.firstIndex(where: { $0.id == custom.id }) {
result[idx] = custom
} else if !seen.contains(custom.id) {
result.append(custom)
seen.insert(custom.id)
}
}
return result
}
}
@@ -0,0 +1,98 @@
import Foundation
/// CRUD + import/export for profiling presets on top of `SettingsStore`
/// (docs/22 §Built-in presets, issue #11).
///
/// - `all()` = built-ins overlaid by custom presets (by `id`).
/// - Built-ins are never written to `settings.json` and cannot be
/// deleted or overwritten by `saveCustom` (a custom id that collides
/// with a built-in still overlays at read time, per spec).
/// - Import/export is single-preset JSON with schema validation.
/// - Imported names/descriptions are untrusted: callers must render
/// them with `Text`, never HTML (#114).
public final class PresetStore: Sendable {
public let settingsStore: SettingsStore
public init(settingsStore: SettingsStore = SettingsStore()) {
self.settingsStore = settingsStore
}
/// All presets: built-ins overlaid by customs, catalog order.
public func all() -> [ProfilingPreset] {
PresetCatalog.all(custom: settingsStore.load().customPresets)
}
/// Custom presets only, as persisted.
public func customs() -> [ProfilingPreset] {
settingsStore.load().customPresets
}
/// Insert or replace a custom preset (matched by `id`). Throws
/// `PresetStoreError.builtIn` when the id belongs to a built-in
/// built-ins are immutable. Validates before persisting.
public func saveCustom(_ preset: ProfilingPreset) throws {
let validated = try preset.validated()
guard !PresetCatalog.isBuiltIn(validated.id) else {
throw PresetStoreError.builtInImmutable(validated.id)
}
var settings = settingsStore.load()
if let idx = settings.customPresets.firstIndex(where: { $0.id == validated.id }) {
settings.customPresets[idx] = validated
} else {
settings.customPresets.append(validated)
}
try settingsStore.save(settings)
}
/// Deletes a custom preset by id. Returns false when the id is a
/// built-in (undeletable) or no custom preset with that id exists.
@discardableResult
public func deleteCustom(id: String) throws -> Bool {
guard !PresetCatalog.isBuiltIn(id) else { return false }
var settings = settingsStore.load()
let before = settings.customPresets.count
settings.customPresets.removeAll { $0.id == id }
guard settings.customPresets.count != before else { return false }
try settingsStore.save(settings)
return true
}
/// Single-preset pretty JSON export.
public func export(_ preset: ProfilingPreset) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(preset)
}
/// Parses + validates a preset from JSON. The preset is assigned a
/// fresh custom id when its id is empty or collides with a built-in.
/// Does **not** persist call `saveCustom` to keep it.
public func `import`(_ data: Data) throws -> ProfilingPreset {
let decoded: ProfilingPreset
do {
decoded = try JSONDecoder().decode(ProfilingPreset.self, from: data)
} catch {
throw PresetStoreError.invalidJSON(error.localizedDescription)
}
var preset = try decoded.validated()
if preset.id.isEmpty || PresetCatalog.isBuiltIn(preset.id) {
preset.id = "custom-\(UUID().uuidString.lowercased())"
}
return preset
}
public enum PresetStoreError: LocalizedError, Equatable {
case builtInImmutable(String)
case invalidJSON(String)
public var errorDescription: String? {
switch self {
case .builtInImmutable(let id):
return "Built-in preset \"\(id)\" cannot be modified or deleted."
case .invalidJSON(let reason):
return "Not a valid preset file: \(reason)"
}
}
}
}
@@ -0,0 +1,272 @@
import Foundation
/// A profiling preset: a complete snapshot of the Stage 1/2 form plus
/// the Stage 4 fields that are stored now and applied in issue 23
/// (docs/22 §ProfilingPreset).
///
/// snake_case keys match the v1 JSON schema so import/export stays
/// compatible. Identity + Stage 1/2 core fields are required; every
/// other field is optional-defaulted. Unknown keys are ignored on
/// decode; missing required fields fail.
public struct ProfilingPreset: Codable, Equatable, Sendable, Identifiable {
// Identity
public var id: String
public var name: String
public var description: String
// Stage 1 (required core)
public var colourSpace: String // "rgb" | "cmyk"
public var patchCount: Int
public var whitePatches: Int
public var blackPatches: Int
// Stage 1 advanced (optional)
public var greySteps: Int?
public var singleChannelSteps: Int?
public var neutralSteps: Int?
public var neutralConcentration: Double?
public var preconditioningProfile: String?
public var ofpsHighQuality: Bool?
public var ofpsAdaptation: Double?
/// Stored as the flag letter: "ofps" or "t","r","R","q","Q","i","I".
public var fullSpreadAlgorithm: String?
public var totalInkLimit: Int?
public var darkEmphasis: Double?
public var devicePower: Double?
// Stage 2 (required core)
public var instrument: String
public var pageSize: String
public var bitDepth: Int
public var dpi: Int
public var randomSeed: Int?
public var noRandomize: Bool?
// Stage 0 / 2 calibration
public var calibrationFile: String?
public var applyCalibration: Bool?
// Stage 4 (stored now, applied by issue 23)
public var colprofAlgorithm: String?
public var colprofQuality: String?
public var colprofIntent: String?
public var colprofFwa: String?
public var colprofIlluminant: String?
public var colprofObserver: String?
public var colprofInputViewingCond: String?
public var colprofOutputViewingCond: String?
public init(
id: String,
name: String,
description: String = "",
colourSpace: String = "rgb",
patchCount: Int = 800,
whitePatches: Int = 4,
blackPatches: Int = 4,
greySteps: Int? = nil,
singleChannelSteps: Int? = nil,
neutralSteps: Int? = nil,
neutralConcentration: Double? = nil,
preconditioningProfile: String? = nil,
ofpsHighQuality: Bool? = nil,
ofpsAdaptation: Double? = nil,
fullSpreadAlgorithm: String? = nil,
totalInkLimit: Int? = nil,
darkEmphasis: Double? = nil,
devicePower: Double? = nil,
instrument: String = "i1",
pageSize: String = "A4",
bitDepth: Int = 8,
dpi: Int = 300,
randomSeed: Int? = 1,
noRandomize: Bool? = false,
calibrationFile: String? = nil,
applyCalibration: Bool? = nil,
colprofAlgorithm: String? = nil,
colprofQuality: String? = nil,
colprofIntent: String? = nil,
colprofFwa: String? = nil,
colprofIlluminant: String? = nil,
colprofObserver: String? = nil,
colprofInputViewingCond: String? = nil,
colprofOutputViewingCond: String? = nil
) {
self.id = id
self.name = name
self.description = description
self.colourSpace = colourSpace
self.patchCount = patchCount
self.whitePatches = whitePatches
self.blackPatches = blackPatches
self.greySteps = greySteps
self.singleChannelSteps = singleChannelSteps
self.neutralSteps = neutralSteps
self.neutralConcentration = neutralConcentration
self.preconditioningProfile = preconditioningProfile
self.ofpsHighQuality = ofpsHighQuality
self.ofpsAdaptation = ofpsAdaptation
self.fullSpreadAlgorithm = fullSpreadAlgorithm
self.totalInkLimit = totalInkLimit
self.darkEmphasis = darkEmphasis
self.devicePower = devicePower
self.instrument = instrument
self.pageSize = pageSize
self.bitDepth = bitDepth
self.dpi = dpi
self.randomSeed = randomSeed
self.noRandomize = noRandomize
self.calibrationFile = calibrationFile
self.applyCalibration = applyCalibration
self.colprofAlgorithm = colprofAlgorithm
self.colprofQuality = colprofQuality
self.colprofIntent = colprofIntent
self.colprofFwa = colprofFwa
self.colprofIlluminant = colprofIlluminant
self.colprofObserver = colprofObserver
self.colprofInputViewingCond = colprofInputViewingCond
self.colprofOutputViewingCond = colprofOutputViewingCond
}
enum CodingKeys: String, CodingKey {
case id, name, description
case colourSpace = "colour_space"
case patchCount = "patch_count"
case whitePatches = "white_patches"
case blackPatches = "black_patches"
case greySteps = "grey_steps"
case singleChannelSteps = "single_channel_steps"
case neutralSteps = "neutral_steps"
case neutralConcentration = "neutral_concentration"
case preconditioningProfile = "preconditioning_profile"
case ofpsHighQuality = "ofps_high_quality"
case ofpsAdaptation = "ofps_adaptation"
case fullSpreadAlgorithm = "full_spread_algorithm"
case totalInkLimit = "total_ink_limit"
case darkEmphasis = "dark_emphasis"
case devicePower = "device_power"
case instrument
case pageSize = "page_size"
case bitDepth = "bit_depth"
case dpi
case randomSeed = "random_seed"
case noRandomize = "no_randomize"
case calibrationFile = "calibration_file"
case applyCalibration = "apply_calibration"
case colprofAlgorithm = "colprof_algorithm"
case colprofQuality = "colprof_quality"
case colprofIntent = "colprof_intent"
case colprofFwa = "colprof_fwa"
case colprofIlluminant = "colprof_illuminant"
case colprofObserver = "colprof_observer"
case colprofInputViewingCond = "colprof_input_viewing_cond"
case colprofOutputViewingCond = "colprof_output_viewing_cond"
}
/// Strict decode: required identity + Stage 1/2 core fields must be
/// present; optionals default to nil. Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
description = try c.decodeIfPresent(String.self, forKey: .description) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
patchCount = try c.decode(Int.self, forKey: .patchCount)
whitePatches = try c.decode(Int.self, forKey: .whitePatches)
blackPatches = try c.decode(Int.self, forKey: .blackPatches)
greySteps = try c.decodeIfPresent(Int.self, forKey: .greySteps)
singleChannelSteps = try c.decodeIfPresent(Int.self, forKey: .singleChannelSteps)
neutralSteps = try c.decodeIfPresent(Int.self, forKey: .neutralSteps)
neutralConcentration = try c.decodeIfPresent(Double.self, forKey: .neutralConcentration)
preconditioningProfile = try c.decodeIfPresent(String.self, forKey: .preconditioningProfile)
ofpsHighQuality = try c.decodeIfPresent(Bool.self, forKey: .ofpsHighQuality)
ofpsAdaptation = try c.decodeIfPresent(Double.self, forKey: .ofpsAdaptation)
fullSpreadAlgorithm = try c.decodeIfPresent(String.self, forKey: .fullSpreadAlgorithm)
totalInkLimit = try c.decodeIfPresent(Int.self, forKey: .totalInkLimit)
darkEmphasis = try c.decodeIfPresent(Double.self, forKey: .darkEmphasis)
devicePower = try c.decodeIfPresent(Double.self, forKey: .devicePower)
instrument = try c.decode(String.self, forKey: .instrument)
pageSize = try c.decode(String.self, forKey: .pageSize)
bitDepth = try c.decode(Int.self, forKey: .bitDepth)
dpi = try c.decode(Int.self, forKey: .dpi)
randomSeed = try c.decodeIfPresent(Int.self, forKey: .randomSeed)
noRandomize = try c.decodeIfPresent(Bool.self, forKey: .noRandomize)
calibrationFile = try c.decodeIfPresent(String.self, forKey: .calibrationFile)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration)
colprofAlgorithm = try c.decodeIfPresent(String.self, forKey: .colprofAlgorithm)
colprofQuality = try c.decodeIfPresent(String.self, forKey: .colprofQuality)
colprofIntent = try c.decodeIfPresent(String.self, forKey: .colprofIntent)
colprofFwa = try c.decodeIfPresent(String.self, forKey: .colprofFwa)
colprofIlluminant = try c.decodeIfPresent(String.self, forKey: .colprofIlluminant)
colprofObserver = try c.decodeIfPresent(String.self, forKey: .colprofObserver)
colprofInputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofInputViewingCond)
colprofOutputViewingCond = try c.decodeIfPresent(String.self, forKey: .colprofOutputViewingCond)
}
// MARK: - Validation (import path)
public enum ValidationError: LocalizedError, Equatable {
case emptyID
case emptyName
case invalidColourSpace(String)
case invalidPatchCount(Int)
case invalidBitDepth(Int)
case invalidDPI(Int)
case emptyPageSize
case emptyInstrument
public var errorDescription: String? {
switch self {
case .emptyID: return "Preset is missing an id."
case .emptyName: return "Preset is missing a name."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .invalidPatchCount(let v):
return "patch_count must be positive, got \(v)."
case .invalidBitDepth(let v):
return "bit_depth must be 8 or 16, got \(v)."
case .invalidDPI(let v):
return "dpi must be between 72 and 600, got \(v)."
case .emptyPageSize: return "page_size is empty."
case .emptyInstrument: return "instrument is empty."
}
}
}
/// Validates the required fields for import / catalog use.
/// `colourSpace` is normalized to lowercase before comparison.
@discardableResult
public func validated() throws -> ProfilingPreset {
var p = self
p.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
p.colourSpace = colourSpace.lowercased()
guard !p.id.isEmpty else { throw ValidationError.emptyID }
guard !p.name.isEmpty else { throw ValidationError.emptyName }
guard p.colourSpace == "rgb" || p.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard p.patchCount > 0 else { throw ValidationError.invalidPatchCount(patchCount) }
guard p.bitDepth == 8 || p.bitDepth == 16 else {
throw ValidationError.invalidBitDepth(bitDepth)
}
guard (72...600).contains(p.dpi) else { throw ValidationError.invalidDPI(dpi) }
guard !p.pageSize.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyPageSize
}
guard !p.instrument.trimmingCharacters(in: .whitespaces).isEmpty else {
throw ValidationError.emptyInstrument
}
return p
}
}
/// Per-element non-throwing decode wrapper one malformed preset entry
/// must not drop the whole `custom_presets` array during migration.
struct FailableDecodable<T: Decodable>: Decodable {
let value: T?
init(from decoder: Decoder) throws {
value = try? T(from: decoder)
}
}
@@ -0,0 +1,47 @@
import Foundation
/// Persists `AppSettings` to
/// `~/Library/Application Support/com.gronod.iccery2/settings.json`
/// (issue #5 the v1 path is never read).
///
/// Writes are atomic (`AtomicFileWriter`). Invalid/corrupt JSON falls
/// back to defaults. Saving posts `settingsDidChange` so #20 can
/// reclassify swatches.
public final class SettingsStore: Sendable {
/// Posted on `NotificationCenter.default` after every successful save.
public static let settingsDidChange =
Notification.Name("com.gronod.iccery2.settingsDidChange")
public let fileURL: URL
public init(fileURL: URL = AppPaths.appDataDir.appendingPathComponent("settings.json")) {
self.fileURL = fileURL
}
public func load() -> AppSettings {
guard let data = try? Data(contentsOf: fileURL),
let settings = try? JSONDecoder().decode(AppSettings.self, from: data)
else {
return .default
}
return settings
}
/// Validates before persisting throws `SettingsError` listing
/// every violation; nothing is written on failure.
public func save(_ settings: AppSettings) throws {
let errors = settings.validate()
guard errors.isEmpty else {
throw SettingsError.validationFailed(errors)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
try AtomicFileWriter.write(encoder.encode(settings), to: fileURL)
NotificationCenter.default.post(name: Self.settingsDidChange, object: nil)
}
public enum SettingsError: Error, Equatable {
case validationFailed([String])
}
}
@@ -0,0 +1,59 @@
import Foundation
/// Artefact-driven stage gating (issue #4, docs/06 §Stages).
///
/// Navigation is *disk*, not buttons: a stage unlocks only when its
/// predecessor artefacts exist. Forward moves are gated; backward is
/// always allowed. Gating is re-evaluated on window focus and on stage
/// entry (#151 files can disappear in Finder).
public enum WizardGating {
/// Whether `stage` is reachable given the probed artefacts.
///
/// - Stage 0 (calibrate): always it is out-of-band, not gated.
/// - Stage 1: always.
/// - Stage 2: `.ti1` exists.
/// - Stage 3: `.ti1` **and** `.ti2`.
/// - Stage 4: `.ti3` exists (accepted measurement only a `.ti2`
/// alone never unlocks it; #109/#110).
/// - Stage 5: `.ti3` **and** `.icc`/`.icm`.
public static func isUnlocked(
_ stage: WizardStage,
artefacts: StageArtefacts
) -> Bool {
switch stage {
case .calibrate: return true
case .generate: return true
case .layOutPrint: return artefacts.stage1Complete
case .measure: return artefacts.stage1Complete && artefacts.stage2Complete
case .buildProfile: return artefacts.stage3Complete
case .verifyInstall: return artefacts.stage3Complete && artefacts.stage4Complete
}
}
/// Whether `go(to:)` may proceed. Backward moves and the current
/// stage are always allowed; forward moves must be unlocked.
public static func canNavigate(
to target: WizardStage,
from current: WizardStage,
artefacts: StageArtefacts
) -> Bool {
if target == current { return true }
if target == .calibrate || current == .calibrate {
// Stage 0 is a side-trip, not stepper navigation.
return true
}
if target.rawValue < current.rawValue { return true }
return isUnlocked(target, artefacts: artefacts)
}
/// The deepest unlocked stepper stage used when revalidation
/// locks the current stage (#151).
public static func deepestUnlocked(artefacts: StageArtefacts) -> WizardStage {
for stage in WizardStage.stepperStages.reversed()
where isUnlocked(stage, artefacts: artefacts) {
return stage
}
return .generate
}
}
@@ -0,0 +1,45 @@
import Foundation
/// The five wizard stages plus Stage 0 (calibration), matching the v1
/// `data-stage` contract in docs/21. Stepper buttons 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>
+62
View File
@@ -0,0 +1,62 @@
import SwiftUI
import ICCeryCore
/// About dialog for ICCery (issue #31, docs/21 §Modals).
struct AboutView: View {
let onClose: () -> Void
private let info = ArtefactFiles.appInfo()
var body: some View {
VStack(spacing: 20) {
Image("ICCery-logo")
.resizable()
.scaledToFit()
.frame(height: 64)
Text("ICCery")
.font(.title)
.foregroundStyle(Theme.text)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Version:")
.foregroundStyle(.secondary)
Text(info.version)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutVersion")
}
HStack {
Text("Build:")
.foregroundStyle(.secondary)
Text(info.build)
.foregroundStyle(Theme.text)
}
HStack {
Text("Build date:")
.foregroundStyle(.secondary)
Text(info.buildDate)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutBuildDate")
}
}
.font(.callout)
Text("Native macOS printer profiling workstation.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
onClose()
}
.controlSize(.large)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeAboutBtn")
}
.padding(32)
.frame(width: 360)
.background(Theme.panel)
.accessibilityIdentifier("aboutDialog")
}
}
+124
View File
@@ -0,0 +1,124 @@
import Foundation
import ICCeryCore
/// App dependency container (docs/02). Production builds resolve the
/// user's `argyll_binary_dir` override or bundled sidecars; DEBUG UI
/// tests inject fixture binaries via `ICCERY_ARGYLL_BINARY_DIR` and
/// redirect `AppPaths` via `ICCERY_TEST_ROOT`, so tests never touch the
/// developer's settings, wizard state, or real Argyll install.
struct AppEnvironment: Sendable {
let stateStore: WizardStateStore
let settingsStore: SettingsStore
let presetStore: PresetStore
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> AppEnvironment {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
var cupsDir = URL(fileURLWithPath: "/usr/bin")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
settingsStore: settingsStore,
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
)
}
}
/// DEBUG-only UI-test hooks. When `ICCERY_UI_TESTING=1` the workflow
/// honours these env-provided paths instead of presenting modal panels
/// (XCUITest cannot drive NSOpenPanel/NSSavePanel reliably). These are
/// compiled out of release builds.
enum UITestHooks {
private static var env: [String: String] {
ProcessInfo.processInfo.environment
}
static var isEnabled: Bool {
#if DEBUG
return env["ICCERY_UI_TESTING"] == "1"
#else
return false
#endif
}
/// `select_target_file` result (Stage 1 save picker).
static var saveTargetURL: URL? { url("ICCERY_TEST_SAVE_TARGET") }
/// `select_existing_target` result (`.ti1`/`.ti2` resume).
static var existingTargetURL: URL? { url("ICCERY_TEST_EXISTING_TARGET") }
/// `select_directory` result (working-directory browse).
static var workDirURL: URL? { url("ICCERY_TEST_WORKDIR") }
/// Dataset import file (`.ti3`, `.txt`, `.cgats`, `.csv`).
static var datasetImportURL: URL? { url("ICCERY_TEST_DATASET_IMPORT") }
/// Preset import file.
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
// MARK: - Print panel / CUPS stubs (issue 13/17)
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts
/// `CupsService.binaryDir` under UI tests.
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
/// Path the mock `lp` script appends its argv to, for assertions.
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
/// Whether the `NSPrintPanel` should be stubbed under UI testing
/// separate from the stub's *result* so "cancel" (`nil`) does not
/// fall through to the real modal.
static var printPanelStubbed: Bool { isEnabled }
/// Canned `NSPrintPanel` outcome XCUITest cannot drive the
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
/// - `cancel` (or unset while testing) user cancelled `nil`
/// - `ok` `PrintPropertiesResult` with
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for).
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0
}
return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0
} ?? queue,
options: PrintOptions(
mediaType: options.flatMap {
CupsParsers.extractMediaType(fromOptionsString: $0)
},
ppdUncorrectedPassthrough: true,
cupsOptions: options))
default:
return nil
}
}
private static func url(_ key: String) -> URL? {
guard let raw = env[key], !raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw)
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
import ICCeryCore
/// Minimal line chart for drift history without depending on the
/// `Charts` framework link. Renders avg/max series with shaded quality
/// bands.
struct DriftChartView: View {
let records: [VerificationRecord]
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack(alignment: .topLeading) {
if let (_, _, _, maxV) = scales(in: height) {
// Quality bands bottom (red, > 3.5) drawn first, then
// orange, yellow, green so the upper-most bands overlay.
band(from: 3.5, to: maxV, color: .red.opacity(0.12), height: height, maxValue: maxV)
band(from: 2.0, to: 3.5, color: .orange.opacity(0.12), height: height, maxValue: maxV)
band(from: 1.0, to: 2.0, color: .yellow.opacity(0.12), height: height, maxValue: maxV)
band(from: 0.0, to: 1.0, color: .green.opacity(0.12), height: height, maxValue: maxV)
}
if !records.isEmpty, let (minT, maxT, minV, maxV) = scales(in: height) {
// Average ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.avgDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.blue, lineWidth: 2)
.accessibilityIdentifier("driftAvgSeries")
// Max ΔE series
Path { path in
for (index, record) in records.enumerated() {
let pt = point(
for: record,
minTime: minT,
maxTime: maxT,
minValue: minV,
maxValue: maxV,
width: width,
height: height,
keyPath: \.maxDE
)
if index == 0 {
path.move(to: pt)
} else {
path.addLine(to: pt)
}
}
}
.stroke(Color.orange, lineWidth: 2)
.accessibilityIdentifier("driftMaxSeries")
} else {
Text("No data")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
private func band(
from lower: Double,
to upper: Double,
color: Color,
height: CGFloat,
maxValue: Double
) -> some View {
let yTop = valueY(lower, minValue: 0, maxValue: maxValue, height: height)
let yBottom = valueY(upper, minValue: 0, maxValue: maxValue, height: height)
return color
.frame(height: yBottom - yTop)
.offset(y: yTop)
}
private func scales(in height: CGFloat) -> (Date, Date, Double, Double)? {
guard let minT = records.first?.timestamp, let maxT = records.last?.timestamp else { return nil }
let maxV = max(records.map { max($0.avgDE, $0.maxDE) }.max() ?? 5.0, 5.0)
return (minT, maxT, 0.0, maxV)
}
private func point(
for record: VerificationRecord,
minTime: Date,
maxTime: Date,
minValue: Double,
maxValue: Double,
width: CGFloat,
height: CGFloat,
keyPath: KeyPath<VerificationRecord, Double>
) -> CGPoint {
let timeSpan = max(1, maxTime.timeIntervalSince(minTime))
let x = width * CGFloat(record.timestamp.timeIntervalSince(minTime) / timeSpan)
let y = valueY(record[keyPath: keyPath], minValue: minValue, maxValue: maxValue, height: height)
return CGPoint(x: x, y: y)
}
private func valueY(_ value: Double, minValue: Double, maxValue: Double, height: CGFloat) -> CGFloat {
let valueSpan = max(1, maxValue - minValue)
return height - height * CGFloat((value - minValue) / valueSpan)
}
}
+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
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
/// Reusable help overlay badge that does not reflow layout (#171).
///
/// When `showing` is `true`, a small indicator is rendered as an overlay at the
/// top-trailing corner of the wrapped view. The native `.help` tooltip is always
/// available on hover, so the overlay is purely a visual cue in help mode.
struct HelpOverlay: ViewModifier {
let text: String
@Binding var showing: Bool
func body(content: Content) -> some View {
content
.help(text)
.overlay(alignment: .topTrailing) {
if showing {
Image(systemName: "questionmark.circle.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.accent)
.offset(x: 8, y: -8)
}
}
}
}
extension View {
/// Adds a non-reflowing help overlay to the view.
func helpOverlay(_ text: String, showing: Binding<Bool>) -> some View {
modifier(HelpOverlay(text: text, showing: showing))
}
}
+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,462 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// A single evaluated swatch for the live grid.
struct Swatch: Sendable, Equatable, Identifiable {
let rowId: String
let loc: String
let isPad: Bool
let intended: DisplayRGB
let measured: DisplayRGB
let deltaE: Double?
let classification: SwatchClassification
var id: String { "\(rowId)\(loc)" }
}
/// A row of swatches in display order.
struct SwatchRow: Sendable, Equatable, Identifiable {
let index: Int
let rowId: String
let patches: [Swatch]
var id: String { rowId }
}
/// Stage of the XY-table badge bar.
enum XYStep: Equatable, Sendable {
case place, align, scan, remove
}
/// Stage 3 workflow state and interaction (issues #18#22).
@MainActor
@Observable
final class MeasurementWorkflowViewModel {
// MARK: - Authorities
let wizard: WizardViewModel
let environment: AppEnvironment
// MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
// MARK: - Chartread session
var isChartreadRunning = false
var chartreadState: ChartreadState = .idle
var currentPrompt: String?
var requestedWarningKey: String?
var chartreadLog: [String] = []
var rows: [ChartreadRow] = []
var swatchRows: [SwatchRow] = []
var showRemoveSheetNotice = false
var lastError: String?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: String?
var finishNoticeIsError = false
var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
loadSettings()
discoverPassSnapshots()
}
// MARK: - Derived state
var basename: String { wizard.basename }
var workingDirectory: URL? { wizard.effectiveWorkingDirectory }
var canDetect: Bool { !isDetecting }
var canStartRead: Bool {
!basename.isEmpty && workingDirectory != nil && !isChartreadRunning
}
var canMeasureAnotherSheet: Bool {
isFinished && !passSnapshots.isEmpty
}
var canFinish: Bool {
isFinished && !passSnapshots.isEmpty
}
var isFinished: Bool {
chartreadState == .allStripsRead || chartreadState == .finished
}
var xyStep: XYStep {
if showRemoveSheetNotice { return .remove }
switch chartreadState {
case .tablePlaceSheet:
return .place
case .tableAlign:
return .align
case .reading, .awaitingStrip:
return .scan
default:
return .place
}
}
var hasCanonicalTi3: Bool {
guard let cwd = workingDirectory else { return false }
let url = cwd.appendingPathComponent("\(basename).ti3")
return FileManager.default.fileExists(atPath: url.path)
}
// MARK: - Settings
func loadSettings() {
let settings = environment.settingsStore.load()
goodMax = settings.deltaEGoodMax
warningMax = settings.deltaEWarningMax
enableLEDs = settings.enableI1Pro2Leds
}
// MARK: - Instrument detection
func detectInstruments() {
guard !isDetecting else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
if case .device(let selected) = self.selectedInstrument,
!devices.contains(where: { $0.port == selected.port }) {
self.selectedInstrument = .auto
}
} catch {
self.detectionError = error.localizedDescription
}
self.isDetecting = false
}
}
// MARK: - Chartread lifecycle
func startRead() {
guard canStartRead, let cwd = workingDirectory else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
func measureAnotherSheet() {
guard let cwd = workingDirectory, isFinished else { return }
let config = buildChartreadConfig(cwd: cwd)
startChartread(config: config)
}
private func buildChartreadConfig(cwd: URL) -> ChartreadConfig {
ChartreadConfig(
basename: basename,
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: enableLEDs,
isXY: selectedInstrument.isXY
)
}
private func startChartread(config: ChartreadConfig) {
guard !isChartreadRunning else { return }
isChartreadRunning = true
chartreadState = .idle
currentPrompt = nil
lastError = nil
chartreadLog.removeAll()
// Optional: reset rows when starting a fresh first pass.
if passSnapshots.isEmpty {
rows.removeAll()
swatchRows.removeAll()
}
let stream = environment.runner.runChartread(config: config)
chartreadTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isChartreadRunning = false
}
}
private func handle(event: ChartreadEvent) {
switch event {
case .prompt(let result):
chartreadState = result.state
currentPrompt = promptText(for: result)
requestedWarningKey = result.requestedWarningKey
showRemoveSheetNotice = result.isRemoveSheetNotice
case .row(let row):
upsert(row: row)
if row.isFinalRow {
chartreadState = .allStripsRead
}
case .log(let batch):
chartreadLog.append(contentsOf: batch)
case .removeSheetNotice:
showRemoveSheetNotice = true
case .exit(let code):
if code != 0 {
lastError = "chartread exited with code \(code)"
}
case .completed(let canonicalURL):
chartreadState = .finished
completePass(canonicalURL: canonicalURL)
case .failed(let error):
lastError = error.localizedDescription
chartreadState = .error
isChartreadRunning = false
}
}
private func promptText(for result: ChartreadClassifyResult) -> String {
switch result.state {
case .calibrating:
return "Place instrument on calibration tile and press Calibrate."
case .awaitingStrip:
return "Press a key to read the next strip."
case .allStripsRead:
return "All strips read. Press Done & Save when ready."
case .warning:
if let key = result.requestedWarningKey {
return "Warning — press '\(key.uppercased())' to continue."
}
return "Warning — press Continue."
case .promptContinue:
return "Press Continue."
case .tablePlaceSheet:
if let n = result.sheetNumber, let t = result.sheetTotal {
return "Place sheet \(n) of \(t) on the table."
}
return "Place the sheet on the table."
case .tableAlign:
if let patch = result.alignmentPatch {
return "Locate patch \(patch) with the sight, then continue."
}
return "Align the fiducial, then continue."
case .reading:
return "Reading..."
case .error:
return "Read error — you can Retry or Cancel."
case .finished:
return "Measurement saved."
case .idle:
return "Press Start to begin reading."
}
}
// MARK: - User actions
func calibrate() {
send(.trigger)
}
func accept() {
if let key = requestedWarningKey {
send(.customKey(key))
requestedWarningKey = nil
} else {
send(.accept)
}
}
func retry() {
send(.trigger)
}
func doneAndSave() {
send(.done)
}
func cancelRead() {
environment.runner.cancelChartread(basename: basename, isXY: selectedInstrument.isXY)
chartreadTask?.cancel()
isChartreadRunning = false
chartreadState = .idle
currentPrompt = nil
requestedWarningKey = nil
showRemoveSheetNotice = false
}
func sendWarningKey(_ key: String) {
send(.customKey(key))
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isChartreadRunning else { return }
try? await self.environment.runner.sendChartreadInput(basename: self.basename, input: input)
}
}
// MARK: - Rows and swatches
private func upsert(row: ChartreadRow) {
if let index = rows.firstIndex(where: { $0.rowIndex == row.rowIndex }) {
rows[index] = row
} else {
rows.append(row)
}
rows.sort { $0.rowIndex < $1.rowIndex }
recomputeSwatches()
}
func recomputeSwatches() {
var displayRows: [SwatchRow] = []
for (rowIndex, row) in rows.enumerated() {
var swatches: [Swatch] = []
for patch in row.patches {
let skip = shouldSkipPad(patch)
if skip { continue }
let eval = ColorDifference.evaluate(
patch: patch,
goodMax: goodMax,
warningMax: warningMax
)
if let eval {
swatches.append(Swatch(
rowId: row.rowId,
loc: patch.loc,
isPad: patch.isPad,
intended: eval.intended,
measured: eval.measured,
deltaE: eval.deltaE,
classification: eval.classification
))
}
}
if !swatches.isEmpty {
displayRows.append(SwatchRow(index: rowIndex, rowId: row.rowId, patches: swatches))
}
}
swatchRows = displayRows
}
private func shouldSkipPad(_ patch: ChartreadPatch) -> Bool {
guard patch.isPad else { return false }
let measuredEmpty = patch.measured.xyz == nil && patch.measured.lab == nil
let deviceAllZero = patch.device.allSatisfy { $0 == 0 }
return measuredEmpty && deviceAllZero
}
// MARK: - Pass management
private func completePass(canonicalURL: URL) {
guard let cwd = workingDirectory else { return }
do {
_ = try MeasurementArtefacts.snapshotPass(basename: basename, cwd: cwd)
discoverPassSnapshots()
wizard.refreshGating()
} catch {
lastError = "Could not snapshot pass: \(error.localizedDescription)"
}
}
func discoverPassSnapshots() {
guard let cwd = workingDirectory else {
passSnapshots = []
return
}
passSnapshots = MeasurementArtefacts.passSnapshots(basename: basename, cwd: cwd)
}
// MARK: - Finish / Average
func finishAndAverage() {
guard !isFinishing, let cwd = workingDirectory, !passSnapshots.isEmpty else { return }
isFinishing = true
finishNotice = nil
finishNoticeIsError = false
Task { @MainActor [weak self] in
guard let self else { return }
do {
let canonical: URL
if self.passSnapshots.count == 1, let pass = self.passSnapshots.first {
canonical = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
} else {
let config = AverageConfig(
workingDirectory: cwd,
basename: self.basename,
passFiles: self.passSnapshots
)
canonical = try await self.environment.runner.runAverage(
config: config,
onLogBatch: { [weak self] batch in
Task { @MainActor [weak self] in
self?.chartreadLog.append(contentsOf: batch)
}
}
)
}
self.discoverPassSnapshots()
self.wizard.refreshGating()
if self.wizard.isUnlocked(.buildProfile) {
self.wizard.go(to: .buildProfile)
} else {
self.finishNotice = "Finished: \(canonical.lastPathComponent) ready."
}
} catch {
// Fallback to pass 1 promotion if averaging failed.
if let pass = self.passSnapshots.first {
do {
_ = try MeasurementArtefacts.promotePass(
pass: pass,
basename: self.basename,
cwd: cwd
)
self.discoverPassSnapshots()
self.wizard.refreshGating()
self.finishNotice = "Averaging failed — promoted first pass."
self.finishNoticeIsError = true
} catch {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
} else {
self.finishNotice = "Finish failed: \(error.localizedDescription)"
self.finishNoticeIsError = true
}
}
self.isFinishing = false
}
}
}
+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
}
}
@@ -0,0 +1,506 @@
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// User-facing FWA selection for the Stage 4 form.
enum ColprofFwaSelection: String, CaseIterable, Sendable, Equatable {
case none = "none"
case empty = ""
case D50 = "D50"
case D65 = "D65"
case custom = "custom"
var displayName: String {
switch self {
case .none: return "None"
case .empty: return "Bare (-f)"
case .D50: return "D50"
case .D65: return "D65"
case .custom: return "Custom .sp"
}
}
}
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
@Observable
final class ProfileWorkflowViewModel {
let wizard: WizardViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Stage 4 form
var algorithm: String = "l" // l | x | X | m
var quality: String = "m" // l | m | h | u
var intent: String = "" // usually empty at Stage 4
var fwaSelection: ColprofFwaSelection = .none
var fwaCustomPath: String = ""
var illuminant: String = ""
var observer: String = ""
var inputViewingCond: String = ""
var outputViewingCond: String = ""
var profileDescription: String = ""
var copyright: String = ""
// MARK: - Run state
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var lastError: String?
var createdProfileURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false
var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
// MARK: - Install (issue #27)
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
restoreCreatedProfileURL()
}
/// Restores `createdProfileURL` from the wizard artefacts or by probing
/// the working directory for an existing `.icc`/`.icm` (#52).
func restoreCreatedProfileURL() {
let cwd = wizard.effectiveWorkingDirectory ?? PathSecurity.resolveSafeCwd(nil)
createdProfileURL = wizard.artefacts.profilePath
?? ArtefactProbe.resolveProfile(basename: wizard.basename, cwd: cwd)
}
// MARK: - Derived
var canCreateProfile: Bool {
!wizard.basename.isEmpty && wizard.effectiveWorkingDirectory != nil && !isColprofRunning
}
var canVerify: Bool {
createdProfileURL != nil && !isProfcheckRunning
}
var fwaValue: String? {
switch fwaSelection {
case .none: return nil
case .empty: return ""
case .D50: return "D50"
case .D65: return "D65"
case .custom: return fwaCustomPath
}
}
// MARK: - Preset application
func applyPreset(_ preset: ProfilingPreset?) {
guard let preset else { return }
algorithm = preset.colprofAlgorithm ?? "l"
quality = preset.colprofQuality ?? "m"
intent = preset.colprofIntent ?? ""
if let fwa = preset.colprofFwa {
switch fwa.lowercased() {
case "none": fwaSelection = .none
case "": fwaSelection = .empty
case "d50": fwaSelection = .D50
case "d65": fwaSelection = .D65
default:
fwaSelection = .custom
fwaCustomPath = fwa
}
}
illuminant = preset.colprofIlluminant ?? ""
observer = preset.colprofObserver ?? ""
inputViewingCond = preset.colprofInputViewingCond ?? ""
outputViewingCond = preset.colprofOutputViewingCond ?? ""
}
/// Stage 4 form values for saving into a custom preset.
func presetSnapshot() -> (
algorithm: String,
quality: String,
intent: String?,
fwa: String?,
illuminant: String?,
observer: String?,
inputViewingCond: String?,
outputViewingCond: String?
) {
(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond
)
}
// MARK: - Stage 4: build profile
func buildColprofConfig() -> ColprofConfig {
let description = profileDescription.isEmpty ? wizard.basename : profileDescription
return ColprofConfig(
algorithm: algorithm,
quality: quality,
intent: intent.isEmpty ? nil : intent,
fwa: fwaValue,
illuminant: illuminant.isEmpty ? nil : illuminant,
observer: observer.isEmpty ? nil : observer,
inputViewingCond: inputViewingCond.isEmpty ? nil : inputViewingCond,
outputViewingCond: outputViewingCond.isEmpty ? nil : outputViewingCond,
description: description,
copyright: copyright.isEmpty ? nil : copyright,
basename: wizard.basename,
workingDirectory: wizard.effectiveWorkingDirectory
)
}
func createProfile() {
guard canCreateProfile, let _ = wizard.effectiveWorkingDirectory else { return }
let config = buildColprofConfig()
isColprofRunning = true
colprofLog = []
colprofProgress = nil
lastError = nil
createdProfileURL = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isColprofRunning = false }
do {
let url = try await runner.runColprof(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
guard let self else { return }
self.colprofLog.append(contentsOf: batch)
if let last = batch.last {
let progress = ColprofProgressClassifier.classify(line: last)
self.updateProgress(progress)
}
}
}
var finalProfileURL = url
if self.applyCalibration, !self.calibrationFile.isEmpty {
let applyConfig = ApplycalConfig(
calibrationPath: self.calibrationFile,
inputProfileURL: url
)
assert(!applyConfig.unapply, "applycal unapply is not supported in v2.0")
finalProfileURL = try await runner.runApplycal(config: applyConfig)
self.colprofLog.append("Calibration embedded: \(self.calibrationFile)")
}
// Gamut extraction is best-effort for Stage 5 / M6 viewer.
do {
let gamConfig = IccgamutConfig(profileURL: finalProfileURL)
_ = try await runner.runIccgamut(config: gamConfig) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.colprofLog.append("Gamut mesh extracted.")
} catch {
self.wizard.showNotice(
"Gamut extraction skipped: \(error.localizedDescription)",
kind: .info
)
}
self.createdProfileURL = finalProfileURL
self.wizard.refreshGating()
self.wizard.showNotice("Profile created: \(finalProfileURL.lastPathComponent)")
self.wizard.go(to: .verifyInstall)
} catch {
self.lastError = error.localizedDescription
self.wizard.showNotice(
"Profile creation failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
private func updateProgress(_ progress: ColprofProgress) {
switch progress {
case .gamutMapping:
colprofProgress = "Gamut mapping calculation…"
case .fittingClut:
colprofProgress = "Fitting cLUT grid points…"
case .writingIcc:
colprofProgress = "Writing ICC profile…"
case .unknown:
break
}
}
// MARK: - Stage 5: verify profile
var knownPrinters: [String] {
var names = Set<String>()
for record in verificationHistory {
if record.printerName.isEmpty {
names.insert("Unknown")
} else {
names.insert(record.printerName)
}
}
return Array(names).sorted()
}
func loadHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.verificationHistory = try await self.environment.historyStore.load()
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
} catch {
self.isHistoryStoreError = error.localizedDescription
self.wizard.showNotice(
"Could not load verification history: \(error.localizedDescription)",
kind: .error
)
}
}
}
var filteredHistory: [VerificationRecord] {
guard let filter = driftPrinterFilter, !filter.isEmpty else {
return verificationHistory
}
return verificationHistory.filter { $0.printerName == filter }
}
func verifyProfile() {
guard canVerify,
let cwd = wizard.effectiveWorkingDirectory,
let profileURL = createdProfileURL else { return }
let ti3URL = ArtefactProbe.artefact(wizard.basename, "ti3", cwd)
let config = ProfcheckConfig(ti3URL: ti3URL, iccURL: profileURL)
isProfcheckRunning = true
profcheckReport = nil
profcheckWarning = nil
let runner = environment.runner
Task { @MainActor [weak self] in
guard let self else { return }
defer { self.isProfcheckRunning = false }
do {
let report = try await runner.runProfcheck(config: config) { [weak self] batch in
Task { @MainActor [weak self] in
self?.colprofLog.append(contentsOf: batch)
}
}
self.profcheckReport = report
if let record = self.makeVerificationRecord(from: report) {
let updated = try await self.environment.historyStore.append(record)
self.verificationHistory = updated
self.driftAlert = DriftAlert.compute(from: self.filteredHistory)
}
} catch let error as ArgyllRunnerError where error == .profcheckUnparseable {
self.profcheckWarning = "profcheck output could not be parsed."
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
} catch {
self.profcheckWarning = error.localizedDescription
self.profcheckReport = ProfcheckReport(warning: self.profcheckWarning)
self.wizard.showNotice(
"Verification failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
func makeVerificationRecord(from report: ProfcheckReport) -> VerificationRecord? {
guard let avg = report.avgDE,
let max = report.maxDE,
let rms = report.rmsDE,
let status = report.status else { return nil }
let timestamp = Date()
let id = "vr-\(Int(timestamp.timeIntervalSince1970))-\(Self.nextSeq())"
let printerName = wizard.printerName?.isEmpty == false ? wizard.printerName! : "Unknown"
return VerificationRecord(
id: id,
profileName: createdProfileURL?.lastPathComponent ?? wizard.basename,
printerName: printerName,
avgDE: avg,
maxDE: max,
rmsDE: rms,
patchCount: report.patchCount ?? 0,
status: status,
timestamp: timestamp
)
}
private static func nextSeq() -> Int {
Int.random(in: 0..<1_000_000)
}
func clearHistory() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
try await self.environment.historyStore.clear()
self.verificationHistory = []
self.driftAlert = nil
} catch {
self.wizard.showNotice(
"Could not clear history: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - Profile install
func beginInstallProfile() {
guard let sourceURL = createdProfileURL,
let _ = wizard.effectiveWorkingDirectory else { return }
let settings = environment.settingsStore.load()
let preferSystem = settings.defaultInstallLocation == .system
let options = InstallProfileOptions(
forceOverwrite: !settings.askBeforeOverwriteProfile,
preferSystem: preferSystem,
collisionPolicy: .overwrite,
openColorPanel: settings.openColorPanelAfterInstall
)
do {
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
let destURL = try ProfileInstaller.resolveDestinationURL(for: config)
let collision = FileManager.default.fileExists(atPath: destURL.path)
if collision && settings.askBeforeOverwriteProfile {
pendingInstallOptions = options
installCollisionMessage = "A profile named \(destURL.lastPathComponent) already exists."
showingInstallCollision = true
return
}
runInstall(sourceURL: sourceURL, options: options)
} catch {
wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
func resolveInstallCollision(policy: ProfileCollisionPolicy) {
showingInstallCollision = false
guard let sourceURL = createdProfileURL,
var options = pendingInstallOptions else { return }
if policy == .cancel {
installResult = InstallProfileResult(
destPath: "",
registered: false,
overwritten: false,
renamed: false,
openedPanel: false,
message: "Install cancelled."
)
return
}
options.collisionPolicy = policy
if policy == .overwrite {
options.forceOverwrite = true
}
runInstall(sourceURL: sourceURL, options: options)
}
private func runInstall(sourceURL: URL, options: InstallProfileOptions) {
let config = InstallProfileConfig(sourceURL: sourceURL, options: options)
Task(priority: .userInitiated) { [weak self] in
do {
let result = try ProfileInstaller.install(config: config)
await MainActor.run { [weak self] in
self?.installResult = result
self?.wizard.showNotice(result.message)
}
} catch {
await MainActor.run { [weak self] in
self?.wizard.showNotice(
"Install failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
}
func exportHistory() {
guard let url = fileDialogs.selectCsvSavePath() else { return }
Task { @MainActor [weak self] in
guard let self else { return }
let csv = await self.environment.historyStore.exportCSV()
do {
try csv.write(to: url, atomically: true, encoding: .utf8)
self.wizard.showNotice("History exported: \(url.lastPathComponent)")
} catch {
self.wizard.showNotice(
"Export failed: \(error.localizedDescription)",
kind: .error
)
}
}
}
// MARK: - File pickers
func browseForSpectrumFile() {
let start = wizard.effectiveWorkingDirectory
let url = UITestHooks.isEnabled
? nil
: fileDialogs.selectSpectrumFile(startingAt: start)
if let url {
fwaSelection = .custom
fwaCustomPath = url.path
}
}
func browseForCalibrationFile() {
let start = wizard.effectiveWorkingDirectory
let url = fileDialogs.selectCalFile(startingAt: start)
if let url {
calibrationFile = url.path
applyCalibration = true
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import AppKit
import SwiftUI
import ICCeryCore
/// Root layout: 270 pt sidebar + main stage area with the notification
/// banner pinned to the top (docs/21 §Shell).
struct RootView: View {
@Bindable var workflow: TargetWorkflowViewModel
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard }
var body: some View {
HStack(spacing: 0) {
SidebarView(
workflow: workflow,
onOpenSettings: { showingSettings = true },
onOpenAbout: { showingAbout = true },
showingAllHelp: $showingAllHelp
)
Rectangle()
.fill(Theme.border)
.frame(width: 1)
VStack(spacing: 0) {
if let notice = model.notice {
NoticeBanner(notice: notice, onClose: model.dismissNotice)
}
stageContent
}
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
// #151: re-probe artefacts when the window regains focus
// files deleted in Finder must re-lock stages.
.onReceive(
NotificationCenter.default.publisher(
for: NSWindow.didBecomeKeyNotification
)
) { _ in model.windowDidBecomeKey() }
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $workflow.showingSavePreset) {
SavePresetDialog(workflow: workflow)
}
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
}
@ViewBuilder
private var stageContent: some View {
switch model.stage {
case .generate:
Stage1View(workflow: workflow)
case .layOutPrint:
Stage2View(workflow: workflow)
case .measure:
Stage3View(model: workflow.measurement)
case .buildProfile:
Stage4View(model: workflow.profile)
case .verifyInstall:
Stage5View(model: workflow.profile)
default:
StagePlaceholderView(stage: model.stage)
}
}
}
+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)
}
}
+146
View File
@@ -0,0 +1,146 @@
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
@Binding var showingAllHelp: Bool
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)
.helpOverlay("Open the Settings dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openSettingsBtn")
Button(action: onOpenAbout) {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.helpOverlay("Open the About dialog.", showing: $showingAllHelp)
.accessibilityIdentifier("openAboutBtn")
Button(action: { showingAllHelp.toggle() }) {
Image(systemName: showingAllHelp ? "questionmark.circle.fill" : "questionmark.circle")
}
.buttonStyle(.plain)
.help("Toggle help overlays")
.accessibilityIdentifier("btnToggleAllHelp")
}
.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)
)
}
}

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