Compare commits

..
153 Commits
Author SHA1 Message Date
gronod efeefb8cea chore: bump MARKETING_VERSION to 2.0.4
macOS CI / build-and-test (push) Successful in 43m3s
macOS CI / package (push) Successful in 3m25s
Bumps the project version ahead of the develop→main merge so the
upcoming release tag can match project.yml.
2026-09-22 19:40:28 +01:00
gronod 7fc38aabbb Merge branch 'milestone/m13-print-ticket' of https://git.i3omb.com/gronod/iccery-v2-mac into milestone/m13-print-ticket
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 14m9s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 1m48s
2026-09-22 19:22:48 +01:00
gronod 972e166430 Merge branch 'feat/218-paper-source' into milestone/m13-print-ticket 2026-09-22 19:21:18 +01:00
gronod ecbf46723b feat(print): #218 Stage 2 paper-source selection + orientation dropdown
macOS CI / build-and-test (push) Skipped
- Detect vendor tray keys in CupsParsers with InputSlot/MediaSource/EPIJ_FdSo/EPIJ_PSrc/CNIJMediaSupply roster; EPIJ_FdSo wins over EPIJ_PSrc.
- PrinterTray now carries raw token + display name; PrinterCapabilities exposes trayKey, trayDefault and tray-specific quality maps.
- CupsService enumerates trays from the detected key, enriches labels from PPD, seeds selection from CUPS default, and precomputes per-tray media→quality constraints.
- MediaQualityConstraints resolves quality availability from both selected media and selected tray, falling back to the media-only map.
- TicketWriteResolver emits the selected tray key=token in the locked write order between PageSize and media key.
- PrintPanelService applies the Stage 2 tray token to PMPrintSettings on open and captures the tray back from the full roster plus AP_D_InputSlot.
- PrintSessionViewModel keeps selectedTrayToken, applies capture-back, preserves unknown captured tokens as synthetic trays, and passes tray into spool overrides.
- Stage2View replaces portrait/landscape buttons with an orientation dropdown (printerOrientationSelect) while preserving portrait/landscape semantics and orientation-requested=3|4.
- Add/update core, UI and fixture tests for tray detection, enumeration, PPD labels, defaults, unknown-token synthesis, panel capture, ticket write order and tray-aware quality clamping.
- Update docs/10-print-system.md, docs/11-print-macos.md, docs/21-ui-reference.md and docs/24-issues-invariants.md.

Verified on the macOS 12.7.6 x86_64 CI runner: xcodebuild build-for-testing and ICCeryCoreTests pass (557 tests, 0 failures).
2026-09-22 19:19:33 +01:00
gronod bd14f30a05 Merge pull request '[Bug/High] Restore captured PrintTicket when reopening the printer properties dialog' (#219) from feat/217-panel-ticket-restore into milestone/m13-print-ticket
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m25s
Reviewed-on: #219
2026-09-22 18:56:31 +01:00
gronod 12061a03ac fix(print): restore captured ticket when reopening properties dialog (#217)
macOS CI / build-and-test (push) Skipped
The bound NSPrintPanel always opened on queue driver defaults —
capturedTickets[queue] was stored but never passed back, so every
vendor-PDE setting outside the four mirrored fields was discarded
on reopen (RC1), and pre-selection writes were never validated into
the ticket before runModal (RC2).

Extract the pre-modal configuration into a testable
configureBoundPanel seam with the spooler's S4→S7 order: ticket
restore (warn-only, unlike the spooler's fatal try — a stale ticket
must not keep the dialog from opening → Stage 2 selections (D6
parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings
→ applyPaper → suppression ②–⑤.

ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions
output and serialised ticket sizes pre/post modal for the XP-55
RC3 verification.

Refs #217
EOF
)
2026-09-22 17:47:09 +01:00
gronod f7f6a8bd87 chore: bump MARKETING_VERSION to 2.0.3
macOS CI / package (push) Successful in 3m37s
macOS CI / build-and-test (push) Skipped
2026-09-18 11:06:42 +01:00
gronod ed91ebf807 test(print): scroll stage-2 via synthesized scroll wheel (#215)
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 24m1s
press(forDuration:thenDragTo:) is inert on macOS — content-drag
scrolling is iOS-only, so the helper burned its 20s budget with the
gallery frame never moving. The scroll wheel is the mechanism the
platform supports: one scroll(byDeltaX:deltaY:) call on a coordinate
inside app.scrollViews["stage-2"] per poll iteration, negative deltaY
to move content up off the Dock zone.
2026-09-18 10:38:57 +01:00
gronod 77c065f433 docs(agents): add remote test build procedure for CI runner host
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 36m50s
- SSH access: localadmin@192.168.0.172, credentials in ~/Projects/build-host.env
- sshpass usage via SSHPASS env var (never command-line)
- Remote checkout sync: git checkout + scp uncommitted changes
- xcodegen PATH workaround: prepend ~/.local/xcodegen/2.38.0/xcodegen/bin (#109)
- Full xcodebuild build-for-testing + codesign + test-without-building sequence mirroring .gitea/workflows/macos.yml
- Unit tests only (ICCeryCoreTests) —
2026-09-18 09:55:22 +01:00
gronod f7099a9808 fix(print): filter Stage 2 quality picker by selected media type (#214)
macOS CI / build-and-test (push) Successful in 44m15s
macOS CI / package (push) Successful in 3m27s
lpoptions -l only exposes the global quality list, so the picker offered every token for every media — the driver then rejected invalid pairs (e.g. Fast Economy on Premium Semigloss) at print time. Neither the Epson nor the Canon PPD carries standard *UIConstraints, so resolve the media-to-quality map from each driver's own data: Epson InkjetPrinter2 *EPIJUIConstraint forbid-rules in the machine bundle's PDEData.dat (non-media LHS terms evaluated against lpoptions defaults), Canon BJPrinter's binary cnb_<TableID>0.tbl record table via *CNIJNameTblPath/*CNIJTableID, and generic PPD *UIConstraints pairs.

MediaQualityConstraints picks the first non-empty source and fails open — queues without constraint data keep the full list. PrinterCapabilities gains qualityIDsByMediaType plus filtered qualities(forMediaType:)/allowsQuality accessors; the view model exposes availableQualities, clamps the pick on media change / seeding / panel apply-back, and substitutes a stale quality defensively in makeRequest so an invalid pair can never reach the ticket.
2026-09-17 23:08:51 +01:00
gronod ddb9245f44 fix(print): anchor raster at page top in bottom-up canvas space (#211)
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 11m49s
macOS CI / build-and-test (push) Failing after 44m23s
macOS CI / package (push) Skipped
226e7b2 un-flipped the canvas but left destinationRect anchored at
pageRect.minY — the page's BOTTOM edge in bottom-up space — so
sub-page targets printed at the foot of the sheet and oversize
targets clipped their top rows. Anchor at pageRect.maxY - height,
restoring the spec'd top-left anchor (docs/14 §6, D9).

Adds the missing regression coverage: the geometry test now asserts
the top anchor with a sub-page raster, and the PDF harness gains a
banded (red-over-blue) fixture that fails on either a mirrored draw
or a bottom anchor — solid fills could catch neither.
2026-09-17 21:53:17 +01:00
gronod 226e7b2a78 test(print): flip canvas coordinate space to bottom-up for NSPrintOperation compatibility
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 34s
- TargetPageCanvasView.isFlipped: true → false (bottom-up CoreGraphics origin)
- TargetCanvasGeometryTests: assertion updated to XCTAssertFalse(view.isFlipped)
2026-09-17 20:49:52 +01:00
gronod 2407abfd9e chore: bump MARKETING_VERSION for M12 native spool release
macOS CI / build-and-test (push) Failing after 4m51s
macOS CI / package (push) Skipped
2026-09-17 18:03:00 +01:00
gronod 356ae0bfa9 Merge pull request 'M12: native NSPrintOperation target spool (#201, #202)' (#209) from milestone/m12-native-spool into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
2026-09-17 18:02:33 +01:00
gronod 60f3aa3991 Merge pull request 'docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6)' (#208) from feat/201-docs into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 32m12s
2026-09-17 18:02:12 +01:00
gronod b89d9777ad docs(print): native spool documentation update + issue #201 analysis (#201 Phase 6)
macOS CI / build-and-test (push) Skipped
- Create docs/issue-printer-quality-ignored.md: lifecycle trace, ticket-loss and override-inversion root causes with file:line evidence, dismissed argv tokenisation hypothesis, locked D1-D13 table.
- docs/11: retitle walkthrough to UI click to NSPrintOperation; replace build_lp_args/print_target with the S1-S14 native-spool trace; add layers 5' (Quartz vocabulary) and 7 (ticket serialise/restore); mark lp flag table and macOS test list historical v1.
- docs/14: decision table rows (Spool, ColorSync ticket, Geometry, Interpolation, AirPrint) marked adopted in v2.0 via #201; note section 7 Quartz vocabulary now live in ICCery proper.
- AGENTS.md: spooler documented in app target (D1); new Print spool section (lp eradicated, parsers retained per D4, ICCERY_TEST_SPOOL_LOG seam); ColorSync SPI rule replaced by the D2 single-path dual-vocabulary rule.
- Eradication sweep: label remaining lp/LpArgs references historical v1 in docs/10, docs/13, docs/25, docs/26; drop deleted LpArgs from README.
2026-09-17 17:40:25 +01:00
gronod 7a3a0886be Merge pull request 'feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5)' (#207) from feat/202-airprint-detection into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 17:31:09 +01:00
gronod 5389e4c2bd feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5)
macOS CI / build-and-test (push) Skipped
- CupsParsers: lpstatDeviceURIs / lpoptionsMakeAndModel /
  detectAirPrint — the six docs/14 §10.2 rules (apple-airprint:// URI,
  *APAirPrint: True, Apple+AirPrint make-and-model, ipps://+PPD
  mention, ipp:// + _universal mDNS subtype, cupsFilter2→image/urf).
- CupsService.listPrinters: one tolerated lpstat -v spawn feeds
  detection; lpoptions -p yields displayName + makeAndModel in a
  single spawn; Printer.isAirPrint populated per queue.
- Printer.isAirPrint defaults false with a tolerant decoder.
- Stage2View: live airPrintWarningBadge — "AirPrint queue —
  unmanaged colour cannot be guaranteed."
- Fixtures: lpstat -v marks Mock_Canon_Pro via _universal subtype;
  lpoptions emits printer-make-and-model.
- Tests: six rule fixtures + USB negative + a no-fallback-respawn
  listPrinters case; Milestone12AirPrintUITests tracks the badge
  across queue selection.

Gate: full xcodebuild test green (515 unit + 70 UI).
2026-09-17 17:20:25 +01:00
gronod 972368aa85 Merge pull request 'feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4)' (#206) from feat/201-native-print-operation into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 16:35:44 +01:00
gronod 4f3caf335d feat(print): native NSPrintOperation spooler, eradicate lp (#201 Phase 4)
macOS CI / build-and-test (push) Skipped
Replace the LpArgs argv builder with the deterministic
TicketWriteResolver (locked 1-10 write order, Stage 2 overrides win
unconditionally, no raw, nil overrides emit no write) and spool via a
headless NSPrintOperation over TargetPageCanvasView instead of the
CUPS lp binary. RecordingTargetSpooler (DEBUG) logs deterministic
request lines to ICCERY_TEST_SPOOL_LOG for tests. Single spool job
toggle switches printAllPages between per-page requests and one
multi-page job. CupsService keeps lpstat/lpoptions enumeration but
loses printTarget; ProcessID.lp* renamed cupsLp*; LpArgs, its tests
and the lp fixture deleted.
2026-09-17 16:30:27 +01:00
gronod de142a9690 Merge pull request 'feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3)' (#205) from feat/201-raster-canvas into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 15:08:06 +01:00
gronod a2f994821f feat(print): 1:1 TIFF raster loader and headless page canvas (#201 Phase 3)
macOS CI / build-and-test (push) Skipped
TargetRasterLoader decodes printtarg TIFFs via ImageIO and re-tags
into DeviceGray/DeviceRGB/DeviceCMYK around the decoder's own
dataProvider — no resample, no bit-depth change (D10). Skip-only
alpha padding (noneSkipLast/First, which ImageIO emits for opaque
RGB TIFFs) is accepted; real coverage alpha, indexed, float and
non-1/3/4-component layouts are rejected as unsupportedLayout.
DPI metadata drives pointSize (px/dpi*72) with a 72 dpi fallback +
warn; manifestDrift reports >0.5 mm disagreement with the printtarg
manifest (D9).

TargetPageCanvasView is the headless flipped canvas NSPrintOperation
paginates: rectForPage stacks pages top-down, destinationRect anchors
1:1 at the page's top-left snapped to 0.001 pt, and draw() forces
interpolation/antialias/font-smoothing off per page with a drawProbe
seam for tests.

PDF harness (NativeSpoolPDFTests, skippable via ICCERY_SKIP_PRINT_PDF=1)
ran on this host: run() -> true, 2 pages at A4 media box, and the
fixture's 72 pt patch verified 1:1 at the painted top-left (printer
unprintable-margin offset tolerated per R10).
2026-09-17 14:57:24 +01:00
gronod 1d6032456d Merge pull request 'feat(print): capture native print ticket on panel OK (#201 Phase 2)' (#204) from feat/201-ticket-capture into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 13:50:45 +01:00
gronod fc1530ab2e feat(print): capture native print ticket on panel OK (#201 Phase 2)
macOS CI / build-and-test (push) Skipped
Serialise PMPrintSettings/PMPageFormat to XML data plus a plist-safe
NSPrintInfo dictionary snapshot (PrintTicket), restore via
PM*CreateWithDataRepresentation -> PMCopy* -> PMSessionValidate* ->
updateFromPM*, and refuse cross-queue replay (R3).

PrintPanelService.showProperties now returns PanelCaptureResult; the
view model stores tickets in capturedTickets keyed by queue.

Also fixes a latent over-release: PMSessionGetCurrentPrinter hands back
the session's own printer (borrowed) — the extracted currentPrinterID
was PMRelease-ing it, which dangled the session and crashed AppKit's
_printerInPrintSession path and session teardown.
2026-09-17 13:43:43 +01:00
gronod ffbe56f43a Merge pull request 'refactor(print): extract PMTicketBridge from PrintPanelService (#201 Phase 1)' (#203) from feat/201-pm-ticket-bridge into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 12:16:26 +01:00
gronod ee5da4a637 refactor(print): extract PMTicketBridge from PrintPanelService
macOS CI / build-and-test (push) Skipped
Centralise every Core Printing (PM*) call in one @MainActor bridge so
the upcoming ticket serialise/restore path and native spooler share a
single documented ownership policy. Pure move — no behaviour change.
2026-09-17 12:15:27 +01:00
gronod 0513b272cb docs(agents): add issue ticket style and dependency tracking guidelines
macOS CI / package (push) Skipped
macOS CI / build-and-test (push) Skipped
2026-09-17 10:34:50 +01:00
gronod 555bf8fda4 chore(release): bump MARKETING_VERSION to 2.0.1
macOS CI / build-and-test (push) Successful in 7s
macOS CI / package (push) Failing after 44s
2026-09-16 06:40:31 +01:00
gronod f3c7b19694 docs(m11): mark milestone shipped on develop
macOS CI / build-and-test (push) Failing after 45m36s
macOS CI / package (push) Skipped
Refs #183

Generated with [Devin](https://devin.ai)
2026-09-16 01:49:12 +01:00
gronod 5e696ff3e9 Merge pull request 'Milestone 11 — Printer settings completeness & dialog binding (#183, #180, #181, #186)' (#199) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 43m58s
macOS CI / package (push) Successful in 3m17s
2026-09-16 00:11:59 +01:00
gronod 19f619ad98 Merge branch 'develop' into chore/m11-sync-develop
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Successful in 42m51s
macOS CI / package (pull_request) Skipped
2026-09-15 21:52:15 +01:00
gronod 09f466ae46 Merge pull request 'docs(ui): M11 stage-2 a11y id roster update' (#197) from chore/m11-docs-hygiene into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 21:52:06 +01:00
gronod d228594bd3 docs(ui): add M11 printer settings a11y ids to stage-2 roster
macOS CI / build-and-test (push) Skipped
Refs #183
2026-09-15 21:51:52 +01:00
gronod 6adbf99bd9 Merge pull request 'fix(print): bind selected printer and settings in NSPrintPanel (#186)' (#196) from feat/186-panel-binding into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 20:41:12 +01:00
gronodandDevin d8c41444b9 fix(print): bind selected printer and settings in NSPrintPanel (#186)
macOS CI / build-and-test (push) Skipped
Refs #186

Co-Authored-By: Devin <devin@cognition.ai>
2026-09-15 20:40:23 +01:00
gronod 1767792f75 Merge pull request 'fix(print): deterministic PPD locale precedence for media labels (#181)' (#195) from feat/181-canon-media-locale into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 19:27:47 +01:00
gronodandDevin e8678586bd fix(print): deterministic PPD locale precedence for media labels (#181)
macOS CI / build-and-test (push) Skipped
Refs #181

Co-Authored-By: Devin <devin@cognition.ai>
2026-09-15 19:25:46 +01:00
gronod c9b423b229 Merge pull request 'fix(print): enumerate all EPIJ_Qual quality options (#180)' (#194) from feat/180-epson-qual-options into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 19:07:18 +01:00
gronodandDevin 79209eb911 fix(print): enumerate all EPIJ_Qual quality options (#180)
macOS CI / build-and-test (push) Skipped
The Phase 1 (#183) quality plumbing already covers detection,
extraction, capture, and lp emission — this change proves the Epson
path end-to-end with recorded fixtures.

- CupsParserTests: replace the synthetic cupsPrintQuality listing with
  the recorded XP-55 line (301 302 *303 308 304 305 307 — driver order,
  308 between 303 and 304); assert all seven ids enumerate unsorted,
  the * default is 303, all seven PPD labels resolve, and EPIJ_Qual
  wins detection over OutputMode/Resolution (R11).
- CupsOptionsFilterTests: captured EPIJ_Qual + canonical quality keys
  survive the filter (roster verified complete post-#183 — no source
  change needed; EPIJ_Quality kept as harmless alias, R16).
- LpArgsTests: explicit quality 305 emits -o EPIJ_Qual=305.
- PrintSessionViewModelTests + UI lpoptions fixture: recorded
  seven-choice line feeds the picker; Stage 2 picker lists all seven
  entries in driver order (new UI test).

Devin-AI: Devin <devin@cognition.ai>
Co-authored-by: Devin <devin@cognition.ai>
2026-09-15 19:06:47 +01:00
gronod 51c30737c7 Merge pull request 'feat(print): Stage 2 paper size + quality selection (#183)' (#193) from feat/183-stage2-print-settings into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 18:43:19 +01:00
gronodandDevin 37d1f7eb24 feat(print): Stage 2 paper size + quality selection (#183)
macOS CI / build-and-test (push) Skipped
Devin-AI: Devin <devin@cognition.ai>
Co-authored-by: Devin <devin@cognition.ai>
2026-09-15 18:42:49 +01:00
gronod 62a45d9c7d Merge pull request 'Milestone/m11 print settings' (#192) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 37m41s
macOS CI / package (push) Successful in 4m9s
Reviewed-on: #192
2026-09-15 18:11:39 +01:00
gronod bb3cc3c858 Merge pull request 'docs(m11): record printer-settings milestone tracking' (#191) from chore/m11-tracking into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Successful in 39m2s
macOS CI / package (pull_request) Skipped
2026-09-15 18:07:23 +01:00
gronod eb73bf0106 docs(m11): record printer-settings milestone tracking
macOS CI / build-and-test (push) Skipped
- BUILD-PLAN: M11 row (issues 183/180/181/186) above Later/#16; source
  line names milestone id 34; note line points at
  milestone/m11-print-settings from 736fd08; #16 not an M11 exit gate
- README: M10 row reflects shipped state (milestone 32 closed); new M11
  row in flight on milestone/m11-print-settings; branch taxonomy names
  the announced integration branch
2026-09-15 18:07:07 +01:00
gronod 736fd080cb docs: ignore local set-version helper; document gitea MCP dependency methods
macOS CI / package (push) Successful in 3m31s
macOS CI / build-and-test (push) Skipped
- .gitignore: /set-version.sh is a local-only developer helper, never committed
- AGENTS.md: gitea MCP issue_write/issue_read expose dependency methods
  (add_dependency, block_issue, list_dependencies, ...) — replaces the
  raw REST notes now that the MCP build supports them
2026-09-15 16:49:25 +01:00
gronod 14cd544e05 feat(#189): release-driven versioning — tag-stamped About + monotonic build
macOS CI / build-and-test (pull_request) Successful in 38m2s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Failing after 37m37s
macOS CI / package (push) Skipped
- scripts/version.sh resolves ICCERY_RELEASE_TAG / MARKETING_VERSION /
  CURRENT_PROJECT_VERSION from RELEASE_TAG env or `git describe`; tag
  builds hard-fail when the tag's X.Y.Z != project.yml MARKETING_VERSION
- build number = `git rev-list --count HEAD` (Apple: macOS
  CFBundleVersion must monotonically increase, no per-version reset)
- tag ships as a bundled ICCeryReleaseTag resource — a generated
  Info.plist can't carry custom keys (INFOPLIST_KEY_* allowlist, and
  ProcessInfoPlistFile runs after script phases); About shows
  "tag (marketing)", e.g. v2.0.0-pre2-grok (2.0.0)
- DMG named ICCery-<tag>-<build>.dmg for tagged/described builds
- CI twins: fetch-depth 0 + RELEASE_TAG env + stamped test builds
2026-09-15 13:10:19 +01:00
gronod 2fefe98434 fix: UI test helpers for nested license window sheet
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 21s
macOS CI / build-and-test (push) Successful in 37m17s
macOS CI / package (push) Successful in 3m29s
- Update element() helper in AboutHelpUITests to search all sheets (nested sheets)
- Add .accessibilityElement(children: .contain) to LicenseWindowView root and attribution section
- All 6 AboutHelpUITests now pass (2 existing + 4 new license window tests)
2026-09-15 01:21:54 +01:00
gronod 90d034baab fix: LicenseWindowView compatibility with macOS 12.0 (remove NavigationStack)
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (push) Failing after 41m0s
macOS CI / package (push) Skipped
2026-09-14 23:40:08 +01:00
gronod 8cb79e8ffa feat: add license window to About dialog (issue #31)
macOS CI / build-and-test (push) Skipped
- Add LicenseWindowView.swift with ICCery license, ArgyllCMS AGPLv3 license (from bundled License.txt), and attribution links to upstream and Gronod fork
- Add "View Licenses" button to AboutView.swift that opens license window as sheet
- Update fetch-argyll.sh to copy License.txt to Vendor/Argyll/ root
- Add UI tests for license window content, links, and AGPL isolation note

The license window includes:
- ICCery license text (embedded from LICENCE.md)
- ArgyllCMS AGPLv3 license (dynamically loaded from bundled Resources/Argyll/License.txt)
- Attribution to Graeme Gill with link to argyllcms.com
- Attribution to Gronod fork with link to git.i3omb.com/gronod/argyllcms
- AGPL subprocess isolation note
2026-09-14 23:35:43 +01:00
gronod c1c37bb7d7 test(m4): retry Start Read until the chartread session is running
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m52s
macOS CI / build-and-test (push) Successful in 37m49s
macOS CI / package (push) Successful in 4m39s
testTwoPassAverageFailurePromotesFirstPass died on Gitea run 35443
waiting for btnCalibrate after a single Start click. Handheld in the
same job succeeded: AX reported enabled and synthesized the click, but
SwiftUI startRead() was still a no-op (same class as 35251).

Re-click until btnCancel/Calibrate/Trigger exist — Cancel is in the
tree for the whole session — then wait for Calibrate.
2026-09-14 18:40:27 +00:00
gronod d2466d9a66 Merge pull request 'test(m2): mock CUPS so Print All enables on GitHub macos-14' (!176) from fix/m2-print-all-mock-cups into develop
macOS CI / build-and-test (push) Failing after 37m56s
macOS CI / package (push) Skipped
Reviewed-on: #176
2026-09-14 17:35:39 +01:00
gronod 4af2d96294 test(m2): mock CUPS so Print All enables on GitHub macos-14
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
testPrinttargFixtureGalleryAndStubbedPrint asserted btnPrintAll
immediately after the gallery appeared, without ICCERY_CUPS_BIN_DIR.
Gitea Monterey has system queues; GitHub macos-14-arm64 does not, so
selectedPrinter stays empty and the button stays disabled (run
34867767434). Point M2 at Fixtures/bin like M3, and wait for enable.
2026-09-14 16:32:57 +00:00
gronod d17aa962ce Merge pull request 'fix(ci): pin GitHub Actions to macos-14 and clamp the window on-screen' (#175) from fix/gh-ci-window-and-runner into develop
macOS CI / build-and-test (push) Canceled after 0s
macOS CI / package (push) Canceled after 0s
Reviewed-on: #175
2026-09-14 17:18:13 +01:00
gronod 93acd32b51 fix(ci): pin GitHub Actions to macos-14 and keep the window on-screen
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
macos-latest is macos-26-arm64 (run 34864198118). A 1280x800 window
centered on that display hangs off the left edge, so XCTest sees
sidebar controls at x ≈ -116 and refuses the click. Pin the GitHub
workflow to macos-14 and clamp the main window to visibleFrame.
2026-09-14 16:17:20 +00:00
gronod 67cf452e59 Update .github/workflows/macos.yml
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 27m49s
2026-09-14 16:44:50 +01:00
gronod 83ed53c7fc Merge pull request 'ci: GitHub Actions twin of the Gitea macOS workflow' (#174) from ci/github-workflows into develop
macOS CI / build-and-test (push) Successful in 40m30s
macOS CI / package (push) Successful in 3m27s
Reviewed-on: #174
2026-09-14 16:42:01 +01:00
gronod cba9476af7 Update .github/workflows/macos.yml
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 3m40s
macOS CI / package (pull_request) Skipped
2026-09-14 16:41:41 +01:00
gronod 288b08e3d9 ci: add GitHub Actions twin of the Gitea macOS workflow
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Failing after 42m38s
macOS CI / package (pull_request) Skipped
Copy .gitea/workflows/macos.yml to .github with the github.com deltas:
macos-14 (macos-12 is gone), upload-artifact@v4, no private-CA bundle,
and gh release upload instead of the Gitea asset script.
2026-09-14 15:33:13 +00:00
gronod 18f1d1ff33 Merge pull request 'test(m4): wait for Start Read enabled before chartread (CI 35251/35293)' (#173) from fix/m4-handheld-start-read into develop
macOS CI / build-and-test (push) Successful in 42m34s
macOS CI / package (push) Canceled after 21s
test(m4): wait for Start Read enabled before chartread

Runs 35251 and 35293 clicked a disabled Start Read.
2026-09-14 16:16:34 +01:00
gronod 9dfa79ebd0 test(m4): wait for Start Read to be enabled before clicking
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Canceled after 0s
macOS CI / package (pull_request) Canceled after 0s
Runs 35251 and 35293 left Stage 3 idle because XCTest clicked a
disabled Start Read. driveOnePass already waits for isEnabled;
the handheld average test now uses it.
2026-09-14 15:16:25 +00:00
gronod 1b91b0a94e Merge pull request 'fix(ui): media Apply missing-printer notice visible in manage sheet (#170)' (#171) from fix/170-media-library-missing-printer-notice into develop
macOS CI / build-and-test (push) Failing after 42m49s
macOS CI / package (push) Skipped
fix(ui): media Apply missing-printer notice visible in manage sheet (#170)

Fixes #170
2026-09-14 14:37:05 +01:00
gronod 890e7281eb fix(ui): show media Apply failure inside the manage sheet (#170)
macOS CI / build-and-test (push) Skipped
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 2m31s
Failed Apply kept the sheet open while the window banner sat
behind it, so Monterey XCTest never saw noticeText. Mirror the
failure string in the dialog (manageMediaNotice) and point the
UI test at that identifier.

Fixes #170
2026-09-14 13:36:59 +00:00
gronod ccaad2d940 docs: sync README with Monterey floor, M10, wiki, and packaging
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 51s
macOS CI / build-and-test (push) Failing after 43m55s
macOS CI / package (push) Skipped
Requirements were still Xcode 15.4 / Swift 6. Point the product
at the user wiki, record host-arch tests, the dmgbuild venv, and
the current develop-first git workflow.
2026-09-14 12:34:16 +00:00
gronod 7e5e20cb69 Merge origin/main into develop (keep README)
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16m10s
2026-09-14 13:23:21 +01:00
gronod e72039c123 Delete README.md 2026-09-14 13:11:43 +01:00
gronod fa02e774ca Merge pull request 'fix(packaging): DMG background visible on Sonoma+ (#95)' (#167) from fix/95-dmg-background-sonoma into develop
macOS CI / build-and-test (pull_request) Skipped
macOS CI / build-and-test (push) Successful in 38m21s
macOS CI / package (push) Successful in 4m14s
Reviewed-on: #167
2026-09-14 11:55:39 +01:00
gronod 2a8ad4d5d4 fix(ci): cap pip below 26.1 in the Python 3.9 dmgbuild venv
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Successful in 4m5s
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 3m29s
Unbounded pip upgrade installed 26.2.1, which uses
dataclass(slots=True) and crashes on Monterey 3.9. Recreate
the venv if pip is already broken, then pin pip>=24.3,<26.1
before force-installing dmgbuild 1.6.7.
2026-09-14 10:34:16 +00:00
gronod c2ac9341c8 fix(ci): force dmgbuild 1.6.7 into the package venv
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m6s
macOS CI / build-and-test (push) Successful in 11s
macOS CI / package (push) Failing after 35s
Python 3.9 hides 1.6.6+ (Requires-Python >=3.10), so
dmgbuild>=1.6.5 left a cached 1.6.5 install. The wheels are
py3-none-any; install 1.6.7 with PIP_IGNORE_REQUIRES_PYTHON,
force-reinstall ds_store>=1.3.3 and mac_alias>=2.2.3, and fail
if the venv is still older than 1.6.7.
2026-09-14 10:29:33 +00:00
gronod 1c310706e4 fix(ci): isolate dmgbuild in a venv, skip it on the test job
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 1m50s
macOS CI / build-and-test (push) Successful in 13s
macOS CI / package (push) Successful in 4m12s
ensure-host-tools.sh always bootstraps xcodegen. dmgbuild now
lives in build/.venv-dmgbuild and is only installed when
INSTALL_DMGBUILD=1 (package job). The test job no longer
pip-installs dmgbuild, which is what failed on Python 3.9.

package-release.sh reuses that venv (or bootstraps it locally)
instead of creating a second copy.
2026-09-14 10:22:09 +00:00
gronod 7f6c47d85c fix(ci): do not require dmgbuild 1.6.7 on Monterey Python 3.9
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 6m31s
The tools step runs on the test job. System Python is 3.9;
dmgbuild 1.6.6+ declares Requires-Python >=3.10, so pip only
lists 1.6.5 and the 1.6.7 floor failed the gate.

Install the newest wheel this interpreter accepts. Keep the
HiDPI TIFF background path and the hard-fail if art is missing.
2026-09-14 10:14:29 +00:00
gronod 3b6323378e fix(packaging): DMG background visible on Sonoma+ (#95)
macOS CI / build-and-test (pull_request) Failing after 20s
macOS CI / package (pull_request) Skipped
Pin dmgbuild >= 1.6.7 so Finder gets a bookmark instead of a
Monterey-era Alias Manager blob. Feed a HiDPI TIFF from
tiffutil rather than the 1x RGBA PNG, and fail the package job
if the art is missing instead of shipping a grey window.

Exclude installer chrome from the app bundle; it is not an
in-app asset.
2026-09-14 09:57:37 +00:00
gronod ad7b91cf91 Merge pull request 'fix(ui): settings sheet — ΔE clip + duplicated numeric-field labels (#165)' (#166) from fix/165-settings-deltae-clip into develop
macOS CI / package (push) Successful in 4m48s
macOS CI / build-and-test (push) Successful in 43m0s
Reviewed-on: #166
2026-09-14 07:56:58 +01:00
gronod 8b931e3625 fix(ui): settings numeric fields no longer render default value as inline label (#165)
macOS CI / build-and-test (pull_request) Successful in 37m40s
macOS CI / package (pull_request) Skipped
- TextField("30"/"2.0"/"5.0") passed the default value as the label,
  which macOS draws inline next to the box — the rows read
  "Stale after 30 [30] days" / "Good ΔE ≤ 2.0 [2.0]".
- The three fields are now direct Form children carrying their
  descriptive label, so it renders once in the label column and the box
  fills the control column, matching the Pickers. The stale-days row
  folds "days" into the label ("Stale after (days)").
- New identifier settingsCalStaleDays; docs/21 roster 357→358.
- New testNumericFieldsCarryLabelsNotDuplicatedValues asserts each
  field's value, a single label-column staticText, and no staticText
  echoing the old label literal. testVerificationRowsStayInsideSheet
  updated for label-column geometry (fields end ~3.5 pt inside the
  sheet, same as the PopUpButtons — the 12 pt inset only applied to the
  old 60 pt boxes).

Refs #165
2026-09-14 02:05:06 +01:00
gronod 73b18dec5b test(settings): drop waitForNonExistence for Xcode 14.2 CI (#165)
macOS CI / build-and-test (pull_request) Failing after 4m12s
macOS CI / package (pull_request) Skipped
waitForNonExistence requires the macOS 14 SDK XCTest; the macos-12 runner toolchain has no such member on XCUIElement. Poll sheet.exists on the run loop instead, matching the waitForGone pattern in Milestone10GamutCompareUITests.
2026-09-14 01:08:01 +01:00
gronod 3bc0d14a34 test(settings): add Foundation import and simplify column alignment assertion
macOS CI / build-and-test (pull_request) Failing after 1m40s
macOS CI / package (pull_request) Skipped
2026-09-14 00:46:32 +01:00
gronod 4aa2815c6e test(settings): use abs diff instead of accuracy for Swift 5.7 compatibility
macOS CI / build-and-test (pull_request) Failing after 1m50s
macOS CI / package (pull_request) Skipped
2026-09-14 00:42:32 +01:00
gronod 8596f15d52 fix(ui): settings ΔE threshold rows no longer clip the sheet edge (#165)
macOS CI / build-and-test (pull_request) Failing after 2m55s
macOS CI / package (pull_request) Skipped
- Split Section("Verification") from one 4-across non-wrapping HStack into
  two adjacent label+field rows (#settingsDeltaEGood, #settingsDeltaEWarning).
- Add .padding(.leading, 45) to the Settings Form so the control column
  aligns at ~522 pt, matching develop, and all labels have 41–100 pt
  breathing room from the left boundary.
- Add SettingsUITests with frame-containment, control alignment, and
  validation/save round-trip coverage.
- Update docs/21-ui-reference.md Settings entry with new identifiers.

Refs #165
2026-09-14 00:34:35 +01:00
gronod 407136c5a2 fix(ui): calibrate dashboard no longer overflows window; Escape exits (#163)
macOS CI / build-and-test (pull_request) Successful in 6m4s
macOS CI / package (pull_request) Skipped
macOS CI / build-and-test (push) Canceled after 1m44s
macOS CI / package (push) Skipped
2026-09-13 22:16:01 +01:00
gronod 83b5f6bf75 Merge pull request 'ci(macos): skip tests on tags containing "prerelease"' (#162) from chore/ci-prerelease-skip into develop
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 16s
Reviewed-on: #162
2026-09-13 21:04:38 +01:00
gronod 16df18127c ci(macos): skip tests on tags containing "prerelease"
macOS CI / build-and-test (pull_request) Successful in 37m58s
macOS CI / package (pull_request) Skipped
2026-09-13 20:41:32 +01:00
gronod 78baa49e61 Merge pull request 'fix(gamut): Close button; skip SCNView without Metal (#147)' (#161) from fix/147-gamut-close-no-gpu into milestone/m10-studio
macOS CI / build-and-test (pull_request) Successful in 32m11s
macOS CI / package (pull_request) Skipped
macOS CI / package (push) Canceled after 0s
macOS CI / build-and-test (push) Canceled after 13m23s
2026-09-13 18:49:35 +01:00
gronod 04a563c54a test(print): scroll gallery until btnPrintPage is hittable (#132) 2026-09-13 18:24:43 +01:00
gronod 2cbd43470d fix(gamut): Close button; skip SCNView without Metal (#147)
Refs #147 #146
2026-09-13 18:24:43 +01:00
gronod 14f9e6f78d Merge pull request 'fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)' (#160) from fix/147-gamut-toggle-disabled into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 5m30s
2026-09-13 17:05:05 +01:00
gronod f16825be72 fix(gamut): don't disable layer toggles when SceneKit is unavailable (#147)
Refs #147 #146
2026-09-13 17:03:56 +01:00
gronod 9fcd21dc2d Merge pull request 'test(m10): combine gamut layer toggle AX; harden gallery wait on macOS 12' (#159) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / package (pull_request) Canceled after 0s
macOS CI / build-and-test (pull_request) Canceled after 30m21s
2026-09-13 15:53:48 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f5c57649e1 test(m10): combine gamut layer toggle AX; harden gallery wait on macOS 12
Run 33803 still reported the layer toggles disabled after a real
waitUntilEnabled: on macOS 12 the identifier lands on the Toggle's
container element, which never reports isEnabled — combine the
Toggle's children so the a11y leaf is the checkbox itself (checkbox
style unchanged). testPrinttargFixtureGalleryAndStubbedPrint flaked
once on the same runner: wait for btnCreateLayout enabled before
clicking and retry once when the gallery never materialises.

Refs #147 #149 #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 15:52:53 +01:00
gronod e967502ea7 Merge pull request 'test(m10): wait for gamut layer enable; drive New via File menu on macOS 12' (#158) from fix/m10-ci-ui-macos12 into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 32m15s
macOS CI / package (pull_request) Skipped
2026-09-13 14:59:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> eb3ae08cf9 test(m10): wait for gamut layer enable; drive New via File menu on macOS 12
Actions run 33718 (Xcode 14.2, macOS 12) failed four UI tests that
pass locally. Gamut layer toggles render as disabled placeholders
until the async layer load lands, so waitFor(id) won the race on the
placeholder — added waitUntilEnabled and gate on isEnabled. The
project New test's ⌘N may not reach the CommandGroup and alert
buttons often lack identifiers on macOS 12 — drive New through the
File menu "New Project" item and confirm the alert by button title
with id fallback.

Refs #147 #149 #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 14:59:11 +01:00
gronod 3d8e70be62 Merge pull request 'fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)' (#157) from fix/146-viewbuilder-ten-child into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 29m46s
macOS CI / package (pull_request) Skipped
2026-09-13 13:35:36 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f43c275152 fix(ui): split ViewBuilders for Swift 5.7 ten-child limit (#146)
Actions run 33666 (Xcode 14.2, macOS 12) failed to compile: the
sidebar VStack had 14 children and SaveMediaRecipeDialog had 13,
over the Swift 5.7 ViewBuilder 10-child cap. Extracted
layout-transparent Group subviews (header/presetBlock/mediaBlock/
studioButtons/stepperAndProject; fields/readOnlyRows) — no id,
order, or layout change.

Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 13:33:42 +01:00
gronod 48d2816c73 Merge pull request 'docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12' (#155) from chore/m10-docs-hygiene into milestone/m10-studio
macOS CI / build-and-test (pull_request) Failing after 1m23s
macOS CI / package (pull_request) Skipped
2026-09-13 12:24:23 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b494b4fffd docs(m10): defaultInstrument seeds Spot Read; README floor is macOS 12
Refs #148

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 12:23:43 +01:00
gronod c39afe0dfd Merge pull request 'feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)' (#154) from feat/149-project-file into milestone/m10-studio 2026-09-13 11:29:58 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 1d78957d7a feat(project): .icceryproj index over basename and cwd + recipe + last ΔE (#149)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 11:28:40 +01:00
gronod 99c0d7e83d Merge pull request 'feat(gamut): second-profile compare and click-inspect (#147)' (#153) from feat/147-gamut-compare into milestone/m10-studio 2026-09-13 10:24:24 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 398d9b4756 feat(gamut): second-profile compare and click-inspect (#147)
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 10:23:25 +01:00
gronod 538ef1e05f Merge pull request 'feat(spotread): spot-read console for live Lab (#148)' (#152) from feat/148-spot-read into milestone/m10-studio 2026-09-13 01:04:45 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d40932f4cf feat(spotread): spot-read console for live Lab (#148)
Refs #148

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-13 01:03:57 +01:00
gronod 1931da8448 Merge pull request 'feat(media): media recipe library bound to presets (#146)' (#151) from feat/146-media-library into milestone/m10-studio 2026-09-12 23:00:21 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f896f60d1a feat(media): media recipe library bound to presets (#146)
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:59:35 +01:00
gronod 2f305c481e Merge pull request 'docs(m10): record studio-workflow milestone tracking' (#150) from chore/m10-tracking into milestone/m10-studio 2026-09-12 22:18:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4249067345 docs(m10): record studio-workflow milestone tracking
Refs #146

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 22:18:21 +01:00
gronod 800c980137 Merge pull request 'Milestone/m9 monterey' (#145) from milestone/m9-monterey into develop
macOS CI / build-and-test (push) Successful in 18m50s
macOS CI / package (push) Successful in 3m22s
Reviewed-on: #145
2026-09-12 18:43:44 +01:00
gronod 67fb3a9b37 Merge pull request 'test(m9): collapse ArgyllRunner waits under ICCERY_UI_TESTING' (#144) from feat/141-ui-test-timing into milestone/m9-monterey
macOS CI / build-and-test (push) Successful in 19m52s
macOS CI / package (push) Successful in 3m12s
macOS CI / build-and-test (pull_request) Successful in 18m23s
macOS CI / package (pull_request) Skipped
2026-09-12 17:00:47 +01:00
gronod eb1503aba9 Merge pull request 'test(m9): replace full-timeout negative waits with dwell assertion' (#143) from feat/140-ui-test-absence-waits into milestone/m9-monterey 2026-09-12 17:00:40 +01:00
gronod db47a975f0 Merge pull request 'ci(m9): skip probe test in full UI suite leg' (#142) from feat/139-skip-probe-rerun into milestone/m9-monterey 2026-09-12 17:00:32 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 990c49a436 test(m9): collapse ArgyllRunner waits under ICCERY_UI_TESTING (#141)
Add a DEBUG-gated testAwareDelay helper (same ICCERY_UI_TESTING=1
convention as AppPaths.testRoot) and apply it to the ensureNotRunning
100ms poll and the XY chartread 500ms pre-kill wait. Under UI tests the
intervals shrink ~10x; release builds compile the branch out entirely,
so production timing is unchanged. No current UI test exercises XY
cancel, so the 500ms site is future-proofing at zero risk today.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:55:56 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 60972a0704 test(m9): replace full-timeout negative waits with dwell assertion (#140)
Two XCTAssertFalse(el.waitForExistence(timeout: 3)) sites in
Milestone2UITests always burned the full 3s on the expected-absent
path. Add assertAbsent(_:dwell:) (0.5s dwell + .exists) and use it at
both sites — same coverage, ~6s back per suite run.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:53:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> aa32cc45d3 ci(m9): skip probe test in full UI suite leg (#139)
The attach probe already runs testAboutDialogShowsVersionAndBuildDate;
the full-suite leg reran it for ~30s of zero extra coverage. Add
-skip-testing so it only runs in the probe leg.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 16:50:57 +01:00
gronod 17c47af9f1 ci(m9): build tests for host arch only; reserve universal for release packaging
macOS CI / build-and-test (push) Successful in 20m59s
macOS CI / package (push) Successful in 3m24s
The CI runner only executes tests on its own architecture, so building
both slices wastes time. Override ARCHS="$(uname -m)" in the workflow
and update all docs to match. Universal builds remain required for
release verification and dmgbuild packaging.
2026-09-12 15:59:20 +01:00
gronod 5bea928042 Merge pull request 'test(m9): query presetSelect as PopUpButton to avoid duplicate match (#137)' (#138) from feat/137-presetselect-dup into milestone/m9-monterey
macOS CI / build-and-test (push) Successful in 19m6s
macOS CI / package (push) Successful in 3m4s
2026-09-12 14:33:41 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5c8973de73 test(m9): query presetSelect as PopUpButton to avoid duplicate match (#137)
On the runner SDK the Picker label inherits the control accessibility
identifier, so the .any query for presetSelect matches both a
StaticText and the PopUpButton and .frame resolution fails.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 14:33:26 +01:00
gronod 29ecc79585 Merge pull request 'test(m9): drive scroller + state-driven triggers in UI suite (#135)' (#136) from feat/135-uitest-scroller-trigger into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 20m7s
macOS CI / package (push) Skipped
2026-09-12 14:04:30 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c5a5e7798 test(m9): drive scroller + state-driven triggers in UI suite (#135)
btnPrintPage-0 reports an in-viewport a11y frame but stays unhittable
and scroll-wheel events on the LazyVGrid are inert; drag the vertical
AXScrollBar thumb to perform a real scroll before clicking.

The handheld chartread test could drop a btnTrigger click racing the
strip state transition; poll btnTrigger until btnDoneRead appears
instead of assuming exactly two strips.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 14:03:59 +01:00
gronod 607a4e0df4 Merge pull request 'test(m9): fall back to coordinate click for per-page print button (#132)' (#134) from feat/132-perpage-scroll2 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 20m28s
macOS CI / package (push) Skipped
2026-09-12 13:30:19 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 67199877a2 test(m9): fall back to coordinate click for per-page print button (#132)
Explicit stage-2 scrolls are synthesized but never move the LazyVGrid
cell's a11y frame (run 32235). If still not hittable, dump the a11y
tree and click the reported point directly — the lp argv assertion
verifies the click landed.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 13:30:04 +01:00
gronod 9a6da4648f Merge pull request 'test(m9): scroll stage-2 explicitly before per-page print click (#132)' (#133) from feat/132-perpage-scroll into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 22m43s
macOS CI / package (push) Skipped
2026-09-12 13:02:38 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b5250591ea test(m9): scroll stage-2 explicitly before per-page print click (#132)
btnPrintPage-0 sits at the window's bottom edge on the 1280x800 runner;
XCUI's automatic scroll-to-visible runs but never moves the frame
(run 32209). Scroll the stage-2 ScrollView manually until hittable.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 13:02:24 +01:00
gronod c1ec07f726 Merge pull request 'fix(m9): revert sidebar container a11y id; measure presetSelect frame (#130)' (#131) from feat/130-sidebar-a11y-revert into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 18m59s
macOS CI / package (push) Skipped
2026-09-12 12:37:39 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a975f1a4e4 fix(m9): revert sidebar container a11y id; measure presetSelect frame (#130)
On SDK 13.1 the 'sidebar' identifier on the root VStack propagates to
every child, erasing openSettingsBtn/openAboutBtn/btnToggleAllHelp
(a11y dump, run 32201). Revert it; the test now compares presetSelect
frame before/after toggling help overlays.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:37:21 +01:00
gronod 7c2282a7dd Merge pull request 'test(m9): dump a11y hierarchy to log on UI probe failure (#128)' (#129) from feat/128-axtree-dump into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 3m43s
macOS CI / package (push) Skipped
2026-09-12 12:31:00 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> caeafb22be test(m9): dump a11y hierarchy to log on UI probe failure (#128)
The probe fails on the runner with an apparently empty accessibility
tree; the xcresult artifact is not retrievable via the API. Print
app.debugDescription + window count so the tree lands in the job log.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:30:44 +01:00
gronod aac4f10d23 Merge pull request 'ci(m9): upload UI-test xcresult on failure for a11y triage (#126)' (#127) from feat/126-xcresult-on-failure into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 4m8s
macOS CI / package (push) Skipped
2026-09-12 12:23:12 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b7ac4f92ed ci(m9): upload UI-test xcresult on failure for a11y triage (#126)
UI failures on the self-hosted runner are hard to triage from stdout
alone — the xcresult already contains the a11y hierarchy snapshot and
screenshots. Upload Logs/Test when build-and-test fails.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 12:22:53 +01:00
gronod 3244efd366 Merge pull request 'ci(m9): accept Xcode >= 14 in toolchain assert (#123)' (#124) from feat/123-xcode-ge-14 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 5m0s
macOS CI / package (push) Skipped
2026-09-12 11:39:47 +01:00
gronod 7a05da3a35 Merge pull request 'fix(m9): stable 'sidebar' a11y identifier for AboutHelp UI test (#122)' (#125) from feat/122-sidebar-a11y-id into milestone/m9-monterey 2026-09-12 11:39:35 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 5134e82e63 fix(m9): stable 'sidebar' a11y identifier for AboutHelp UI test (#122)
Xcode 14.2 / SDK 13.1 does not surface the sidebar root as an AXGroup,
so the groups-containing query in testHelpOverlaysDoNotChangeSidebarHeight
found nothing (run 32029). Give the sidebar root a stable identifier and
query it directly.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 11:39:16 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7fd93247cb ci(m9): accept Xcode >= 14 in toolchain assert (#123)
The grep on "Xcode 14." pinned CI to the 14.x series, blocking builds
on macOS 14 / Xcode 16 runners. Parse the major version and require
>= 14 instead.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 11:31:43 +01:00
gronod a0fcede454 Merge pull request 'fix(m9): Debug entitlements disable library validation for test host (#119)' (#121) from feat/119-debug-entitlements into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 21m3s
macOS CI / package (push) Skipped
2026-09-12 09:42:16 +01:00
gronod 97eafd11fb fix(m9): Debug entitlements file to disable library validation for test host (#119) 2026-09-12 09:41:11 +01:00
gronod ed7487c2b6 Merge pull request 'fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119)' (#120) from feat/119-debug-disable-lib-validation into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 3m15s
macOS CI / package (push) Skipped
Reviewed-on: #120
2026-09-12 09:30:54 +01:00
gronod a2e3f11e70 fix(m9): ad-hoc sign embedded ICCeryCore framework so test host launches (#119) 2026-09-12 09:29:40 +01:00
gronod c3c9bbc5ba Merge pull request 'fix(m9): link ICCeryCore statically so the app launches standalone (#117)' (#118) from feat/117-static-iccerycore into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 26s
macOS CI / package (push) Skipped
Reviewed-on: #118
2026-09-12 09:17:22 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b5683aa36a fix(m9): link ICCeryCore statically so the app launches standalone (#117)
When the app and test bundles share the local ICCeryCore package, Xcode
builds it as a dynamic package-product framework that is not embedded
in ICCery.app — the app crashes in dyld at launch when run outside the
test host (macOS 12 VM crash, run 31985 products). Declare the product
.static so each consumer links it directly.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:14:54 +01:00
gronod 707455dcfb Merge pull request 'test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)' (#116) from feat/115-assertisolated-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m53s
macOS CI / package (push) Skipped
Reviewed-on: #116
2026-09-12 09:07:06 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 17ee5d6717 test(m9): replace MainActor.assertIsolated for Swift 5.7 (#115)
MainActor.assertIsolated() requires Swift 5.9 (Xcode 15); the macOS 12
CI runner compiles the test bundle with Swift 5.7 and failed run 31985.
Assert Thread.isMainThread instead — it checks the same contract (the
coalesced log sink hops back to the main actor).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:06:45 +01:00
gronod 2295bcdbae Merge pull request 'fix(m9): clear printTask without defer for Swift 5.7 (#113)' (#114) from feat/113-printtask-defer-swift57 into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m59s
macOS CI / package (push) Skipped
Reviewed-on: #114
2026-09-12 09:01:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c9bff50a1a fix(m9): clear printTask without defer for Swift 5.7 (#113)
Swift 5.7 on the macOS 12 CI runner (Xcode 14.2) rejects mutation of
actor-isolated properties inside defer bodies, failing run 31978 at
PrintSessionViewModel.swift:113 and :144. Clear printTask explicitly at
each exit point of the print task closures.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 09:00:48 +01:00
gronod 1b3d3dd821 Merge pull request 'fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)' (#112) from feat/111-stage4-viewbuilder-limit into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 1m28s
macOS CI / package (push) Skipped
Reviewed-on: #112
2026-09-12 08:55:57 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 55dee0f0cd fix(m9): keep Stage4View under Swift 5.7 ViewBuilder 10-child limit (#111)
The formSection VStack had 11 direct children; Xcode 14.2's ViewBuilder
only provides buildBlock overloads up to 10 arguments, so the CI build
on the macOS 12 runner failed at line 144 with "extra argument in call"
(run 31958). Nest the calibration toggle and conditional file row in an
inner VStack with identical alignment and spacing.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:55:12 +01:00
gronod 45fd2b988f Merge pull request 'fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)' (#110) from feat/109-ci-xcodegen-pin into milestone/m9-monterey
macOS CI / build-and-test (push) Failing after 2m34s
macOS CI / package (push) Skipped
Reviewed-on: #110
2026-09-12 08:35:51 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7c6ad1e6ce fix(m9): pin prebuilt xcodegen for macOS 12 CI (#109)
brew install xcodegen resolves to a formula requiring Xcode 15.3, which
cannot be installed on the macOS 12 runner (Xcode 14.2), failing run
31390 before project generation. Install a pinned prebuilt XcodeGen
2.38.0 release via scripts/ensure-host-tools.sh, shared by the
build-and-test and package jobs (package-release.sh also runs xcodegen).

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-12 08:34:37 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9857ddb4d0 test(m9): retry dropped calibration generate tap in UI test
macOS CI / build-and-test (push) Failing after 48s
macOS CI / package (push) Skipped
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 22:38:30 +01:00
gronod 7a6b82816b Merge pull request 'feat(m9): state management and view demotions for macOS 12 (Slice 4)' (#108) from feat/m9-slice4-state-and-views into milestone/m9-monterey 2026-09-11 21:57:55 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 32d2184d2e feat(m9): migrate state to Combine and demote SwiftUI views for macOS 12
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:51:18 +01:00
gronod a76120d9f7 Merge pull request 'test(m9): complete migration of stateful and async suites to XCTest (Slice 3)' (#107) from feat/m9-slice3-xctest-async-stateful into milestone/m9-monterey 2026-09-11 21:34:15 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a30a8fc551 test(m9): complete migration of all remaining test suites to XCTest
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 21:29:16 +01:00
gronod 83f3a4f0e2 Merge pull request 'test(m9): convert pure core suites to XCTest (Slice 2)' (#106) from feat/m9-slice2-xctest-core-pure into milestone/m9-monterey 2026-09-11 21:06:33 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 150d094632 test(m9): convert pure core test suites from Swift Testing to XCTest
Slice 2 (Phase 1a) of the M9 Monterey retarget. Converts all pure Core
suites (argv builders, CGATS/CUPS/profcheck/gamut-mesh parsers, colour
math, file/artefact helpers, classifiers) to XCTestCase, and adds the
shared assertAsyncThrows helper for the async-throws sites that Slice 3
will convert.

Deferred to Slice 3 (kept on Swift Testing in mixed files):
ProcessManagerTests, ArgyllRunner*Tests, ProcessRunSupportTests,
store/UI suites, and the runner suites embedded in TargenTests /
PrinttargTests / ProcessManagerTests.

Also qualifies six bare `throw .case` expressions in CGATSParser with
CGATSParseError — they only compiled under typed throws, which Slice 1
demoted to untyped `throws`; without this the ICCeryCore target fails to
build and no test gate can run.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:43:42 +01:00
gronod 3184c50fb9 Merge pull request 'chore(m9): toolchain, CI config, and core syntax demotion (Slice 1)' (#105) from feat/m9-slice1-config-and-core-syntax into milestone/m9-monterey 2026-09-11 20:26:52 +01:00
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> b2b85ae835 chore(m9): retarget project spec, CI, and core syntax to macOS 12 / Swift 5.7
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-11 20:02:57 +01:00
gronod d37dcedfd9 Update README.md 2026-09-09 19:18:43 +01:00
174 changed files with 18469 additions and 2924 deletions
+98 -6
View File
@@ -14,29 +14,83 @@ jobs:
build-and-test:
# Prefer a self-hosted Mac runner if your Gitea has one. If not,
# macos-14 works for this pipeline.
runs-on: macos-14
runs-on: macos-12
env:
DERIVED: build/DerivedData-test
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
# only honours values matching 'v[0-9]*'.
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
- name: Assert Xcode 14+ toolchain
run: |
line="$(xcodebuild -version | head -1)"
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
echo "Unexpected Xcode version: $line" >&2; exit 1
fi
echo "$line"
# Tag pushes whose name contains "prerelease" skip the test build and both
# test legs: they exist to package a build already validated elsewhere.
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead. dmgbuild is not installed here — the
# test job does not package (#95).
- name: Ensure host tools
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: xcodegen generate --spec project.yml
- name: Build for testing (universal)
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
# Stamp the test build with the same version triple packaging uses
# so CI-built apps are traceable (and mis-tags fail early) (#189).
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS='arm64 x86_64' \
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-'
CODE_SIGN_IDENTITY='-' \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
# Xcode embeds the shared ICCeryCore package framework into the app
# and the test bundle without signing it. Ad-hoc hosts still require
# every loaded dylib to carry a cdhash — dyld killed the test host at
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
echo "signing $fw"
codesign --force --sign - --timestamp=none "$fw"
done
- name: Test unit (ICCeryCoreTests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
@@ -59,6 +113,7 @@ jobs:
# tag packaging can proceed. Real XCTest assertion failures
# still fail the job.
- name: Test UI (ICCeryUITests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
@@ -102,7 +157,8 @@ jobs:
echo "error: UI probe failed with a real test error" >&2
exit 1
fi
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests; then
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
exit 0
fi
if is_runner_attach_failure; then
@@ -118,17 +174,53 @@ jobs:
echo "warning: skipping UI tests after repeated runner attach/activate failures"
exit 0
# XCTest stores the a11y hierarchy snapshot and screenshots in the
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Prepare Node CA bundle (failure path)
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
NODE_CA_FILE="/tmp/macos-ca-bundle.pem"
security find-certificate -a -p \
/System/Library/Keychains/SystemRootCertificates.keychain \
/Library/Keychains/System.keychain \
> "$NODE_CA_FILE" 2>/dev/null || true
if [ ! -s "$NODE_CA_FILE" ] && [ -f /etc/ssl/cert.pem ]; then
cp /etc/ssl/cert.pem "$NODE_CA_FILE"
fi
- name: Upload UI test xcresult
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
uses: actions/upload-artifact@v3
env:
NODE_EXTRA_CA_CERTS: /tmp/macos-ca-bundle.pem
with:
name: ui-test-xcresult
path: build/DerivedData-test/Logs/Test
package:
needs: build-and-test
runs-on: macos-14
runs-on: macos-12
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
# the test job never pip-installs it (#95).
- name: Ensure host tools
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
env:
RELEASE_TAG: ${{ github.ref_name }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
+250
View File
@@ -0,0 +1,250 @@
# GitHub Actions twin of .gitea/workflows/macos.yml.
# Deltas from the Gitea file (everything else is the same jobs/steps):
# - runs-on macos-14: github.com retired macos-12. Do not use
# macos-latest — in 2026 that is macos-26-arm64, where a 1280×800
# window hangs off the virtual display and XCTest marks sidebar
# controls (x ≈ -116) as not hittable (run 34864198118).
# - actions/upload-artifact@v4: v3 is shut down on github.com. Gitea act_runner
# still uses v3.
# - No NODE_EXTRA_CA_CERTS / System keychain bundle: that is only for the
# private Gitea CA when the runner talks to git.i3omb.com.
# - Tag DMGs go to a GitHub Release via `gh` instead of
# scripts/attach-release-asset.sh (Gitea /api/v1).
name: macOS CI
on:
push:
branches:
- develop
tags:
- 'v*'
pull_request:
branches:
- develop
permissions:
contents: read
jobs:
build-and-test:
runs-on: macos-14
env:
DERIVED: build/DerivedData-test
# Tag name on tag pushes, branch name otherwise — scripts/version.sh
# only honours values matching 'v[0-9]*'.
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
- name: Assert Xcode 14+ toolchain
run: |
line="$(xcodebuild -version | head -1)"
major="$(printf '%s' "$line" | sed -n 's/^Xcode \([0-9][0-9]*\)\..*/\1/p')"
if [ -z "$major" ] || [ "$major" -lt 14 ]; then
echo "Unexpected Xcode version: $line" >&2; exit 1
fi
echo "$line"
# Tag pushes whose name contains "prerelease" skip the test build and both
# test legs: they exist to package a build already validated elsewhere.
# The job still succeeds quickly so `package`'s `needs:` stays satisfied.
# Homebrew's xcodegen formula requires Xcode 15.3, which cannot be
# installed on macOS 12 (#109). The script installs a pinned
# prebuilt release instead. dmgbuild is not installed here — the
# test job does not package (#95).
- name: Ensure host tools
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: scripts/ensure-host-tools.sh
- name: Generate Xcode project
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: xcodegen generate --spec project.yml
# Tests only ever run on the runner's own architecture; build
# just that slice. Packaging (scripts/package-release.sh) still
# produces the universal Release binary.
- name: Build for testing (host arch)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
# Stamp the test build with the same version triple packaging uses
# so CI-built apps are traceable (and mis-tags fail early) (#189).
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
xcodebuild build-for-testing \
-scheme ICCery \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
-configuration Debug \
ARCHS="$(uname -m)" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY='-' \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG"
# Xcode embeds the shared ICCeryCore package framework into the app
# and the test bundle without signing it. Ad-hoc hosts still require
# every loaded dylib to carry a cdhash — dyld killed the test host at
# launch (run 31992) — so sign every embedded copy once the build is
# done (embed steps run after any build script phase) (#119).
- name: Sign package product frameworks
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
find "$DERIVED/Build/Products/Debug" -depth -name '*_PackageProduct.framework' -print0 \
| while IFS= read -r -d '' fw; do
echo "signing $fw"
codesign --force --sign - --timestamp=none "$fw"
done
- name: Test unit (ICCeryCoreTests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
if [ -z "$XCTESTRUN" ] || [ ! -f "$XCTESTRUN" ]; then
echo "error: no xctestrun produced by build-for-testing" >&2
exit 1
fi
echo "xctestrun: $XCTESTRUN"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-only-testing:ICCeryCoreTests \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED"
# UI tests need macOS Automation / Accessibility permission on the
# runner. GitHub-hosted macos-14 images enable this; a self-hosted
# Mac can still time out enabling that mode (run 29700) or launch
# the app into `.runningBackground` (run 29804). Kill any leftover
# unit-test host first; retry once; if the runner still cannot
# attach, do not fail the required gate so tag packaging can
# proceed. Real XCTest assertion failures still fail the job.
- name: Test UI (ICCeryUITests)
if: "!(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
run: |
set -o pipefail
XCTESTRUN="$(find "$DERIVED" -name 'ICCery*.xctestrun' | head -n 1)"
LOG="$DERIVED/ui-test.log"
pkill -x ICCery 2>/dev/null || true
sleep 1
run_ui() {
local label="$1"
shift
echo "::group::UI tests $label"
set +e
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination 'platform=macOS' \
-derivedDataPath "$DERIVED" \
"$@" | tee "$LOG"
rc=${PIPESTATUS[0]}
set -e
echo "::endgroup::"
return "$rc"
}
is_runner_attach_failure() {
grep -Eq "Timed out while enabling automation mode|Failed to activate application|current state: Running Background" "$LOG"
}
attempt=1
while [ "$attempt" -le 2 ]; do
# Probe one case first. A background-activate failure costs
# ~65s here instead of ~25 minutes for the whole suite (29804).
if ! run_ui "probe attempt $attempt" \
-only-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
if is_runner_attach_failure; then
echo "warning: UI runner could not attach/activate the app (attempt $attempt)"
pkill -x ICCery 2>/dev/null || true
attempt=$((attempt + 1))
sleep 8
continue
fi
echo "error: UI probe failed with a real test error" >&2
exit 1
fi
if run_ui "full suite attempt $attempt" -only-testing:ICCeryUITests \
-skip-testing:ICCeryUITests/AboutHelpUITests/testAboutDialogShowsVersionAndBuildDate; then
exit 0
fi
if is_runner_attach_failure; then
echo "warning: UI runner lost activation mid-suite (attempt $attempt)"
pkill -x ICCery 2>/dev/null || true
attempt=$((attempt + 1))
sleep 8
continue
fi
echo "error: UI tests failed with a real test error" >&2
exit 1
done
echo "warning: skipping UI tests after repeated runner attach/activate failures"
exit 0
# XCTest stores the a11y hierarchy snapshot and screenshots in the
# xcresult on failure — upload it so UI failures can be triaged
# without access to the runner (#126).
- name: Upload UI test xcresult
if: "failure() && !(startsWith(github.ref, 'refs/tags/') && contains(github.ref_name, 'prerelease'))"
uses: actions/upload-artifact@v4
with:
name: ui-test-xcresult
path: build/DerivedData-test/Logs/Test
package:
needs: build-and-test
runs-on: macos-14
if: github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history + tags: scripts/version.sh needs `git describe` and
# `git rev-list --count`; the default depth-1 clone has neither.
fetch-depth: 0
# scripts/package-release.sh runs `xcodegen generate` and dmgbuild;
# see build-and-test for why brew is not used on macOS 12 (#109).
# INSTALL_DMGBUILD isolates dmgbuild in build/.venv-dmgbuild so
# the test job never pip-installs it (#95).
- name: Ensure host tools
run: INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh
- name: Package release
run: scripts/package-release.sh
env:
RELEASE_TAG: ${{ github.ref_name }}
CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }}
DEVELOPMENT_TEAM: ${{ secrets.DEVELOPMENT_TEAM }}
NOTARIZE_APPLE_ID: ${{ secrets.NOTARIZE_APPLE_ID }}
NOTARIZE_PASSWORD: ${{ secrets.NOTARIZE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload DMG artifact
uses: actions/upload-artifact@v4
with:
name: iccery-dmg
path: ICCery-*.dmg
- name: Attach DMG to GitHub release
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -eu
TAG="$GITHUB_REF_NAME"
case "$TAG" in
*prerelease*) PRE_FLAG=--prerelease ;;
*) PRE_FLAG= ;;
esac
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "$TAG" --target "$GITHUB_SHA" $PRE_FLAG
fi
gh release upload "$TAG" ICCery-*.dmg --clobber
+3
View File
@@ -18,6 +18,9 @@ Vendor/Argyll/
# XcodeGen output (regenerate with `make gen`)
ICCery.xcodeproj/
# Local-only developer helpers (never committed)
/set-version.sh
# macOS
.DS_Store
+115 -4
View File
@@ -11,8 +11,12 @@ Native macOS printer ICC/ICM profiling frontend. Drives the Gronod ArgyllCMS 3.5
- 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).
- `ICCery` — app target (SwiftUI shell). Also owns the native print stack in
`Sources/ICCery/Print/`: `PMTicketBridge`, `PrintTicket`,
`TicketWriteResolver`, `NativeTargetSpooler` (+ `RecordingTargetSpooler`),
`TargetRaster`, `TargetPageCanvasView` (#201 D1 — AppKit/`NSPrintOperation`
lives here, never in `ICCeryCore`).
- `ICCeryCore` — wizard state, ProcessManager, argv builders, settings, CGATS, ΔE₀₀ (no AppKit print panel; CUPS enumeration/parsers only).
- `ICCeryPrintKit` — v2.1 only (issue 16). Zero deps on wizard types.
## AGPL boundary
@@ -39,17 +43,124 @@ 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).
## Print spool — native since v2.0 (#201); v1 `lp` path eradicated
Target printing is a headless `NSPrintOperation` via `NativeTargetSpooler`
(#201): restore the captured `PrintTicket`, apply `TicketWriteResolver`
(Stage 2 always wins, D6), draw 1:1 with interpolation off.
`lp` is eradicated from the target-print path (historical v1: `LpArgs`,
`CupsService.printTarget`, `ICCERY_TEST_LP_ARGV` all deleted).
`CupsParsers`/`CupsOptionsFilter` stay (D4): enumeration, capabilities,
media/quality/bypass key detection and the Stage 2 mirror.
UI-test seam: `ICCERY_TEST_SPOOL_LOG` — DEBUG `RecordingTargetSpooler`
appends one resolved-ticket line per page (D8).
## Versioning
`scripts/version.sh` is the single source: tag/describe → `ICCERY_RELEASE_TAG`
(About shows `tag (marketing)`), `MARKETING_VERSION` = strict `X.Y.Z`,
`CURRENT_PROJECT_VERSION` = `git rev-list --count HEAD` (#189).
`v*` tag builds hard-fail if tag's X.Y.Z ≠ `project.yml` MARKETING_VERSION —
bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
## Branching
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
## Issue ticket style
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
belongs to an active `mN` milestone.
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
`## Dependencies` → `## References`.
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
concrete implementation plan.
- Dependencies/blockers must **always** be recorded via the gitea MCP
`issue_write` methods (`add_dependency`, `block_issue`; reads via
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
dependencies"), not just mentioned in the body. This is
mandatory when issues share a milestone with an implementation order:
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
the order is machine-readable. The `## Dependencies` body section may
still summarise them for readability, but the MCP links are authoritative.
## Verify
```
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
codesign -dvv <sidecar>
```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
## Remote test builds (runner host)
**Policy: all builds and test runs that _can_ execute on the remote host
_should_ execute there.** Use `localadmin@192.168.0.172` whenever the
Gitea `macos_12` runner is not actively executing a workflow job.
Test builds done as part of change implementations run on the CI runner
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
with Xcode 14.2, and CI failures there are often environmental and do not
reproduce locally.
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
environment.
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
the `SSHPASS` env var — never on the command line:
```sh
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
```
First connection needs `-o StrictHostKeyChecking=accept-new`.
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
full history needed by `scripts/version.sh`). Before testing, sync it to
the same base commit as the local work:
`git fetch origin && git checkout <local base sha>`, then `scp` each
locally-changed file over its remote counterpart (uncommitted work
transfers this way — there is no push).
- `xcodegen` is **not** on PATH: prepend
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
impossible on macOS 12 — #109).
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
`DERIVED=build/DerivedData-test`:
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
-derivedDataPath "$DERIVED"`.
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
GUI session and fail environmentally on the VM (runs 29700, 29804,
42183) — do not use them for verification unless asked.
- xcodebuild takes minutes — run the ssh command in a background shell
and poll rather than blocking.
## 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.
One spool path remains (#201 D2): write **both** vocabularies on the native
path — locked AP_* (`AP_ColorMatchingMode` + `AP.ColorMatchingMode` =
`AP_ApplicationColorMatching`) **and** the Quartz dictionary
(`PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`,
legacy `com.apple.print.PrintSettings.PMColorMatchingMode`, nested
`com.apple.print.printSettings` mirror).
## Gitea issue dependencies
Use the `gitea` MCP (custom build with blocking support — verified working):
- `issue_write` methods:
- `add_dependency` — `blocking_issue` blocks `issue_number`.
- `remove_dependency` — removes `blocking_issue` from `issue_number`'s blockers.
- `block_issue` / `unblock_issue` — `issue_number` blocks/unblocks `blocked_issue`.
- `issue_read` methods: `list_dependencies` (issues blocking N),
`list_blocks` (issues N blocks).
- All issue numbers are *display numbers*, not db ids.
+8 -2
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + Later.
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + M11 (id 34) + Later.
## Sprint rule
Do not start milestone N+1 implementation until milestone N **CI/mock gate** is green.
@@ -17,9 +17,15 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M5 | Profile / verify / install | 23–27 | colprof → `.icc`; profcheck parse; atomic history; install into temp dir | Full `.ti1`→`.icc`; profile visible in ColorSync Utility |
| M6 | Gamut, Stage 0, CGATS, release | 28–32 | `.gam` fixtures; cal argv; CGATS round-trip; signed sidecars; dmgbuild | Stage 0 on a real printer; gamut of a real profile |
| M7 | Deduplicate & consolidate | 79–86 | Shared runner loop; JSONFileStore; preset↔config maps; Notice/log helper; ProcessManager factory; PrintSession VM; identity + colour-type cleanup | N/A |
| M8 | Deduplicate & consolidate | 79–86 | (already shipped on `develop`) | N/A |
| M9 | macOS 12 / Xcode 14.2 retarget | (milestone/m9-monterey, PR #145) | XCTest + ObservableObject + macos-12 CI | N/A |
| M10 | Studio workflow | 146–149 | Media library + spot-read + gamut compare + project file unit/UI smoke | Real printer+paper+.cal; live spot-read; two `.gam`; reopen `.icceryproj` |
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
Issue **16 is not an M3 or M6 exit gate.**
M8–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
## Branch taxonomy
+2 -2
View File
@@ -1,9 +1,9 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "ICCeryCore",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v12)],
products: [
.library(name: "ICCeryCore", targets: ["ICCeryCore"]),
],
@@ -192,6 +192,18 @@ public struct ArgyllRunner: Sendable {
// MARK: - Shared collection
/// DEBUG-only fast path: under `ICCERY_UI_TESTING=1` polling/wait
/// intervals shrink ~10x — same env convention as `AppPaths.testRoot`.
/// Release builds compile the branch out entirely; no static state.
private static func testAwareDelay(_ nanos: UInt64) -> UInt64 {
#if DEBUG
if ProcessInfo.processInfo.environment["ICCERY_UI_TESTING"] == "1" {
return nanos / 10
}
#endif
return nanos
}
/// 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).
@@ -200,7 +212,7 @@ public struct ArgyllRunner: Sendable {
await processManager.kill(id: id)
var attempts = 0
while await processManager.isRunning(id), attempts < 30 {
try? await Task.sleep(for: .milliseconds(100))
try? await Task.sleep(nanoseconds: Self.testAwareDelay(100_000_000))
attempts += 1
}
}
@@ -243,7 +255,7 @@ public struct ArgyllRunner: Sendable {
if flushPartialLines {
dotFlushTask = Task { [processManager] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
try? await Task.sleep(nanoseconds: 500_000_000)
if Task.isCancelled { break }
await processManager.flushPartialLine(id: processId)
}
@@ -592,7 +604,7 @@ public struct ArgyllRunner: Sendable {
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))
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
}
}
@@ -730,6 +742,148 @@ public struct ArgyllRunner: Sendable {
}
}
// MARK: - spotread (spot-read console, issue #148)
/// Runs `spotread` and returns an `AsyncStream` of typed events.
///
/// Same subscribe-before-spawn shape as `runChartread`, but there is
/// no artefact: the stream ends with `.exit(code)`. The single-lease
/// process id is `ProcessID.spotread` — never `chartread_{basename}`.
/// Missing sidecar surfaces as `.failed`; there is no `$PATH` or
/// `chartread` fallback (#116, R14/R21).
public func runSpotread(config: SpotReadConfig) -> AsyncStream<SpotReadEvent> {
let cwd = PathSecurity.resolveSafeCwd(config.workingDirectory)
let args = SpotReadArgs.build(config: config)
let binaryURL = binaryResolver.resolve("spotread")
let processId = ProcessID.spotread
let processManager = self.processManager
let isXY = config.isXY
let instrumentName = config.instrumentName
let instrumentPort = config.instrumentPort
return AsyncStream { continuation in
let task = Task {
await ensureNotRunning(id: processId)
let events = processManager.events()
// XY parking hook before any kill, same as chartread.
await processManager.setPreKillHook(id: processId) { [processManager] in
if isXY {
try? await processManager.sendStdin(id: processId, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: Self.testAwareDelay(500_000_000))
}
}
guard binaryResolver.exists(binaryURL) else {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "spotread", code: -1,
logs: ["spotread sidecar missing — run fetch-argyll"])))
continuation.finish()
return
}
do {
try await processManager.runStreaming(
id: processId,
binary: binaryURL,
arguments: args,
workingDirectory: cwd
)
} catch {
continuation.yield(.failed(ArgyllRunnerError.toolFailed(
tool: "spotread", code: -1, logs: [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):
if let parsed = SpotReadParser.parse(line: line) {
continuation.yield(.sample(SpotReadSample(
lab: parsed.lab,
xyz: parsed.xyz,
instrumentName: instrumentName,
port: instrumentPort,
rawLine: line
)))
}
let classified = SpotReadClassifier.classify(
line: line, previousState: state)
if classified.state != state
|| classified.requestedWarningKey != nil {
state = classified.state
continuation.yield(.prompt(classified))
}
pendingLogs.append(line)
case .stderr(_, let line):
pendingLogs.append(line)
case .jsonRow:
// spotread is never run with `-u`.
break
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
}
}
continuation.yield(.exit(exitCode ?? -1))
continuation.finish()
}
continuation.onTermination = { _ in
task.cancel()
Task {
await processManager.kill(id: processId)
}
}
}
}
/// Send input bytes to the running `spotread` child. Reuses
/// `ChartreadInput` — the stdin protocol is identical.
public func sendSpotreadInput(_ input: ChartreadInput) async throws {
try await processManager.sendStdin(id: ProcessID.spotread, bytes: input.bytes)
}
/// Terminate a running `spotread` child. The XY park (`q\n` +
/// ~500 ms) runs in the pre-kill hook registered by `runSpotread`.
public func cancelSpotread() {
Task {
await processManager.kill(id: ProcessID.spotread)
}
}
// MARK: - Stage 0 calibration
/// Generates a calibration wedge `.ti1`.
@@ -816,6 +970,20 @@ public enum ChartreadEvent: Sendable {
case failed(ArgyllRunnerError)
}
/// Events emitted by a running `spotread` session (issue #148).
public enum SpotReadEvent: Sendable {
/// Classified prompt / state update (reuses `ChartreadState`).
case prompt(ChartreadClassifyResult)
/// A parsed `Result is …` sample line.
case sample(SpotReadSample)
/// A batched log chunk (stdout + stderr lines).
case log([String])
/// Process exited with the given code.
case exit(Int32)
/// Failure (missing sidecar, spawn error).
case failed(ArgyllRunnerError)
}
/// Exact bytes sent to `chartread` stdin.
public enum ChartreadInput: Sendable {
case trigger // " \n"
@@ -0,0 +1,28 @@
import Foundation
/// Pure argv builder for Argyll's `spotread` tool (issue #148).
public enum SpotReadArgs {
/// Builds `spotread` argv per the Gronod fork protocol.
///
/// - Always `-v -e` (paper / reflective; never display `-d`).
/// - `-c N` is emitted only for `selectedPort != nil` and `N > 1`
/// (Auto and port 1 omit it, #111).
/// - `-Y l` (letter L) is emitted only when `enableLEDs` is `true` (#204).
/// - Never `-u`: the v2.0 `-u` policy covers printtarg + chartread +
/// profcheck only.
/// - No basename — `spotread` writes no artefact.
public static func build(config: SpotReadConfig) -> [String] {
var args: [String] = ["-v", "-e"]
if let port = config.selectedPort, port > 1 {
args.append(contentsOf: ["-c", "\(port)"])
}
if config.enableLEDs {
args.append(contentsOf: ["-Y", "l"])
}
return args
}
}
@@ -0,0 +1,37 @@
import Foundation
/// Configuration for a `spotread` invocation (issue #148).
///
/// `spotread` writes no artefact; `workingDirectory` is still required
/// for the spawn (#59 — empty cwd is illegal).
public struct SpotReadConfig: Codable, Equatable, Sendable {
public var workingDirectory: URL?
/// Communication port for `spotread -c`.
/// `nil` means omit `-c` (Auto or port 1, #111). Never an array index.
public var selectedPort: Int?
/// Enable i1Pro 2 visual LEDs (`-Y l`, #204).
public var enableLEDs: Bool
/// Whether the selected instrument is an XY table — controls the
/// `q\n` + ~500 ms park before kill on cancel.
public var isXY: Bool
/// Display name stamped onto each `SpotReadSample`.
public var instrumentName: String
/// Instrument port stamped onto each sample (nil for Auto).
public var instrumentPort: Int?
public init(
workingDirectory: URL? = nil,
selectedPort: Int? = nil,
enableLEDs: Bool = false,
isXY: Bool = false,
instrumentName: String = "",
instrumentPort: Int? = nil
) {
self.workingDirectory = workingDirectory
self.selectedPort = selectedPort
self.enableLEDs = enableLEDs
self.isXY = isXY
self.instrumentName = instrumentName
self.instrumentPort = instrumentPort
}
}
@@ -72,8 +72,8 @@ public enum CGATSParser {
public static func parse(
_ contents: String,
sourceURL: URL? = nil
) throws(CGATSParseError) -> CGATSDataset {
guard !contents.isEmpty else { throw .emptyFile }
) throws -> CGATSDataset {
guard !contents.isEmpty else { throw CGATSParseError.emptyFile }
let ext = sourceURL?.pathExtension.lowercased() ?? ""
let isCSV = ext == "csv" || contents.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -101,10 +101,10 @@ public enum CGATSParser {
}
guard let formatStart, let formatEnd, formatEnd > formatStart + 1 else {
throw .missingBeginDataFormat
throw CGATSParseError.missingBeginDataFormat
}
guard let dataStart, let dataEnd, dataEnd > dataStart + 1 else {
throw .missingBeginData
throw CGATSParseError.missingBeginData
}
let rawFieldNames = splitFields(lines[formatStart + 1])
@@ -139,7 +139,7 @@ public enum CGATSParser {
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)
throw CGATSParseError.incorrectArity(line: lineIndex + 1, expected: fieldNames.count, got: rawRow.count)
}
var sample = RawSample(id: String(offset), lineIndex: lineIndex)
@@ -153,7 +153,7 @@ public enum CGATSParser {
groupMax[group, default: 0] = max(groupMax[group, default: 0], number)
}
} else if !cleaned.isEmpty {
throw .nonNumericValue(field: name, value: raw, line: lineIndex + 1)
throw CGATSParseError.nonNumericValue(field: name, value: raw, line: lineIndex + 1)
}
} else {
sample.strings[name] = raw
@@ -213,7 +213,7 @@ public enum CGATSParser {
private static func preprocess(
_ contents: String,
isCSV: Bool
) throws(CGATSParseError) -> (CGATSFormat, [String]) {
) throws -> (CGATSFormat, [String]) {
let allLines = contents.components(separatedBy: .newlines)
var lines = [String]()
@@ -239,7 +239,7 @@ public enum CGATSParser {
lines.append(line)
}
guard !lines.isEmpty else { throw .emptyFile }
guard !lines.isEmpty else { throw CGATSParseError.emptyFile }
// Wrap a bare CSV / ISO28178 file in the canonical CGATS block
// structure so the boundary-based parser below can handle it.
@@ -20,8 +20,14 @@ public enum ArtefactFiles {
bundle: Bundle = .main
) -> (version: String, build: String, buildDate: String) {
let info = bundle.infoDictionary ?? [:]
let version = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
let short = info["CFBundleShortVersionString"] as? String ?? "0.0.0"
let build = info["CFBundleVersion"] as? String ?? "0"
var tag = ""
if let url = bundle.url(forResource: "ICCeryReleaseTag", withExtension: nil),
let raw = try? String(contentsOf: url, encoding: .utf8) {
tag = raw
}
let version = displayVersion(shortVersion: short, releaseTag: tag)
let url = bundle.executableURL ?? bundle.bundleURL
let buildDate: String
@@ -37,4 +43,15 @@ public enum ArtefactFiles {
return (version, build, buildDate)
}
/// About "Version:" string from the release tag + marketing version
/// (#189): `v2.0.0-pre2 (2.0.0)`; dedupes to `v2.0.0` on an exact
/// release tag; falls back to the short version when untagged.
static func displayVersion(shortVersion: String, releaseTag: String) -> String {
let tag = releaseTag.trimmingCharacters(in: .whitespacesAndNewlines)
if tag.isEmpty || tag == shortVersion || tag == "v\(shortVersion)" {
return tag.isEmpty ? shortVersion : tag
}
return "\(tag) (\(shortVersion))"
}
}
@@ -0,0 +1,64 @@
import Foundation
/// Approximate sRGB → CIELab D50 conversion for the gamut inspect panel
/// (issue #147).
///
/// This is a fixed-matrix helper, **not** a colour-management module: it
/// never touches ICC profiles, ColorSync, or lcms. The UI labels its
/// output "approx. Lab, not ColorSync".
public enum ApproximateLab {
/// linear-sRGB → XYZ (D65) matrix, IEC 61966-2-1.
private static let srgbToXYZ: [[Double]] = [
[0.4124, 0.3576, 0.1805],
[0.2126, 0.7152, 0.0722],
[0.0193, 0.1192, 0.9505],
]
/// 8-bit sRGB triple → Lab D50 (approximate).
public static func srgb8ToLab(r: Int, g: Int, b: Int) -> LabColor {
srgbToLab(DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0))
}
/// 0–1 sRGB triple → Lab D50 (approximate).
public static func srgbToLab(_ rgb: DisplayRGB) -> LabColor {
func linear(_ c: Double) -> Double {
c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4)
}
let v = [linear(rgb.r), linear(rgb.g), linear(rgb.b)]
// 0–1 XYZ D65 → the 0–100 scale `LabColorMath` works in.
let xyz65 = XYZColor(
x: (srgbToXYZ[0][0] * v[0] + srgbToXYZ[0][1] * v[1] + srgbToXYZ[0][2] * v[2]) * 100,
y: (srgbToXYZ[1][0] * v[0] + srgbToXYZ[1][1] * v[1] + srgbToXYZ[1][2] * v[2]) * 100,
z: (srgbToXYZ[2][0] * v[0] + srgbToXYZ[2][1] * v[1] + srgbToXYZ[2][2] * v[2]) * 100)
return LabColorMath.xyzToLab(adaptD65ToD50(xyz65))
}
/// Bradford D65 → D50 chromatic adaptation — the mirror of
/// `LabColorMath.adaptD50ToD65`.
private static func adaptD65ToD50(_ xyz: XYZColor) -> XYZColor {
let m = LabColorMath.bradford
let inv = LabColorMath.bradfordInv
let d65 = LabColorMath.d65White
let d50 = LabColorMath.d50White
let source = multiply(m, [xyz.x, xyz.y, xyz.z])
let srcWhite = multiply(m, [d65.X, d65.Y, d65.Z])
let dstWhite = multiply(m, [d50.X, d50.Y, d50.Z])
let scaled = [
source[0] * (dstWhite[0] / srcWhite[0]),
source[1] * (dstWhite[1] / srcWhite[1]),
source[2] * (dstWhite[2] / srcWhite[2]),
]
return XYZColor(
x: inv[0][0] * scaled[0] + inv[0][1] * scaled[1] + inv[0][2] * scaled[2],
y: inv[1][0] * scaled[0] + inv[1][1] * scaled[1] + inv[1][2] * scaled[2],
z: inv[2][0] * scaled[0] + inv[2][1] * scaled[1] + inv[2][2] * scaled[2])
}
private static func multiply(_ m: [[Double]], _ v: [Double]) -> [Double] {
m.map { row in zip(row, v).reduce(0) { $0 + $1.0 * $1.1 } }
}
}
@@ -0,0 +1,115 @@
import Foundation
import simd
/// Result of a point-in-gamut test (issue #147).
public enum GamutContainment: String, Sendable, Equatable {
/// The point lies inside the mesh volume.
case inside
/// The point lies outside the mesh volume.
case outside
/// The mesh has no faces to test against.
case unknown
}
/// Point-in-mesh containment and volume estimation for ``GamutMesh``.
///
/// Both tests run on the scene-space positions stored on
/// ``GamutVertex/position`` (`x = a*`, `y = L*`, `z = b*`), the same
/// mapping the SceneKit viewer uses. No colour management is involved.
public enum GamutGeometry {
/// Whether `lab` is inside `mesh`.
///
/// Ray-casts through the face list: an odd crossing count means the
/// point is inside a closed surface. A ray that grazes a vertex or
/// edge gives an ambiguous count, so the test retries with off-axis
/// directions before answering. Meshes without faces report
/// ``GamutContainment/unknown``.
public static func containment(of lab: LabColor, in mesh: GamutMesh) -> GamutContainment {
guard !mesh.faces.isEmpty else { return .unknown }
let origin = SIMD3<Double>(lab.a, lab.l, lab.b)
for direction in rayDirections {
if let inside = castRay(from: origin, direction: direction, mesh: mesh) {
return inside ? .inside : .outside
}
}
return .unknown
}
/// Approximate mesh volume in Lab-cubic units.
///
/// Sums signed tetrahedra from the vertex centroid to each face; for
/// a closed surface the magnitude equals the enclosed volume
/// regardless of face winding. Returns 0 for empty or face-less
/// meshes.
public static func volume(of mesh: GamutMesh) -> Double {
guard !mesh.faces.isEmpty, !mesh.vertices.isEmpty else { return 0 }
var centroid = SIMD3<Double>.zero
for vertex in mesh.vertices {
centroid += SIMD3<Double>(vertex.position)
}
centroid /= Double(mesh.vertices.count)
var sum = 0.0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let a = SIMD3<Double>(mesh.vertices[Int(face.a)].position) - centroid
let b = SIMD3<Double>(mesh.vertices[Int(face.b)].position) - centroid
let c = SIMD3<Double>(mesh.vertices[Int(face.c)].position) - centroid
sum += simd_dot(a, simd_cross(b, c)) / 6.0
}
return abs(sum)
}
// MARK: - Ray casting
/// Primary +X ray, then off-axis retries for degenerate edge hits.
private static let rayDirections: [SIMD3<Double>] = [
SIMD3(1, 0, 0),
simd_normalize(SIMD3<Double>(0.71, 1.0, 0.53)),
simd_normalize(SIMD3<Double>(0.53, 0.71, 1.0)),
]
/// Möller–Trumbore crossing count. Returns `nil` when a crossing
/// lands on a triangle edge or vertex (ambiguous parity) so the
/// caller can retry with a different direction.
private static func castRay(
from origin: SIMD3<Double>,
direction dir: SIMD3<Double>,
mesh: GamutMesh
) -> Bool? {
let epsilon = 1e-9
var crossings = 0
let count = mesh.vertices.count
for face in mesh.faces {
guard Int(face.a) < count, Int(face.b) < count, Int(face.c) < count else {
continue
}
let va = SIMD3<Double>(mesh.vertices[Int(face.a)].position)
let vb = SIMD3<Double>(mesh.vertices[Int(face.b)].position)
let vc = SIMD3<Double>(mesh.vertices[Int(face.c)].position)
let e1 = vb - va
let e2 = vc - va
let p = simd_cross(dir, e2)
let det = simd_dot(e1, p)
if abs(det) < 1e-12 { continue } // ray parallel to face
let inv = 1.0 / det
let tvec = origin - va
let u = simd_dot(tvec, p) * inv
let q = simd_cross(tvec, e1)
let v = simd_dot(dir, q) * inv
let t = simd_dot(e2, q) * inv
guard t > epsilon else { continue }
if u < -epsilon || v < -epsilon || u + v > 1 + epsilon { continue }
// Crossing on an edge or vertex — parity is ambiguous.
if u < epsilon || v < epsilon || u + v > 1 - epsilon { return nil }
crossings += 1
}
return crossings % 2 == 1
}
}
@@ -0,0 +1,39 @@
import Foundation
/// A parsed gamut mesh plus the display metadata the compare viewer
/// needs (issue #147).
///
/// `displayName` is user-derived (a file name); views must render it
/// through `Text` only (#114).
public struct NamedGamut: Sendable, Equatable, Identifiable {
/// What the layer is used for in the compare UI.
public enum Role: String, Sendable, Equatable {
/// Bundled reference space (sRGB). Cannot be removed, only hidden.
case reference
/// The workflow's own profile gamut.
case profileA
/// The user-added compare gamut. Replaced, never stacked.
case profileB
}
public var id: String
public var displayName: String
public var role: Role
public var mesh: GamutMesh
public var sourceURL: URL
public init(
id: String,
displayName: String,
role: Role,
mesh: GamutMesh,
sourceURL: URL
) {
self.id = id
self.displayName = displayName
self.role = role
self.mesh = mesh
self.sourceURL = sourceURL
}
}
@@ -0,0 +1,218 @@
import Foundation
/// The `.icceryproj` file — a JSON **index** over a basename + working
/// directory + optional media recipe/preset binding + last verification
/// snapshot (issue #149, docs/06 §Project file).
///
/// The project is never a second source of truth: artefact gating stays
/// on disk (`ArtefactProbe`), and `wizard_state.json` continues to
/// persist the live session. snake_case keys match the v1 schema;
/// `schema_version != 1` is a hard decode error — v2 fields are never
/// partially decoded.
public struct ICCeryProject: Codable, Equatable, Sendable {
/// The only schema version this build reads and writes.
public static let currentSchemaVersion = 1
public var schemaVersion: Int
public var name: String
public var notes: String
/// Run name without extension — never `CAL_`-persisted (#60/R11).
public var basename: String
/// Absolute working directory holding the artefacts (#59).
public var cwd: String
/// May differ from `basename` after a `.ti3` import (#94).
public var profileBasename: String?
/// CUPS queue id, when a printer was selected.
public var printerID: String?
public var printerDisplayName: String?
/// `MediaRecipe.id` — optional; ignored when the library file is
/// absent or the id is unknown (soft-dependency, #146).
public var mediaRecipeID: String?
public var presetID: String?
/// Absolute `.cal` path stored verbatim; `nil` = none.
public var calibrationURL: String?
public var lastVerification: VerificationSnapshot?
public var updated: Date
public init(
schemaVersion: Int = ICCeryProject.currentSchemaVersion,
name: String = "",
notes: String = "",
basename: String,
cwd: String,
profileBasename: String? = nil,
printerID: String? = nil,
printerDisplayName: String? = nil,
mediaRecipeID: String? = nil,
presetID: String? = nil,
calibrationURL: String? = nil,
lastVerification: VerificationSnapshot? = nil,
updated: Date = Date()
) {
self.schemaVersion = schemaVersion
self.name = name
self.notes = notes
self.basename = basename
self.cwd = cwd
self.profileBasename = profileBasename
self.printerID = printerID
self.printerDisplayName = printerDisplayName
self.mediaRecipeID = mediaRecipeID
self.presetID = presetID
self.calibrationURL = calibrationURL
self.lastVerification = lastVerification
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case name, notes, basename, cwd
case profileBasename = "profile_basename"
case printerID = "printer_id"
case printerDisplayName = "printer_display_name"
case mediaRecipeID = "media_recipe_id"
case presetID = "preset_id"
case calibrationURL = "calibration_url"
case lastVerification = "last_verification"
case updated
}
/// Strict decode: `schema_version` is required and must equal 1 —
/// anything else throws before a single v2 field is read. Required
/// strings (`basename`, `cwd`) must be present; optionals default.
/// Unknown keys are ignored.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let version = try c.decode(Int.self, forKey: .schemaVersion)
guard version == ICCeryProject.currentSchemaVersion else {
throw ValidationError.unsupportedSchema(version)
}
schemaVersion = version
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
basename = try c.decode(String.self, forKey: .basename)
cwd = try c.decode(String.self, forKey: .cwd)
profileBasename = try c.decodeIfPresent(String.self, forKey: .profileBasename)
printerID = try c.decodeIfPresent(String.self, forKey: .printerID)
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName)
mediaRecipeID = try c.decodeIfPresent(String.self, forKey: .mediaRecipeID)
presetID = try c.decodeIfPresent(String.self, forKey: .presetID)
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
lastVerification = try c.decodeIfPresent(VerificationSnapshot.self, forKey: .lastVerification)
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
}
public enum ValidationError: LocalizedError, Equatable {
case unsupportedSchema(Int)
case emptyBasename
case invalidBasename(String)
case emptyCwd
case unsafeCwd(String)
case invalidCalibrationURL(String)
public var errorDescription: String? {
switch self {
case .unsupportedSchema:
return "This project file is not schema 1."
case .emptyBasename:
return "A project needs a target basename."
case .invalidBasename(let v):
return "Illegal project basename \"\(v)\"."
case .emptyCwd:
return "A project needs a working folder."
case .unsafeCwd(let v):
return "cwd must be an absolute path, got \"\(v)\"."
case .invalidCalibrationURL(let v):
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
}
}
}
/// Validates the index fields. Empty basename/cwd refuse (#59/#60);
/// basename still rejects `/`, `\`, `..`; cwd must be absolute. An
/// empty `name` falls back to the basename.
@discardableResult
public func validated() throws -> ICCeryProject {
var p = self
p.basename = basename.trimmingCharacters(in: .whitespacesAndNewlines)
p.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !p.basename.isEmpty else { throw ValidationError.emptyBasename }
guard PathSecurity.isValidBasename(p.basename) else {
throw ValidationError.invalidBasename(p.basename)
}
let trimmedCwd = p.cwd.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedCwd.isEmpty else { throw ValidationError.emptyCwd }
guard trimmedCwd.hasPrefix("/"), !trimmedCwd.contains("\0") else {
throw ValidationError.unsafeCwd(p.cwd)
}
p.cwd = trimmedCwd
if p.name.isEmpty { p.name = p.basename }
if let cal = p.calibrationURL, !cal.isEmpty {
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
throw ValidationError.invalidCalibrationURL(cal)
}
}
return p
}
/// Reads a `.icceryproj` file. Throws `unsupportedSchema` for
/// `schema_version != 1` and the decode/validation error otherwise;
/// callers must leave live state untouched on failure.
public static func load(from url: URL) throws -> ICCeryProject {
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(ICCeryProject.self, from: data).validated()
}
/// Atomic `.tmp` + rename write via `AtomicFileWriter` (#213).
/// Validation runs first — a refused project never touches disk.
public func save(to url: URL) throws {
let encoder = JSONEncoder.icceryPretty(dateEncoding: .iso8601)
try AtomicFileWriter.write(try encoder.encode(validated()), to: url)
}
}
/// The last `VerificationRecord` frozen into the project file — notes
/// only, never gating truth.
public struct VerificationSnapshot: Codable, Equatable, Sendable {
public var date: Date
public var avgDE00: Double
public var maxDE00: Double
/// `VerificationStatus.rawValue`.
public var status: String
public var profileFilename: String
public init(
date: Date,
avgDE00: Double,
maxDE00: Double,
status: String,
profileFilename: String
) {
self.date = date
self.avgDE00 = avgDE00
self.maxDE00 = maxDE00
self.status = status
self.profileFilename = profileFilename
}
public init(record: VerificationRecord) {
self.init(
date: record.timestamp,
avgDE00: record.avgDE,
maxDE00: record.maxDE,
status: record.status.rawValue,
profileFilename: record.profileName
)
}
enum CodingKeys: String, CodingKey {
case date
case avgDE00 = "avg_de00"
case maxDE00 = "max_de00"
case status
case profileFilename = "profile_filename"
}
}
@@ -0,0 +1,117 @@
import Foundation
/// Persistence for `MediaRecipe` entries (issue #146).
///
/// `media_library.json` is a sibling of `settings.json`, never a field
/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter`
/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is
/// never overwritten — the view model turns the throw into an empty
/// list plus a persistent warning banner.
public actor MediaLibraryStore {
/// Default cap.
public static let defaultCapacity = 200
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var recipes: [MediaRecipe] = []
/// Explicit load flag — an empty file is still "loaded".
private var loaded = false
private let capacity: Int
private let fileStore: JSONFileStore<[MediaRecipe]>
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"),
capacity: Int = defaultCapacity
) {
self.url = url
self.capacity = capacity
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads recipes 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 and `loaded` stays false
/// so the next call re-reads.
public func load() throws -> [MediaRecipe] {
guard !loaded else { return recipes }
guard FileManager.default.fileExists(atPath: url.path) else {
loaded = true
return []
}
recipes = try fileStore.load()
loaded = true
return recipes
}
/// Returns all cached recipes.
public func all() -> [MediaRecipe] {
recipes
}
/// Inserts or replaces a recipe matched by `id`, then writes
/// atomically. Replacement preserves `created` and bumps `updated`;
/// inserts beyond `capacity` throw `.capacityReached` — no silent
/// eviction.
///
/// Loads the existing library first and propagates any load error
/// so an unparseable file is never overwritten.
@discardableResult
public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] {
let validated = try recipe.validated()
try load()
var updated = recipes
if let index = updated.firstIndex(where: { $0.id == validated.id }) {
var existing = validated
existing.created = updated[index].created
existing.updated = Date()
updated[index] = existing
} else {
guard updated.count < capacity else {
throw MediaLibraryError.capacityReached(capacity)
}
updated.append(validated)
}
try fileStore.save(updated)
recipes = updated
return updated
}
/// Removes a recipe by id and writes atomically. Returns false when
/// no recipe with that id exists.
@discardableResult
public func delete(id: String) throws -> Bool {
try load()
let before = recipes.count
let updated = recipes.filter { $0.id != id }
guard updated.count != before else { return false }
try fileStore.save(updated)
recipes = updated
return true
}
public enum MediaLibraryError: LocalizedError, Equatable {
case capacityReached(Int)
public var errorDescription: String? {
switch self {
case .capacityReached(let cap):
return "Media library is full (\(cap)). Delete a recipe first."
}
}
}
}
@@ -0,0 +1,146 @@
import Foundation
/// A media recipe: a named binding of CUPS queue + paper + ink set +
/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media
/// library).
///
/// snake_case keys match the v1 JSON schema so `media_library.json`
/// stays import/export compatible. Identity + binding fields are
/// required; every other field is optional-defaulted. Unknown keys are
/// ignored on decode; missing required fields fail the whole array
/// decode (corrupt-file policy, never silently dropped).
public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable {
/// `"recipe-<uuid>"`, never user-typed.
public var id: String
public var name: String
public var notes: String
/// CUPS queue id (`Printer.name` — `Printer` has no `id` member).
public var printerID: String
/// Human label from `Printer.displayName`.
public var printerDisplayName: String
/// Library metadata only — never written to targen `-P`/`-I` flags.
public var paperName: String
/// Last captured CUPS `media_type`, read-only.
public var driverMediaType: String?
/// Free text: `"PK"`, `"MK"`, `"Photo Black"`, …
public var inkSet: String
/// `"rgb"` | `"cmyk"` — must match the bound preset.
public var colourSpace: String
/// `ProfilingPreset.id` (built-in or custom).
public var presetID: String
/// Absolute `.cal` path stored verbatim; `nil` = none.
public var calibrationURL: String?
public var applyCalibration: Bool
public var created: Date
public var updated: Date
public init(
id: String,
name: String,
notes: String = "",
printerID: String,
printerDisplayName: String = "",
paperName: String = "",
driverMediaType: String? = nil,
inkSet: String = "",
colourSpace: String,
presetID: String,
calibrationURL: String? = nil,
applyCalibration: Bool = false,
created: Date = Date(),
updated: Date = Date()
) {
self.id = id
self.name = name
self.notes = notes
self.printerID = printerID
self.printerDisplayName = printerDisplayName
self.paperName = paperName
self.driverMediaType = driverMediaType
self.inkSet = inkSet
self.colourSpace = colourSpace
self.presetID = presetID
self.calibrationURL = calibrationURL
self.applyCalibration = applyCalibration
self.created = created
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case id, name, notes
case printerID = "printer_id"
case printerDisplayName = "printer_display_name"
case paperName = "paper_name"
case driverMediaType = "driver_media_type"
case inkSet = "ink_set"
case colourSpace = "colour_space"
case presetID = "preset_id"
case calibrationURL = "calibration_url"
case applyCalibration = "apply_calibration"
case created, updated
}
/// Strict decode: required identity + binding fields must be
/// present; optionals default. 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)
notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? ""
printerID = try c.decode(String.self, forKey: .printerID)
printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? ""
paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? ""
driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType)
inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? ""
colourSpace = try c.decode(String.self, forKey: .colourSpace)
presetID = try c.decode(String.self, forKey: .presetID)
calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL)
applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false
created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date()
updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date()
}
public enum ValidationError: LocalizedError, Equatable {
case emptyName
case emptyPrinterID
case invalidColourSpace(String)
case emptyPresetID
case invalidCalibrationURL(String)
public var errorDescription: String? {
switch self {
case .emptyName: return "Media recipe is missing a name."
case .emptyPrinterID: return "Media recipe is missing a printer."
case .invalidColourSpace(let v):
return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"."
case .emptyPresetID: return "Media recipe is missing a preset."
case .invalidCalibrationURL(let v):
return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"."
}
}
}
/// Validates the binding fields. `colourSpace` is normalized to
/// lowercase before comparison. `CAL_` cal names are **not**
/// rejected — that is an apply-time policy, not schema.
@discardableResult
public func validated() throws -> MediaRecipe {
var r = self
r.id = id.trimmingCharacters(in: .whitespacesAndNewlines)
r.name = name.trimmingCharacters(in: .whitespacesAndNewlines)
r.colourSpace = colourSpace.lowercased()
guard !r.name.isEmpty else { throw ValidationError.emptyName }
guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID }
guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else {
throw ValidationError.invalidColourSpace(colourSpace)
}
guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID }
if let cal = r.calibrationURL, !cal.isEmpty {
guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else {
throw ValidationError.invalidCalibrationURL(cal)
}
}
return r
}
}
@@ -0,0 +1,95 @@
import Foundation
/// `Save Report…` — a one-page UTF-8 Markdown summary written next to
/// the artefacts as `{basename}-report.md` (issue #149). Generated
/// output, safe to overwrite. Filenames only — never absolute paths —
/// and no HTML.
public enum ProjectReport {
/// `{cwd}/{basename}-report.md`.
public static func url(for project: ICCeryProject) -> URL {
URL(fileURLWithPath: project.cwd, isDirectory: true)
.appendingPathComponent("\(project.basename)-report.md")
}
/// Renders the report. `artefacts` is the live probe result so the
/// checklist shows what is actually on disk, not what the project
/// JSON claims (R18).
public static func markdown(
project: ICCeryProject,
recipeName: String?,
artefacts: StageArtefacts
) -> String {
var lines: [String] = []
lines.append("# \(project.name)")
lines.append("")
if let printer = project.printerDisplayName ?? project.printerID,
!printer.isEmpty {
lines.append("- **Printer:** \(printer)")
}
if let recipeName, !recipeName.isEmpty {
lines.append("- **Media recipe:** \(recipeName)")
}
if let preset = project.presetID, !preset.isEmpty {
lines.append("- **Preset:** \(preset)")
}
lines.append("")
lines.append("## Artefacts")
lines.append("")
lines.append("| File | Status |")
lines.append("|------|--------|")
lines.append(row("\(project.basename).ti1", exists: artefacts.stage1Complete))
lines.append(row("\(project.basename).ti2", exists: artefacts.stage2Complete))
lines.append(row("\(project.basename).ti3", exists: artefacts.stage3Complete))
let profileName = artefacts.profilePath?.lastPathComponent
?? "\(project.basename).\(ArtefactProbe.defaultProfileExtension)"
lines.append(row(profileName, exists: artefacts.stage4Complete))
if let gam = artefacts.gamPath {
lines.append(row(gam.lastPathComponent, exists: true))
}
lines.append("")
if let verification = project.lastVerification {
lines.append("## Last verification")
lines.append("")
lines.append(
"- avg ΔE₀₀ \(f(verification.avgDE00)), max ΔE₀₀ \(f(verification.maxDE00))"
+ " (\(verification.status))")
lines.append("- Profile: \(verification.profileFilename)")
lines.append("- Date: \(ISO8601DateFormatter().string(from: verification.date))")
lines.append("")
}
if !project.notes.isEmpty {
lines.append("## Notes")
lines.append("")
lines.append(project.notes)
lines.append("")
}
return lines.joined(separator: "\n")
}
/// Writes `{cwd}/{basename}-report.md` atomically, overwriting an
/// existing generated report.
@discardableResult
public static func write(
project: ICCeryProject,
recipeName: String?,
artefacts: StageArtefacts
) throws -> URL {
let destination = url(for: project)
try AtomicFileWriter.write(
markdown(project: project, recipeName: recipeName, artefacts: artefacts),
to: destination)
return destination
}
private static func row(_ filename: String, exists: Bool) -> String {
"| \(filename) | \(exists ? "exists" : "missing") |"
}
private static func f(_ value: Double) -> String {
String(format: "%.2f", value)
}
}
@@ -0,0 +1,162 @@
import Foundation
/// One row in `recent_projects.json` — bookmark `Data` *and* the plain
/// absolute path so the entry still resolves when the bookmark can't.
public struct RecentProjectEntry: Codable, Equatable, Sendable, Identifiable {
public var name: String
/// Absolute path to the `.icceryproj` file.
public var path: String
/// File bookmark; optional — path is the fallback.
public var bookmark: Data?
public var updated: Date
public var id: String { path }
/// Stable digest of `path` for `projectRecent-{hash}` menu ids —
/// FNV-1a 64, stable across launches unlike `hashValue`.
public var bookmarkHash: String {
var hash: UInt64 = 0xcbf29ce484222325
for byte in path.utf8 {
hash ^= UInt64(byte)
hash &*= 0x100000001b3
}
return String(hash, radix: 16)
}
public init(name: String, path: String, bookmark: Data? = nil, updated: Date = Date()) {
self.name = name
self.path = path
self.bookmark = bookmark
self.updated = updated
}
enum CodingKeys: String, CodingKey {
case name, path, bookmark, updated
}
}
/// Recent-projects list (issue #149). Lives in
/// `AppPaths.appDataDir/recent_projects.json` — app data, never inside
/// the project file (R12).
///
/// Cap 20, newest first, deduplicated by path. Entries whose file is
/// gone are dropped by `pruneMissing()` (called when the Open Recent
/// submenu builds). A corrupt file throws on load and is never
/// overwritten — the view model shows an empty list and keeps the
/// bytes (`.throwCorrupt`, R12).
public actor RecentProjectsStore {
/// Default cap.
public static let defaultCapacity = 20
/// Path to the JSON store.
public let url: URL
/// In-memory cache, kept in sync with disk.
private var entries: [RecentProjectEntry] = []
/// Explicit load flag — an empty file is still "loaded".
private var loaded = false
private let capacity: Int
private let fileStore: JSONFileStore<[RecentProjectEntry]>
private let fileManager: FileManager
public init(
url: URL = AppPaths.appDataDir.appendingPathComponent("recent_projects.json"),
capacity: Int = defaultCapacity,
fileManager: FileManager = .default
) {
self.url = url
self.capacity = capacity
self.fileManager = fileManager
self.fileStore = JSONFileStore(
fileURL: url,
corrupt: .throwCorrupt,
defaultValue: { [] },
dateEncoding: .iso8601,
dateDecoding: .iso8601
)
}
/// Loads entries 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 -> [RecentProjectEntry] {
guard !loaded else { return entries }
guard fileManager.fileExists(atPath: url.path) else {
loaded = true
return []
}
entries = try fileStore.load()
loaded = true
return entries
}
/// Returns all cached entries, newest first.
public func all() -> [RecentProjectEntry] {
entries
}
/// Pushes a project to the front, deduplicating by path and
/// trimming to `capacity`. Writes atomically.
///
/// Loads the existing list first and propagates any load error so
/// an unparseable file is never overwritten.
@discardableResult
public func add(url fileURL: URL, name: String) throws -> [RecentProjectEntry] {
try load()
let bookmark = try? fileURL.bookmarkData()
var updated = entries.filter { $0.path != fileURL.path }
updated.insert(
RecentProjectEntry(
name: name,
path: fileURL.path,
bookmark: bookmark,
updated: Date()),
at: 0)
if updated.count > capacity {
updated = Array(updated.prefix(capacity))
}
try fileStore.save(updated)
entries = updated
return updated
}
/// Removes an entry by path and writes atomically. Returns false
/// when no entry with that path exists.
@discardableResult
public func remove(path: String) throws -> Bool {
try load()
let before = entries.count
let updated = entries.filter { $0.path != path }
guard updated.count != before else { return false }
try fileStore.save(updated)
entries = updated
return true
}
/// `Clear Menu` — wipes the recents file only; `.icceryproj` files
/// are never deleted (R12).
public func clear() throws {
try load()
try fileStore.save([])
entries = []
}
/// Drops entries whose file is gone and rewrites the store.
/// Called when the Open Recent submenu builds — missing files are
/// dropped there, not at launch (issue #149).
@discardableResult
public func pruneMissing() throws -> [RecentProjectEntry] {
try load()
let kept = entries.filter {
fileManager.fileExists(atPath: $0.path)
}
if kept.count != entries.count {
try fileStore.save(kept)
entries = kept
}
return kept
}
}
@@ -0,0 +1,48 @@
import Foundation
/// Line classifier for `spotread` stdout (issue #148).
///
/// `spotread` shares `chartread`'s white-tile calibration phrasing, so
/// this wraps `ChartreadClassifier` and only intercepts the lines that
/// would otherwise misclassify:
///
/// - `… and then hit any key to continue,` / `or hit Esc or Q to abort:`
/// continuation lines that trail the calibration and spot prompts —
/// sticky to the current prompt state instead of `PROMPT_CONTINUE`.
/// - `Place instrument on a spot to be measured,` /
/// `and hit a key to take a reading,` → `AWAITING_STRIP` (the Read
/// prompt; the generic chartread matcher does not know "take a
/// reading").
///
/// Sample lines (`Result is XYZ: …, D50 Lab: …`) are parsed by
/// `SpotReadParser`, not classified here.
public enum SpotReadClassifier {
public static func classify(
line: String,
previousState: ChartreadState
) -> ChartreadClassifyResult {
let text = line.lowercased()
// Spot-read prompt continuations keep the current prompt state.
if previousState == .calibrating || previousState == .awaitingStrip {
if text.contains("hit any key")
|| text.contains("hit space")
|| text.contains("esc or")
|| text.contains("abort")
|| text.contains("to abort") {
return ChartreadClassifyResult(state: previousState)
}
}
// "Place instrument on a spot to be measured," /
// " and hit a key to take a reading," — the Read trigger prompt.
if text.contains("spot to be measured")
|| text.contains("take a reading")
|| text.contains("measure the spot") {
return ChartreadClassifyResult(state: .awaitingStrip)
}
return ChartreadClassifier.classify(line: line, previousState: previousState)
}
}
@@ -0,0 +1,80 @@
import Foundation
/// One patch measurement emitted by a running `spotread` child (#148).
public struct SpotReadSample: Codable, Sendable, Equatable, Identifiable {
public var id: UUID
public var timestamp: Date
/// D50 Lab (always present — derived from XYZ when needed).
public var lab: LabColor
/// XYZ on the 0–100 scale used by the fork, when the line carried it.
public var xyz: XYZColor?
public var instrumentName: String
public var port: Int?
/// Diagnostics only — never rendered as HTML or shown in the table.
public var rawLine: String
public init(
id: UUID = UUID(),
timestamp: Date = Date(),
lab: LabColor,
xyz: XYZColor? = nil,
instrumentName: String = "",
port: Int? = nil,
rawLine: String = ""
) {
self.id = id
self.timestamp = timestamp
self.lab = lab
self.xyz = xyz
self.instrumentName = instrumentName
self.port = port
self.rawLine = rawLine
}
}
/// Parses `spotread` result lines into Lab / XYZ triples.
///
/// The fork's line shape is the upstream
/// `Result is XYZ: <x> <y> <z>, D50 Lab: <L> <a> <b>`; a Lab-only line
/// also parses, and an XYZ-only line derives Lab via
/// `LabColorMath.xyzToLab` (D50).
public enum SpotReadParser {
public static func parse(line: String) -> (xyz: XYZColor?, lab: LabColor)? {
guard line.range(of: "result is", options: .caseInsensitive) != nil
|| line.range(of: #"\bLab\b"#, options: .regularExpression) != nil
|| line.range(of: #"\bXYZ\b"#, options: .regularExpression) != nil
else { return nil }
var xyz: XYZColor?
var lab: LabColor?
if let m = triple(#"\bXYZ\b[:\s]"#, in: line) {
xyz = XYZColor(x: m.0, y: m.1, z: m.2)
}
if let m = triple(#"\bLab\b[:\s]"#, in: line) {
lab = LabColor(l: m.0, a: m.1, b: m.2)
}
if lab == nil, let xyz {
lab = LabColorMath.xyzToLab(xyz)
}
guard let lab else { return nil }
return (xyz, lab)
}
private static func triple(_ marker: String, in line: String) -> (Double, Double, Double)? {
let pattern = marker + #"\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(
in: line, options: [], range: NSRange(line.startIndex..., in: line)),
match.numberOfRanges == 4,
let r1 = Range(match.range(at: 1), in: line),
let r2 = Range(match.range(at: 2), in: line),
let r3 = Range(match.range(at: 3), in: line),
let a = Double(line[r1]),
let b = Double(line[r2]),
let c = Double(line[r3])
else { return nil }
return (a, b, c)
}
}
@@ -46,4 +46,23 @@ public enum ColorMatchingAttempts {
"AP_ColorMatchingMode",
"AP.ColorMatchingMode",
]
/// The Quartz/`NSPrintOperation` colour-matching vocabulary
/// (docs/14 §7). With the `lp` path removed there is a single
/// spool path and it carries **both** dictionaries — these keys
/// are written alongside the AP_* pair (#201 D2).
///
/// `PMColorMatchingMode=APCustomColorMatching` plus an empty
/// `PMCustomColorMatchingProfile` tell Quartz the application
/// supplies device colour; the `com.apple.print.PrintSettings.*`
/// legacy spelling covers drivers that read the flattened
/// dictionary.
public static let quartzModeKey = "PMColorMatchingMode"
public static let quartzCustomMatching = "APCustomColorMatching"
public static let quartzProfileKey = "PMCustomColorMatchingProfile"
public static let quartzLegacyModeKey =
"com.apple.print.PrintSettings.PMColorMatchingMode"
/// The nested sub-dictionary inside `NSPrintInfo.dictionary()` the
/// colour keys are mirrored into.
public static let quartzNestedDictKey = "com.apple.print.printSettings"
}
@@ -17,7 +17,8 @@ public enum CupsOptionsFilter {
// Media
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
// Tray
"InputSlot", "AP_D_InputSlot",
"InputSlot", "MediaSource", "AP_D_InputSlot",
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
// Size
"PageSize",
// Colour bypass
@@ -25,8 +26,9 @@ public enum CupsOptionsFilter {
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode", "ColorModel",
// Quality
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
"CNIJQuality", "StpQuality", "OutputMode",
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Qual",
"EPIJ_Quality", "CNIJQuality", "CNIJPrintQuality",
"PrintQuality", "StpQuality", "OutputMode",
// Duplex
"Duplex", "sides",
]
@@ -73,6 +73,26 @@ public enum CupsParsers {
return nil
}
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
/// Entries with no URI (e.g. `network <name>` remote stubs) are
/// skipped (#202 AirPrint detection).
public static func lpstatDeviceURIs(output: String) -> [String: String] {
var result: [String: String] = [:]
for line in output.split(separator: "\n") {
let text = line.trimmingCharacters(in: .whitespaces)
guard text.hasPrefix("device for ") else { continue }
let rest = text.dropFirst("device for ".count)
guard let colon = rest.firstIndex(of: ":") else { continue }
let name = String(rest[..<colon])
.trimmingCharacters(in: .whitespaces)
let uri = String(rest[rest.index(after: colon)...])
.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty, !uri.isEmpty else { continue }
result[name] = uri
}
return result
}
// MARK: - lpoptions -p <queue>
/// `lpoptions -p` — `key=value` pairs, values may be
@@ -137,6 +157,16 @@ public enum CupsParsers {
return value
}
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
/// output — an AirPrint detection input (#202).
public static func lpoptionsMakeAndModel(output: String) -> String? {
guard let value = lpoptions(output)
.first(where: { $0.key == "printer-make-and-model" })?.value,
!value.isEmpty
else { return nil }
return value
}
// MARK: - lpoptions -l
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
@@ -174,8 +204,13 @@ public enum CupsParsers {
/// PPD `*<key> <id>/<Human Label>:` lines → `id → label` map.
/// Language-qualified forms (`*en_US.<key> id/Label:`) also match.
/// Precedence is deterministic, not positional (#181): unqualified
/// `*Key` > `en_US.` > `en.` > first-qualified-seen, so a trailing
/// locale block (Canon `th.CNIJMediaType`) can never overwrite the
/// base English labels — and a qualified-only id still gets its
/// first-seen qualified label (R9).
public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] {
var map: [String: String] = [:]
var hits: [String: [(qualifier: String?, label: String)]] = [:]
for rawLine in ppd.split(separator: "\n") {
var line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue }
@@ -183,7 +218,9 @@ public enum CupsParsers {
// 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.
// are left alone. The qualifier is recorded for precedence
// rather than dropped (#181).
var qualifier: String?
if let dot = line.firstIndex(of: ".") {
let prefix = line[..<dot]
let looksLikeLocale = (2...5).contains(prefix.count)
@@ -191,6 +228,7 @@ public enum CupsParsers {
&& (prefix.count == 2 || prefix.contains("_"))
let candidate = line[line.index(after: dot)...]
if looksLikeLocale && candidate.hasPrefix(key) {
qualifier = prefix.lowercased()
line = String(candidate)
}
}
@@ -201,15 +239,50 @@ public enum CupsParsers {
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 }
let id = ppdUnescape(String(rest[..<slash])
.trimmingCharacters(in: .whitespaces))
let human = ppdUnescape(String(rest[rest.index(after: slash)...])
.trimmingCharacters(in: .whitespaces))
guard !id.isEmpty else { continue }
hits[id, default: []].append(
(qualifier, human.isEmpty ? id : human))
}
var map: [String: String] = [:]
for (id, candidates) in hits {
// First occurrence wins inside each qualifier class, in
// file order — same as the old sequential behaviour.
map[id] = candidates.first { $0.qualifier == nil }?.label
?? candidates.first { $0.qualifier == "en_us" }?.label
?? candidates.first { $0.qualifier == "en" }?.label
?? candidates[0].label
}
return map
}
/// PPD `<XX>` hex escapes → the literal byte (`<2F>` → `/`,
/// `<20>` → space). Anything that is not `<` + two hex digits +
/// `>` passes through untouched (#181).
private static func ppdUnescape(_ text: String) -> String {
var result = ""
var index = text.startIndex
while index < text.endIndex {
guard text[index] == "<",
let hexEnd = text.index(
index, offsetBy: 3, limitedBy: text.endIndex),
hexEnd < text.endIndex, text[hexEnd] == ">",
let byte = UInt8(
text[text.index(after: index)..<hexEnd], radix: 16)
else {
result.append(text[index])
index = text.index(after: index)
continue
}
result.append(Character(UnicodeScalar(byte)))
index = text.index(after: hexEnd)
}
return result
}
// MARK: - Detection (docs/11)
/// Media-type option key in preference order — used both to read a
@@ -223,13 +296,110 @@ public enum CupsParsers {
}
/// Media type from a captured `key=value key=value` options string.
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
/// Prefers `MediaType` (docs/11 §tests), then the remaining roster
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
/// `StpMediaType` (#186 capture-return).
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
for key in mediaTypeKeys where key != "MediaType" {
if let v = pairs.first(where: { $0.key == key })?.value {
return v
}
}
return nil
}
/// Print-quality option key in preference order — vendor-first,
/// weakest last (#183). `OutputMode`/`Resolution` sit last: on some
/// drivers they are colour-mode keys, not quality (#180).
/// `CNIJPrintMode2`/`CNIJPQualitySlider` are deferred (#16).
public static let qualityKeys = [
"EPIJ_Qual", "CNIJPrintQuality", "CNIJQuality",
"cupsPrintQuality", "PrintQuality", "Quality", "StpQuality",
"EPIJ_Quality", "OutputMode", "Resolution",
]
public static func detectQualityKey(optionKeys: Set<String>) -> String? {
qualityKeys.first { optionKeys.contains($0) }
}
/// A single value from a captured `key=value key=value` options
/// string — case-insensitive key match (#183 capture-return).
public static func extractOption(
named key: String,
fromOptionsString options: String
) -> String? {
lpoptions(options).first {
$0.key.caseInsensitiveCompare(key) == .orderedSame
}?.value
}
/// Print-quality token from a captured options string — the queue's
/// quality key is detected from the roster before extracting (#183).
public static func extractQuality(fromOptionsString options: String) -> String? {
let pairs = lpoptions(options)
guard let key = detectQualityKey(
optionKeys: Set(pairs.map(\.key)))
else { return nil }
return pairs.first(where: { $0.key == key })?.value
}
/// `orientation-requested=3|4` → `"portrait"`/`"landscape"` (#183).
public static func extractOrientation(fromOptionsString options: String) -> String? {
extractOption(named: "orientation-requested", fromOptionsString: options)
.map { $0 == "4" ? "landscape" : "portrait" }
}
// MARK: - Tray detection (#218)
/// Paper-source option key in preference order. `EPIJ_FdSo` is
/// preferred over `EPIJ_PSrc` because Epson InkjetPrinter2 PPDs
/// label `EPIJ_FdSo` as "Paper Source" and `EPIJ_PSrc` as
/// "Page Setup" — the feed source is what Stage 2 should control.
/// `AP_D_InputSlot` is a capture-side alias only.
public static let trayKeys = [
"InputSlot", "MediaSource", "EPIJ_FdSo", "EPIJ_PSrc",
"CNIJMediaSupply"
]
public static func detectTrayKey(optionKeys: Set<String>) -> String? {
trayKeys.first { optionKeys.contains($0) }
}
/// Extract a paper-source token from a captured `key=value` string.
/// If `preferredKey` is supplied and present, it wins; otherwise
/// every roster key is tried. `AP_D_InputSlot` is accepted as an
/// alias when it carries a non-empty value. Empty values are
/// ignored so an `AP_D_InputSlot=` placeholder never maps to a
/// selection (#218 capture-back).
public static func extractTray(
fromOptionsString options: String,
preferredKey: String? = nil
) -> String? {
let pairs = lpoptions(options)
if let preferredKey,
let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(preferredKey) == .orderedSame
})?.value, !value.isEmpty {
return value
}
for key in trayKeys {
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare(key) == .orderedSame
})?.value, !value.isEmpty {
return value
}
}
// Capture-side alias used by some macOS Core Printing paths.
if let value = pairs.first(where: {
$0.key.caseInsensitiveCompare("AP_D_InputSlot") == .orderedSame
})?.value, !value.isEmpty {
return value
}
return nil
}
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
@@ -261,4 +431,62 @@ public enum CupsParsers {
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
"EpsonColorMode",
]
// MARK: - AirPrint detection (#202, docs/14 §10.2)
/// AirPrint queues always colour-manage the URF raster path, so
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
///
/// 1. device URI contains `apple-airprint://`
/// 2. PPD declares `*APAirPrint: True`
/// 3. make-and-model contains "Apple" **and** "AirPrint"
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
/// (case-insensitive)
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
/// subtype (`_universal._sub._ipp._tcp`)
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
/// type is `image/urf` — the raster only AirPrint consumes
public static func detectAirPrint(
deviceURI: String?, makeAndModel: String?, ppd: String
) -> Bool {
if let uri = deviceURI {
let lower = uri.lowercased()
if lower.contains("apple-airprint://") { return true }
if lower.hasPrefix("ipps://"),
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
return true
}
if lower.hasPrefix("ipp://"),
lower.contains("_universal._sub._ipp._tcp") {
return true
}
}
if ppd.contains("*APAirPrint: True") { return true }
if let model = makeAndModel,
model.contains("Apple"), model.contains("AirPrint") {
return true
}
return ppdCarriesURFFilter(ppd)
}
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
/// optional); `image/urf` as the destination token means the queue
/// consumes the AirPrint raster.
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
for rawLine in ppd.split(separator: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard line.lowercased().hasPrefix("*cupsfilter2"),
let colon = line.firstIndex(of: ":")
else { continue }
let tokens = line[line.index(after: colon)...]
.replacingOccurrences(of: "\"", with: "")
.split(separator: " ")
if tokens.count >= 2,
tokens[1].lowercased() == "image/urf" {
return true
}
}
return false
}
}
@@ -3,7 +3,6 @@ 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? {
@@ -13,8 +12,6 @@ public enum CupsError: LocalizedError, Equatable {
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."
}
@@ -27,9 +24,11 @@ public enum CupsError: LocalizedError, Equatable {
/// `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.
/// Target spooling is **not** here — it is the app-target
/// `NativeTargetSpooler` (`NSPrintOperation`, #201 D1/D4).
public struct CupsService: Sendable {
public let processManager: ProcessManager
/// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in
/// Directory containing `lpstat`/`lpoptions` — `/usr/bin` in
/// production, a fixture dir under test.
public let binaryDir: URL
/// `/etc/cups/ppd` in production.
@@ -53,24 +52,34 @@ public struct CupsService: Sendable {
// 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)
"lpstat", ["-e"], id: ProcessID.cupsLpstat("e"), tolerateFailure: true)
let statusOut = try await run(
"lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true)
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
let defaultOut = try await run(
"lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true)
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
// One-shot device-URI fetch for AirPrint detection (#202). A
// failed `lpstat -v` is tolerated — enumeration proceeds with
// no URIs and no fallback respawn.
let deviceOut = try await run(
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
var printers: [Printer] = []
for name in names {
let displayName = try? await displayName(for: name)
let identity = try? await queueIdentity(for: name)
printers.append(Printer(
name: name,
status: statuses[name] ?? .unknown,
isDefault: name == defaultName,
displayName: displayName
displayName: identity?.displayName,
isAirPrint: CupsParsers.detectAirPrint(
deviceURI: deviceURIs[name],
makeAndModel: identity?.makeAndModel,
ppd: loadPPD(for: name) ?? "")
))
}
return printers
@@ -79,9 +88,19 @@ public struct CupsService: Sendable {
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
/// display name, docs/11 §binding).
public func displayName(for queue: String) async throws -> String? {
try await queueIdentity(for: queue).displayName
}
/// One `lpoptions -p <queue>` spawn yields both identity fields —
/// `printer-info` (display name) and `printer-make-and-model`
/// (AirPrint rule 3, #202).
private func queueIdentity(
for queue: String
) async throws -> (displayName: String?, makeAndModel: String?) {
let result = try await run(
"lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue))
return CupsParsers.lpoptionsDisplayName(result.stdout)
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
return (CupsParsers.lpoptionsDisplayName(result.stdout),
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
}
// MARK: - Capabilities (lpoptions -l + PPD)
@@ -90,15 +109,42 @@ public struct CupsService: Sendable {
/// 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"))
"lpoptions", ["-p", queue, "-l"], id: ProcessID.cupsLpoptions("\(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.
/// Also resolves the per-media quality map from vendor driver
/// data (`MediaQualityConstraints`, #214) — empty when the driver
/// exposes none.
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
let listings = try await optionListings(for: queue)
return capabilities(from: listings, ppd: loadPPD(for: queue))
let ppd = loadPPD(for: queue)
var caps = capabilities(from: listings, ppd: ppd)
// Default queue-level map, then per-tray overrides (#218).
// Share one file-read cache across all contexts so multiple
// tray-token resolutions read Epson PDEData.dat only once.
var fileCache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = fileCache[$0] { return cached }
guard let data = try? Data(contentsOf: $0) else { return nil }
fileCache[$0] = data
return data
}
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd,
optionOverrides: [:], readFile: readFile)
if let trayKey = caps.trayKey {
for tray in caps.trays {
let map = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd,
optionOverrides: [trayKey: tray.token],
readFile: readFile)
caps.qualityIDsByMediaTypeByTray[tray.token] = map
}
}
return caps
}
/// Pure mapping — extracted so fixture tests need no process.
@@ -106,16 +152,12 @@ public struct CupsService: Sendable {
from listings: [CupsOptionListing],
ppd: String?
) -> PrinterCapabilities {
var trays: [PrinterTray] = []
let optionKeys = Set(listings.map(\.key))
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)
@@ -132,8 +174,49 @@ public struct CupsService: Sendable {
continue
}
}
// Paper source — one deterministic vendor roster key (#218).
let trayKey = CupsParsers.detectTrayKey(optionKeys: optionKeys)
let trays: [PrinterTray]
let trayDefault: String?
if let trayKey,
let listing = listings.first(where: { $0.key == trayKey }) {
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: trayKey)
} ?? [:]
trays = listing.choices.enumerated().map {
PrinterTray(
id: $0.offset + 1,
token: $0.element,
name: labels[$0.element] ?? $0.element)
}
trayDefault = listing.defaultChoice
} else {
trays = []
trayDefault = nil
}
// Print quality — the detected roster key's listing maps to
// `PrinterQuality` with PPD labels and the `*` default (#183).
var qualities: [PrinterQuality] = []
var qualityDefault: String?
let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
if let qualityKey,
let listing = listings.first(where: { $0.key == qualityKey }) {
let labels = ppd.map {
CupsParsers.ppdChoiceLabels($0, key: qualityKey)
} ?? [:]
qualities = listing.choices.map {
PrinterQuality(id: $0, name: labels[$0] ?? $0)
}
qualityDefault = listing.defaultChoice
}
return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media)
trays: trays, paperSizes: sizes, mediaTypes: media,
trayKey: trayKey, qualityKey: qualityKey,
qualities: qualities, trayDefault: trayDefault,
qualityDefault: qualityDefault)
}
/// The set of option keys a queue advertises — input to
@@ -142,32 +225,15 @@ public struct CupsService: Sendable {
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)
// UTF-8 first — the Canon Thai labels are UTF-8 and a blanket
// Latin-1 read would mojibake them (#181, R10). Latin-1 only
// when UTF-8 decoding fails outright.
return (try? String(contentsOf: url, encoding: .utf8))
?? (try? String(contentsOf: url, encoding: .isoLatin1))
}
// MARK: - Spawn
@@ -1,126 +0,0 @@
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,333 @@
import Foundation
/// Per-media quality validity (#214): which of the queue's quality
/// tokens the driver actually accepts for a given media type. The
/// answer is not in `lpoptions -l`, IPP, or standard PPD
/// `*UIConstraints` for the two driver families handled here —
///
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
/// lines in the machine bundle's
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
/// choice while every LHS `*<key> <value>` term holds. LHS keys
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
/// defaults — reproducing the driver's PDE state when it opens.
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
/// u16 0, u16 quality}` records enumerate the allowed
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
/// `| 0x10000` marks the borderless variant of the same media).
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
/// (Gutenprint etc.) — first non-empty source wins.
///
/// Every parser is pure; file access is injected so tests need no
/// installed driver. Failure or absence yields an empty map — callers
/// treat that as "unconstrained" and keep the full quality list.
public enum MediaQualityConstraints {
/// media-type id → allowed quality ids. Sources tried in order
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
/// the first source producing any entries wins.
public static func resolve(
listings: [CupsOptionListing],
ppd: String?,
optionOverrides: [String: String] = [:],
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key))
guard let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys),
let qualityKey = CupsParsers.detectQualityKey(
optionKeys: optionKeys),
let mediaListing = listings.first(where: {
$0.key == mediaKey }),
let qualityListing = listings.first(where: {
$0.key == qualityKey })
else { return [:] }
let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices)
var defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first })
for (key, value) in optionOverrides {
defaults[key] = value
}
guard let ppd else { return [:] }
let ppdMap = allowedMap(
forbidden: ppdUIConstraints(
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !ppdMap.isEmpty { return ppdMap }
if let url = epijPDEDataPath(ppd: ppd),
let data = readFile(url),
let dat = String(data: data, encoding: .utf8)
?? String(data: data, encoding: .isoLatin1) {
let map = allowedMap(
forbidden: epijUIConstraints(
dat, mediaKey: mediaKey, qualityKey: qualityKey,
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
defaults: defaults),
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
if let url = cnijTablePath(ppd: ppd),
let data = readFile(url) {
let map = cnijMediaQualityTable(
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
if !map.isEmpty { return map }
}
return [:]
}
// MARK: - PPD keyword lookup
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
/// for `CNIJTableID`.
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
for raw in ppd.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*\(key)") else { continue }
let rest = line.dropFirst(key.count + 1)
guard rest.first == ":" else { continue }
var value = rest.dropFirst()
.trimmingCharacters(in: .whitespaces)
if value.count >= 2,
value.hasPrefix("\""), value.hasSuffix("\"") {
value = String(value.dropFirst().dropLast())
}
if !value.isEmpty { return value }
}
return nil
}
// MARK: - Generic PPD constraints
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
/// option choices; the ones naming both the media key and the
/// quality key produce forbidden (media, quality) pairs.
/// `*cupsUIConstraints` resolver triples do not match the
/// `*…Constraints:` prefixes, so they are skipped naturally.
public static func ppdUIConstraints(
_ ppd: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in ppd.split(separator: "\n") {
var line = raw.trimmingCharacters(in: .whitespaces)
if line.hasPrefix("*UIConstraints:") {
line = String(line.dropFirst("*UIConstraints:".count))
} else if line.hasPrefix("*Constraints:") {
line = String(line.dropFirst("*Constraints:".count))
} else {
continue
}
let terms = constraintTerms(line)
guard let media = terms[mediaKey], mediaIDs.contains(media),
let quality = terms[qualityKey],
qualityIDs.contains(quality)
else { continue }
forbidden[media, default: []].insert(quality)
}
return forbidden
}
// MARK: - Epson PDEData.dat
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
/// InkjetPrinter2 family).
public static func epijPDEDataPath(ppd: String) -> URL? {
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
else { return nil }
return URL(fileURLWithPath: base)
.appendingPathComponent("Machine")
.appendingPathComponent(bundle)
.appendingPathComponent("Contents/Resources/PDEData.dat")
}
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
/// pairs the driver greys out. A rule *fires* when its LHS
/// `*<mediaKey>` term equals the media (absent media term →
/// applies to every media) and every other LHS term's value
/// equals that key's `lpoptions` default. A term whose key the
/// queue does not advertise counts as satisfied — hiding a usable
/// quality is a soft restriction, while showing an invalid one
/// re-creates the reported print failure.
public static func epijUIConstraints(
_ dat: String,
mediaKey: String,
qualityKey: String,
mediaIDs: Set<String>,
qualityIDs: Set<String>,
defaults: [String: String]
) -> [String: Set<String>] {
var forbidden: [String: Set<String>] = [:]
for raw in dat.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.hasPrefix("*EPIJUIConstraint:"),
let bar = line.firstIndex(of: "|")
else { continue }
let rhs = constraintTerms(
String(line[line.index(after: bar)...]))
guard let quality = rhs[qualityKey],
qualityIDs.contains(quality)
else { continue }
let lhs = constraintTerms(
String(line[
line.index(
line.startIndex,
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
// Non-media terms must hold at the queue's defaults;
// a key absent from `defaults` is satisfied (see above).
let fires = lhs.allSatisfy { key, value in
key == mediaKey || value.isEmpty
|| (defaults[key].map { $0 == value } ?? true)
}
guard fires else { continue }
if let media = lhs[mediaKey] {
guard mediaIDs.contains(media) else { continue }
forbidden[media, default: []].insert(quality)
} else {
for media in mediaIDs {
forbidden[media, default: []].insert(quality)
}
}
}
return forbidden
}
// MARK: - Canon cnb table
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
public static func cnijTablePath(ppd: String) -> URL? {
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
let tableID = ppdKeyword(ppd, "CNIJTableID")
else { return nil }
return URL(fileURLWithPath: dir)
.appendingPathComponent("cnb_\(tableID)0.tbl")
}
/// Scans the Canon table for 20-byte LE records anchored on
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
/// listed media id, and `quality` is a listed quality id — so
/// unrelated tables cannot inject false entries. Records carry a
/// `family` field (0x03 on the Pro9500 II); the modal family is
/// used so sibling record layouts in the same file are ignored.
/// Returns allowed (not forbidden) sets directly.
public static func cnijMediaQualityTable(
_ data: Data,
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
return [:]
}
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00]
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
data.withUnsafeBytes { buffer in
guard let base = buffer.baseAddress else { return }
var i = 0
while i + 20 <= buffer.count {
if memcmp(base + i, anchor, anchor.count) == 0 {
let family = readU16(base, i + 8)
let media = readU32(base, i + 12)
let pad = readU16(base, i + 16)
let quality = readU16(base, i + 18)
let baseMedia = media & 0xFFFF
if pad == 0,
mediaNums.contains(baseMedia),
qualityNums.contains(quality) {
rows[family, default: []].append(
(baseMedia, quality))
}
}
i += 2
}
}
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
else { return [:] }
var map: [String: Set<String>] = [:]
for row in dominant.value {
map[String(row.media), default: []].insert(String(row.quality))
}
// Keep strict subsets only — a media with the full set (or an
// empty one) is unconstrained as far as the UI is concerned.
map = map.filter { _, ids in
!ids.isEmpty && ids != qualityIDs
}
return map
}
// MARK: - Shared
/// `*<key> <value>` term extraction for both PPD and Epson
/// constraint syntax. A `*<key>` not followed by a value is a
/// wildcard term (empty-string value → always satisfied).
private static func constraintTerms(_ text: String) -> [String: String] {
var terms: [String: String] = [:]
let tokens = text.split(separator: " ").map(String.init)
var index = 0
while index < tokens.count {
guard tokens[index].hasPrefix("*") else {
index += 1
continue
}
let key = String(tokens[index].dropFirst())
if index + 1 < tokens.count,
!tokens[index + 1].hasPrefix("*") {
terms[key] = tokens[index + 1]
index += 2
} else {
terms[key] = ""
index += 1
}
}
return terms
}
/// forbidden (media → quality ids) → allowed map entries,
/// emitting only strict non-empty subsets: a media whose allowed
/// set is empty or equals the full roster is unconstrained as far
/// as the picker is concerned.
private static func allowedMap(
forbidden: [String: Set<String>],
mediaIDs: Set<String>,
qualityIDs: Set<String>
) -> [String: Set<String>] {
var map: [String: Set<String>] = [:]
for media in mediaIDs {
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
if !allowed.isEmpty, allowed != qualityIDs {
map[media] = allowed
}
}
return map
}
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
}
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
}
}
@@ -17,28 +17,48 @@ public struct Printer: Codable, Equatable, Sendable {
public var status: PrinterStatus
public var isDefault: Bool
public var displayName: String?
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
/// colour-managed, so Stage 2 shows a persistent warning badge.
public var isAirPrint: Bool = false
public init(
name: String,
status: PrinterStatus = .unknown,
isDefault: Bool = false,
displayName: String? = nil
displayName: String? = nil,
isAirPrint: Bool = false
) {
self.name = name
self.status = status
self.isDefault = isDefault
self.displayName = displayName
self.isAirPrint = isAirPrint
}
/// `isAirPrint` predates #202 payloads — a missing key decodes as
/// `false` instead of failing the whole decode.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
name = try c.decode(String.self, forKey: .name)
status = try c.decode(PrinterStatus.self, forKey: .status)
isDefault = try c.decode(Bool.self, forKey: .isDefault)
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
}
}
/// Paper source. `id` is the 1-based index of the `InputSlot` /
/// `MediaSource` choice (not a PPD code) — docs/10.
/// `MediaSource` / vendor choice (not a PPD code); `token` is the
/// exact CUPS/PPD machine value written to the ticket; `name` is the
/// PPD-enriched display label (#218).
public struct PrinterTray: Codable, Equatable, Sendable {
public var id: Int
public var token: String
public var name: String
public init(id: Int, name: String) {
public init(id: Int, token: String, name: String) {
self.id = id
self.token = token
self.name = name
}
}
@@ -66,6 +86,18 @@ public struct PrinterMediaType: Codable, Equatable, Sendable {
}
}
/// Print quality: `id` is the option token (e.g. `"303"`), `name` the
/// human label after PPD enrichment (mirrors `PrinterMediaType`, #183).
public struct PrinterQuality: 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]
@@ -73,35 +105,122 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// Always `true` on macOS (spec parity — CUPS honours
/// `orientation-requested`).
public var supportsOrientation: Bool
/// The queue's detected tray enumeration key
/// (`CupsParsers.detectTrayKey`), e.g. `EPIJ_FdSo` (#218).
public var trayKey: String?
/// The queue's detected quality enumeration key
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
public var qualityKey: String?
public var qualities: [PrinterQuality]
/// The `*`-marked default tray token from `lpoptions -l`, if any
/// (#218).
public var trayDefault: String?
/// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it
/// (#214 — `MediaQualityConstraints`). Empty when the driver
/// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>]
/// Tray token → media-type id → allowed quality ids. Computed
/// once at capability load; a missing tray falls back to
/// `qualityIDsByMediaType` (#218).
public var qualityIDsByMediaTypeByTray: [String: [String: Set<String>]]
public init(
trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true
supportsOrientation: Bool = true,
trayKey: String? = nil,
qualityKey: String? = nil,
qualities: [PrinterQuality] = [],
trayDefault: String? = nil,
qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:],
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
) {
self.trays = trays
self.paperSizes = paperSizes
self.mediaTypes = mediaTypes
self.supportsOrientation = supportsOrientation
self.trayKey = trayKey
self.qualityKey = qualityKey
self.qualities = qualities
self.trayDefault = trayDefault
self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
}
/// The quality map for the requested tray token, if any.
/// Prefers the tray-specific map; otherwise falls back to the
/// queue-default map. An explicitly empty tray map means the tray
/// is unconstrained, so `nil` is returned only when neither map
/// exists.
private func qualityMap(
forTrayToken trayToken: String?
) -> [String: Set<String>]? {
if let trayToken,
let map = qualityIDsByMediaTypeByTray[trayToken],
!map.isEmpty {
return map
}
return qualityIDsByMediaType.isEmpty ? nil : qualityIDsByMediaType
}
/// Qualities valid for `mediaID` and an optional tray token,
/// preserving the driver's listing order (#180/#218). Falls back
/// to the full list when the driver has no per-media data, the
/// media is absent from the map, or the filter would empty the
/// picker (#214).
public func qualities(
forMediaType mediaID: String?,
trayToken: String? = nil
) -> [PrinterQuality] {
guard let mediaID,
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty
else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered
}
/// Whether `qualityID` is usable with `mediaID` and an optional
/// tray token (#218). A quality absent from `qualities` entirely
/// is still allowed: a driver-captured token can be legitimate
/// even when `lpoptions` never listed it.
public func allowsQuality(
_ qualityID: String,
forMediaType mediaID: String?,
trayToken: String? = nil
) -> Bool {
guard let mediaID,
let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty
else { return true }
return allowed.contains(qualityID)
}
}
/// 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.
/// The Stage 2 mirror — panel selections and the captured `k=v`
/// string (docs/10 §PrintOptions). Since #201 removed the `lp` path
/// these fields feed `TargetPrintOverrides` (Stage 2 always wins, D6)
/// and the mirror apply-back; the opaque vendor state now travels
/// inside the `PrintTicket`, not a flattened option string.
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).
/// Stage 2 paper token → `PageSize=` ticket write + `PMPaper`.
public var paperSize: String?
public var mediaType: String?
/// Print-quality token → `<detectedQualityKey>=` ticket write.
public var quality: String?
public var ppdUncorrectedPassthrough: Bool?
/// Space-separated `key=value` captured from
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥).
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
/// the Stage 2 mirror only, never an `lp` payload.
public var cupsOptions: String?
public init(
@@ -109,6 +228,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
orientation: String? = nil,
paperSize: String? = nil,
mediaType: String? = nil,
quality: String? = nil,
ppdUncorrectedPassthrough: Bool? = nil,
cupsOptions: String? = nil
) {
@@ -116,6 +236,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
self.orientation = orientation
self.paperSize = paperSize
self.mediaType = mediaType
self.quality = quality
self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough
self.cupsOptions = cupsOptions
}
@@ -4,6 +4,8 @@ import Foundation
/// filter events on `id` — historical bug #56 was an id mismatch.
public enum ProcessID {
public static let instlist = "instlist"
/// Spot-read console (issue #148) — single lease, like `instlist`.
public static let spotread = "spotread"
public static func targen(_ basename: String) -> String { "targen_\(basename)" }
public static func printtarg(_ basename: String) -> String { "printtarg_\(basename)" }
@@ -16,8 +18,8 @@ public enum ProcessID {
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)" }
/// streaming Argyll children. Named `cups*` so no `lp*`-prefixed
/// identifier survives the target-print removal (#201).
public static func cupsLpstat(_ mode: String) -> String { "lpstat_\(mode)" }
public static func cupsLpoptions(_ queue: String) -> String { "lpoptions_\(queue)" }
}
@@ -559,7 +559,7 @@ public actor ProcessManager {
// 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))
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self else { return }
await self.forceKill(id: id)
await self.forceFinalize(id: id)
+185 -50
View File
@@ -1,43 +1,76 @@
# ICCery
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from chart generation through measurement, `colprof`, verification, and ColorSync install. It is **not** a colour engine.
Native macOS frontend for printer ICC/ICM profiling. ICCery walks a user from
chart generation through measurement, `colprof`, verification, and ColorSync
install. It is **not** a colour engine.
All measurement, chart generation, and profile mathematics live in the [Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
**End-user guide:** the [repository wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
covers every screen (Getting Started through Troubleshooting). This README is
for building, packaging, and contributing.
All measurement, chart generation, and profile mathematics live in the
[Gronod ArgyllCMS 3.5.0 fork](https://git.i3omb.com/gronod/argyllcms), spawned
as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| | |
|---|---|
| Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` |
| Floor | macOS 14 Sonoma, universal `arm64` + `x86_64` |
| Version | 2.0.3 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner |
| Default branch | `develop` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped on `develop` |
| M7 | Pre-UAT hardening & baseline consolidation — shipped on `develop` |
| M8 | Deduplication/consolidation contracts & UAT-ready hardening (#79–#86) — in flight on `milestone/m8-consolidation` |
| M6 | Stage 0 calibration, CGATS import, SceneKit gamut viewer, packaging — shipped |
| M7 | Pre-UAT hardening — shipped |
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does
The wizard is artefact-gated:
1. **Stage 0** — printer calibration: `printcal` / `applycal` session, `CAL_` basename restore
2. **Stage 1** — `targen` → `.ti1`
3. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
4. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
5. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
6. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
1. **Stage 1** — `targen` → `.ti1`
2. **Stage 2** — `printtarg` → `.ti2` + TIFF, unmanaged `lp` spool, bound `NSPrintPanel`
3. **Stage 3** — `instlist` + streaming `chartread` (strip / XY / handheld) → `.ti3`, multi-pass average, CIEDE2000
4. **Stage 4** — `colprof` → `.icc` / `.icm`; optional `applycal`; `iccgamut` next to the profile
5. **Stage 5** — `profcheck`, verification history, ColorSync user/system install
Plus CGATS dataset import (`.ti3` / `.txt` / `.cgats` / `.csv`), SceneKit gamut preview with sRGB overlay, and signed `.dmg` packaging.
Plus:
**Not this product:** display calibration (`dispwin` / `dispread`), i18n, Windows/Linux print trees, in-process Argyll, App Sandbox.
- **Calibrate Printer** — optional `printcal` / `applycal` session under a `CAL_` basename
- **CGATS import** — `.ti3` / `.txt` / `.cgats` / `.csv`
- **Media recipes and presets** — printer + paper + ink bound to a preset and optional `.cal`
- **Spot Read** — live one-patch Lab/XYZ from the instrument
- **Project files** — `.icceryproj` bookmark over folder, basename, recipe, last ΔE
- **Gamut viewer** — SceneKit Lab hull, sRGB overlay, second-profile compare, click-inspect
- **Settings** — default instrument, ΔE good/warning cutoffs, install location, logging
- Signed `.dmg` packaging with a HiDPI Finder background (Monterey through Sonoma)
**Not this product:** display calibration (`dispwin` / `dispread`), i18n,
Windows/Linux print trees, in-process Argyll, App Sandbox.
## Requirements
- macOS 14+
- Xcode 15.4+ with the macOS 14 SDK and Swift 6.0
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)
- Network once, to fetch Argyll sidecars
To **run** a packaged build:
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in `ICCery.entitlements`.
- macOS 12.0 Monterey or later (Intel or Apple silicon)
To **build** on the supported CI/host floor:
- macOS 12 with **Xcode 14.2** (macOS 12 SDK, Swift 5.7)
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) **2.38.0** (Homebrew’s current
formula needs Xcode 15.3; CI installs the pinned zip via
`scripts/ensure-host-tools.sh`)
- Network once, to fetch Argyll sidecars
- For DMGs: Python 3.9+ and `dmgbuild==1.6.7` in `build/.venv-dmgbuild`
(`INSTALL_DMGBUILD=1 scripts/ensure-host-tools.sh`)
App Sandbox is **off**. Hardened Runtime is **on**. Entitlements live in
`ICCery.entitlements`.
## Build
@@ -47,7 +80,7 @@ cd iccery-v2-mac
git checkout develop
make fetch-argyll # Vendor/Argyll/macos-universal/, ad-hoc signed
make test # xcodegen + xcodebuild build test
make test # xcodegen + xcodebuild build test (host arch)
make universal # ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
```
@@ -57,10 +90,17 @@ Equivalent without Make:
xcodegen generate
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
```
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or `ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if `codesign -dvv` or the `instlist` marker is missing.
`project.yml` sets `ARCHS: "$(ARCHS_STANDARD)"`. CI and `make test` override
that with `ARCHS="$(uname -m)"` so unit/UI tests build the host slice only.
Fat binaries are `make universal` / `scripts/package-release.sh`.
Sidecars are **not** in git. `scripts/fetch-argyll.sh` pulls the latest (or
`ARGYLL_RELEASE_TAG`) macOS-universal release from `gronod/argyllcms`, extracts
to `Vendor/Argyll/macos-universal/`, ad-hoc signs every Mach-O, and fails if
`codesign -dvv` or the `instlist` marker is missing.
```bash
# optional
@@ -70,9 +110,11 @@ export ARGYLL_RELEASE_TAG=… # default: latest
export GITEA_TOKEN=… # private releases
```
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and `Packages/ICCeryCore/.build`.
`make clean` drops `ICCery.xcodeproj`, `DerivedData`, and
`Packages/ICCeryCore/.build`.
Do not open the generated xcodeproj as the source of truth. Edit `project.yml` and regenerate.
Do not open the generated xcodeproj as the source of truth. Edit `project.yml`
and regenerate.
## Release packaging
@@ -80,11 +122,48 @@ Do not open the generated xcodeproj as the source of truth. Edit `project.yml` a
scripts/package-release.sh # fetch → sign → universal build → verify → DMG
```
The script builds with a fixed derived data path (`build/DerivedData`),
locates `Release/ICCery.app` from it, signs the bundle, recursively verifies
every bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), and
writes `ICCery-${VERSION}-${BUILD_NUM}.dmg` via `dmgbuild`. Sidecars stay
ad-hoc signed inside the bundle — the app is never `codesign --deep`ed.
The script builds with a fixed derived data path (`build/DerivedData`), locates
`Release/ICCery.app` from it, signs the bundle, recursively verifies every
bundled Mach-O sidecar (`scripts/verify-sidecar-signatures.sh`), builds a
HiDPI TIFF from `Resources/dmg-background.png` (+ `@2x`) via `tiffutil`, and
writes the DMG with `dmgbuild==1.6.7`.
### Versioning
`scripts/version.sh` resolves the version triple and is the single source for
packaging and CI:
- **`ICCERY_RELEASE_TAG`** — `RELEASE_TAG` env when it matches `v[0-9]*` (CI
tag builds), else `git describe --tags --always --dirty --match 'v[0-9]*'`.
Stamped into the bundle's `ICCeryReleaseTag` resource (a generated
Info.plist cannot carry custom keys); the About dialog shows
`tag (marketing)` — e.g. `v2.0.0-pre2-grok (2.0.0)` — falling back to the
plain version when untagged.
- **`MARKETING_VERSION`** (`CFBundleShortVersionString`) — first three numeric
components of the tag core. Must equal `project.yml`'s
`MARKETING_VERSION` on tag builds — packaging hard-fails on mismatch, so
bump `project.yml` on `develop` *before* tagging.
- **`CURRENT_PROJECT_VERSION`** (`CFBundleVersion`) — `BUILD_NUMBER` env
override, else `git rev-list --count HEAD`. A single monotonically
increasing integer per Apple's macOS convention (Mac build numbers never
reset per version, unlike iOS).
Tagged/described DMGs carry the tag: `ICCery-2.0.0-pre2-grok-<build>.dmg`;
plain releases keep `ICCery-<ver>-<build>.dmg`.
Release procedure: bump `MARKETING_VERSION` in `project.yml` on `develop` →
merge `develop` → `main` → tag the release commit `vX.Y.Z[-suffix]` → push
the tag. CI builds, signs, notarizes (when secrets exist) and attaches the
DMG to the release.
Sidecars stay ad-hoc signed inside the bundle — the app is never
`codesign --deep`ed.
`dmgbuild` is **not** a test-job dependency. The package job sets
`INSTALL_DMGBUILD=1` so `scripts/ensure-host-tools.sh` creates
`build/.venv-dmgbuild`. On the Monterey runner (Python 3.9) that install uses
`PIP_IGNORE_REQUIRES_PYTHON=1` and pins `pip>=24.3,<26.1` (pip 26.1+ needs
3.10). Missing background art is a hard fail (#95).
Environment variables read by the pipeline:
@@ -95,6 +174,8 @@ Environment variables read by the pipeline:
| `CODESIGN_IDENTITY` | Developer ID identity for the outer `.app`; unset or `-` = ad-hoc |
| `DEVELOPMENT_TEAM` | team ID passed to `xcodebuild` when signing |
| `NOTARIZE_APPLE_ID` / `NOTARIZE_PASSWORD` / `APPLE_TEAM_ID` | `notarytool` + staple when all three are set |
| `RELEASE_TAG` | release tag string (CI sets `github.ref_name`); stamped into the bundle + DMG name |
| `BUILD_NUMBER` | `CFBundleVersion` override; default `git rev-list --count HEAD` |
## Layout
@@ -106,28 +187,49 @@ Resources/ assets; Argyll reference files (not the tools)
Vendor/Argyll/ fetched sidecars (gitignored)
Tests/ICCeryCoreTests/ argv goldens, parsers, stores
Tests/ICCeryUITests/ fixture / mock-binary UI tests
scripts/ensure-host-tools.sh
scripts/fetch-argyll.sh
scripts/package-release.sh
docs/ functional spec + v2 ticket plan
```
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is not in this tree.
`ICCeryPrintKit` (issue #16, Quartz / AirPrint / TargetPrint) is v2.1 and is
not in this tree.
## Architecture
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1` is always set.
- **`ProcessManager` actor** owns child lifetime. Streaming tools (`chartread`, `printcal`, etc.) use the event bus; one-shot tools use `runCaptured`. Exclusive `ProcessID` leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`, `ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`, `ProfcheckArgs`, `LpArgs`, …). UI must not concatenate flags.
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal` must not replace the input profile on cancel or non-zero exit.
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main actor. SwiftUI `@Observable` for new state.
- **Print.** Unmanaged `lp` with ColorSync suppression (`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel` options win over derived CUPS keys. Never `lp -o raw`.
- **Spawn, never link.** Tools resolve through `BinaryResolver` inside the
bundle / `Vendor` tree. `$PATH` is not searched. `ARGYLL_NOT_INTERACTIVE=1`
is always set.
- **`ProcessManager` actor** owns child lifetime. Streaming tools
(`chartread`, `printtarg`, `colprof`, …) use the event bus; one-shot tools
(`printcal`, `applycal`, CUPS) use `runCaptured`. Exclusive `ProcessID`
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
`ProfcheckArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
- **Atomic artefacts.** Writes go to `*.tmp` then `replaceItemAt`. `applycal`
must not replace the input profile on cancel or non-zero exit.
- **Concurrency.** View models are `@MainActor`. No blocking I/O on the main
actor. Swift 5.7 / macOS 12: `ObservableObject`, not Observation
`@Observable`.
- **Print.** Unmanaged headless `NSPrintOperation` (#201 — no `lp`):
restore the captured `PrintTicket`, write both ColorSync vocabularies
(locked `AP_*` + Quartz `PMColorMatchingMode`), Stage 2 selections always
win over the ticket, draw 1:1 with interpolation off.
- **SwiftUI ViewBuilder.** Xcode 14.2 / Swift 5.7 still has the ten-child
limit. Split large `VStack`/`Group` trees (#146).
## Tests
```bash
# full suite (universal)
# full suite (host arch) — same as CI
xcodebuild test -scheme ICCery \
-destination 'platform=macOS' \
ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
ARCHS="$(uname -m)"
# fat compile-check (not the default test path):
# ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO
# examples
xcodebuild test -scheme ICCery -destination 'platform=macOS' \
@@ -136,9 +238,24 @@ xcodebuild test -scheme ICCery -destination 'platform=macOS' \
-only-testing:ICCeryUITests/Milestone5UITests
```
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll / CUPS fixtures live under the test bundles; they must not be treated as proof that a real `.gam` / `.icc` was extracted.
CI (`.gitea/workflows/macos.yml` on Gitea, `.github/workflows/macos.yml` on
GitHub) runs `build-and-test` then `package` on
`develop` and on `v*` tags. Tags whose name contains `prerelease` skip the
test job and still package. `pull_request` is wired for **`develop` only**.
The GitHub file is the same pipeline on `macos-14` (github.com retired
`macos-12`), `actions/upload-artifact@v4`, and `gh release upload` for tag
DMGs.
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are manual and block release, not compile.
UI tests need an unlocked console (`IOConsoleLocked=false`). Mock Argyll /
CUPS fixtures live under the test bundles; they must not be treated as proof
that a real `.gam` / `.icc` was extracted.
Hardware gates (real instrument, real printer, Gatekeeper-open `.dmg`) are
manual and block release, not compile.
`ArgyllRunnerPrinttargTests.testSuccess` can flake if streaming stdout is
dropped on a fast mock exit; that is a `ProcessManager` drain race, not a
missing fixture.
## Instruments
@@ -149,11 +266,18 @@ Detected via bundled `instlist`:
- SpyderPrint (`p3`)
- SpectroScan (`SS`)
- DTP20 / 22 / 41 / 51
- XY tables (SpectroScan, i1iO) when the `instlist` name matches `/spectro\s?scan|i1io/i`
- XY tables (SpectroScan, i1iO) when the `instlist` name matches
`/spectro\s?scan|i1io/i`
## Docs
Normative spec is [`docs/`](docs/README.md). Implementation order:
| Where | Audience |
|---|---|
| [Wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki) | End users — screens, workflow, troubleshooting |
| [`docs/`](docs/README.md) | Functional spec (normative for implementers) |
| [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md) | Agent / branch rules |
Implementation order in `docs/`:
| Doc | Topic |
|---|---|
@@ -161,29 +285,40 @@ Normative spec is [`docs/`](docs/README.md). Implementation order:
| [`docs/03-ipc-and-process-manager.md`](docs/03-ipc-and-process-manager.md) | Spawn / stdin / kill |
| [`docs/04-argyll-binaries.md`](docs/04-argyll-binaries.md) | CLI argv |
| [`docs/06-wizard-and-artefacts.md`](docs/06-wizard-and-artefacts.md) | Gating |
| [`docs/23-assets.md`](docs/23-assets.md) | Icons, DMG chrome |
| [`docs/24-issues-invariants.md`](docs/24-issues-invariants.md) | Bugs that must not return |
| [`docs/26-v2-mac-ticket-plan.md`](docs/26-v2-mac-ticket-plan.md) | Gitea tickets |
| [`docs/PREUAT.md`](docs/PREUAT.md) | Pre-UAT tester kit |
Agent / branch rules: [`AGENTS.md`](AGENTS.md), [`BUILD-PLAN.md`](BUILD-PLAN.md).
## Git
```
develop
└── milestone/m8-consolidation # integration branch
└── feat/<issue>-<slug> # one issue per branch
develop # integration; PRs land here unless a milestone branch is announced
main # protected release line (PR from develop)
feat/<issue>-<slug>
fix/<issue>-<slug>
```
Feature PRs target the current milestone branch, not `develop`. The milestone branch merges to `develop` when its issues are green. Completion PRs for issues #79–#86 target `milestone/m8-consolidation`; `milestone/m8-consolidation` merges into `develop` once all milestone gates pass. Do not open umbrella "bugfix" branches that mix tickets.
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
branch is used only while that milestone is assembling; `milestone/m10-studio`
and `milestone/m11-print-settings` have been merged (branches retained). Do
not open umbrella “bugfix” branches that mix tickets.
`main` is push-protected and requires status check
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
block PR merges that touch matching paths — do not set that field to `*`.
## Licence
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing and personal evaluation only unless a separate grant says otherwise.
GUI source: © 2026 Gordon Bolton — see [`LICENCE.md`](LICENCE.md). Viewing
and personal evaluation only unless a separate grant says otherwise.
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning via `$PATH`, is a licence break.
ArgyllCMS binaries fetched into `Vendor/Argyll/` are **AGPLv3**. They stay
subprocess-isolated (stdin / stdout / stderr only). Linking them, or spawning
via `$PATH`, is a licence break.
## Related
- [User wiki](https://git.i3omb.com/gronod/iccery-v2-mac/wiki)
- [gronod/argyllcms](https://git.i3omb.com/gronod/argyllcms) — Argyll 3.5.0 fork (`-u` JSON, `instlist`)
- [gronod/ICCery](https://git.i3omb.com/gronod/ICCery) — v1 Tauri application (spec source, not this tree)
+16
View File
@@ -0,0 +1,16 @@
<?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/>
<!-- Debug only: the shared ICCeryCore package framework embedded in the
test products is ad-hoc signed with no Team ID, so hardened-runtime
library validation kills the test host at launch (run 31992, #119).
Release uses ICCery.entitlements and links the package statically. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+11
View File
@@ -6,6 +6,8 @@ import ICCeryCore
struct AboutView: View {
let onClose: () -> Void
@State private var showingLicenses = false
private let info = ArtefactFiles.appInfo()
var body: some View {
@@ -54,6 +56,12 @@ struct AboutView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("View Licenses") {
showingLicenses = true
}
.controlSize(.large)
.accessibilityIdentifier("viewLicensesBtn")
Button("Close") {
onClose()
}
@@ -66,5 +74,8 @@ struct AboutView: View {
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("aboutDialog")
.sheet(isPresented: $showingLicenses) {
LicenseWindowView()
}
}
}
+66 -7
View File
@@ -13,6 +13,8 @@ struct AppEnvironment: Sendable {
let runner: ArgyllRunner
let cupsService: CupsService
let historyStore: VerificationHistoryStore
let mediaStore: MediaLibraryStore
let recentProjectsStore: RecentProjectsStore
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -20,14 +22,24 @@ struct AppEnvironment: Sendable {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
var bundledRoot = AppPaths.bundledArgyllDir
var cupsDir = URL(fileURLWithPath: "/usr/bin")
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_ARGYLL_BUNDLED_ROOT"], !dir.isEmpty {
bundledRoot = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
// #214 — fixture PPD dir lets UI tests exercise the media→
// quality constraint resolver without installed drivers.
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
ppdDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
@@ -35,12 +47,15 @@ struct AppEnvironment: Sendable {
presetStore: PresetStore(settingsStore: settingsStore),
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
binaryResolver: BinaryResolver(
bundledRoot: bundledRoot, overrideDir: overrideDir)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
historyStore: VerificationHistoryStore()
binaryDir: cupsDir, ppdDir: ppdDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
)
}
}
@@ -74,15 +89,37 @@ enum UITestHooks {
static var presetImportURL: URL? { url("ICCERY_TEST_PRESET_IMPORT") }
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
/// Spot-read CSV export destination (`selectCsvSavePath`, #148).
static var csvExportURL: URL? { url("ICCERY_TEST_CSV_EXPORT") }
/// `.gam` compare picker result (gamut sheet, #147). Unset → cancel.
static var gamutFileURL: URL? { url("ICCERY_TEST_GAMUT_FILE") }
/// `.icc/.icm` compare picker result (gamut sheet, #147). Unset → cancel.
static var gamutProfileURL: URL? { url("ICCERY_TEST_GAMUT_PROFILE") }
/// TIFF sample picker result (gamut sheet, #147). Unset → cancel.
static var gamutTiffURL: URL? { url("ICCERY_TEST_GAMUT_TIFF") }
/// `selectProjectFile` result (`.icceryproj` open, #149). Unset → cancel.
static var projectOpenURL: URL? { url("ICCERY_TEST_PROJECT_OPEN") }
/// `selectProjectSavePath` result (`.icceryproj` save-as, #149).
static var projectSaveURL: URL? { url("ICCERY_TEST_PROJECT_SAVE") }
/// Relocate-folder result when a project's `cwd` is missing (#149).
static var projectRelocateURL: URL? { url("ICCERY_TEST_PROJECT_RELOCATE") }
/// Forces the gamut sheet into its no-Metal fallback even on a GPU
/// host (#147). Set per-test only — never in a default launch env,
/// or CI's future GPU run would skip SceneKit too.
static var skipSceneKit: Bool {
isEnabled && env["ICCERY_TEST_SKIP_SCENEKIT"] == "1"
}
// MARK: - Print panel / CUPS stubs (issue 13/17)
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
/// Directory of mock `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") }
/// Path the `RecordingTargetSpooler` appends resolved ticket
/// lines to, for assertions (#201 D8 — replaces the `lp` argv
/// log).
static var spoolLogURL: URL? { url("ICCERY_TEST_SPOOL_LOG") }
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
/// separate from the stub's *result* so "cancel" (`nil`) does not
@@ -96,20 +133,42 @@ enum UITestHooks {
/// `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? {
static func printPanelResult(
forQueue queue: String,
trays: [PrinterTray] = [],
preferredTrayKey: String? = nil
) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0
}
let trayToken = options.flatMap {
CupsParsers.extractTray(
fromOptionsString: $0, preferredKey: preferredTrayKey)
}
let paperSource = trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0
} ?? queue,
options: PrintOptions(
paperSource: paperSource,
orientation: options.flatMap {
CupsParsers.extractOrientation(fromOptionsString: $0)
},
paperSize: options.flatMap {
CupsParsers.extractOption(
named: "PageSize", fromOptionsString: $0)
},
mediaType: options.flatMap {
CupsParsers.extractMediaType(fromOptionsString: $0)
},
quality: options.flatMap {
CupsParsers.extractQuality(fromOptionsString: $0)
},
ppdUncorrectedPassthrough: true,
cupsOptions: options))
default:
+123 -91
View File
@@ -3,107 +3,139 @@ import ICCeryCore
/// Stage 0 calibration dashboard (issue #29, docs/07).
struct CalibrationView: View {
@Bindable var model: CalibrationViewModel
@Bindable var wizard: WizardViewModel
@ObservedObject var model: CalibrationViewModel
@ObservedObject var wizard: WizardViewModel
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Calibrate Printer")
.font(.title2.bold())
.padding(.horizontal, 16)
.padding(.top, 16)
Form {
Section("Wedge Settings") {
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
HStack {
Text("Steps per channel")
Spacer()
TextField("", value: $model.steps, format: .number)
.frame(width: 60)
.accessibilityIdentifier("calSteps")
}
HStack {
Text("White patches")
Spacer()
TextField("", value: $model.whitePatches, format: .number)
.frame(width: 60)
}
if model.colourSpace == .cmyk {
HStack {
Text("Ink-limit exploration")
Spacer()
TextField("", text: $model.inkLimit)
.frame(width: 60)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
}
Section("Workflow") {
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.onChange(of: model.applyToProfile) { model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.calibrationLog.isEmpty {
Section("Log") {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(model.calibrationLog, id: \.self) { line in
Text(line)
.font(.system(.caption, design: .monospaced))
}
}
}
.frame(minHeight: 80, maxHeight: 120)
}
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Calibrate Printer")
.font(.title2.bold())
.foregroundStyle(Theme.text)
wedgeSection
workflowSection
if !model.calibrationLog.isEmpty { logSection }
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
}
.formStyle(.grouped)
.background(Theme.background)
Divider().overlay(Theme.border)
HStack {
Spacer()
Button("Return to Profiling") { model.returnToProfiling() }
Button("Return to Profiling", role: .cancel) { model.returnToProfiling() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCalReturn")
}
.padding(16)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("stage-cal")
}
// MARK: - Wedge settings
private var wedgeSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Wedge Settings").font(.headline).foregroundStyle(Theme.text)
Picker("Colour Space", selection: $model.colourSpace) {
Text("RGB").tag(ColourSpace.rgb)
Text("CMYK").tag(ColourSpace.cmyk)
}
.pickerStyle(.segmented)
.frame(maxWidth: 220)
HStack(spacing: 12) {
Text("Steps per channel")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.steps, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calSteps")
}
HStack(spacing: 12) {
Text("White patches")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", value: $model.whitePatches, format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
}
if model.colourSpace == .cmyk {
HStack(spacing: 12) {
Text("Ink-limit exploration")
.foregroundStyle(Theme.text)
.frame(width: 140, alignment: .leading)
TextField("", text: $model.inkLimit)
.textFieldStyle(.roundedBorder)
.frame(width: 70)
.accessibilityIdentifier("calInkExplore")
}
}
Toggle("Neutral emphasis", isOn: $model.includeNeutralEmphasis)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("calNeutralEmphasis")
}
}
// MARK: - Workflow
private var workflowSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Workflow").font(.headline).foregroundStyle(Theme.text)
HStack(spacing: 12) {
Button("Generate Target") { model.generateTarget() }
.accessibilityIdentifier("btnCalGenerate")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Create Layout & Print") { model.createLayout() }
.accessibilityIdentifier("btnCalLayout")
.disabled(wizard.basename.isEmpty
|| wizard.effectiveWorkingDirectory == nil
|| model.isGenerating)
Button("Measure") { model.measureChart() }
.accessibilityIdentifier("btnCalMeasure")
.disabled(model.calibrationTi3URL == nil)
Button("Compute Curves") { model.computeCurves() }
.accessibilityIdentifier("btnCalCompute")
.disabled(!model.canCompute)
}
if let url = model.computedCalURL {
Toggle("Apply calibration to next profile", isOn: $model.applyToProfile)
.toggleStyle(.checkbox)
.foregroundStyle(Theme.text)
.onChange(of: model.applyToProfile) { _ in model.updateApplyToProfile() }
.accessibilityIdentifier("calApplyToggle")
Text("Loaded: \(url.lastPathComponent)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Log
private var logSection: some View {
ProcessLogView(
lines: model.calibrationLog,
minHeight: 80,
maxHeight: 120,
containerId: "calLogContainer",
logId: "calLog"
)
}
}
+12 -13
View File
@@ -1,13 +1,12 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 0 calibration workflow: generate wedge, print, measure, and
/// compute `.cal` curves.
@MainActor
@Observable
final class CalibrationViewModel {
final class CalibrationViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let profile: ProfileWorkflowViewModel
@@ -15,16 +14,16 @@ final class CalibrationViewModel {
// MARK: - Form state
var colourSpace: ColourSpace = .cmyk
var steps: Int = 21
var whitePatches: Int = 4
var includeNeutralEmphasis: Bool = false
var inkLimit: String = "320"
var applyToProfile: Bool = false
var computedCalURL: URL?
var calibrationLog: [String] = []
var isGenerating = false
var isComputing = false
@Published var colourSpace: ColourSpace = .cmyk
@Published var steps: Int = 21
@Published var whitePatches: Int = 4
@Published var includeNeutralEmphasis: Bool = false
@Published var inkLimit: String = "320"
@Published var applyToProfile: Bool = false
@Published var computedCalURL: URL?
@Published var calibrationLog: [String] = []
@Published var isGenerating = false
@Published var isComputing = false
init(workflow: TargetWorkflowViewModel, profile: ProfileWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
+35 -2
View File
@@ -70,6 +70,19 @@ final class FileDialogService {
message: "Choose a calibration file (.cal)")
}
/// `selectGamutFile` — `.gam` surface mesh for the compare slot (#147).
func selectGamutFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["gam"], startingAt: start,
message: "Choose a .gam surface mesh",
allowsOtherFileTypes: false)
}
/// `selectTiffFile` — `.tif`/`.tiff` target page for gamut sampling (#147).
func selectTiffFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["tif", "tiff"], startingAt: start,
message: "Choose a target TIFF page")
}
/// `btnImportPreset` — open a `.json` preset file.
func selectPresetFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["json"], startingAt: start,
@@ -82,6 +95,23 @@ final class FileDialogService {
message: "Export this preset as JSON")
}
/// `selectProjectFile` — open `.icceryproj` only (issue #149).
func selectProjectFile(startingAt start: URL? = nil) -> URL? {
open(extensions: ["icceryproj"], startingAt: start,
message: "Open ICCery Project",
title: "Open ICCery Project",
allowsOtherFileTypes: false)
}
/// `selectProjectSavePath` — save `.icceryproj`, suggested name
/// `{basename}.icceryproj` in the working folder (issue #149).
func selectProjectSavePath(
basename: String, startingAt start: URL? = nil
) -> URL? {
save(named: "\(basename).icceryproj", extensions: ["icceryproj"],
startingAt: start, message: "Save ICCery Project")
}
// MARK: - Internals (private — not a shared public picker API)
private func save(
@@ -102,16 +132,19 @@ final class FileDialogService {
private func open(
extensions: [String],
startingAt start: URL?,
message: String?
message: String?,
title: String? = nil,
allowsOtherFileTypes: Bool = true
) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowsMultipleSelection = false
panel.allowedContentTypes = utTypes(extensions)
panel.allowsOtherFileTypes = true
panel.allowsOtherFileTypes = allowsOtherFileTypes
panel.directoryURL = start
if let message { panel.message = message }
if let title { panel.title = title }
return run(panel)
}
+527 -78
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SceneKit
import Metal
import ICCeryCore
import simd
@@ -62,77 +63,322 @@ internal struct GamutSceneGeometryBuilder {
}
}
/// Native SceneKit 3D gamut viewer.
/// Native SceneKit 3D gamut viewer (issues #28, #147).
///
/// Displays a profile gamut mesh and the bundled `sRGB.gam` reference. Uses
/// the CIELAB coordinate convention `x = a*`, `y = L*`, `z = b*` so that the
/// a* (green-red) axis is horizontal, L* (lightness) is vertical, and b*
/// (blue-yellow) is depth.
/// Displays the bundled `sRGB.gam` reference plus up to two profile
/// meshes with independent visibility toggles, a status line, and an
/// inspect panel (click a mesh, type a Lab value, or sample a TIFF
/// pixel). Uses the CIELAB coordinate convention `x = a*`, `y = L*`,
/// `z = b*`.
struct GamutView: View {
@State private var viewModel: GamutViewModel
@StateObject private var viewModel: GamutViewModel
@State private var pause: () -> Void = {}
@FocusState private var isFocused: Bool
@Environment(\.dismiss) private var dismiss
@Binding var showingAllHelp: Bool
init(profileGamURL: URL? = nil) {
_viewModel = State(wrappedValue: GamutViewModel(profileGamURL: profileGamURL))
init(
environment: AppEnvironment,
profileGamURL: URL? = nil,
showingAllHelp: Binding<Bool>
) {
_viewModel = StateObject(wrappedValue: GamutViewModel(
environment: environment, profileGamURL: profileGamURL))
_showingAllHelp = showingAllHelp
}
var body: some View {
ZStack {
GamutSceneView(
profileMesh: viewModel.profileMesh,
referenceMesh: viewModel.sRGBMesh,
onReset: $viewModel.resetCamera,
onPause: $pause
)
.focusable()
.focused($isFocused)
.focusEffectDisabled()
.onKeyPress(.init("R"), action: {
viewModel.resetCamera()
return .handled
})
.onAppear { isFocused = true }
VStack {
HStack {
Spacer()
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
Spacer()
HStack {
Text(viewModel.status)
.font(.caption)
.padding(8)
.background(.thinMaterial)
.cornerRadius(6)
.accessibilityIdentifier("gamutStatusText")
Spacer()
}
.padding(8)
}
VStack(spacing: 0) {
toolbar
Divider().overlay(Theme.border)
sceneArea
Divider().overlay(Theme.border)
statusLine
inspectPanel
Divider().overlay(Theme.border)
footer
}
.frame(minWidth: 500, minHeight: 400)
.frame(minWidth: 720, minHeight: 520)
.background(Theme.background)
.onDisappear { pause() }
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutView")
.sheet(isPresented: $viewModel.showingTiffPreview) {
tiffPreviewSheet
}
}
// MARK: - Toolbar
private var toolbar: some View {
HStack(spacing: 12) {
layerToggle(id: GamutViewModel.srgbLayerID, fallback: "sRGB")
layerToggle(id: GamutViewModel.profileLayerID, fallback: "Profile")
layerToggle(id: GamutViewModel.compareLayerID, fallback: "Compare")
Spacer()
addCompareMenu
Button("Remove compare") { viewModel.removeCompare() }
.disabled(viewModel.layer(id: GamutViewModel.compareLayerID) == nil)
.accessibilityIdentifier("btnGamutRemoveCompare")
Button("Sample TIFF…") { viewModel.openTiffSample() }
.accessibilityIdentifier("btnGamutSampleTiff")
.helpOverlay(
"Sample a colour from a target TIFF page.",
showing: $showingAllHelp)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private func layerToggle(id: String, fallback: String) -> some View {
let layer = viewModel.layer(id: id)
return Toggle(isOn: Binding(
get: { layer != nil && viewModel.visibleIDs.contains(id) },
set: { on in
if on {
viewModel.visibleIDs.insert(id)
} else {
viewModel.visibleIDs.remove(id)
}
}
)) {
Text(layer?.displayName ?? fallback)
}
.toggleStyle(.checkbox)
.disabled(layer == nil)
.help(layer.map { $0.sourceURL.lastPathComponent } ?? "No profile .gam loaded")
// macOS 12 puts the identifier on the Toggle's container, an
// element that never reports isEnabled — combine so the a11y
// leaf is the checkbox itself.
.accessibilityElement(children: .combine)
.accessibilityIdentifier("gamutLayer-\(id)")
}
private var addCompareMenu: some View {
Menu("Add compare…") {
Button("Open .gam…") { viewModel.openCompareGam() }
.accessibilityIdentifier("btnGamutOpenGam")
Button("Open profile…") { viewModel.openCompareProfile() }
.accessibilityIdentifier("btnGamutOpenProfile")
}
.accessibilityIdentifier("btnGamutAddCompare")
.helpOverlay(
"Add a second profile or .gam mesh to compare against.",
showing: $showingAllHelp)
}
// MARK: - Scene
private var sceneArea: some View {
ZStack(alignment: .topTrailing) {
if viewModel.viewerUnavailable {
Text("3D gamut viewer is unavailable on this Mac; the rest of ICCery still works.")
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityIdentifier("gamutViewerUnavailable")
} else {
GamutSceneView(
layers: viewModel.layers,
visibleIDs: viewModel.visibleIDs,
onReset: $viewModel.resetCamera,
onPause: $pause,
onUnavailable: { viewModel.viewerUnavailable = true },
onInspect: { point, layerID in
if let layerID {
viewModel.inspectSceneHit(world: point, layerID: layerID)
} else {
viewModel.clearInspect()
}
}
)
.focusable()
.focused($isFocused)
.onAppear { isFocused = true }
}
Button(action: { viewModel.resetCamera() }) {
Text("Reset view")
}
.accessibilityIdentifier("btnResetGamutCamera")
.padding(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Status
private var statusLine: some View {
HStack(spacing: 10) {
Text(viewModel.status)
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutStatusText")
if let notice = viewModel.noticeText {
Text(notice)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutNoticeText")
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
// MARK: - Inspect panel
private var inspectPanel: some View {
HStack(spacing: 12) {
if let lab = viewModel.inspectLab {
inspectSwatch
labReadout(lab)
containmentColumn
if viewModel.inspectIsApproximate {
Text("approx. Lab, not ColorSync")
.font(.caption2)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectApprox")
}
} else {
Text("Click the mesh, or enter Lab, to inspect.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("gamutInspectIdle")
}
Spacer()
labEntryFields
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(minHeight: 56)
.background(Theme.panel)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutInspectPanel")
.helpOverlay(
"Inspect a Lab point against each loaded gamut.",
showing: $showingAllHelp)
}
@ViewBuilder
private var inspectSwatch: some View {
if let swatch = viewModel.inspectSwatch {
Color(red: swatch.r, green: swatch.g, blue: swatch.b)
.frame(width: 16, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 2))
.overlay(RoundedRectangle(cornerRadius: 2).stroke(Theme.border))
.accessibilityIdentifier("gamutInspectSwatch")
}
}
private func labReadout(_ lab: LabColor) -> some View {
HStack(spacing: 10) {
Text(String(format: "L %.1f", lab.l))
.accessibilityIdentifier("gamutInspectL")
Text(String(format: "a %.1f", lab.a))
.accessibilityIdentifier("gamutInspectA")
Text(String(format: "b %.1f", lab.b))
.accessibilityIdentifier("gamutInspectB")
}
.font(.caption.monospacedDigit())
.foregroundStyle(Theme.text)
}
private var containmentColumn: some View {
HStack(spacing: 10) {
ForEach(viewModel.inspectResults, id: \.id) { result in
Text("\(result.name) \(containmentWord(result.containment))")
.font(.caption)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("gamutInspect-\(result.id)")
}
}
}
private func containmentWord(_ containment: GamutContainment) -> String {
switch containment {
case .inside: return "in"
case .outside: return "out"
case .unknown: return "?"
}
}
private var labEntryFields: some View {
HStack(spacing: 6) {
Text("Lab 0–100 · ±128")
.font(.caption2)
.foregroundStyle(.secondary)
TextField("L", text: $viewModel.labEntryL)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryL")
TextField("a", text: $viewModel.labEntryA)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryA")
TextField("b", text: $viewModel.labEntryB)
.textFieldStyle(.roundedBorder)
.frame(width: 56)
.accessibilityIdentifier("gamutLabEntryB")
Button("Inspect") { viewModel.inspectEnteredLab() }
.disabled(!viewModel.canInspectLab)
.accessibilityIdentifier("btnGamutInspectLab")
}
}
// MARK: - Footer
/// Always-visible Close (#147) — the fallback banner keeps it
/// reachable and Escape works via `.cancelAction` without SceneKit.
private var footer: some View {
HStack {
Spacer()
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCloseGamut")
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
// MARK: - TIFF sample sheet
private var tiffPreviewSheet: some View {
VStack(spacing: 12) {
Text("Click a pixel to sample its colour.")
.font(.headline)
.foregroundStyle(Theme.text)
if let png = viewModel.tiffPreviewPNG {
TiffSampleImageView(pngData: png) { r, g, b in
viewModel.sampleTiffPixel(r: r, g: g, b: b)
}
.frame(minWidth: 320, minHeight: 240)
}
HStack {
Spacer()
Button("Cancel") { viewModel.showingTiffPreview = false }
.accessibilityIdentifier("btnCloseGamutTiffPreview")
}
}
.padding(16)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("gamutTiffPreview")
}
}
/// `NSViewRepresentable` wrapper around an `SCNView` that builds the scene from
/// one or two ``GamutMesh`` values.
/// `NSViewRepresentable` wrapper around an `SCNView` rendering one node
/// per ``NamedGamut`` layer.
///
/// Scene construction and camera reset are coordinated through a typed callback
/// binding owned by the view model.
/// Layer toggles hide/show `SCNNode`s — the scene is built once and the
/// camera is only reset through the explicit reset path, never on a
/// mesh or visibility update.
private struct GamutSceneView: NSViewRepresentable {
var profileMesh: GamutMesh?
var referenceMesh: GamutMesh?
var layers: [NamedGamut]
var visibleIDs: Set<String>
var onReset: Binding<() -> Void>
var onPause: Binding<() -> Void>
var onUnavailable: () -> Void
var onInspect: (SIMD3<Float>, String?) -> Void
func makeNSView(context: Context) -> SCNView {
let scnView = SCNView()
@@ -147,13 +393,25 @@ private struct GamutSceneView: NSViewRepresentable {
context.coordinator.scnView = scnView
context.coordinator.scene = scene
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.buildSceneOnce()
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
context.coordinator.installKeyMonitor()
context.coordinator.installClickGesture()
// Safety net only — the primary no-Metal check is
// `GamutSceneAvailability.isAvailable`, evaluated before this
// view is mounted. Never respawn the view in a loop.
if MTLCreateSystemDefaultDevice() == nil {
DispatchQueue.main.async { onUnavailable() }
}
return scnView
}
func updateNSView(_ nsView: SCNView, context: Context) {
context.coordinator.buildScene(profile: profileMesh, reference: referenceMesh)
context.coordinator.onInspect = onInspect
context.coordinator.syncLayers(layers, visibleIDs: visibleIDs)
}
func makeCoordinator() -> Coordinator {
@@ -168,6 +426,10 @@ private struct GamutSceneView: NSViewRepresentable {
}
static func dismantleNSView(_ nsView: SCNView, coordinator: Coordinator) {
coordinator.removeKeyMonitor()
if let click = coordinator.clickGesture {
nsView.removeGestureRecognizer(click)
}
nsView.isPlaying = false
}
@@ -175,10 +437,14 @@ private struct GamutSceneView: NSViewRepresentable {
final class Coordinator: NSObject {
weak var scnView: SCNView?
weak var scene: SCNScene?
var onInspect: (SIMD3<Float>, String?) -> Void = { _, _ in }
private(set) var clickGesture: NSClickGestureRecognizer?
private var keyMonitor: Any?
private let profileNode = SCNNode()
private let referenceGroup = SCNNode()
/// One `SCNNode` per loaded layer, keyed by `NamedGamut.id`.
private var layerNodes: [String: SCNNode] = [:]
private let axisNode = SCNNode()
private let layerGroup = SCNNode()
private let cameraNode: SCNNode = {
let node = SCNNode()
node.camera = SCNCamera()
@@ -186,33 +452,97 @@ private struct GamutSceneView: NSViewRepresentable {
return node
}()
func buildScene(profile: GamutMesh?, reference: GamutMesh?) {
guard let scene else { return }
/// Builds the static scene furniture exactly once — axis
/// scaffold, lights, camera home. Layer content lives under
/// `layerGroup` and is managed by `syncLayers`.
func buildSceneOnce() {
guard let scene, scene.rootNode.childNodes.isEmpty else { return }
// Rebuild from scratch on every mesh change to avoid stale geometry.
scene.rootNode.childNodes.forEach { $0.removeFromParentNode() }
scene.rootNode.addChildNode(axisNode)
scene.rootNode.addChildNode(profileNode)
scene.rootNode.addChildNode(referenceGroup)
scene.rootNode.addChildNode(layerGroup)
scene.rootNode.addChildNode(cameraNode)
buildAxisScaffold()
if let profile {
profileNode.addChildNode(profileMeshNode(profile, name: "profile"))
} else {
profileNode.childNodes.forEach { $0.removeFromParentNode() }
}
if let reference {
referenceGroup.childNodes.forEach { $0.removeFromParentNode() }
referenceGroup.addChildNode(referenceMeshNode(reference))
}
addLights(to: scene)
resetCamera()
}
/// Reconciles the node set with `layers` and `visibleIDs`.
///
/// New layers get a node; removed layers lose theirs; hidden
/// layers keep their mesh (`isHidden` only). Never rebuilds the
/// scene, so the camera is untouched by a checkbox toggle.
func syncLayers(_ layers: [NamedGamut], visibleIDs: Set<String>) {
guard scene != nil else { return }
let wanted = Set(layers.map { $0.id })
for (id, node) in layerNodes where !wanted.contains(id) {
node.removeFromParentNode()
layerNodes.removeValue(forKey: id)
}
for layer in layers {
if layerNodes[layer.id] == nil {
let node = makeLayerNode(for: layer)
layerNodes[layer.id] = node
layerGroup.addChildNode(node)
}
layerNodes[layer.id]?.isHidden = !visibleIDs.contains(layer.id)
}
}
private func makeLayerNode(for layer: NamedGamut) -> SCNNode {
let node: SCNNode
switch layer.role {
case .reference:
node = referenceMeshNode(layer.mesh)
case .profileA:
node = profileMeshNode(layer.mesh)
case .profileB:
node = compareMeshNode(layer.mesh)
}
node.name = layer.id
return node
}
// MARK: - Click inspect (#147)
/// Click (not drag) hit-tests the scene. `NSClickGestureRecognizer`
/// only fires on a press+release in place, so orbit drags are
/// untouched.
func installClickGesture() {
guard let scnView, clickGesture == nil else { return }
let gesture = NSClickGestureRecognizer(target: self, action: #selector(handleClick(_:)))
scnView.addGestureRecognizer(gesture)
clickGesture = gesture
}
@objc private func handleClick(_ gesture: NSClickGestureRecognizer) {
guard let scnView else { return }
let point = gesture.location(in: scnView)
for hit in scnView.hitTest(point, options: nil) {
if let layerID = layerID(for: hit.node) {
let world = hit.worldCoordinates
onInspect(
SIMD3<Float>(Float(world.x), Float(world.y), Float(world.z)),
layerID)
return
}
}
// Axis scaffold / empty background → back to idle.
onInspect(.zero, nil)
}
/// Walks the hit node's ancestor chain looking for a layer node.
private func layerID(for node: SCNNode) -> String? {
var current: SCNNode? = node
while let node = current {
if let name = node.name, layerNodes[name] != nil { return name }
current = node.parent
}
return nil
}
// MARK: - Scene furniture (unchanged from #28)
private func addLights(to scene: SCNScene) {
let ambient = SCNNode()
ambient.light = SCNLight()
@@ -353,7 +683,8 @@ private struct GamutSceneView: NSViewRepresentable {
return SCNNode(geometry: geometry)
}
private func profileMeshNode(_ mesh: GamutMesh, name: String) -> SCNNode {
/// Profile A: solid vertex-coloured surface.
private func profileMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
@@ -363,11 +694,26 @@ private struct GamutSceneView: NSViewRepresentable {
material.isDoubleSided = true
geometry.materials = [material]
let node = SCNNode(geometry: geometry)
node.name = name
return node
return SCNNode(geometry: geometry)
}
/// Compare profile B: same vertex colours at ~30 % opacity so
/// overlaps with A and the sRGB reference stay readable.
private func compareMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
let material = SCNMaterial()
material.lightingModel = .lambert
material.diffuse.contents = NSColor.white
material.transparency = 0.30
material.isDoubleSided = true
material.writesToDepthBuffer = false
geometry.materials = [material]
return SCNNode(geometry: geometry)
}
/// Bundled sRGB reference: faint fill + structural edge lines.
private func referenceMeshNode(_ mesh: GamutMesh) -> SCNNode {
let (geometry, _) = scnGeometry(for: mesh)
@@ -431,6 +777,31 @@ private struct GamutSceneView: NSViewRepresentable {
scnView?.isPlaying = false
}
/// Local key-down monitor for the R camera-reset shortcut (the
/// SwiftUI key-press modifier is unavailable on macOS 12). Only
/// events aimed at this view's window are handled; everything
/// else passes through untouched.
func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) {
[weak self] event in
guard let self,
let scnView = self.scnView,
event.window === scnView.window,
event.charactersIgnoringModifiers?.uppercased() == "R"
else { return event }
self.resetCamera()
return nil
}
}
func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
func resetCamera() {
guard let scnView else { return }
@@ -465,3 +836,81 @@ private struct GamutSceneView: NSViewRepresentable {
}
}
}
/// Click-to-sample image view for the TIFF preview sheet (#147).
///
/// The TIFF is already decoded to PNG on the host side (#58); the view
/// reports 8-bit sRGB pixel values at the clicked point — the Lab
/// conversion is the documented approximate matrix helper, not a CMM.
private struct TiffSampleImageView: NSViewRepresentable {
let pngData: Data
var onSample: (Int, Int, Int) -> Void
func makeNSView(context: Context) -> TiffSampleNSView {
let view = TiffSampleNSView()
view.image = NSImage(data: pngData)
view.onSample = onSample
return view
}
func updateNSView(_ nsView: TiffSampleNSView, context: Context) {
nsView.onSample = onSample
}
}
private final class TiffSampleNSView: NSView {
var image: NSImage? {
didSet {
bitmapRep = image?.cgImage(forProposedRect: nil, context: nil, hints: nil)
.flatMap { NSBitmapImageRep(cgImage: $0) }
invalidateIntrinsicContentSize()
needsDisplay = true
}
}
var onSample: ((Int, Int, Int) -> Void)?
private var bitmapRep: NSBitmapImageRep?
override var intrinsicContentSize: NSSize {
image?.size ?? NSSize(width: 320, height: 240)
}
override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) {
NSColor(red: 0.055, green: 0.055, blue: 0.078, alpha: 1).setFill()
dirtyRect.fill()
guard let image else { return }
image.draw(in: imageRect())
}
override func mouseUp(with event: NSEvent) {
guard let rep = bitmapRep else { return }
let rect = imageRect()
let location = convert(event.locationInWindow, from: nil)
guard rect.contains(location), rect.width > 0, rect.height > 0 else { return }
let x = Int((location.x - rect.minX) / rect.width * CGFloat(rep.pixelsWide))
// This view is not flipped: y grows up, bitmap rows grow down.
let y = rep.pixelsHigh - 1
- Int((location.y - rect.minY) / rect.height * CGFloat(rep.pixelsHigh))
guard x >= 0, x < rep.pixelsWide, y >= 0, y < rep.pixelsHigh else { return }
guard let color = rep.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) else { return }
onSample?(
Int((color.redComponent * 255).rounded()),
Int((color.greenComponent * 255).rounded()),
Int((color.blueComponent * 255).rounded()))
}
/// Aspect-fit rect of the image inside `bounds`.
private func imageRect() -> NSRect {
guard let image, image.size.width > 0, image.size.height > 0 else { return .zero }
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
let size = NSSize(width: image.size.width * scale, height: image.size.height * scale)
return NSRect(
x: (bounds.width - size.width) / 2,
y: (bounds.height - size.height) / 2,
width: size.width,
height: size.height)
}
}
+291 -27
View File
@@ -1,54 +1,318 @@
import Combine
import Foundation
import ICCeryCore
import Observation
import Metal
import simd
/// View model for the native SceneKit gamut viewer.
/// Whether the SceneKit gamut scene can render on this host (#147).
///
/// Loads the bundled `sRGB.gam` reference immediately and, optionally, a
/// printer/profile `.gam` from the current working directory.
/// Checked **before** `GamutSceneView` is mounted — constructing an
/// `SCNView` on a Metal-less machine can wedge the main thread, which
/// also stalls app quit behind the open sheet.
enum GamutSceneAvailability {
static var isAvailable: Bool {
if UITestHooks.skipSceneKit { return false }
return MTLCreateSystemDefaultDevice() != nil
}
}
/// View model for the native SceneKit gamut viewer (issues #28, #147).
///
/// Loads the bundled `sRGB.gam` reference immediately, the workflow's own
/// profile `.gam` when one exists, and an optional compare mesh the user
/// adds from the sheet toolbar. `iccgamut` failure is an in-sheet info
/// notice, never fatal (#24).
@MainActor
@Observable
final class GamutViewModel {
final class GamutViewModel: ObservableObject {
/// Parsed reference sRGB gamut mesh.
var sRGBMesh: GamutMesh?
/// Stable layer ids — also the `gamutLayer-<id>` a11y suffixes.
static let srgbLayerID = "sRGB"
static let profileLayerID = "profile"
static let compareLayerID = "compare"
/// Parsed printer/profile gamut mesh.
var profileMesh: GamutMesh?
/// Loaded meshes: bundled sRGB plus up to two profiles.
@Published var layers: [NamedGamut] = []
/// User-facing status line.
var status = "Loading gamut…"
/// Layer ids currently shown in the scene. Toggling never unloads
/// the mesh — the `SCNNode` is hidden only.
@Published var visibleIDs: Set<String> = [srgbLayerID]
/// User-facing status line (`gamutStatusText`). Always non-empty
/// once set — `Milestone6GamutUITests` asserts it.
@Published var status = "Loading gamut…"
/// In-sheet info line (`gamutNoticeText`). The main `NoticeBanner`
/// sits behind the sheet, so notices surface here instead.
@Published var noticeText: String?
/// Set when `SCNView` cannot create a render context; the scene is
/// replaced by the docs/18 fallback text (`gamutViewerUnavailable`).
@Published var viewerUnavailable = false
/// Closure injected into the SceneKit view to request a camera reset.
var resetCamera: () -> Void = {}
@Published var resetCamera: () -> Void = {}
// MARK: - Inspect panel
/// Lab point currently inspected, or `nil` for the idle state.
@Published var inspectLab: LabColor?
/// Swatch colour: the hit vertex's `rgb`, or the approximate sRGB of
/// the inspected Lab.
@Published var inspectSwatch: DisplayRGB?
/// `true` when the swatch/Lab came from the approximate helper or a
/// typed value — drives the "approx. Lab, not ColorSync" caption.
@Published var inspectIsApproximate = false
/// Per-layer containment for `inspectLab`, in layer order.
@Published var inspectResults: [(id: String, name: String, containment: GamutContainment)] = []
/// Manual Lab entry fields (`gamutLabEntry*`).
@Published var labEntryL = ""
@Published var labEntryA = ""
@Published var labEntryB = ""
// MARK: - TIFF sampling
/// PNG bytes for the preview sheet (`gamutTiffPreview`).
@Published var tiffPreviewPNG: Data?
@Published var showingTiffPreview = false
private let environment: AppEnvironment
private let profileGamURL: URL?
private let fileDialogs = FileDialogService.shared
init(profileGamURL: URL? = nil) {
init(environment: AppEnvironment, profileGamURL: URL? = nil) {
self.environment = environment
self.profileGamURL = profileGamURL
Task { await load() }
// Never let the view mount an SCNView without Metal (#147).
viewerUnavailable = !GamutSceneAvailability.isAvailable
loadTask = Task { await load() }
}
private var loadTask: Task<Void, Never>?
/// Awaits the initial sRGB/profile load — used by tests.
func awaitInitialLoad() async {
await loadTask?.value
}
func layer(id: String) -> NamedGamut? {
layers.first { $0.id == id }
}
// MARK: - Initial load
private func load() async {
do {
let referenceURL = BinaryResolver().referenceGamut("sRGB")
let referenceURL = environment.runner.binaryResolver.referenceGamut("sRGB")
let reference = try await parse(url: referenceURL)
sRGBMesh = reference
if let profileGamURL {
let profile = try await parse(url: profileGamURL)
profileMesh = profile
status = "Profile gamut (\(profile.faces.count) faces) vs sRGB reference"
} else {
status = "sRGB reference gamut (\(reference.faces.count) faces)"
}
layers.append(NamedGamut(
id: Self.srgbLayerID,
displayName: "sRGB",
role: .reference,
mesh: reference,
sourceURL: referenceURL))
visibleIDs.insert(Self.srgbLayerID)
} catch {
status = "Could not load gamut: \(error.localizedDescription)"
return
}
if let profileGamURL {
do {
let profile = try await parse(url: profileGamURL)
layers.append(NamedGamut(
id: Self.profileLayerID,
displayName: profileGamURL.deletingPathExtension().lastPathComponent,
role: .profileA,
mesh: profile,
sourceURL: profileGamURL))
visibleIDs.insert(Self.profileLayerID)
} catch {
// #24 — a missing/unparseable profile mesh is info, not fatal.
noticeText = "Profile gamut could not be loaded: \(error.localizedDescription)"
}
}
refreshStatus()
}
// MARK: - Compare slot (profile B)
/// `btnGamutOpenGam` — pick an existing `.gam` for the compare slot.
func openCompareGam() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutFileURL
: fileDialogs.selectGamutFile()
guard let url else { return }
Task { await loadCompareGam(url: url) }
}
/// `btnGamutOpenProfile` — pick `.icc/.icm`; uses a sibling `.gam`
/// when present, otherwise runs bundled `iccgamut -v -d 10` (#24).
func openCompareProfile() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutProfileURL
: fileDialogs.selectProfileFile()
guard let url else { return }
Task { await loadCompareProfile(url: url) }
}
/// `btnGamutRemoveCompare` — drops layer B, leaves sRGB + A.
func removeCompare() {
layers.removeAll { $0.id == Self.compareLayerID }
visibleIDs.remove(Self.compareLayerID)
refreshStatus()
}
/// Parses `url` into the compare slot. Internal for tests — the UI
/// reaches it through `openCompareGam` / `openCompareProfile`.
func loadCompareGam(url: URL) async {
do {
let mesh = try await parse(url: url)
installCompare(NamedGamut(
id: Self.compareLayerID,
displayName: url.deletingPathExtension().lastPathComponent,
role: .profileB,
mesh: mesh,
sourceURL: url))
} catch {
noticeText = "Could not load compare gamut: \(error.localizedDescription)"
}
}
/// Parses a `.gam` file off the main actor so large meshes do not stall
/// the UI.
/// `.icc/.icm` → sibling `.gam` or `iccgamut` → compare slot.
/// Internal for tests.
func loadCompareProfile(url: URL) async {
let gamURL = url.deletingPathExtension().appendingPathExtension("gam")
do {
if !FileManager.default.fileExists(atPath: gamURL.path) {
_ = try await environment.runner.runIccgamut(
config: IccgamutConfig(profileURL: url))
}
await loadCompareGam(url: gamURL)
} catch {
noticeText = "Gamut extraction failed: \(error.localizedDescription)"
}
}
/// A third profile replaces B — the compare slot never stacks and
/// sRGB is never touched.
private func installCompare(_ gamut: NamedGamut) {
if layers.contains(where: { $0.id == gamut.id }) {
noticeText = "Compare slot holds one profile. The previous compare mesh was replaced."
}
layers.removeAll { $0.id == gamut.id }
layers.append(gamut)
visibleIDs.insert(gamut.id)
refreshStatus()
}
// MARK: - Status line
/// `sRGB 448v / 892 faces · Profile 1024v / 2048 faces · vol 62% of sRGB`.
/// Unloaded layers are omitted; the volume clause appears only when
/// both volumes are finite and positive.
private func refreshStatus() {
var clauses = layers.map {
"\($0.displayName) \($0.mesh.vertices.count)v / \($0.mesh.faces.count) faces"
}
if let srgb = layer(id: Self.srgbLayerID),
let profile = layer(id: Self.profileLayerID) {
let srgbVolume = GamutGeometry.volume(of: srgb.mesh)
let profileVolume = GamutGeometry.volume(of: profile.mesh)
if srgbVolume.isFinite, srgbVolume > 0,
profileVolume.isFinite, profileVolume > 0 {
clauses.append("vol \(Int((profileVolume / srgbVolume * 100).rounded()))% of sRGB")
}
}
status = clauses.isEmpty ? "No gamut loaded" : clauses.joined(separator: " · ")
}
// MARK: - Inspect
/// Whether every manual Lab field parses as a number; the Inspect
/// button is disabled while this is false.
var canInspectLab: Bool {
[labEntryL, labEntryA, labEntryB].allSatisfy { Double($0) != nil }
}
/// Runs containment for a Lab point and publishes the inspect row.
func inspect(lab: LabColor, swatch: DisplayRGB?, isApproximate: Bool) {
inspectLab = lab
inspectSwatch = swatch ?? LabColorMath.labToSRGB(lab)
inspectIsApproximate = isApproximate
inspectResults = layers.map {
($0.id, $0.displayName, GamutGeometry.containment(of: lab, in: $0.mesh))
}
}
/// Click on the axis scaffold or empty background returns the panel
/// to idle.
func clearInspect() {
inspectLab = nil
inspectSwatch = nil
inspectIsApproximate = false
inspectResults = []
}
/// SceneKit hit callback: world `(x, y, z)` → Lab `(x→a*, y→L*, z→b*)`.
/// The swatch is the nearest vertex colour of the hit layer's mesh.
func inspectSceneHit(world: SIMD3<Float>, layerID: String) {
let lab = LabColor(l: Double(world.y), a: Double(world.x), b: Double(world.z))
var swatch: DisplayRGB?
var approximate = true
if let mesh = layer(id: layerID)?.mesh,
let nearest = mesh.vertices.min(by: {
simd_distance($0.position, world) < simd_distance($1.position, world)
}) {
swatch = nearest.rgb
approximate = false
}
inspect(lab: lab, swatch: swatch, isApproximate: approximate)
}
/// `btnGamutInspectLab` — typed L*a*b* path. No clamping; out-of-axis
/// values still run containment and report `?` outside every hull.
func inspectEnteredLab() {
guard let l = Double(labEntryL),
let a = Double(labEntryA),
let b = Double(labEntryB) else { return }
inspect(lab: LabColor(l: l, a: a, b: b), swatch: nil, isApproximate: true)
}
// MARK: - TIFF sampling
/// `btnGamutSampleTiff` — pick a target TIFF, decode a host-side PNG
/// preview (#58), and open the click-to-sample sheet.
func openTiffSample() {
let url = UITestHooks.isEnabled
? UITestHooks.gamutTiffURL
: fileDialogs.selectTiffFile()
guard let url else { return }
guard let png = TiffPreview.previewPNG(tiff: url) else {
noticeText = "Could not decode TIFF preview."
return
}
tiffPreviewPNG = png
showingTiffPreview = true
}
/// Pixel tap inside the preview sheet: sRGB8 → approximate Lab D50.
func sampleTiffPixel(r: Int, g: Int, b: Int) {
inspect(
lab: ApproximateLab.srgb8ToLab(r: r, g: g, b: b),
swatch: DisplayRGB(
r: Double(r) / 255.0,
g: Double(g) / 255.0,
b: Double(b) / 255.0),
isApproximate: true)
showingTiffPreview = false
}
/// Parses a `.gam` file off the main actor so large meshes do not
/// stall the UI.
private func parse(url: URL) async throws -> GamutMesh {
try await Task.detached {
try GamutMeshParser.parse(url: url)
+62 -10
View File
@@ -5,11 +5,11 @@ import SwiftUI
@main
struct ICCeryApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var workflow: TargetWorkflowViewModel
@StateObject private var workflow: TargetWorkflowViewModel
init() {
let environment = AppEnvironment.live()
_workflow = State(initialValue: TargetWorkflowViewModel(environment: environment))
_workflow = StateObject(wrappedValue: TargetWorkflowViewModel(environment: environment))
try? AppPaths.ensureDirectories()
// Log level is runtime state — apply persisted settings at
// startup (#158); the Settings sheet re-applies on save.
@@ -17,15 +17,22 @@ struct ICCeryApp: App {
}
var body: some Scene {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700).
Window("ICCery", id: "main") {
// Single fixed window (docs/21 §Shell: 1280×800, min 1100×700);
// metrics are applied by AppDelegate once the window exists.
WindowGroup("ICCery") {
RootView(workflow: workflow)
.frame(minWidth: 1100, minHeight: 700)
.preferredColorScheme(.dark)
}
.defaultSize(width: 1280, height: 800)
.windowResizability(.contentMinSize)
.defaultPosition(.center)
.commands {
// Single-window app: the File menu carries the project
// commands (issue #149). Replacing `.newItem` keeps
// SwiftUI's empty default New from stacking (R19 — the
// group is filled, so no second New appears).
CommandGroup(replacing: .newItem) {
ProjectCommands(workflow: workflow)
}
}
}
}
@@ -37,16 +44,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var terminationRequested = false
func applicationDidFinishLaunching(_ notification: Notification) {
// SwiftUI `Window` scenes launched by XCTest stay
// `.runningBackground` unless the app takes regular activation
// and orders the window front (CI run 29804).
// SwiftUI scenes launched by XCTest stay `.runningBackground`
// unless the app takes regular activation and orders the window
// front (CI run 29804).
NSApp.setActivationPolicy(.regular)
for window in NSApp.windows {
configureMainWindow(window)
window.makeKeyAndOrderFront(nil)
}
NSApp.activate(ignoringOtherApps: true)
}
/// docs/21 §Shell: 1280×800 content, min 1100×700, centred.
/// GitHub-hosted Macs (and any display smaller than 1280×800) must
/// not get a window that hangs off-screen — XCTest then reports
/// sidebar controls at negative x as not hittable (run 34864198118).
private func configureMainWindow(_ window: NSWindow) {
let desired = NSSize(width: 1280, height: 800)
let minimum = NSSize(width: 1100, height: 700)
let visible = (window.screen ?? NSScreen.main)?.visibleFrame
?? NSRect(origin: .zero, size: desired)
window.contentMinSize = NSSize(
width: min(minimum.width, visible.width),
height: min(minimum.height, visible.height)
)
window.setContentSize(NSSize(
width: min(desired.width, visible.width),
height: min(desired.height, max(minimum.height, visible.height - 40))
))
window.center()
var frame = window.frame
if frame.width > visible.width {
frame.size.width = visible.width
}
if frame.height > visible.height {
frame.size.height = visible.height
}
frame.origin.x = min(
max(frame.origin.x, visible.minX),
visible.maxX - frame.width
)
frame.origin.y = min(
max(frame.origin.y, visible.minY),
visible.maxY - frame.height
)
window.setFrame(frame, display: true)
}
/// Dock-click reopen: let the WindowGroup re-show or recreate the
/// main window when none are visible.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
true
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
+148
View File
@@ -0,0 +1,148 @@
import AppKit
import SwiftUI
/// Detailed license and attribution window (issue #31, docs/21 §Modals).
struct LicenseWindowView: View {
@Environment(\.dismiss) private var dismiss
private let icceryLicense: String
private let argyllLicense: String
init() {
// ICCery license from LICENCE.md (embedded at compile time)
self.icceryLicense = """
# 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.
"""
// ArgyllCMS license from bundled License.txt
if let url = Bundle.main.url(forResource: "License", withExtension: "txt", subdirectory: "Argyll"),
let content = try? String(contentsOf: url, encoding: .utf8) {
self.argyllLicense = content
} else {
self.argyllLicense = """
ArgyllCMS license not found — run `scripts/fetch-argyll.sh` to bundle binaries and license.
The ArgyllCMS binaries are licensed under the GNU Affero General Public License v3 (AGPLv3).
A copy of the license should be present at Resources/Argyll/License.txt.
See: https://git.i3omb.com/gronod/argyllcms/releases
"""
}
}
var body: some View {
VStack(spacing: 0) {
// Title bar
HStack {
Text("Licenses & Attribution")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button("Close") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("closeLicenseBtn")
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Theme.panel.opacity(0.9))
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Section 1: ICCery License
licenseSection(
title: "ICCery License",
content: icceryLicense,
identifier: "icceryLicenseSection"
)
Divider()
// Section 2: ArgyllCMS License
licenseSection(
title: "ArgyllCMS License (AGPLv3)",
content: argyllLicense,
identifier: "argyllLicenseSection"
)
Divider()
// Section 3: Attribution & Links
VStack(alignment: .leading, spacing: 12) {
Text("Attribution & Links")
.font(.headline)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("attributionHeader")
VStack(alignment: .leading, spacing: 8) {
Link("ArgyllCMS by Graeme Gill → https://www.argyllcms.com/",
destination: URL(string: "https://www.argyllcms.com/")!)
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("argyllUpstreamLink")
Link("Gronod ArgyllCMS fork (v3.5.0-ICCery.1.x) → https://git.i3omb.com/gronod/argyllcms",
destination: URL(string: "https://git.i3omb.com/gronod/argyllcms")!)
.font(.callout)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("argyllForkLink")
}
Text("ICCery bundles and invokes ArgyllCMS binaries as isolated subprocesses per AGPLv3 isolation requirements. The ArgyllCMS binaries are never linked, loaded, or incorporated into the ICCery application binary.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("agplIsolationNote")
}
.padding(.horizontal, 4)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("attributionSection")
}
.padding(24)
}
.frame(minWidth: 600, minHeight: 500)
.background(Theme.panel)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("licenseWindow")
}
private func licenseSection(title: String, content: String, identifier: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundStyle(Theme.text)
.accessibilityIdentifier(identifier + "Header")
Text(content)
.font(.system(.body, design: .monospaced))
.foregroundStyle(Theme.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier(identifier + "Content")
}
.padding(.horizontal, 4)
}
}
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
@@ -32,8 +32,7 @@ enum XYStep: Equatable, Sendable {
/// Stage 3 workflow state and interaction (issues #18–#22).
@MainActor
@Observable
final class MeasurementWorkflowViewModel {
final class MeasurementWorkflowViewModel: ObservableObject {
// MARK: - Authorities
@@ -42,37 +41,37 @@ final class MeasurementWorkflowViewModel {
// MARK: - Settings-driven thresholds
private(set) var goodMax: Double = 2.0
private(set) var warningMax: Double = 5.0
private(set) var enableLEDs: Bool = false
@Published private(set) var goodMax: Double = 2.0
@Published private(set) var warningMax: Double = 5.0
@Published private(set) var enableLEDs: Bool = false
// MARK: - Instrument detection
var instruments: [InstrumentDevice] = []
var selectedInstrument: InstrumentSelection = .auto
var isDetecting = false
var detectionError: String?
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published 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
@Published var isChartreadRunning = false
@Published var chartreadState: ChartreadState = .idle
@Published var currentPrompt: String?
@Published var requestedWarningKey: String?
@Published var chartreadLog: [String] = []
@Published var rows: [ChartreadRow] = []
@Published var swatchRows: [SwatchRow] = []
@Published var showRemoveSheetNotice = false
/// Stage-local chartread error notice (`#chartreadLastError`, #80).
var chartreadNotice: Notice?
@Published var chartreadNotice: Notice?
private var chartreadTask: Task<Void, Never>?
// MARK: - Averaging
var passSnapshots: [URL] = []
var isFinishing = false
var finishNotice: Notice?
var resumedFromTi2 = false
@Published var passSnapshots: [URL] = []
@Published var isFinishing = false
@Published var finishNotice: Notice?
@Published var resumedFromTi2 = false
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
+278
View File
@@ -0,0 +1,278 @@
import SwiftUI
import ICCeryCore
/// `#saveMediaRecipeDialog` — capture the current printer + paper +
/// ink + `.cal` bound to the selected preset (issue #146). Clones
/// `SavePresetDialog` chrome; names render via `Text` only (#114).
struct SaveMediaRecipeDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
private var printerCaption: String {
let queue = printSession.selectedPrinter
guard !queue.isEmpty else { return "None" }
let display = printSession.printers
.first { $0.name == queue }?.displayName ?? queue
return "\(display) (\(queue))"
}
private func captureRow(
_ label: String, value: String, identifier: String
) -> some View {
HStack {
Text(label).foregroundStyle(.secondary)
Spacer()
Text(value)
.foregroundStyle(Theme.text)
.lineLimit(1)
.truncationMode(.middle)
.accessibilityIdentifier(identifier)
}
}
private var saveDisabled: Bool {
media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty
|| media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty
|| media.captureColourSpaceMismatch
}
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
// children (#146); Group blocks are layout-transparent, so field
// order and every docs/21 id are unchanged.
private var fields: some View {
Group {
TextField("Name", text: $media.saveMediaName)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaName")
TextField("Notes (optional)", text: $media.saveMediaNotes)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaNotes")
TextField("Paper", text: $media.saveMediaPaper)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaPaper")
TextField("Ink set", text: $media.saveMediaInk)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("saveMediaInk")
}
}
private var readOnlyRows: some View {
Group {
captureRow("Printer", value: printerCaption,
identifier: "saveMediaPrinter")
captureRow("Preset",
value: workflow.selectedPreset?.name ?? "No preset",
identifier: "saveMediaPreset")
captureRow("Colour space",
value: workflow.colourSpace.rawValue.uppercased(),
identifier: "saveMediaColourSpace")
captureRow("Calibration",
value: workflow.profile.calibrationFile.isEmpty
? "None" : workflow.profile.calibrationFile,
identifier: "saveMediaCal")
}
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text)
fields
readOnlyRows
Toggle("Apply calibration to profile",
isOn: $media.saveMediaApplyCal)
.disabled(!media.calApplyable)
.accessibilityIdentifier("saveMediaApplyCal")
if media.captureColourSpaceMismatch {
Text("Colour space does not match the selected preset.")
.font(.caption).foregroundStyle(.orange)
}
if let error = media.saveMediaError {
Text(error).font(.caption).foregroundStyle(.orange)
}
HStack {
Spacer()
Button("Cancel") { workflow.showingSaveMedia = false }
.accessibilityIdentifier("btnCloseSaveMediaDialog")
Button("Save") {
Task {
if await media.captureFromSession() {
workflow.showingSaveMedia = false
}
}
}
.disabled(saveDisabled)
.accessibilityIdentifier("btnConfirmSaveMedia")
}
}
.padding(20)
.frame(width: 380)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("saveMediaRecipeDialog")
}
}
/// `#manageMediaDialog` — list, apply, delete, capture (issue #146).
/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`.
struct ManageMediaDialog: View {
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var media: MediaLibraryViewModel
@State private var selection: String?
@State private var pendingDelete: MediaRecipe?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._media = ObservedObject(wrappedValue: workflow.media)
}
private func presetCaption(for recipe: MediaRecipe) -> String {
workflow.presets.first { $0.id == recipe.presetID }?.name
?? "Missing preset"
}
private func calCaption(for recipe: MediaRecipe) -> String {
if media.staleReasons[recipe.id]?.contains(.calibration) == true {
return "Stale"
}
if let days = media.calAgeDays[recipe.id] {
return "Cal \(days)d"
}
return "No cal"
}
private func applyAndDismiss(_ recipe: MediaRecipe) {
Task {
if await media.apply(recipe) {
workflow.showingManageMedia = false
}
}
}
private func presetMissing(_ recipe: MediaRecipe) -> Bool {
!workflow.presets.contains { $0.id == recipe.presetID }
}
private func calStale(_ recipe: MediaRecipe) -> Bool {
media.staleReasons[recipe.id]?.contains(.calibration) == true
}
@ViewBuilder
private func row(_ recipe: MediaRecipe) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(recipe.name).foregroundStyle(Theme.text)
Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 8) {
Text(presetCaption(for: recipe))
.font(.caption)
.foregroundStyle(presetMissing(recipe) ? .orange : .secondary)
Text(calCaption(for: recipe))
.font(.caption)
.foregroundStyle(calStale(recipe) ? .orange : .secondary)
}
}
Spacer()
Button("Apply") { applyAndDismiss(recipe) }
.accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)")
Button("Delete", role: .destructive) {
pendingDelete = recipe
}
.accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("mediaRow-\(recipe.id)")
.tag(recipe.id)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture(count: 2).onEnded { applyAndDismiss(recipe) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text)
List(selection: $selection) {
if media.recipes.isEmpty {
Text("No media recipes yet. Capture the current printer, paper and preset.")
.font(.callout).foregroundStyle(.secondary)
.accessibilityIdentifier("mediaLibraryEmpty")
}
ForEach(media.recipes) { recipe in
row(recipe)
}
}
.accessibilityIdentifier("mediaLibraryList")
.frame(minHeight: 260)
if let notice = media.manageApplyNotice {
Text(notice)
.font(.callout)
.foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("manageMediaNotice")
.accessibilityValue(notice)
}
HStack {
Button("Apply selected") {
if let id = selection,
let recipe = media.recipes.first(where: { $0.id == id }) {
applyAndDismiss(recipe)
}
}
.disabled(selection == nil)
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("btnMediaLibraryApply")
Button("Capture current…") {
media.captureAfterManageDismiss = true
workflow.showingManageMedia = false
}
.accessibilityIdentifier("btnMediaLibraryCaptureFromManage")
Spacer()
Button("Close") { workflow.showingManageMedia = false }
.accessibilityIdentifier("btnCloseManageMediaDialog")
}
}
.padding(20)
.frame(width: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("manageMediaDialog")
.onAppear {
media.reload()
media.refreshStaleness()
}
.alert(
"Delete media recipe?",
isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
),
presenting: pendingDelete
) { recipe in
Button("Cancel", role: .cancel) { pendingDelete = nil }
Button("Delete", role: .destructive) {
media.delete(recipe)
pendingDelete = nil
}
} message: { recipe in
Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.")
}
}
}
+429
View File
@@ -0,0 +1,429 @@
import Combine
import Foundation
import ICCeryCore
/// Media recipe library: capture / apply / staleness for issue #146.
///
/// A recipe binds a CUPS queue + paper + ink + `.cal` to a
/// `ProfilingPreset`. Applying a recipe goes through the existing
/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper
/// and ink are library metadata only; they are never written to the
/// targen label fields.
@MainActor
final class MediaLibraryViewModel: ObservableObject {
/// Why a recipe row is flagged stale.
struct StaleReason: OptionSet {
let rawValue: Int
static let calibration = StaleReason(rawValue: 1 << 0)
static let printer = StaleReason(rawValue: 1 << 1)
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let store: MediaLibraryStore
private var cancellables = Set<AnyCancellable>()
@Published var recipes: [MediaRecipe] = []
/// Sidebar picker selection; `"none"` = no media recipe. Written
/// only on a successful apply so a failed apply snaps back.
@Published var selectedRecipeID = "none"
/// `recipe.id` → stale reasons for the sidebar badge / manage sheet.
@Published var staleReasons: [String: StaleReason] = [:]
/// `recipe.id` → whole days since the bound `.cal` was created.
@Published var calAgeDays: [String: Int] = [:]
// Save-sheet state (mirrors savePresetName/savePresetDesc).
@Published var saveMediaName = ""
@Published var saveMediaNotes = ""
@Published var saveMediaPaper = ""
@Published var saveMediaInk = ""
@Published var saveMediaApplyCal = false
/// Inline caption inside the capture sheet (no a11y id — roster complete).
@Published var saveMediaError: String?
/// Last failed Apply while Manage is open. The window banner sits
/// behind the sheet on Monterey, so the dialog shows this too (#170).
@Published var manageApplyNotice: String?
/// Pure flow flag — the manage sheet's "Capture current…" asks the
/// sheet's `onDismiss` to open the capture sheet, avoiding a
/// present-while-dismissing race.
var captureAfterManageDismiss = false
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.store = environment.mediaStore
reload()
refreshStaleness()
// The library needs queues enumerated at launch so Capture can
// enable and the not-installed badge is computable; Stage 2 only
// enumerates when a printtarg manifest exists.
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
NotificationCenter.default
.publisher(for: SettingsStore.settingsDidChange)
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$printers
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
workflow.print.$selectedPrinter
.sink { [weak self] _ in self?.refreshStaleness() }
.store(in: &cancellables)
}
deinit { cancellables.removeAll() }
// MARK: - Load / corrupt
func reload() {
Task { await reloadAsync() }
}
func reloadAsync() async {
do {
recipes = try await store.load()
} catch {
// Corrupt-file policy: keep the file, keep the cache,
// persistent warning; the picker falls back to "none".
workflow.wizard.showNotice(
"Media library is unreadable — the existing file was kept.",
kind: .warning,
autoHideAfter: nil
)
if recipes.isEmpty { selectedRecipeID = "none" }
}
}
// MARK: - Selection / apply
/// Sidebar `mediaSelect` binding. `"none"` clears the selection
/// without resetting any Stage 1/2/4 field — it is not "reset to
/// factory".
func selectRecipe(_ id: String) {
if id == "none" {
selectedRecipeID = "none"
return
}
guard let recipe = recipes.first(where: { $0.id == id }) else { return }
Task { _ = await apply(recipe) }
}
/// The single apply path — sidebar picker, manage-row Apply, and
/// the manage footer all funnel here.
///
/// Returns `false` when any bound resource is unresolved (missing
/// preset, colour-space mismatch, queue absent, missing/unparseable
/// `.cal`) so the manage sheet stays open and the picker reverts.
/// A `CAL_`-blocked calibration counts as applied (`true` — success
/// with warning; the refusal is permanent so re-clicking can't help).
@discardableResult
func apply(_ recipe: MediaRecipe) async -> Bool {
manageApplyNotice = nil
guard let r = try? recipe.validated() else {
return failApply("Media recipe is invalid — not applied.", kind: .error)
}
guard let preset = environment.presetStore.all()
.first(where: { $0.id == r.presetID })
else {
return failApply(
"Preset \(r.presetID) no longer exists — recipe not applied.",
kind: .error)
}
guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else {
return failApply(
"Recipe colour space does not match its preset — not applied.",
kind: .error)
}
// Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields.
workflow.applyPreset(preset)
// Literal per issue: displayName, not the queue id.
workflow.wizard.printerName = r.printerDisplayName
// Queue: enumerate fresh via the session's serialized path —
// listPrinters uses fixed process ids, so an overlapping
// enumeration would throw duplicateID. An empty result is a
// valid list.
if let queues = await workflow.print.enumeratePrinters() {
if queues.contains(where: { $0.name == r.printerID }) {
workflow.print.selectedPrinter = r.printerID
await workflow.print.reloadSelectedCapabilities()
} else {
return failApply(
"Printer \(r.printerDisplayName) is not installed.",
kind: .warning)
}
} else {
return failApply(
"Could not enumerate printers — queue left unchanged.",
kind: .warning)
}
// Calibration — the recipe is authoritative and runs after
// applyPreset so the preset's own cal fields don't win.
let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? ""
let calStem = URL(fileURLWithPath: calPath)
.deletingPathExtension().lastPathComponent
let blocked = r.applyCalibration && !calPath.isEmpty
&& (CalibrationIdentity.isCalibration(calStem)
|| CalibrationIdentity.isCalibration(workflow.wizard.basename))
if blocked {
// Literal CAL_ refusal on both names (decision 1): keep the
// path for display but never let `printtarg -K` see it.
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
selectedRecipeID = r.id
refreshStaleness()
workflow.wizard.showNotice(
"Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.",
kind: .warning,
autoHideAfter: nil)
return true
}
if r.applyCalibration && !calPath.isEmpty {
guard FileManager.default.fileExists(atPath: calPath) else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
return failApply(
"Calibration file is missing: \(calPath)", kind: .error)
}
do {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let calStore = CalibrationStore(staleDays: staleDays)
try await calStore.load(url: URL(fileURLWithPath: calPath))
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
// Age check only — the .cal DESCRIPTOR is free text, not
// a queue id, so a name compare false-positives.
if await calStore.isStale() {
workflow.wizard.showNotice(
"Applied \(r.name) — calibration is stale.",
kind: .warning)
}
} catch {
workflow.profile.applyCalibration = false
return failApply(
"Could not load calibration: \(error.localizedDescription)",
kind: .error)
}
} else {
workflow.profile.applyCalibration = false
workflow.profile.calibrationFile = calPath
}
selectedRecipeID = r.id
workflow.wizard.showNotice("Applied \(r.name)")
refreshStaleness()
return true
}
private func failApply(_ text: String, kind: Notice.Kind) -> Bool {
manageApplyNotice = text
workflow.wizard.showNotice(text, kind: kind)
refreshStaleness()
return false
}
// MARK: - Capture
/// Whether the live `profile.calibrationFile` may be applied:
/// non-empty, not a `CAL_` stem, and present on disk.
var calApplyable: Bool {
let path = workflow.profile.calibrationFile
guard !path.isEmpty else { return false }
let stem = URL(fileURLWithPath: path)
.deletingPathExtension().lastPathComponent
guard !CalibrationIdentity.isCalibration(stem) else { return false }
return FileManager.default.fileExists(atPath: path)
}
/// A bound preset whose colour space disagrees with the live form —
/// the sheet shows the mismatch caption and disables Save.
var captureColourSpaceMismatch: Bool {
guard let preset = workflow.selectedPreset else { return false }
return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue
}
/// Opens the capture sheet, prefilled from the selected recipe else
/// the most recently captured one ("last recipe … or empty").
func beginCapture() {
let source = recipes.first(where: { $0.id == selectedRecipeID })
?? recipes.last
saveMediaPaper = source?.paperName ?? ""
saveMediaInk = source?.inkSet ?? ""
saveMediaName = ""
saveMediaNotes = ""
saveMediaError = nil
saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable
if workflow.print.printers.isEmpty {
workflow.print.refreshPrinters()
}
workflow.showingSaveMedia = true
}
/// Save button — the sheet closes only on `true`.
func captureFromSession() async -> Bool {
let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines)
let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines)
let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else {
saveMediaError = "Name, paper and ink are required."
return false
}
let queue = workflow.print.selectedPrinter
guard !queue.isEmpty else {
saveMediaError = "Select a printer in Stage 2 first."
return false
}
// Preset binding: the selected preset when its colour space
// matches the live form; otherwise auto-snapshot the live form
// as a custom preset (decision 2).
let presetID: String
if let bound = workflow.selectedPreset {
guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else {
saveMediaError = "Colour space does not match the selected preset."
return false
}
presetID = bound.id
} else {
let snapshot = ProfilingPreset(
id: "custom-\(UUID().uuidString.lowercased())",
name: name,
description: "Auto-saved for media recipe",
targen: workflow.buildTargenConfig(),
printtarg: workflow.buildPrinttargConfig(),
colprof: workflow.profile.buildColprofConfig(),
calibrationFile: nil,
applyCalibration: nil
)
do {
try environment.presetStore.saveCustom(snapshot)
workflow.reloadPresets()
workflow.selectedPresetID = snapshot.id
} catch {
saveMediaError = "Could not save preset: \(error.localizedDescription)"
return false
}
presetID = snapshot.id
}
// The cal path is stored verbatim; applyCalibration is forced
// off for CAL_/missing paths via calApplyable.
let calPath = workflow.profile.calibrationFile
let printer = workflow.print.printers.first { $0.name == queue }
let now = Date()
let recipe = MediaRecipe(
id: "recipe-\(UUID().uuidString.lowercased())",
name: name,
notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines),
printerID: queue,
printerDisplayName: printer?.displayName ?? queue,
paperName: paper,
driverMediaType: workflow.print.selectedMediaType,
inkSet: ink,
colourSpace: workflow.colourSpace.rawValue,
presetID: presetID,
calibrationURL: calPath.isEmpty ? nil : calPath,
applyCalibration: saveMediaApplyCal && calApplyable,
created: now,
updated: now
)
do {
let validated = try recipe.validated()
try await store.upsert(validated)
await reloadAsync()
selectedRecipeID = validated.id
workflow.wizard.showNotice("Media recipe saved: \(validated.name)")
return true
} catch let error as MediaLibraryStore.MediaLibraryError {
saveMediaError = error.errorDescription
return false
} catch {
saveMediaError = "Could not save: \(error.localizedDescription)"
return false
}
}
// MARK: - Delete
func delete(_ recipe: MediaRecipe) {
Task {
do {
try await store.delete(id: recipe.id)
await reloadAsync()
if selectedRecipeID == recipe.id {
selectedRecipeID = "none"
}
} catch {
workflow.wizard.showNotice(
"Could not delete: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Staleness
func refreshStaleness() {
Task { await refreshStalenessAsync() }
}
/// Recomputes `staleReasons` + `calAgeDays` for every recipe.
///
/// `.printer` fires only when `printerID` is absent from a
/// **non-empty** enumerated queue list — an un-enumerated list is
/// indeterminate, not stale (decision 5). `.calibration` is a pure
/// age check (`isStale()` with no `comparedTo:`) — the `.cal`
/// DESCRIPTOR is free text, not a queue id.
func refreshStalenessAsync() async {
let staleDays = environment.settingsStore.load().calibrationStaleDays
let queues = workflow.print.printers
let now = Date()
let calStore = CalibrationStore(staleDays: staleDays)
var reasons: [String: StaleReason] = [:]
var ages: [String: Int] = [:]
for r in recipes {
var flags: StaleReason = []
if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) {
flags.insert(.printer)
}
if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces),
!raw.isEmpty,
FileManager.default.fileExists(atPath: raw),
(try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil,
let created = await calStore.data?.created {
ages[r.id] = Calendar.current
.dateComponents([.day], from: created, to: now).day ?? 0
if await calStore.isStale() {
flags.insert(.calibration)
}
}
if !flags.isEmpty { reasons[r.id] = flags }
}
staleReasons = reasons
calAgeDays = ages
}
// MARK: - Manage sheet flow
/// Called from the manage sheet's `onDismiss`. A deferred capture
/// request opens the save sheet only now, after the manage sheet has
/// fully dismissed.
func manageDismissed() {
if captureAfterManageDismiss {
captureAfterManageDismiss = false
beginCapture()
}
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ 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
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 14) {
@@ -35,7 +35,7 @@ struct SavePresetDialog: View {
/// `#managePresetsDialog` — list, delete (custom only), import, export.
struct ManagePresetsDialog: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
+84 -16
View File
@@ -84,6 +84,35 @@ struct ColorSyncSuppressor {
return applied
}
// MARK: - Layer ⑤′ Quartz vocabulary (#201 D2)
/// With `lp` gone there is one print path and it carries **both**
/// dictionaries: write keys 3–5 of the resolver order into
/// `PMPrintSettings` so the PDE and the spool job see them
/// (docs/14 §7). Warn-only — a rejected key never aborts.
@discardableResult
func applyQuartzMode(to settings: PMPrintSettings) -> Int {
var applied = 0
let pairs: [(key: String, value: String, locked: Bool)] = [
(ColorMatchingAttempts.quartzModeKey,
ColorMatchingAttempts.quartzCustomMatching, true),
(ColorMatchingAttempts.quartzProfileKey, "", false),
(ColorMatchingAttempts.quartzLegacyModeKey,
ColorMatchingAttempts.quartzCustomMatching, false),
]
for pair in pairs {
let status = PMPrintSettingsSetValue(
settings, pair.key as CFString, pair.value as CFString,
pair.locked)
if status == 0 { applied += 1 }
}
if applied == 0 {
log("ColorSync: PMPrintSettingsSetValue rejected the "
+ "Quartz colour-matching keys")
}
return applied
}
// MARK: - Layer ④ driver bypass
/// Pre-select the driver "no colour adjustment" option, unlocked —
@@ -112,7 +141,11 @@ struct ColorSyncSuppressor {
// MARK: - Layer ⑤ NSPrintInfo mirror
/// Mirror the applied keys into `printSettings` so the PDE pick
/// sees them.
/// sees them. Also populates the nested
/// `com.apple.print.printSettings` sub-dictionary of
/// `printInfo.dictionary()` with the AP_* **and** Quartz keys —
/// the single remaining path carries both vocabularies (#201 D2,
/// docs/14 §7).
func mirror(
into printInfo: NSPrintInfo,
driverBypass: (key: String, value: String)?
@@ -121,36 +154,71 @@ struct ColorSyncSuppressor {
for key in ColorMatchingAttempts.printSettingsKeys {
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
}
settings[ColorMatchingAttempts.quartzModeKey as NSString] =
ColorMatchingAttempts.quartzCustomMatching as NSString
settings[ColorMatchingAttempts.quartzProfileKey as NSString] =
"" as NSString
settings[ColorMatchingAttempts.quartzLegacyModeKey as NSString] =
ColorMatchingAttempts.quartzCustomMatching as NSString
if let driverBypass {
settings[driverBypass.key as NSString] = driverBypass.value as NSString
}
// Nested mirror — drivers that read the flattened dictionary.
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
let nested = (settings[nestedKey] as? NSMutableDictionary)
?? NSMutableDictionary()
for key in ColorMatchingAttempts.printSettingsKeys {
nested[key] = ColorMatchingAttempts.applicationMatchingValue
}
nested[ColorMatchingAttempts.quartzModeKey] =
ColorMatchingAttempts.quartzCustomMatching
nested[ColorMatchingAttempts.quartzProfileKey] = ""
nested[ColorMatchingAttempts.quartzLegacyModeKey] =
ColorMatchingAttempts.quartzCustomMatching
settings[nestedKey] = nested
}
// MARK: - Layer ⑥ capture
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`.
/// The malloc'd C string is freed after copying.
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType,
/// tray)`. The malloc'd C string is freed after copying. `trayKey`
/// is the queue's detected tray enumeration key; if nil, the full
/// roster plus the capture-only `AP_D_InputSlot` alias is tried.
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)
from settings: PMPrintSettings,
trayKey: String?
) -> (cupsOptions: String?, mediaType: String?, tray: String?) {
guard let unfiltered = rawOptionsDump(from: settings) else {
return (nil, 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)
CupsParsers.extractMediaType(fromOptionsString: unfiltered),
CupsParsers.extractTray(
fromOptionsString: unfiltered,
preferredKey: trayKey)
)
}
/// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics
/// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`).
/// `nil` when the symbol or the call fails.
func rawOptionsDump(from settings: PMPrintSettings) -> String? {
guard let toOptions = optionsResolver() else {
log("ColorSync: PMPrintSettingsToOptions unavailable — "
+ "panel options not captured")
return nil
}
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return nil
}
defer { free(raw) }
return String(cString: raw)
}
// MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
@@ -0,0 +1,317 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// One page to draw — the TIFF plus its printtarg manifest size for
/// the drift check (#201 D9).
struct TargetPrintPage: Equatable {
let url: URL
let expectedWidthMm: Double?
let expectedHeightMm: Double?
}
/// One spool job — a single-element `pages` in per-page mode, all
/// pages when `singleJobForAllPages` is on (#201 D5).
struct TargetPrintRequest {
let queue: String
let displayName: String?
let pages: [TargetPrintPage]
let jobTitle: String // "ICCery Target - <basename>"
/// nil → bind + PM defaults + suppression only (the ticket carries
/// the captured PDE state; without it the job is Stage 2 writes
/// over queue defaults).
let ticket: PrintTicket?
let overrides: TargetPrintOverrides
/// The queue's `lpoptions -l` key roster — feeds vendor media-key
/// and driver-bypass detection.
let optionKeys: Set<String>
/// Test-only: `.save` to this URL instead of `.spool` (#201 D8).
var savePDFTo: URL?
}
enum TargetSpoolError: LocalizedError, Equatable {
case noPages
case printerUnresolved(queue: String)
case operationFailed(queue: String, jobTitle: String)
var errorDescription: String? {
switch self {
case .noPages:
return "Nothing to print."
case .printerUnresolved(let queue):
return "The print queue '\(queue)' could not be resolved."
case .operationFailed(_, let jobTitle):
return "The print operation for '\(jobTitle)' failed."
}
}
}
@MainActor
protocol TargetSpooling {
/// Warn-only diagnostics (manifest drift, oversize page, rejected
/// key) are delivered here; the throw path is reserved for real
/// failures.
var diagnostics: ((Notice) -> Void)? { get set }
func spool(_ request: TargetPrintRequest) throws
}
/// Production spooler — rehydrates the captured ticket, applies the
/// resolved Stage 2 writes, and draws each TIFF 1:1 through a silent
/// `NSPrintOperation` (#201, the S1–S14 trace in the M12 megaplan).
@MainActor
final class NativeTargetSpooler: TargetSpooling {
var suppressor = ColorSyncSuppressor()
var diagnostics: ((Notice) -> Void)?
var log: (LogLevel, String) -> Void = { AppLogger.shared.log($0, $1) }
func spool(_ request: TargetPrintRequest) throws {
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
// The PM printer handle is held for the whole operation and
// released on every path (S14).
let printer = PMTicketBridge.makePrinter(queue: request.queue)
defer { if let printer { PMTicketBridge.release(printer) } }
let (printInfo, _) = try makePrintInfo(for: request, boundTo: printer)
// S10 — decode every page 1:1. Warn-only findings ride the
// `diagnostics` seam, never the throw path (D9).
var rasters: [TargetPageRaster] = []
rasters.reserveCapacity(request.pages.count)
for page in request.pages {
let raster = try TargetRasterLoader.load(
tiff: page.url,
expectedWidthMm: page.expectedWidthMm,
expectedHeightMm: page.expectedHeightMm)
if let drift = raster.manifestDrift {
diagnostics?(Notice(kind: .warning, text: drift))
}
if raster.pointSize.width > printInfo.paperSize.width + 0.5
|| raster.pointSize.height
> printInfo.paperSize.height + 0.5 {
diagnostics?(Notice(
kind: .warning,
text: "\(page.url.lastPathComponent) exceeds the "
+ "paper size — it will be clipped, not scaled."))
}
rasters.append(raster)
}
// S11–S13 — headless canvas + silent operation (D7).
let canvas = TargetPageCanvasView(
pages: rasters, paperSize: printInfo.paperSize)
let operation = NSPrintOperation(view: canvas, printInfo: printInfo)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.canSpawnSeparateThread = false
operation.jobTitle = request.jobTitle
guard operation.run() else {
throw TargetSpoolError.operationFailed(
queue: request.queue, jobTitle: request.jobTitle)
}
}
/// S1–S9 — a fully configured `NSPrintInfo` plus the resolved
/// write list. Extracted for the PDF harness + unit tests;
/// `spool(_:)` is the production entry point (it keeps the PM
/// printer handle alive through `run()`).
func makePrintInfo(
for request: TargetPrintRequest
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
let printer = PMTicketBridge.makePrinter(queue: request.queue)
defer { if let printer { PMTicketBridge.release(printer) } }
return try makePrintInfo(for: request, boundTo: printer)
}
private func makePrintInfo(
for request: TargetPrintRequest,
boundTo printer: PMPrinter?
) throws -> (NSPrintInfo, ResolvedTicketWrites) {
// S1
let printInfo = NSPrintInfo()
// S2 — Cocoa-level printer first: it runs before binding and
// ticket restore, so a printer-driven settings reset cannot
// clobber the ticket (R9). Best effort; PM binding is
// authoritative.
var resolvedNSPrinter = false
if let nsPrinter = NSPrinter(name: request.queue)
?? request.displayName.flatMap({ NSPrinter(name: $0) }) {
printInfo.printer = nsPrinter
resolvedNSPrinter = true
}
// S3 — PM binding + session defaults. A failed bind degrades
// to the NSPrinter fallback; with neither, the job would land
// on the default queue — a wrong answer, so it throws.
var boundViaPM = false
if let printer {
do {
try PMTicketBridge.bind(printer: printer, to: printInfo)
boundViaPM = true
} catch {
log(.warn, "Spool: PM bind failed for "
+ "'\(request.queue)' — continuing on the "
+ "NSPrinter fallback")
}
}
guard boundViaPM || resolvedNSPrinter else {
throw TargetSpoolError.printerUnresolved(queue: request.queue)
}
// S4 — rehydrate the captured ticket. `restore` itself refuses
// a cross-queue replay (R3).
if let ticket = request.ticket {
try PMTicketBridge.restore(ticket, into: printInfo)
}
// S5 — Stage 2 writes (AP_* + Quartz vocabularies + media /
// quality / bypass, D2/D6) onto the live PMPrintSettings.
let resolved = TicketWriteResolver.resolve(
overrides: request.overrides, optionKeys: request.optionKeys)
let settings = PMTicketBridge.settings(printInfo)
for write in resolved.writes {
// R4 — write only when the value differs, and log old→new
// so a vendor companion-key desync is diagnosable.
let current = PMTicketBridge.stringValue(
forKey: write.key, in: settings)
guard current != write.value else { continue }
if let current {
log(.info, "Spool: \(write.key): '\(current)' "
+ "→ '\(write.value)'")
}
PMTicketBridge.setValue(
write.value, forKey: write.key, locked: write.locked,
in: settings, context: "Spool")
}
printInfo.updateFromPMPrintSettings()
mirror(resolved.writes, into: printInfo)
// S6 — paper override: `PMPaper` match on the bound printer;
// `Custom.<w>x<h>` tokens (already points) set the Cocoa paper
// size directly.
if let token = resolved.paperToken {
if let printer, boundViaPM {
PMTicketBridge.applyPaper(
token: token, printer: printer,
session: PMTicketBridge.session(printInfo),
printInfo: printInfo)
} else if let custom = PMTicketBridge
.customPaperDimensions(from: token) {
printInfo.paperSize = NSSize(
width: custom.width, height: custom.height)
}
}
// S7 — orientation on the Cocoa page format; the
// `orientation-requested` write above is the driver half.
if let orientation = resolved.orientation {
printInfo.orientation = orientation == "landscape"
? .landscape : .portrait
}
// S8 — private SPI on the live session; only meaningful with
// a bound printer (same constraint as the panel path).
if boundViaPM {
suppressor.applySPIMode(to: PMTicketBridge.session(printInfo))
}
// S9 — Cocoa geometry: margins 0, `.clip` pagination, 1:1
// scale, no centring; `.spool`, or `.save` + jobSavingURL
// under the PDF harness (D8/D9).
printInfo.topMargin = 0
printInfo.bottomMargin = 0
printInfo.leftMargin = 0
printInfo.rightMargin = 0
printInfo.horizontalPagination = .clip
printInfo.verticalPagination = .clip
printInfo.scalingFactor = 1.0
printInfo.isHorizontallyCentered = false
printInfo.isVerticallyCentered = false
if let saveURL = request.savePDFTo {
printInfo.jobDisposition = .save
printInfo.dictionary()[
NSPrintInfo.AttributeKey.jobSavingURL] = saveURL
} else {
printInfo.jobDisposition = .spool
}
return (printInfo, resolved)
}
/// The resolved writes also land in `NSPrintInfo.printSettings` so
/// the driver sees them through Cocoa — the
/// `com.apple.print.printSettings` sub-dictionary carries the
/// colour keys (D2 / docs/14 §7).
private func mirror(
_ writes: [TicketWrite], into printInfo: NSPrintInfo
) {
let settings = printInfo.printSettings
for write in writes {
settings[write.key as NSString] = write.value as NSString
}
let nestedKey = ColorMatchingAttempts.quartzNestedDictKey as NSString
let nested = (settings[nestedKey] as? NSMutableDictionary)
?? NSMutableDictionary()
for write in writes where Self.isColourKey(write.key) {
nested[write.key] = write.value
}
settings[nestedKey] = nested
}
private static func isColourKey(_ key: String) -> Bool {
ColorMatchingAttempts.printSettingsKeys.contains(key)
|| key == ColorMatchingAttempts.quartzModeKey
|| key == ColorMatchingAttempts.quartzProfileKey
|| key == ColorMatchingAttempts.quartzLegacyModeKey
}
}
#if DEBUG
/// UI-test seam (#201 D8). Runs the same `TicketWriteResolver` the
/// native spooler runs, appends one deterministic line per request to
/// `ICCERY_TEST_SPOOL_LOG`, and never touches the print system — so
/// UI tests need no queue, no driver and no PM binding.
///
/// `ICCERY_TEST_SPOOL_FAIL=1` injects an `operationFailed` error for
/// the failure-notice tests.
@MainActor
final class RecordingTargetSpooler: TargetSpooling {
let logURL: URL
var diagnostics: ((Notice) -> Void)?
init(logURL: URL) {
self.logURL = logURL
}
/// `queue=<q> title=<t> page=<basename> pages=<n> paper=<token|->
/// orientation=<o> keys=<k=v k=v …>` — keys sorted alphabetically
/// so assertions are order-free.
func spool(_ request: TargetPrintRequest) throws {
guard !request.pages.isEmpty else { throw TargetSpoolError.noPages }
if ProcessInfo.processInfo.environment["ICCERY_TEST_SPOOL_FAIL"]
== "1" {
throw TargetSpoolError.operationFailed(
queue: request.queue, jobTitle: request.jobTitle)
}
let resolved = TicketWriteResolver.resolve(
overrides: request.overrides, optionKeys: request.optionKeys)
let keys = resolved.writes
.sorted { $0.key < $1.key }
.map { "\($0.key)=\($0.value)" }
.joined(separator: " ")
let line = "queue=\(request.queue)"
+ " title=\(request.jobTitle)"
+ " page=\(request.pages[0].url.lastPathComponent)"
+ " pages=\(request.pages.count)"
+ " paper=\(resolved.paperToken ?? "-")"
+ " orientation=\(resolved.orientation ?? "-")"
+ " keys=\(keys)\n"
// Atomic append — existing content + one line via the
// `.tmp`-then-rename convention (#213).
let existing = (try? String(contentsOf: logURL, encoding: .utf8))
?? ""
try AtomicFileWriter.write(existing + line, to: logURL)
}
}
#endif
+389
View File
@@ -0,0 +1,389 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// Errors raised by Core Printing bridge operations.
enum PMTicketError: LocalizedError, Equatable {
case printerUnknown(queue: String)
case sessionBindingFailed(OSStatus)
case serialiseFailed(stage: String, status: OSStatus)
case restoreFailed(stage: String, status: OSStatus)
var errorDescription: String? {
switch self {
case .printerUnknown(let queue):
return "No printer found for queue '\(queue)'."
case .sessionBindingFailed(let status):
return "Could not bind the print session to the queue (OSStatus \(status))."
case .serialiseFailed(let stage, let status):
return "Print ticket capture failed at \(stage) (OSStatus \(status))."
case .restoreFailed(let stage, let status):
return "Print ticket restore failed at \(stage) (OSStatus \(status))."
}
}
}
/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12).
///
/// Ownership — the ONLY rules, do not improvise:
/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`,
/// `PMPrintSettingsCreateWithDataRepresentation`,
/// `PMPageFormatCreateWithDataRepresentation` return **+1** →
/// `PMRelease(object(_:))` on every path (`defer`).
/// * `PM*CreateDataRepresentation` writes a **+1 CFData** →
/// `takeRetainedValue()` (the headers say "the caller is responsible
/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated,
/// so Swift hands back an unbalanced `Unmanaged`).
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
/// never released. `PMSessionGetCurrentPrinter` hands back the
/// session's own printer — also borrowed, never `PMRelease`d (an
/// over-release here dangles the session and crashes AppKit's
/// `_printerInPrintSession` / session teardown).
/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are
/// borrowed from the `NSPrintInfo` → never released.
@MainActor
enum PMTicketBridge {
// MARK: Handles
/// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released.
static func session(_ printInfo: NSPrintInfo) -> PMPrintSession {
unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self)
}
/// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released.
static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings {
unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self)
}
/// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released.
static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat {
unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self)
}
/// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API
/// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`.
static func object<Handle>(_ handle: Handle) -> PMObject {
unsafeBitCast(handle, to: PMObject.self)
}
// MARK: Binding (layer ①)
/// `PMPrinterCreateFromPrinterID` — **+1**, caller must
/// `PMTicketBridge.release` on every path.
static func makePrinter(queue: String) -> PMPrinter? {
PMPrinterCreateFromPrinterID(queue as CFString)
}
/// Balances `makePrinter(queue:)` / any +1 PM handle.
static func release(_ printer: PMPrinter) {
PMRelease(object(printer))
}
/// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only).
/// Throws `PMTicketError.sessionBindingFailed` when the bind itself
/// fails — the caller still owns `printer` on every path.
static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws {
let session = session(printInfo)
let status = PMSessionSetCurrentPMPrinter(session, printer)
guard status == 0 else {
throw PMTicketError.sessionBindingFailed(status)
}
// Warn-only: defaults keep the panel consistent with the
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings(printInfo))
_ = PMSessionDefaultPageFormat(session, pageFormat(printInfo))
}
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
static func currentPrinterID(
session: PMPrintSession,
fallback: String
) -> String {
var current: PMPrinter?
guard PMSessionGetCurrentPrinter(session, &current) == 0,
let printer = current
else { return fallback }
// Borrowed from the session — never released (see header doc).
guard let id = PMPrinterGetID(printer)
else { return fallback }
return id.takeUnretainedValue() as String
}
// MARK: Ticket (#201 D3)
/// Capture the live `PMPrintSettings` + `PMPageFormat` as XML
/// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback.
/// The CFData out-params are **+1** by header contract
/// ("the caller is responsible for releasing") →
/// `takeRetainedValue()`.
static func serialise(
_ printInfo: NSPrintInfo,
queue: String
) throws -> PrintTicket {
var settingsRef: Unmanaged<CFData>?
let settingsStatus = PMPrintSettingsCreateDataRepresentation(
settings(printInfo), &settingsRef, kPMDataFormatXMLDefault)
guard settingsStatus == noErr, let settingsRef else {
throw PMTicketError.serialiseFailed(
stage: "printSettings", status: settingsStatus)
}
let settingsData = settingsRef.takeRetainedValue() as Data
var pageFormatRef: Unmanaged<CFData>?
let pageFormatStatus = PMPageFormatCreateDataRepresentation(
pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault)
guard pageFormatStatus == noErr, let pageFormatRef else {
throw PMTicketError.serialiseFailed(
stage: "pageFormat", status: pageFormatStatus)
}
let pageFormatData = pageFormatRef.takeRetainedValue() as Data
// Cocoa-level fallback — warn-only, never fatal: filter the
// dictionary to plist-safe values so one exotic attribute
// cannot fail the whole snapshot.
let plist = try? PropertyListSerialization.data(
fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:],
format: .binary, options: 0)
return PrintTicket(
queue: queue,
printSettings: settingsData,
pageFormat: pageFormatData,
printInfoPlist: plist,
capturedAt: Date())
}
/// Rehydrate a ticket into `printInfo`'s live PM objects. Order is
/// load-bearing: create → copy → session-validate → Cocoa update.
/// Cross-queue replay is refused (R3) — the destination's bound
/// queue is the session's current printer; a destination with no
/// bound printer accepts the ticket (the spooler always binds
/// first, so production replay is always guarded).
/// A page-format failure is warn-only — paper is re-derived
/// upstream by the spooler's S6/S7.
static func restore(
_ ticket: PrintTicket,
into printInfo: NSPrintInfo
) throws {
let bound = currentPrinterID(
session: session(printInfo),
fallback: ticket.queue)
guard ticket.queue == bound else {
AppLogger.shared.warn(
"PrintTicket: refusing to replay a ticket captured "
+ "for '\(ticket.queue)' onto '\(bound)'")
return
}
var srcSettings: PMPrintSettings?
let createStatus = PMPrintSettingsCreateWithDataRepresentation(
ticket.printSettings as CFData, &srcSettings)
defer {
if let srcSettings { PMRelease(object(srcSettings)) }
}
guard createStatus == noErr, let srcSettings else {
throw PMTicketError.restoreFailed(
stage: "printSettings", status: createStatus)
}
let copyStatus = PMCopyPrintSettings(
srcSettings, settings(printInfo))
guard copyStatus == noErr else {
throw PMTicketError.restoreFailed(
stage: "printSettings", status: copyStatus)
}
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(
session(printInfo), settings(printInfo), &changed)
if changed.boolValue {
AppLogger.shared.info(
"PrintTicket: driver adjusted the restored ticket")
}
printInfo.updateFromPMPrintSettings()
// Page format — warn-only.
var srcFormat: PMPageFormat?
let formatStatus = PMPageFormatCreateWithDataRepresentation(
ticket.pageFormat as CFData, &srcFormat)
defer {
if let srcFormat { PMRelease(object(srcFormat)) }
}
guard formatStatus == noErr, let srcFormat else {
AppLogger.shared.warn(
"PrintTicket: page format restore failed "
+ "(\(formatStatus)) — paper re-derived upstream")
return
}
guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr
else {
AppLogger.shared.warn(
"PrintTicket: page format copy failed — "
+ "paper re-derived upstream")
return
}
var formatChanged = DarwinBoolean(false)
_ = PMSessionValidatePageFormat(
session(printInfo), pageFormat(printInfo), &formatChanged)
printInfo.updateFromPMPageFormat()
}
/// Recursive plist-safety filter for `NSPrintInfo.dictionary()`:
/// keeps String / NSNumber / Bool / Date / Data / URL (→
/// absoluteString) / Array / Dictionary, drops everything else, so
/// one non-plist attribute cannot fail the whole snapshot.
private static func plistSafe(_ value: Any) -> Any? {
switch value {
case let string as String:
return string
case let number as NSNumber:
return number
case let date as Date:
return date
case let data as Data:
return data
case let url as URL:
return url.absoluteString
case let array as [Any]:
return array.compactMap(plistSafe)
case let dictionary as [String: Any]:
var safe: [String: Any] = [:]
for (key, element) in dictionary {
if let filtered = plistSafe(element) {
safe[key] = filtered
}
}
return safe
case let dictionary as [NSPrintInfo.AttributeKey: Any]:
var safe: [String: Any] = [:]
for (key, element) in dictionary {
if let filtered = plistSafe(element) {
safe[key.rawValue] = filtered
}
}
return safe
default:
return nil
}
}
// MARK: Values
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
/// must not abort the caller's flow. Returns `true` on success.
@discardableResult
static func setValue(
_ value: String,
forKey key: String,
locked: Bool,
in settings: PMPrintSettings,
context: String
) -> Bool {
let status = PMPrintSettingsSetValue(
settings, key as CFString, value as CFString, locked)
if status != 0 {
AppLogger.shared.warn(
"\(context): PMPrintSettingsSetValue(\(key)) "
+ "rejected (\(status))")
return false
}
return true
}
/// `PMPrintSettingsGetValue` — borrowed value, never released.
/// `nil` for absent keys, non-string values, or lookup errors.
static func stringValue(
forKey key: String,
in settings: PMPrintSettings
) -> String? {
var value: Unmanaged<CFTypeRef>?
guard PMPrintSettingsGetValue(
settings, key as CFString, &value) == 0,
let ref = value?.takeUnretainedValue()
else { return nil }
return ref as? String
}
// MARK: Paper
/// The `PMPageFormat` half of paper preselect (#186 E1): the
/// panel's paper dropdown reflects the page format's `PMPaper`,
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
/// paper from `PMPrinterGetPaperList`, rebuild the page format
/// around it, and copy it into the printInfo's format (TN2248:
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
/// missed match must not keep the panel from opening.
static func applyPaper(
token: String,
printer: PMPrinter,
session: PMPrintSession,
printInfo: NSPrintInfo
) {
if let custom = customPaperDimensions(from: token) {
printInfo.paperSize = NSSize(
width: custom.width, height: custom.height)
return
}
var paperList: Unmanaged<CFArray>?
guard PMPrinterGetPaperList(printer, &paperList) == 0,
let papers = paperList?.takeUnretainedValue()
else {
AppLogger.shared.warn(
"Print panel: PMPrinterGetPaperList failed — "
+ "paper preselect skipped")
return
}
// The list (and its elements) is owned by the printer —
// borrowed, never released.
var match: PMPaper?
for index in 0..<CFArrayGetCount(papers) {
let paper = unsafeBitCast(
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
var idRef: Unmanaged<CFString>?
guard PMPaperGetID(paper, &idRef) == 0,
let paperID = idRef?.takeUnretainedValue() as String?
else { continue }
if paperID == token {
match = paper
break
}
}
guard let paper = match else {
AppLogger.shared.warn(
"Print panel: no PMPaper id matches '\(token)'")
return
}
var created: PMPageFormat?
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
let newFormat = created
else {
AppLogger.shared.warn(
"Print panel: PMCreatePageFormatWithPMPaper failed "
+ "for '\(token)'")
return
}
defer { PMRelease(object(newFormat)) }
_ = PMSessionValidatePageFormat(session, newFormat, nil)
_ = PMCopyPageFormat(newFormat, pageFormat(printInfo))
printInfo.updateFromPMPageFormat()
}
/// `Custom.<w>x<h>` → dimensions in points (the token builder
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
/// malformed tokens — a malformed `Custom.*` then misses the
/// `PMPaper` match and logs instead of guessing a size.
static func customPaperDimensions(
from token: String
) -> (width: Double, height: Double)? {
guard token.hasPrefix("Custom.") else { return nil }
let dims = token.dropFirst("Custom.".count).split(separator: "x")
guard dims.count == 2,
let width = Double(dims[0]), let height = Double(dims[1]),
width > 0, height > 0
else { return nil }
return (width, height)
}
}
+275 -86
View File
@@ -4,19 +4,55 @@ 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)'."
}
}
}
/// Stage 2 selections pre-applied to the bound print panel before it
/// opens (#183/#186). Every write is warn-only — the panel still
/// opens when a driver ignores a key.
struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String?
/// The queue's detected tray enumeration key, e.g. `EPIJ_FdSo`.
var trayKey: String?
/// The selected tray machine token.
var tray: String?
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String?
/// The selected quality token.
var quality: String?
/// The selected media token — written to the queue's detected
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
var mediaType: String?
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
var orientation: String?
}
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
/// key roster, the Stage 2 pre-selections, and the session's captured
/// ticket — everything `makePanelPrintInfo` needs before the modal.
struct PrintPanelRequest {
let queue: String
let displayName: String?
let optionKeys: Set<String>
let initialSelections: PrintPanelInitialSelections
/// The queue's previously captured ticket — rehydrated before the
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
let ticket: PrintTicket?
/// Roster of paper-source choices used to map a captured option
/// token back to a 1-based ID. Empty for queues with no tray key
/// (#218).
let trays: [PrinterTray]
}
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11).
///
@@ -41,11 +77,20 @@ struct PrintPanelService {
func showProperties(
queue: String,
displayName: String?,
cupsService: CupsService
) async throws -> PrintPropertiesResult? {
cupsService: CupsService,
initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections(),
ticket: PrintTicket? = nil,
trays: [PrinterTray] = []
) async throws -> PanelCaptureResult? {
#if DEBUG
if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue)
return UITestHooks.printPanelResult(
forQueue: queue, trays: trays,
preferredTrayKey: initialSelections.trayKey
).map {
PanelCaptureResult(properties: $0, ticket: nil)
}
}
#endif
// `??` rhs is a non-async @autoclosure — fetch first.
@@ -56,72 +101,140 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? []
return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys)
PrintPanelRequest(
queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket,
trays: trays))
}
// MARK: - Panel
private func runNativePanel(
queue: String,
displayName: String?,
optionKeys: Set<String>
) throws -> PrintPropertiesResult? {
/// The pre-modal half of `runNativePanel` — a fully configured
/// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
/// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
/// assert ticket restore / Stage 2 precedence without driving the
/// modal (#217). The caller owns `printer` — it must stay alive
/// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
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
if let printer {
try PMTicketBridge.bind(printer: printer, to: printInfo)
configureBoundPanel(
printInfo, request: request, printer: printer)
} else {
// Fallback: NSPrinter by display name (docs/11 §binding).
guard let displayName,
// Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn(
"Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)")
guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName)
else {
throw PrintPanelError.noPrinterFound(
displayName ?? queue)
request.displayName ?? request.queue)
}
printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
}
defer {
if let printer = pmPrinter {
PMRelease(Self.pmObject(printer))
return (printInfo, true)
}
/// Post-bind configuration in locked order (#217 — mirrors the
/// spooler's S4→S7): ticket restore → Stage 2 selections →
/// validate → paper → suppression ②–⑤. Internal so unit tests can
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
/// need a bound printer and skip when it is absent, while ticket
/// restore / selections / validate still run on the session-less
/// `NSPrintInfo`.
func configureBoundPanel(
_ printInfo: NSPrintInfo,
request: PrintPanelRequest,
printer: PMPrinter?
) {
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
// Rehydrate the captured ticket *before* the Stage 2 writes
// (#217 — restore-then-override). Warn-only, unlike the
// spooler's fatal `try`: a stale/corrupt ticket must not keep
// the dialog from opening. `restore` itself refuses a
// cross-queue replay (R3).
if let ticket = request.ticket {
do {
try PMTicketBridge.restore(ticket, into: printInfo)
} catch {
AppLogger.shared.warn(
"Print panel: ticket restore failed — "
+ "opening on queue defaults "
+ "(\(error.localizedDescription))")
}
}
// ②–⑤ 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) // ⑤
// Initial selections — Stage 2 wins over the restored ticket
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
request.initialSelections, to: settings,
optionKeys: request.optionKeys)
// Reconcile the pre-writes into the ticket before the modal
// (#217 RC2): the vendor PDE derives pane state from a
// validated ticket. Warn-only — log when the driver adjusts
// so a companion-key desync is diagnosable.
var changed = DarwinBoolean(false)
_ = PMSessionValidatePrintSettings(session, settings, &changed)
if changed.boolValue {
AppLogger.shared.info(
"Print panel: driver adjusted the pre-selections")
}
printInfo.updateFromPMPrintSettings()
if let printer, let paperToken = request.initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
// ②–⑤ ColourSync suppression. The SPI needs a session with a
// current printer to attach to; the SetValue/mirror layers
// are warn-only and run regardless.
if printer != nil {
suppressor.applySPIMode(to: session) // ②
}
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
let driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: request.optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
private func runNativePanel(
_ request: PrintPanelRequest
) throws -> PanelCaptureResult? {
// The PM printer handle is held through the modal and
// released on every path.
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
defer {
if let printer = pmPrinter {
PMTicketBridge.release(printer)
}
}
let (printInfo, boundViaPM) = try makePanelPrintInfo(
for: request, boundTo: pmPrinter)
debugDump(printInfo, when: "pre-modal")
let panel = NSPrintPanel()
panel.options = [
@@ -138,49 +251,125 @@ struct PrintPanelService {
// ⑥ 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.
// we read back what the modal wrote. Paper size, quality, and
// orientation ride back parsed from the captured `k=v` string
// (#183); the PDE may rewrite or drop them (R12).
var cupsOptions: String?
var mediaType: String?
var trayToken: String?
if boundViaPM {
settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let captured = suppressor.captureOptions(from: settings)
debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo),
trayKey: request.initialSelections.trayKey)
cupsOptions = captured.cupsOptions
mediaType = captured.mediaType
trayToken = captured.tray
}
return PrintPropertiesResult(
selectedPrinter: boundViaPM
? Self.currentPrinterID(
session: unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self),
fallback: queue)
: nil,
options: PrintOptions(
mediaType: mediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: cupsOptions))
let capturedOptions = cupsOptions ?? ""
let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: request.queue)
: request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above.
let ticket = try? PMTicketBridge.serialise(
printInfo, queue: resolvedQueue)
let paperSource = request.trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PanelCaptureResult(
properties: PrintPropertiesResult(
selectedPrinter: boundViaPM ? resolvedQueue : nil,
options: PrintOptions(
paperSource: paperSource,
orientation: CupsParsers.extractOrientation(
fromOptionsString: capturedOptions),
paperSize: CupsParsers.extractOption(
named: "PageSize",
fromOptionsString: capturedOptions),
mediaType: mediaType,
quality: CupsParsers.extractQuality(
fromOptionsString: capturedOptions),
ppdUncorrectedPassthrough: true,
cupsOptions: cupsOptions)),
ticket: ticket)
}
// 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)
/// Initial-selection `PMPrintSettings` writes — paper, tray,
/// quality, media type, orientation. All warn-only: a driver that
/// ignores a key must not keep the panel from opening (R12 surfaces
/// via the capture echo instead).
private func applyInitialSelections(
_ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings,
optionKeys: Set<String>
) {
if let paperSize = selections.paperSize {
PMTicketBridge.setValue(
paperSize, forKey: "PageSize", locked: false,
in: settings, context: "Print panel")
}
// Paper source via the queue's detected tray roster key (#218).
if let tray = selections.tray, !tray.isEmpty,
let trayKey = selections.trayKey {
PMTicketBridge.setValue(
tray, forKey: trayKey, locked: false,
in: settings, context: "Print panel")
}
if let key = selections.qualityKey, let value = selections.quality {
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: settings, context: "Print panel")
}
// Media type via the queue's detected vendor key (#186).
if let mediaType = selections.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) {
PMTicketBridge.setValue(
mediaType, forKey: mediaKey, locked: false,
in: settings, context: "Print panel")
}
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
if let orientation = selections.orientation {
let code = orientation == "landscape" ? "4" : "3"
PMTicketBridge.setValue(
code, forKey: "orientation-requested", locked: false,
in: settings, context: "Print panel")
}
}
/// `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
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
/// `PMPrintSettingsToOptions` output and the serialised
/// settings/page-format byte counts (#217 diagnostics for the
/// Epson RC3 question: does the PDE source pane state from the
/// session ticket or lpoptions?). DEBUG-only, warn-only.
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
#if DEBUG
guard ProcessInfo.processInfo.environment[
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
let raw = suppressor.rawOptionsDump(
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
var settingsSize = -1
var ref: Unmanaged<CFData>?
if PMPrintSettingsCreateDataRepresentation(
PMTicketBridge.settings(printInfo), &ref,
kPMDataFormatXMLDefault) == noErr, let ref {
settingsSize = (ref.takeRetainedValue() as Data).count
}
var formatSize = -1
var formatRef: Unmanaged<CFData>?
if PMPageFormatCreateDataRepresentation(
PMTicketBridge.pageFormat(printInfo), &formatRef,
kPMDataFormatXMLDefault) == noErr, let formatRef {
formatSize = (formatRef.takeRetainedValue() as Data).count
}
AppLogger.shared.info(
"Print panel dump (\(phase)): settings=\(settingsSize)B "
+ "pageFormat=\(formatSize)B options: \(raw)")
#endif
}
}
+360 -46
View File
@@ -1,48 +1,128 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// CUPS queue selection, bound print panel, and `lp` spool (issues 12–15, 17 / #85).
/// CUPS queue selection, bound print panel, and native headless spool
/// (issues 12–15, 17 / #85, #201).
@MainActor
@Observable
final class PrintSessionViewModel {
final class PrintSessionViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
/// Stage 1/2 form state — read for paper seeding/mirroring only;
/// `workflow.pageSize` is the printtarg layout and is never written
/// back from the print side (#183).
weak var workflow: TargetWorkflowViewModel?
var printers: [Printer] = []
var selectedPrinter = ""
var printerCaps = PrinterCapabilities()
var selectedTray: Int?
var selectedMediaType: String?
var printOrientation = "portrait"
var capturedCupsOptions: [String: String] = [:]
var printNotice: Notice?
var isPrinting = false
@Published var printers: [Printer] = []
@Published var selectedPrinter = "" {
didSet {
// Tickets are queue- and driver-version-specific — a stale
// ticket must never replay onto a re-selected queue (R3).
if selectedPrinter != oldValue {
capturedTickets[oldValue] = nil
}
}
}
@Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? {
didSet {
// A tray switch can invalidate the current quality pick
// using the tray-specific Epson constraint map (#218).
if selectedTray != oldValue {
clampQualityToMedia()
}
}
}
@Published var selectedMediaType: String? {
didSet {
// A media switch can invalidate the current quality pick —
// re-clamp into the driver's allowed set (#214).
if selectedMediaType != oldValue {
clampQualityToMedia()
}
}
}
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
@Published var selectedPaperSize: Int?
/// Print-quality option token, e.g. `"303"` (#183).
@Published var selectedQuality: String?
@Published var printOrientation = "portrait"
@Published var capturedCupsOptions: [String: String] = [:]
/// Native print tickets per queue — the spool payload (#201).
/// Session-only; cleared whenever the queue's mirror is.
@Published var capturedTickets: [String: PrintTicket] = [:]
@Published var printNotice: Notice?
@Published var isPrinting = false
/// Session-only job granularity (#201 D5): off → one job per page
/// (per-page notices + error attribution); on → a single job.
@Published var singleJobForAllPages = false
/// The spool backend — the `ICCERY_TEST_SPOOL_LOG` recording seam
/// under UI testing, `NSPrintOperation` otherwise (#201 D8).
/// `internal` so unit tests inject.
var spooler: TargetSpooling
private var printTask: Task<Void, Never>?
init(wizard: WizardViewModel, environment: AppEnvironment) {
self.wizard = wizard
self.environment = environment
#if DEBUG
if UITestHooks.isEnabled, let logURL = UITestHooks.spoolLogURL {
spooler = RecordingTargetSpooler(logURL: logURL)
} else {
spooler = NativeTargetSpooler()
}
#else
spooler = NativeTargetSpooler()
#endif
attachDiagnostics()
}
/// Warn-only spooler diagnostics (manifest drift, oversize page)
/// surface as the in-panel notice. Re-attached per request so an
/// injected spooler picks it up too — and so `spooler` needs no
/// `didSet` (mutating `diagnostics` through the existential would
/// re-fire the observer and recurse).
private func attachDiagnostics() {
spooler.diagnostics = { [weak self] notice in
self?.printNotice = notice
}
}
private var printerEnumTask: Task<[Printer]?, Never>?
func refreshPrinters() {
let cups = environment.cupsService
Task { @MainActor in
Task { @MainActor in _ = await enumeratePrinters() }
}
/// Serialized queue enumeration — `listPrinters` uses fixed process
/// ids, so overlapping calls would throw `duplicateID`. Concurrent
/// callers coalesce onto the in-flight task (#146).
@discardableResult
func enumeratePrinters() async -> [Printer]? {
if let pending = printerEnumTask { return await pending.value }
let task = Task { @MainActor [weak self] () -> [Printer]? in
guard let self else { return nil }
do {
let list = try await cups.listPrinters()
printers = list
if !list.contains(where: { $0.name == selectedPrinter }) {
selectedPrinter = list.first { $0.isDefault }?.name
let list = try await self.environment.cupsService.listPrinters()
self.printers = list
if !list.contains(where: { $0.name == self.selectedPrinter }) {
self.selectedPrinter = list.first { $0.isDefault }?.name
?? list.first?.name ?? ""
}
await reloadSelectedCapabilities()
await self.reloadSelectedCapabilities()
return list
} catch {
printNotice = Notice(
self.printNotice = Notice(
kind: .error,
text: "Could not list printers: \(error.localizedDescription)"
)
return nil
}
}
printerEnumTask = task
let result = await task.value
printerEnumTask = nil
return result
}
func reloadSelectedCapabilities() async {
@@ -56,25 +136,142 @@ final class PrintSessionViewModel {
if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id
}
if selectedTray == nil {
selectedTray = printerCaps.trays.first?.id
if selectedTray == nil || !printerCaps.trays
.contains(where: { $0.id == selectedTray }) {
selectedTray = printerCaps.trayDefault.flatMap { token in
printerCaps.trays.first { $0.token == token }?.id
} ?? printerCaps.trays.first?.id
}
// Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first
// allowed choice.
if selectedQuality == nil {
let allowed = availableQualities
selectedQuality = printerCaps.qualityDefault.flatMap { d in
allowed.contains(where: { $0.id == d }) ? d : nil
} ?? allowed.first?.id
} else {
clampQualityToMedia()
}
// Caps reload is a re-mirror trigger for the paper picker
// (#183 E4) — pageSize + printer changes route here too.
seedPaperSelection()
} catch {
printerCaps = PrinterCapabilities()
}
}
// MARK: - Paper / quality selection (#183)
/// The raw tray token for the currently selected source (#218).
var selectedTrayToken: String? {
guard let id = selectedTray else { return nil }
return printerCaps.trays.first { $0.id == id }?.token
}
/// The quality picker's source (#214/#218): `printerCaps.qualities`
/// filtered to what the driver accepts for `selectedMediaType` and
/// the current tray token. Equals the full list whenever the queue
/// exposes no per-media/per-tray quality matrix — and never empty
/// while `qualities` is not.
var availableQualities: [PrinterQuality] {
printerCaps.qualities(
forMediaType: selectedMediaType,
trayToken: selectedTrayToken)
}
/// Keep `selectedQuality` inside the allowed set for the current
/// media and tray: keep the pick when still valid, else the driver
/// default when valid, else the first allowed choice (#214/#218).
private func clampQualityToMedia() {
let allowed = availableQualities
guard !allowed.isEmpty else { return }
if let quality = selectedQuality,
allowed.contains(where: { $0.id == quality }) {
return
}
if let fallback = printerCaps.qualityDefault,
allowed.contains(where: { $0.id == fallback }) {
selectedQuality = fallback
} else {
selectedQuality = allowed.first?.id
}
}
/// The tray token for a captured option string, used both for
/// applying panel results and for synthesising unknown selections.
private func capturedTrayToken(from options: String?) -> String? {
guard let options else { return nil }
return CupsParsers.extractTray(
fromOptionsString: options,
preferredKey: printerCaps.trayKey)
}
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
/// no match → nil (never guess). Called only on pageSize / printer /
/// caps triggers — never on unrelated publishes (R14).
func seedPaperSelection() {
guard let pageSize = workflow?.pageSize else { return }
if pageSize == .custom {
let token = customPaperToken()
if let index = printerCaps.paperSizes.firstIndex(where: { $0.id == 0 }) {
printerCaps.paperSizes[index].name = token
} else {
printerCaps.paperSizes.append(
PrinterPaperSize(id: 0, name: token))
}
selectedPaperSize = 0
return
}
selectedPaperSize = printerCaps.paperSizes
.first { $0.name == pageSize.rawValue }?.id
}
/// `Custom.<w>x<h>` in **points** — mm × 72/25.4 (#183 E5/R8). The
/// PPD template token `Custom.WIDTHxHEIGHT` is never emitted verbatim.
func customPaperToken() -> String {
let w = workflow?.customPageW ?? 0
let h = workflow?.customPageH ?? 0
let wPt = (w * 72.0 / 25.4).rounded()
let hPt = (h * 72.0 / 25.4).rounded()
return "Custom.\(Int(wPt))x\(Int(hPt))"
}
/// The CUPS `PageSize` token for the current Stage 2 pick — live
/// `Custom.<pt>x<pt>` for the synthetic entry, else the capability
/// name. Resolved into the `PageSize` ticket write and the
/// `PMPaper` match on the native spool path (#201).
var selectedPaperSizeToken: String? {
guard let id = selectedPaperSize else { return nil }
if id == 0 { return customPaperToken() }
return printerCaps.paperSizes.first { $0.id == id }?.name
}
func openPrinterPreferences() {
guard !selectedPrinter.isEmpty else { return }
let queue = selectedPrinter
let displayName = printers.first { $0.name == queue }?.displayName
let cups = environment.cupsService
let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
qualityKey: printerCaps.qualityKey,
quality: selectedQuality,
mediaType: selectedMediaType,
orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in
do {
guard let result = try await PrintPanelService()
.showProperties(
queue: queue, displayName: displayName,
cupsService: cups)
cupsService: cups,
initialSelections: selections,
ticket: ticket,
trays: printerCaps.trays)
else {
printNotice = Notice(
kind: .info,
@@ -83,18 +280,64 @@ final class PrintSessionViewModel {
)
return
}
if let selected = result.selectedPrinter,
if let selected = result.properties.selectedPrinter,
printers.contains(where: { $0.name == selected }),
selected != queue {
selectedPrinter = selected
await reloadSelectedCapabilities()
}
if let captured = result.options.cupsOptions {
if let captured = result.properties.options.cupsOptions {
capturedCupsOptions[selectedPrinter] = captured
}
if let media = result.options.mediaType {
// The native ticket rides alongside the mirror (#201).
if let ticket = result.ticket {
capturedTickets[ticket.queue] = ticket
}
if let media = result.properties.options.mediaType {
selectedMediaType = media
}
// Capture-return paper source (#218): a dialog tray
// change updates Stage 2; unknown tokens are appended
// so a driver-only source remains selectable.
if let options = result.properties.options.cupsOptions,
let trayToken = capturedTrayToken(from: options),
!trayToken.isEmpty {
if let known = printerCaps.trays
.first(where: { $0.token == trayToken }) {
selectedTray = known.id
} else {
let nextID = (printerCaps.trays.map(\.id).max() ?? 0) + 1
let synthetic = PrinterTray(
id: nextID, token: trayToken, name: trayToken)
printerCaps.trays.append(synthetic)
selectedTray = nextID
}
} else if let paperSource = result.properties.options.paperSource,
printerCaps.trays.contains(where: {
$0.id == paperSource
}) {
selectedTray = paperSource
}
// Capture-return (#183/#186): a dialog paper/quality/
// orientation change updates the Stage 2 selections —
// never `workflow.pageSize` (printtarg layout is
// sacred).
if let paper = result.properties.options.paperSize,
let match = printerCaps.paperSizes
.first(where: { $0.name == paper }) {
selectedPaperSize = match.id
}
// A captured quality the driver rejects for the
// (possibly just-captured) media is dropped — keeping
// it would only re-create a failed print (#214).
if let quality = result.properties.options.quality,
printerCaps.allowsQuality(
quality, forMediaType: selectedMediaType) {
selectedQuality = quality
}
if let orientation = result.properties.options.orientation {
printOrientation = orientation
}
printNotice = Notice(
kind: .info,
text: "Settings captured for \(selectedPrinter).",
@@ -106,16 +349,38 @@ final class PrintSessionViewModel {
}
}
func printAllPages(from result: PrinttargResult, pageSize: PageSize) {
func printAllPages(from result: PrinttargResult) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
// `defer` cannot mutate isolated state under Swift 5.7
// (Xcode 14.2 / macOS 12 runner), so clear explicitly (#113).
if singleJobForAllPages {
// D5 — one job carries every page; a failure names the
// job, not a page (R14).
do {
try await spoolAll(result)
printNotice = Notice(
kind: .info,
text: "Sent \(result.pages.count) page(s) in "
+ "one job to \(selectedPrinter).",
autoHideAfter: nil
)
} catch {
printNotice = Notice(
kind: .error,
text: "Print failed: \(error.localizedDescription)"
)
}
isPrinting = false
self.printTask = nil
return
}
var printed = 0
for page in result.pages {
do {
try await spool(page, index: page.index, pageSize: pageSize)
try await spool(page)
printed += 1
} catch {
printNotice = Notice(
@@ -124,6 +389,7 @@ final class PrintSessionViewModel {
+ error.localizedDescription
)
isPrinting = false
self.printTask = nil
return
}
}
@@ -133,18 +399,18 @@ final class PrintSessionViewModel {
autoHideAfter: nil
)
isPrinting = false
self.printTask = nil
}
printTask = task
}
func printPage(_ page: GalleryPage, pageSize: PageSize) {
func printPage(_ page: GalleryPage) {
guard !isPrinting else { return }
isPrinting = true
let task = Task { @MainActor [weak self] in
guard let self else { return }
defer { self.printTask = nil }
do {
try await spool(page, index: page.index, pageSize: pageSize)
try await spool(page)
printNotice = Notice(
kind: .info,
text: "Sent \(page.page.filename) to \(selectedPrinter).",
@@ -157,25 +423,73 @@ final class PrintSessionViewModel {
)
}
isPrinting = false
self.printTask = nil
}
printTask = task
}
private func spool(_ page: GalleryPage, index: Int, pageSize: PageSize) async throws {
private func spool(_ page: GalleryPage) async throws {
let request = try await makeRequest(
pages: [page], title: page.page.filename)
try spooler.spool(request)
wizard.printerName = selectedPrinter
}
private func spoolAll(_ result: PrinttargResult) async throws {
let request = try await makeRequest(
pages: result.pages,
title: result.ti2URL.deletingPathExtension()
.lastPathComponent)
try spooler.spool(request)
wizard.printerName = selectedPrinter
}
/// Assemble the deterministic spool request: the captured ticket
/// (when the panel produced one) plus the Stage 2 overrides and
/// the queue's option-key roster for vendor-key detection (#201).
/// The Stage 2 paper token feeds the `PageSize` write;
/// `workflow.pageSize` remains the printtarg layout input only
/// (#183).
private func makeRequest(
pages: [GalleryPage], title: String
) async throws -> TargetPrintRequest {
guard !selectedPrinter.isEmpty else {
throw CupsError.noPrinterSelected
}
let options = PrintOptions(
orientation: printOrientation,
paperSize: pageSize == .custom ? nil : pageSize.rawValue,
mediaType: selectedMediaType,
ppdUncorrectedPassthrough: true,
cupsOptions: capturedCupsOptions[selectedPrinter])
try await environment.cupsService.printTarget(
queue: selectedPrinter,
tiffPath: page.fileURL.path,
options: options,
page: index)
wizard.printerName = selectedPrinter
let queue = selectedPrinter
let optionKeys = (try? await environment.cupsService
.optionKeys(for: queue)) ?? []
attachDiagnostics()
// Defence-in-depth (#214): an invalid media+quality pair can
// never reach the ticket — substitute the first allowed
// quality when the pick is stale (UI already clamps; this is
// the last gate before the driver).
var quality = selectedQuality
if let q = quality,
!printerCaps.allowsQuality(
q, forMediaType: selectedMediaType,
trayToken: selectedTrayToken) {
quality = availableQualities.first?.id ?? q
}
return TargetPrintRequest(
queue: queue,
displayName: printers.first { $0.name == queue }?.displayName,
pages: pages.map {
TargetPrintPage(
url: $0.fileURL,
expectedWidthMm: $0.page.widthMm,
expectedHeightMm: $0.page.heightMm)
},
jobTitle: "ICCery Target - \(title)",
ticket: capturedTickets[queue],
overrides: TargetPrintOverrides(
paperSize: selectedPaperSizeToken,
mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey,
quality: quality,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
orientation: printOrientation),
optionKeys: optionKeys)
}
}
+30
View File
@@ -0,0 +1,30 @@
import Foundation
import ICCeryCore
/// A captured native print ticket (#201) — the payload `lp -o` could
/// never carry. Vendor PDE state, including opaque binary blobs, is
/// preserved verbatim inside `printSettings`.
///
/// In-memory, session-only. Never written to `settings.json` or an
/// `.icceryproj` (a ticket is queue- and driver-version-specific).
struct PrintTicket: Equatable, Sendable {
/// CUPS queue id this ticket was captured for. Replay onto any other
/// queue is refused (R3).
let queue: String
/// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
let printSettings: Data
/// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`.
let pageFormat: Data
/// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered.
/// Cocoa-level fallback only — never the primary restore path.
let printInfoPlist: Data?
let capturedAt: Date
}
/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult`
/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the
/// spooler replays (#201).
struct PanelCaptureResult {
var properties: PrintPropertiesResult
var ticket: PrintTicket?
}
@@ -0,0 +1,88 @@
import AppKit
/// Headless print canvas (#201, docs/14 §6). Never installed in a
/// window; `NSPrintOperation` is its only client.
@MainActor
final class TargetPageCanvasView: NSView {
private let pages: [TargetPageRaster]
private let paperSize: NSSize
/// Test probe — invoked once per drawn page with the live context
/// so the interpolation/antialias contract is assertable without a
/// printer.
var drawProbe: ((NSGraphicsContext, Int, NSRect) -> Void)?
init(pages: [TargetPageRaster], paperSize: NSSize) {
self.pages = pages
self.paperSize = paperSize
super.init(frame: NSRect(
x: 0, y: 0,
width: paperSize.width,
height: paperSize.height * CGFloat(pages.count)))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("TargetPageCanvasView is code-only")
}
/// Bottom-up CoreGraphics space: `CGContext.draw(_:in:)` renders
/// the raster upright — a flipped view prints mirrored (#211).
/// Page 1 is the BOTTOM band of the frame, the non-flipped
/// pagination convention.
override var isFlipped: Bool { false }
override var isOpaque: Bool { true }
override func knowsPageRange(_ range: NSRangePointer) -> Bool {
range.pointee = NSRange(location: 1, length: pages.count)
return true
}
/// 1-based page → its paper-sized rect, stacked bottom-up.
override func rectForPage(_ page: Int) -> NSRect {
NSRect(x: 0,
y: CGFloat(page - 1) * paperSize.height,
width: paperSize.width,
height: paperSize.height)
}
/// Where page `page`'s image lands: top-left anchored inside
/// `rectForPage`, sized `raster.pointSize`, origin snapped to
/// 0.001 pt (docs/14 §6) so a fractional origin cannot trigger
/// device resampling. `internal` for unit tests.
func destinationRect(forPage page: Int) -> NSRect {
let pageRect = rectForPage(page)
let size = pages[page - 1].pointSize
return NSRect(x: Self.snap(pageRect.minX),
y: Self.snap(pageRect.maxY - size.height),
width: size.width,
height: size.height)
}
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current else { return }
ctx.imageInterpolation = .none
ctx.shouldAntialias = false
let cg = ctx.cgContext
cg.interpolationQuality = .none
cg.setShouldAntialias(false)
cg.setAllowsAntialiasing(false)
cg.setShouldSmoothFonts(false)
for index in pages.indices {
let pageRect = rectForPage(index + 1)
guard pageRect.intersects(dirtyRect) else { continue }
cg.saveGState() // page N never inherits N-1's CTM
NSColor.white.setFill()
pageRect.fill()
cg.draw(pages[index].cgImage,
in: destinationRect(forPage: index + 1))
cg.restoreGState()
drawProbe?(ctx, index + 1, destinationRect(forPage: index + 1))
}
}
private static func snap(_ value: CGFloat) -> CGFloat {
(value * 1000).rounded() / 1000
}
}
+221
View File
@@ -0,0 +1,221 @@
import CoreGraphics
import Foundation
import ICCeryCore
import ImageIO
/// One decoded target page ready to draw 1:1 (#201, docs/14 §6).
struct TargetPageRaster: Equatable {
let sourceURL: URL
/// Re-tagged into a **device** colour space — the image Quartz draws.
let cgImage: CGImage
let pixelWidth: Int
let pixelHeight: Int
let dpiX: Double
let dpiY: Double
let componentCount: Int
let bitsPerComponent: Int
/// Expected physical size from the printtarg manifest, when known.
let expectedWidthMm: Double?
let expectedHeightMm: Double?
/// `pixels / dpi * 72`. The ONLY size the canvas ever draws at.
/// 72 pt = 1 in. **Never** `backingScaleFactor` (docs/14 §6).
var pointSize: CGSize {
CGSize(width: Double(pixelWidth) / dpiX * 72,
height: Double(pixelHeight) / dpiY * 72)
}
/// `pixels / dpi * 25.4` — the physical size the DPI implies.
var physicalMm: CGSize {
CGSize(width: Double(pixelWidth) / dpiX * 25.4,
height: Double(pixelHeight) / dpiY * 25.4)
}
/// `nil` when within 0.5 mm of the manifest, else the drift
/// message (D9 — warn + spool, never rescale).
var manifestDrift: String? {
var parts: [String] = []
if let expectedWidthMm,
abs(physicalMm.width - expectedWidthMm) > 0.5 {
parts.append(String(
format: "width %.2f mm vs manifest %.2f mm",
physicalMm.width, expectedWidthMm))
}
if let expectedHeightMm,
abs(physicalMm.height - expectedHeightMm) > 0.5 {
parts.append(String(
format: "height %.2f mm vs manifest %.2f mm",
physicalMm.height, expectedHeightMm))
}
guard !parts.isEmpty else { return nil }
return "TIFF DPI disagrees with the printtarg manifest — "
+ parts.joined(separator: "; ")
}
static func == (lhs: TargetPageRaster, rhs: TargetPageRaster) -> Bool {
lhs.sourceURL == rhs.sourceURL
&& lhs.cgImage === rhs.cgImage
&& lhs.pixelWidth == rhs.pixelWidth
&& lhs.pixelHeight == rhs.pixelHeight
&& lhs.dpiX == rhs.dpiX
&& lhs.dpiY == rhs.dpiY
&& lhs.componentCount == rhs.componentCount
&& lhs.bitsPerComponent == rhs.bitsPerComponent
&& lhs.expectedWidthMm == rhs.expectedWidthMm
&& lhs.expectedHeightMm == rhs.expectedHeightMm
}
}
enum TargetRasterError: LocalizedError, Equatable {
case tiffMissing(String)
case undecodable(String)
case unsupportedLayout(path: String, components: Int,
bitsPerComponent: Int, hasAlpha: Bool)
var errorDescription: String? {
switch self {
case .tiffMissing(let path):
return "Target TIFF is missing: \(path)"
case .undecodable(let path):
return "Target TIFF could not be decoded: \(path)"
case .unsupportedLayout(let path, let components,
let bitsPerComponent, let hasAlpha):
return "Target TIFF has an unsupported layout "
+ "(\(components) components, \(bitsPerComponent) bpc"
+ "\(hasAlpha ? ", alpha" : "")): \(path)"
}
}
}
enum TargetRasterLoader {
/// Absent DPI metadata defaults to 72 (docs/14 §6) — and logs `warn`.
static let fallbackDPI: Double = 72
static func load(
tiff url: URL,
expectedWidthMm: Double? = nil,
expectedHeightMm: Double? = nil
) throws -> TargetPageRaster {
guard FileManager.default.fileExists(atPath: url.path) else {
throw TargetRasterError.tiffMissing(url.path)
}
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil)
else {
throw TargetRasterError.undecodable(url.path)
}
let options: [CFString: Any] = [
kCGImageSourceShouldCache: true,
kCGImageSourceShouldAllowFloat: false,
]
guard let image = CGImageSourceCreateImageAtIndex(
source, 0, options as CFDictionary)
else {
throw TargetRasterError.undecodable(url.path)
}
// DPI lives in the container metadata, not the CGImage.
var dpiX = fallbackDPI
var dpiY = fallbackDPI
var foundX = false
var foundY = false
if let properties = CGImageSourceCopyPropertiesAtIndex(
source, 0, nil) as? [CFString: Any] {
if let x = (properties[kCGImagePropertyDPIWidth]
as? NSNumber)?.doubleValue, x > 0 {
dpiX = x
foundX = true
}
if let y = (properties[kCGImagePropertyDPIHeight]
as? NSNumber)?.doubleValue, y > 0 {
dpiY = y
foundY = true
}
}
if !foundX || !foundY {
AppLogger.shared.warn(
"TargetRaster: '\(url.lastPathComponent)' carries no "
+ "usable DPI metadata — assuming \(fallbackDPI) dpi")
}
let components = image.colorSpace?.numberOfComponents ?? 0
guard !Self.hasRealAlpha(image), let tagged = deviceTagged(image)
else {
throw TargetRasterError.unsupportedLayout(
path: url.path,
components: components,
bitsPerComponent: image.bitsPerComponent,
hasAlpha: Self.hasRealAlpha(image))
}
return TargetPageRaster(
sourceURL: url,
cgImage: tagged,
pixelWidth: image.width,
pixelHeight: image.height,
dpiX: dpiX,
dpiY: dpiY,
componentCount: components,
bitsPerComponent: image.bitsPerComponent,
expectedWidthMm: expectedWidthMm,
expectedHeightMm: expectedHeightMm)
}
/// Re-tag into `DeviceGray` (1 comp) / `DeviceRGB` (3) /
/// `DeviceCMYK` (4) by reusing the decoded image's own
/// `dataProvider`, `bitsPerComponent`, `bitsPerPixel`, `bytesPerRow`
/// and `bitmapInfo` — **no resample, no bit-depth change, no
/// `CGImageCreateCopyWithColorSpace` into a calibrated space**
/// (docs/14 §6.3). `shouldInterpolate: false`,
/// `intent: .defaultIntent`.
///
/// Returns `nil` (caller throws `.unsupportedLayout`) for alpha,
/// indexed, float, or >4-component images — a profiling target
/// never has those, and guessing would corrupt patches.
static func deviceTagged(_ image: CGImage) -> CGImage? {
guard !hasRealAlpha(image),
!image.bitmapInfo.contains(.floatComponents),
let colorSpace = image.colorSpace,
colorSpace.model != .indexed,
colorSpace.model != .pattern,
let space = deviceSpace(for: colorSpace.numberOfComponents),
let provider = image.dataProvider
else { return nil }
return CGImage(
width: image.width,
height: image.height,
bitsPerComponent: image.bitsPerComponent,
bitsPerPixel: image.bitsPerPixel,
bytesPerRow: image.bytesPerRow,
space: space,
bitmapInfo: image.bitmapInfo,
provider: provider,
decode: image.decode,
shouldInterpolate: false,
intent: .defaultIntent)
}
/// Skip-only padding (`noneSkipLast`/`noneSkipFirst`) is **not**
/// alpha — ImageIO pads opaque RGB TIFFs to 32 bpp that way, so
/// accepting it is required for real printtarg files. True coverage
/// alpha (`last`/`first`/`premultiplied*`/`only`) is rejected.
static func hasRealAlpha(_ image: CGImage) -> Bool {
switch image.alphaInfo {
case .none, .noneSkipLast, .noneSkipFirst:
return false
default:
return true
}
}
/// The device colour space for a component count — `nil` for
/// layouts a profiling target can never legally be (2 channels,
/// DeviceN, >4 components).
static func deviceSpace(for componentCount: Int) -> CGColorSpace? {
switch componentCount {
case 1: return CGColorSpaceCreateDeviceGray()
case 3: return CGColorSpaceCreateDeviceRGB()
case 4: return CGColorSpaceCreateDeviceCMYK()
default: return nil
}
}
}
@@ -0,0 +1,137 @@
import Foundation
import ICCeryCore
/// Stage 2 inline overrides applied on top of a rehydrated ticket
/// (#201 AC3 / D6 — Stage 2 always wins).
struct TargetPrintOverrides: Equatable {
var paperSize: String? // CUPS `PageSize` token or `Custom.<w>x<h>`
var mediaType: String?
var qualityKey: String? // PrinterCapabilities.qualityKey
var quality: String?
var trayKey: String? // PrinterCapabilities.trayKey
var tray: String? // raw tray token (#218)
var orientation: String? // "portrait" | "landscape"
}
/// One resolved ticket write.
struct TicketWrite: Equatable {
let key: String
let value: String
let locked: Bool
}
/// The fully-resolved write list for one job — a **pure value**,
/// computed with zero Core Printing calls so it is unit- and
/// UI-testable without a live queue. Replaces the old `lp` argv
/// builder (#201 D4/D8).
struct ResolvedTicketWrites: Equatable {
var writes: [TicketWrite]
var paperToken: String?
var orientation: String?
}
enum TicketWriteResolver {
/// Quartz vocabulary constants (docs/14 §7) live on
/// `ColorMatchingAttempts` next to the AP_* half — written
/// alongside because there is no `lp` path left (D2).
///
/// Order is locked and is the contract the tests assert:
/// 1 `AP_ColorMatchingMode` = AP_ApplicationColorMatching (locked)
/// 2 `AP.ColorMatchingMode` = AP_ApplicationColorMatching (locked)
/// 3 `PMColorMatchingMode` = APCustomColorMatching (locked)
/// 4 `PMCustomColorMatchingProfile` = "" (unlocked)
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
/// = APCustomColorMatching (unlocked)
/// 6 `PageSize` = overrides.paperSize
/// 7 overrides.trayKey = overrides.tray
/// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType
/// 9 overrides.qualityKey = overrides.quality
/// 10 detectDriverColorBypass(optionKeys) (unlocked)
/// 11 `orientation-requested` = 3 | 4
///
/// Invariants: `raw` can never appear (there is no `lp`); nothing
/// is suppressed because a captured key already exists (D6 — the
/// ticket carries the captured keys and Stage 2 writes land on
/// top unconditionally); an override with a `nil` value emits no
/// write.
static func resolve(
overrides: TargetPrintOverrides,
optionKeys: Set<String>
) -> ResolvedTicketWrites {
var writes: [TicketWrite] = []
// 1–2 — locked AP_* pair (layer ③).
for key in ColorMatchingAttempts.printSettingsKeys {
writes.append(TicketWrite(
key: key,
value: ColorMatchingAttempts.applicationMatchingValue,
locked: true))
}
// 3–5 — Quartz/`NSPrintOperation` vocabulary (D2).
writes.append(TicketWrite(
key: ColorMatchingAttempts.quartzModeKey,
value: ColorMatchingAttempts.quartzCustomMatching,
locked: true))
writes.append(TicketWrite(
key: ColorMatchingAttempts.quartzProfileKey,
value: "",
locked: false))
writes.append(TicketWrite(
key: ColorMatchingAttempts.quartzLegacyModeKey,
value: ColorMatchingAttempts.quartzCustomMatching,
locked: false))
// 6 — the Stage 2 paper token.
var paperToken: String?
if let paperSize = overrides.paperSize, !paperSize.isEmpty {
paperToken = paperSize
writes.append(TicketWrite(
key: "PageSize", value: paperSize, locked: false))
}
// 7 — paper source via the queue's detected tray roster key.
if let trayKey = overrides.trayKey, !trayKey.isEmpty,
let tray = overrides.tray, !tray.isEmpty {
writes.append(TicketWrite(
key: trayKey, value: tray, locked: false))
}
// 8 — media type via the queue's detected vendor key.
if let mediaType = overrides.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) {
writes.append(TicketWrite(
key: mediaKey, value: mediaType, locked: false))
}
// 9 — print quality; the detected enumeration key travels in
// the overrides (`PrinterCapabilities.qualityKey`, #183).
if let qualityKey = overrides.qualityKey,
let quality = overrides.quality {
writes.append(TicketWrite(
key: qualityKey, value: quality, locked: false))
}
// 10 — driver "no colour adjustment" bypass. Never gated on
// `ppdUncorrectedPassthrough` — macOS always bypasses.
if let bypass = CupsParsers.detectDriverColorBypass(
optionKeys: optionKeys) {
writes.append(TicketWrite(
key: bypass.key, value: bypass.value, locked: false))
}
// 11 — orientation, CUPS IPP codes portrait=3 / landscape=4.
if let orientation = overrides.orientation {
writes.append(TicketWrite(
key: "orientation-requested",
value: orientation == "landscape" ? "4" : "3",
locked: false))
}
return ResolvedTicketWrites(
writes: writes.filter { $0.key.lowercased() != "raw" },
paperToken: paperToken,
orientation: overrides.orientation)
}
}
+30 -31
View File
@@ -1,12 +1,11 @@
import Combine
import Foundation
import Observation
import SwiftUI
import ICCeryCore
/// Stage 4/5 workflow: build a profile, verify it, track drift, and install.
@MainActor
@Observable
final class ProfileWorkflowViewModel {
final class ProfileWorkflowViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -14,50 +13,50 @@ final class ProfileWorkflowViewModel {
// 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 = ""
@Published var algorithm: String = "l" // l | x | X | m
@Published var quality: String = "m" // l | m | h | u
@Published var intent: String = "" // usually empty at Stage 4
@Published var fwaSelection: ColprofFwaSelection = .none
@Published var fwaCustomPath: String = ""
@Published var illuminant: String = ""
@Published var observer: String = ""
@Published var inputViewingCond: String = ""
@Published var outputViewingCond: String = ""
@Published var profileDescription: String = ""
@Published var copyright: String = ""
// MARK: - Run state
var isColprofRunning = false
var colprofLog: [String] = []
var colprofProgress: String?
var createdProfileURL: URL?
@Published var isColprofRunning = false
@Published var colprofLog: [String] = []
@Published var colprofProgress: String?
@Published var createdProfileURL: URL?
/// Path to the `.gam` gamut mesh extracted post-`colprof` (issue #28).
var createdGamutURL: URL?
@Published var createdGamutURL: URL?
// MARK: - Stage 4/5 calibration (issue #24)
var applyCalibration = false
var calibrationFile: String = ""
@Published var applyCalibration = false
@Published var calibrationFile: String = ""
// MARK: - Stage 5 verification (issue #25)
var profcheckReport: ProfcheckReport?
var profcheckWarning: String?
var isProfcheckRunning = false
@Published var profcheckReport: ProfcheckReport?
@Published var profcheckWarning: String?
@Published var isProfcheckRunning = false
// MARK: - History / drift (issue #26)
var verificationHistory: [VerificationRecord] = []
var driftPrinterFilter: String? = nil
var driftAlert: String?
var isHistoryStoreError: String?
@Published var verificationHistory: [VerificationRecord] = []
@Published var driftPrinterFilter: String? = nil
@Published var driftAlert: String?
@Published var isHistoryStoreError: String?
// MARK: - Install (issue #27)
var installResult: InstallProfileResult?
var showingInstallCollision = false
var installCollisionMessage: String = ""
@Published var installResult: InstallProfileResult?
@Published var showingInstallCollision = false
@Published var installCollisionMessage: String = ""
var pendingInstallOptions: InstallProfileOptions?
init(wizard: WizardViewModel, environment: AppEnvironment) {
+689
View File
@@ -0,0 +1,689 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Project file session (issue #149): `.icceryproj` open/save/new/close,
/// recents, the dirty flag, and the window title.
///
/// The project is an **index** — disk artefacts still own the stepper
/// (R18). Open writes `WizardState` through the normal setters and ends
/// in the same probe `windowDidBecomeKey` uses; it never auto-runs
/// `targen`/`colprof`/`chartread` and never unlocks a stage the disk
/// does not back.
@MainActor
final class ProjectSession: ObservableObject {
/// What to do once the dirty alert resolves.
enum PendingAction {
case new
case open(URL)
case close
}
/// A live session snapshot, compared against the bound project for
/// the dirty flag (title `•`, `btnProjectSave`).
private struct Snapshot: Equatable {
var basename: String
var cwd: String
var printerID: String?
var presetID: String?
var mediaRecipeID: String?
var calibrationURL: String?
}
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
private let recentsStore: RecentProjectsStore
private var cancellables = Set<AnyCancellable>()
/// Bound project file location; `nil` = no project.
@Published var projectURL: URL?
/// Last saved/opened project payload.
@Published var project: ICCeryProject?
/// Open Recent entries, newest first, missing files pruned.
@Published var recents: [RecentProjectEntry] = []
/// JSON claimed a finished profile but disk stops earlier —
/// `projectChipStale` + info banner (R18).
@Published var diskBehindNotes = false
/// `ICCery` / `ICCery — {name}` / `ICCery — {name} •`.
@Published var windowTitle = "ICCery"
/// Live session fields differ from the bound project — computed
/// fresh so decision paths (`requestNew`/`requestClose`) never see
/// a stale willSet value.
var isDirty: Bool {
guard let project else { return false }
return liveSnapshot() != snapshot(of: project)
}
// Flow state for RootView.
@Published var showingNewAlert = false
@Published var showingDirtyAlert = false
@Published var showingRelocateSheet = false
private var pendingAction: PendingAction?
/// Loaded project whose `cwd` is missing — relocate sheet payload.
@Published private(set) var pendingRelocate:
(project: ICCeryProject, url: URL)?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
self.recentsStore = environment.recentProjectsStore
refreshRecents()
// Dirty / title derive from live fields — observe each source;
// child VMs are not tracked through the parent's
// objectWillChange (R22-style weak sinks). `@Published` fires on
// willSet, so the recompute is deferred one main turn to read
// post-set values.
let wizard = workflow.wizard
let printSession = workflow.print
let media = workflow.media
let profile = workflow.profile
wizard.$basename.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$workingDirectory.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
wizard.$printerName.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
printSession?.$selectedPrinter.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
workflow.$selectedPresetID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
media?.$selectedRecipeID.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
profile.$calibrationFile.sink { [weak self] _ in self?.scheduleRecompute() }
.store(in: &cancellables)
recomputeDerived()
}
deinit { cancellables.removeAll() }
// MARK: - Derived
var isBound: Bool { project != nil }
/// A chartread / spotread / colprof child is live — project changes
/// wait for the instrument run to finish.
var childSessionLive: Bool {
workflow.measurement.isChartreadRunning
|| workflow.spotRead.isRunning
|| workflow.profile.isColprofRunning
}
/// Basename eligible for persistence. A live `CAL_` stem resolves
/// only through the persisted original — never by stripping, so a
/// bare `CAL_` (Force-Quit anomaly, empty persisted original) is
/// refused rather than trusted (R11).
var resolvedBasename: String? {
let live = workflow.wizard.basename
guard CalibrationIdentity.isCalibration(live) else {
return live.isEmpty ? nil : live
}
let original = workflow.wizard.calibrationOriginalBasename
return original.isEmpty ? nil : original
}
/// ⌘S / `btnProjectSave` enable rule. A `CAL_` live basename stays
/// enabled so the refusal can show its banner.
var canSave: Bool {
isBound && !workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectSaveAs` — no binding required.
var canSaveAs: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
/// `menuProjectReport` — needs a real basename and cwd on disk.
var canReport: Bool {
!workflow.wizard.basename.isEmpty
&& workflow.wizard.workingDirectory != nil
}
private func liveSnapshot() -> Snapshot {
Snapshot(
basename: resolvedBasename ?? "",
cwd: workflow.wizard.workingDirectory?.path ?? "",
printerID: workflow.print.selectedPrinter.isEmpty
? nil : workflow.print.selectedPrinter,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile)
}
private func snapshot(of project: ICCeryProject) -> Snapshot {
Snapshot(
basename: project.basename,
cwd: project.cwd,
printerID: project.printerID,
presetID: project.presetID,
mediaRecipeID: project.mediaRecipeID,
calibrationURL: project.calibrationURL)
}
/// Recomputes the window title from live fields.
func recomputeDerived() {
if let project {
windowTitle = "ICCery — \(project.name)" + (isDirty ? " •" : "")
} else {
windowTitle = "ICCery"
}
}
/// Defer the recompute one main turn — the Combine sinks fire on
/// willSet, before the changed property holds its new value.
private func scheduleRecompute() {
Task { @MainActor [weak self] in self?.recomputeDerived() }
}
// MARK: - New
/// ⌘N / `menuProjectNew`. Dirty sessions detour through the dirty
/// alert first; a live child gets a banner instead of the alert.
func requestNew() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before starting a project.",
kind: .warning)
return
}
if isDirty {
pendingAction = .new
showingDirtyAlert = true
return
}
showingNewAlert = true
}
/// `btnProjectNewConfirm` — unbinds and clears the basename. Empty
/// is the legal "no target yet" state (#60); artefacts and
/// `media_library.json` are never deleted (R18).
func confirmNew() {
showingNewAlert = false
projectURL = nil
project = nil
pendingRelocate = nil
diskBehindNotes = false
workflow.wizard.basename = ""
workflow.targetBasename = ""
workflow.media.selectedRecipeID = "none"
workflow.wizard.sessionMode = .profile
// Re-probe — an empty basename locks stages 2–5.
workflow.wizard.windowDidBecomeKey()
recomputeDerived()
}
// MARK: - Open
/// ⌘O / `btnProjectOpen` — `selectProjectFile` (`.icceryproj`
/// only). Cancel is a no-op.
func requestOpen() {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectOpenURL
: fileDialogs.selectProjectFile()
guard let url else { return }
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Open from the recents submenu — same path, no open panel. A
/// missing file is dropped with a banner; no relocate is offered.
func openRecent(_ entry: RecentProjectEntry) {
guard !childSessionLive else {
workflow.wizard.showNotice(
"Finish the instrument session before opening a project.",
kind: .warning)
return
}
let url = URL(fileURLWithPath: entry.path)
guard FileManager.default.fileExists(atPath: url.path) else {
Task { try? await recentsStore.remove(path: entry.path) }
refreshRecents()
workflow.wizard.showNotice("Project file is gone.", kind: .warning)
return
}
if isDirty {
pendingAction = .open(url)
showingDirtyAlert = true
return
}
open(url)
}
/// Shared open path — validates, routes to relocate when `cwd` is
/// gone, then applies. Failures leave live state untouched.
func open(_ url: URL) {
guard let loaded = loadProject(url) else { return }
apply(loaded, from: url)
}
/// Awaitable open for tests — identical to `open` but completes
/// after apply finishes.
func openAsync(_ url: URL) async {
guard let loaded = loadProject(url) else { return }
await applyAsync(loaded, from: url)
}
/// Decode + validate + cwd-existence check. Returns the project
/// ready to apply, or nil after presenting an error banner / the
/// relocate sheet.
private func loadProject(_ url: URL) -> ICCeryProject? {
let loaded: ICCeryProject
do {
loaded = try ICCeryProject.load(from: url)
} catch let error as ICCeryProject.ValidationError {
workflow.wizard.showNotice(
error.errorDescription ?? "Could not open project.",
kind: .error)
return nil
} catch {
workflow.wizard.showNotice(
"Could not open project: \(error.localizedDescription)",
kind: .error)
return nil
}
var isDir: ObjCBool = false
guard FileManager.default.fileExists(
atPath: loaded.cwd, isDirectory: &isDir), isDir.boolValue
else {
pendingRelocate = (loaded, url)
showingRelocateSheet = true
return nil
}
return loaded
}
/// Sync entry — the apply half runs in a Task so `media.apply`
/// (async) can run inside it.
private func apply(_ project: ICCeryProject, from url: URL) {
Task { @MainActor [weak self] in
await self?.applyAsync(project, from: url)
}
}
/// Apply order (issue #149):
/// 1. cwd + basename on the wizard **and** Stage 1 fields.
/// 2. `mediaRecipeID` → `MediaLibraryViewModel.apply`; else
/// `presetID` → existing preset apply. Missing recipe is not
/// fatal — open still succeeds.
/// 3. Re-probe via the existing window-focus path (#151).
/// 4. Never auto-run `targen` / `colprof` / `chartread`.
private func applyAsync(_ project: ICCeryProject, from url: URL) async {
let cwdURL = URL(fileURLWithPath: project.cwd, isDirectory: true)
workflow.wizard.setTarget(
basename: project.basename, workingDirectory: cwdURL)
workflow.targetBasename = project.basename
workflow.targetDirectory = cwdURL
workflow.wizard.printerName = project.printerDisplayName
?? project.printerID
workflow.wizard.profileBasename = project.profileBasename
workflow.wizard.sessionMode = .profile
if let queue = project.printerID,
workflow.print.printers.contains(where: { $0.name == queue }) {
workflow.print.selectedPrinter = queue
}
var appliedRecipe = false
if let recipeID = project.mediaRecipeID {
if workflow.media.recipes.isEmpty {
await workflow.media.reloadAsync()
}
if let recipe = workflow.media.recipes
.first(where: { $0.id == recipeID }) {
appliedRecipe = await workflow.media.apply(recipe)
}
}
if !appliedRecipe, let presetID = project.presetID,
let preset = workflow.presets
.first(where: { $0.id == presetID }) {
workflow.applyPreset(preset)
}
// Disk owns the stepper — same probe window focus uses.
workflow.wizard.windowDidBecomeKey()
// JSON-vs-disk mismatch: notes say the profile is done but the
// artefacts stop earlier — index drift, not gating truth.
let artefacts = workflow.wizard.artefacts
if project.lastVerification != nil && !artefacts.stage4Complete {
let already = diskBehindNotes && projectURL == url
diskBehindNotes = true
if !already {
let last = artefacts.stage3Complete ? ".ti3"
: artefacts.stage2Complete ? ".ti2"
: artefacts.stage1Complete ? ".ti1" : nil
let detail = last.map { "artefacts on disk stop at \($0)." }
?? "no artefacts found on disk."
workflow.wizard.showNotice(
"Project notes say profile done; \(detail)",
kind: .info)
}
} else {
diskBehindNotes = false
}
self.project = project
projectURL = url
pushRecent(url: url, name: project.name)
recomputeDerived()
}
// MARK: - Relocate
/// `btnProjectRelocate` — pick a new cwd, rewrite the project file
/// atomically, then continue Apply.
func chooseRelocateFolder() {
guard let pending = pendingRelocate else { return }
let url = UITestHooks.isEnabled
? UITestHooks.projectRelocateURL
: fileDialogs.selectDirectory()
guard let url else { return }
var rewritten = pending.project
rewritten.cwd = url.path
rewritten.updated = Date()
do {
try rewritten.save(to: pending.url)
} catch {
workflow.wizard.showNotice(
"Could not update project: \(error.localizedDescription)",
kind: .error)
return
}
pendingRelocate = nil
showingRelocateSheet = false
apply(rewritten, from: pending.url)
}
/// `btnProjectRelocateCancel` — aborts the open; live session
/// unchanged.
func cancelRelocate() {
pendingRelocate = nil
showingRelocateSheet = false
}
// MARK: - Save / Save As
/// ⌘S / `btnProjectSave` — writes the live session into the bound
/// URL. A live `CAL_` stem refuses unless the persisted original
/// resolves; `CAL_` is never persisted (R11).
func saveProject() {
Task { @MainActor in _ = await saveProjectAsync() }
}
@discardableResult
func saveProjectAsync() async -> Bool {
guard let url = projectURL, project != nil else { return false }
guard let basename = resolvedBasename else {
refuseUnsavable()
return false
}
return await write(to: url, basename: basename)
}
/// ⇧⌘S / `menuProjectSaveAs` — always shows the save picker, then
/// binds the chosen URL and pushes recents.
func saveProjectAs() {
guard let basename = resolvedBasename else {
refuseUnsavable()
return
}
guard let cwd = workflow.wizard.workingDirectory else {
refuseUnsavable()
return
}
let url = UITestHooks.isEnabled
? UITestHooks.projectSaveURL
: fileDialogs.selectProjectSavePath(
basename: basename, startingAt: cwd)
guard let url else { return }
Task { @MainActor in _ = await write(to: url, basename: basename) }
}
private func refuseUnsavable() {
if CalibrationIdentity.isCalibration(workflow.wizard.basename) {
workflow.wizard.showNotice(
"Finish or exit calibration before saving a project.",
kind: .warning)
} else {
workflow.wizard.showNotice(
"Set a target basename and working folder before saving a project.",
kind: .warning)
}
}
/// Builds the project payload from live session fields and writes
/// it atomically. On success binds `url`, refreshes recents, and
/// clears the stale-chip flag. Failure → error banner, bound URL
/// unchanged.
@discardableResult
private func write(to url: URL, basename: String) async -> Bool {
guard let cwd = workflow.wizard.workingDirectory,
!cwd.path.isEmpty else {
refuseUnsavable()
return false
}
let stem = workflow.wizard.profileBasename ?? basename
let snapshot = await lastVerificationSnapshot(stem: stem)
?? project?.lastVerification
let queue = workflow.print.selectedPrinter
let display = workflow.print.printers
.first { $0.name == queue }?.displayName
?? workflow.wizard.printerName
let existing = project
let name = (existing?.name.isEmpty == false)
? existing!.name : basename
let updated = ICCeryProject(
name: name,
notes: existing?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: workflow.media.selectedRecipeID == "none"
? nil : workflow.media.selectedRecipeID,
presetID: workflow.selectedPresetID == "none"
? nil : workflow.selectedPresetID,
calibrationURL: workflow.profile.calibrationFile.isEmpty
? nil : workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
try updated.save(to: url)
} catch {
workflow.wizard.showNotice(
"Could not save project: \(error.localizedDescription)",
kind: .error)
return false
}
project = updated
projectURL = url
diskBehindNotes = false
pushRecent(url: url, name: updated.name)
workflow.wizard.showNotice("Project saved: \(url.lastPathComponent)")
recomputeDerived()
return true
}
/// Last `VerificationHistoryStore` record for this profile stem,
/// if any — notes only.
private func lastVerificationSnapshot(
stem: String
) async -> VerificationSnapshot? {
guard let records = try? await environment.historyStore.load()
else { return nil }
guard let record = records.last(where: {
$0.profileName == stem || $0.profileName == workflow.wizard.basename
}) else { return nil }
return VerificationSnapshot(record: record)
}
// MARK: - Close
/// `menuProjectClose` — unbinds; live basename/cwd/artefacts stay.
func requestClose() {
guard isBound else { return }
if isDirty {
pendingAction = .close
showingDirtyAlert = true
return
}
closeProject()
}
func closeProject() {
projectURL = nil
project = nil
diskBehindNotes = false
recomputeDerived()
}
// MARK: - Dirty alert
/// `btnProjectDirtySave` / `btnProjectDirtyDiscard`. Save proceeds
/// to the pending action only when the write succeeded.
func resolveDirty(save: Bool) {
showingDirtyAlert = false
guard let action = pendingAction else { return }
if save {
Task { @MainActor [weak self] in
guard let self else { return }
if await self.saveProjectAsync() {
self.pendingAction = nil
self.proceed(with: action)
}
}
} else {
pendingAction = nil
proceed(with: action)
}
}
/// `btnProjectDirtyCancel` — abandons the pending action.
func cancelDirty() {
pendingAction = nil
showingDirtyAlert = false
}
private func proceed(with action: PendingAction) {
switch action {
case .new:
confirmNew()
case .open(let url):
open(url)
case .close:
closeProject()
}
}
// MARK: - Report
/// `menuProjectReport` — writes `{cwd}/{basename}-report.md`
/// atomically (generated; overwrites). No picker.
func saveReport() {
guard canReport, let cwd = workflow.wizard.workingDirectory else {
return
}
let basename = workflow.wizard.basename
Task { @MainActor [weak self] in
guard let self else { return }
let stem = self.workflow.wizard.profileBasename ?? basename
let snapshot = await self.lastVerificationSnapshot(stem: stem)
?? self.project?.lastVerification
let queue = self.workflow.print.selectedPrinter
let display = self.workflow.print.printers
.first { $0.name == queue }?.displayName
?? self.workflow.wizard.printerName
let recipeName = self.workflow.media.recipes
.first { $0.id == self.workflow.media.selectedRecipeID }?.name
let payload = ICCeryProject(
name: self.project?.name.isEmpty == false
? self.project!.name : basename,
notes: self.project?.notes ?? "",
basename: basename,
cwd: cwd.path,
profileBasename: self.workflow.wizard.profileBasename,
printerID: queue.isEmpty ? nil : queue,
printerDisplayName: display,
mediaRecipeID: self.workflow.media.selectedRecipeID == "none"
? nil : self.workflow.media.selectedRecipeID,
presetID: self.workflow.selectedPresetID == "none"
? nil : self.workflow.selectedPresetID,
calibrationURL: self.workflow.profile.calibrationFile.isEmpty
? nil : self.workflow.profile.calibrationFile,
lastVerification: snapshot,
updated: Date())
do {
let written = try ProjectReport.write(
project: payload,
recipeName: recipeName,
artefacts: self.workflow.wizard.artefacts)
self.workflow.wizard.showNotice(
"Wrote \(written.lastPathComponent)")
} catch {
self.workflow.wizard.showNotice(
"Report failed: \(error.localizedDescription)",
kind: .error)
}
}
}
// MARK: - Recents / reveal
/// Rebuilds the recents list; entries whose file is gone are
/// dropped here (submenu build), not at launch. Corrupt → `[]`,
/// file kept (R12).
func refreshRecents() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
self.recents = try await self.recentsStore.pruneMissing()
} catch {
self.recents = []
}
}
}
private func pushRecent(url: URL, name: String) {
Task { @MainActor [weak self] in
guard let self else { return }
try? await self.recentsStore.add(url: url, name: name)
self.refreshRecents()
}
}
/// `menuProjectRecentsClear` — wipes `recent_projects.json` only;
/// `.icceryproj` files are never deleted.
func clearRecents() {
Task { @MainActor [weak self] in
try? await self?.recentsStore.clear()
self?.recents = []
}
}
/// `btnProjectReveal` — reveals the bound **project file** in
/// Finder, not the cwd.
func revealInFinder() {
guard let projectURL else { return }
NSWorkspace.shared.activateFileViewerSelecting([projectURL])
}
}
+151
View File
@@ -0,0 +1,151 @@
import SwiftUI
import ICCeryCore
/// File-menu commands for the project file (issue #149). Content of the
/// `CommandGroup(replacing: .newItem)` in `ICCeryApp` — split out so the
/// app body stays small (type-checker budget). Menu ids are distinct
/// from the sidebar chip ids (`menuProject*` vs `btnProject*`).
struct ProjectCommands: View {
@ObservedObject private var project: ProjectSession
init(workflow: TargetWorkflowViewModel) {
self._project = ObservedObject(wrappedValue: workflow.project)
}
var body: some View {
Button("New Project") { project.requestNew() }
.keyboardShortcut("n")
.accessibilityIdentifier("menuProjectNew")
Button("Open Project…") { project.requestOpen() }
.keyboardShortcut("o")
.accessibilityIdentifier("menuProjectOpen")
Menu("Open Recent") {
ForEach(project.recents) { entry in
// Names render via Text only (#114); never the raw path.
Button(entry.name) { project.openRecent(entry) }
.accessibilityIdentifier("projectRecent-\(entry.bookmarkHash)")
}
Divider()
Button("Clear Menu") { project.clearRecents() }
.accessibilityIdentifier("menuProjectRecentsClear")
}
.disabled(project.recents.isEmpty)
.accessibilityIdentifier("menuProjectRecents")
Divider()
Button("Save Project") { project.saveProject() }
.keyboardShortcut("s")
.disabled(!project.canSave)
.accessibilityIdentifier("menuProjectSave")
Button("Save Project As…") { project.saveProjectAs() }
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!project.canSaveAs)
.accessibilityIdentifier("menuProjectSaveAs")
Button("Save Report…") { project.saveReport() }
.disabled(!project.canReport)
.accessibilityIdentifier("menuProjectReport")
Divider()
Button("Close Project") { project.requestClose() }
.disabled(!project.isBound)
.accessibilityIdentifier("menuProjectClose")
}
}
/// Compact project footer at the bottom of the 270 pt sidebar (issue
/// #149) — never a fourth row of large buttons (R10).
struct ProjectChip: View {
@ObservedObject var project: ProjectSession
@Binding var showingAllHelp: Bool
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if let bound = project.project {
Text(bound.name)
.font(.callout)
.foregroundStyle(Theme.text)
.lineLimit(1)
.accessibilityIdentifier("projectChipName")
Text(URL(fileURLWithPath: bound.cwd).lastPathComponent)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
// The raw path is a tooltip — never the window title.
.help(bound.cwd)
.accessibilityIdentifier("projectChipPath")
if project.diskBehindNotes {
Text("Disk behind project notes")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("projectChipStale")
}
HStack(spacing: 8) {
Button("Show in Finder") { project.revealInFinder() }
.accessibilityIdentifier("btnProjectReveal")
Button("Save") { project.saveProject() }
.disabled(!project.canSave)
.accessibilityIdentifier("btnProjectSave")
Spacer()
}
.font(.caption)
.controlSize(.small)
} else {
HStack(spacing: 8) {
Text("No project")
.font(.callout)
.foregroundStyle(.secondary)
.accessibilityIdentifier("projectChipName")
Spacer()
Button("Open…") { project.requestOpen() }
.controlSize(.small)
.accessibilityIdentifier("btnProjectOpen")
}
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.fill(Theme.panel)
)
.overlay(
RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)
.stroke(Theme.border)
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("projectChip")
.helpOverlay(
"A project remembers printer, preset and folder. The stepper still follows files on disk.",
showing: $showingAllHelp)
}
}
/// `projectRelocateSheet` — shown when an opened project's `cwd` no
/// longer exists (issue #149). Choosing a folder rewrites `cwd` in the
/// project file atomically, then continues Apply; Cancel aborts the
/// open with live state untouched.
struct ProjectRelocateSheet: View {
@ObservedObject var project: ProjectSession
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Missing project folder")
.font(.title3)
.foregroundStyle(Theme.text)
Text(
"The folder for \(project.pendingRelocate?.project.name ?? "this project") is missing. Choose a new working folder."
)
.foregroundStyle(Theme.text)
HStack {
Spacer()
Button("Cancel") { project.cancelRelocate() }
.accessibilityIdentifier("btnProjectRelocateCancel")
Button("Choose Folder…") { project.chooseRelocateFolder() }
.accessibilityIdentifier("btnProjectRelocate")
}
}
.padding(20)
.frame(width: 420)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("projectRelocateSheet")
}
}
+68 -6
View File
@@ -5,12 +5,20 @@ 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
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var project: ProjectSession
@State private var showingSettings = false
@State private var showingAbout = false
@State private var showingAllHelp = false
private var model: WizardViewModel { workflow.wizard }
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._project = ObservedObject(wrappedValue: workflow.project)
}
var body: some View {
HStack(spacing: 0) {
@@ -31,6 +39,7 @@ struct RootView: View {
}
WizardStageContent(model: model, workflow: workflow)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 1100, minHeight: 700)
.background(Theme.background)
@@ -50,6 +59,25 @@ struct RootView: View {
.sheet(isPresented: $workflow.showingManagePresets) {
ManagePresetsDialog(workflow: workflow)
}
// Media library sheets live on RootView, never inside the
// 270 pt sidebar column (#146).
.sheet(isPresented: $workflow.showingSaveMedia) {
SaveMediaRecipeDialog(workflow: workflow)
}
.sheet(
isPresented: $workflow.showingManageMedia,
onDismiss: { workflow.media.manageDismissed() }
) {
ManageMediaDialog(workflow: workflow)
}
// Spot-read console sheet (issue #148). Dismiss runs the same
// `q\n` + ~500 ms + kill path as the sheet's Stop button.
.sheet(
isPresented: $workflow.showingSpotRead,
onDismiss: { workflow.spotRead.sheetClosed() }
) {
SpotReadView(model: workflow.spotRead)
}
.sheet(isPresented: $showingAbout) {
AboutView { showingAbout = false }
}
@@ -57,18 +85,52 @@ struct RootView: View {
get: { workflow.wizard.showingGamutViewer },
set: { workflow.wizard.showingGamutViewer = $0 }
)) {
GamutView(profileGamURL: workflow.wizard.gamutProfileURL)
GamutView(
environment: workflow.environment,
profileGamURL: workflow.wizard.gamutProfileURL,
showingAllHelp: $showingAllHelp)
}
// Project file chrome (issue #149): window title, New confirm
// alert, dirty alert, relocate sheet. Panels never appear from
// a View — UITestHooks inject fixture paths instead.
.onReceive(project.$windowTitle) { title in
for window in NSApp.windows where !(window is NSPanel) {
window.title = title
}
}
.alert("Start a new project?", isPresented: $project.showingNewAlert) {
Button("Cancel", role: .cancel) {}
.accessibilityIdentifier("btnProjectNewCancel")
Button("Start") { project.confirmNew() }
.accessibilityIdentifier("btnProjectNewConfirm")
} message: {
Text("The working folder and targets on disk are not deleted.")
.accessibilityIdentifier("projectNewAlert")
}
.alert(
"Save the current project first?",
isPresented: $project.showingDirtyAlert
) {
Button("Save") { project.resolveDirty(save: true) }
.accessibilityIdentifier("btnProjectDirtySave")
Button("Don't Save") { project.resolveDirty(save: false) }
.accessibilityIdentifier("btnProjectDirtyDiscard")
Button("Cancel", role: .cancel) { project.cancelDirty() }
.accessibilityIdentifier("btnProjectDirtyCancel")
}
.sheet(isPresented: $project.showingRelocateSheet) {
ProjectRelocateSheet(project: project)
}
}
}
/// Content for the active wizard stage. Isolated into its own view so that
/// `WizardViewModel` is tracked via `@Bindable` instead of the parent's
/// `WizardViewModel` is tracked via `@ObservedObject` instead of the parent's
/// `TargetWorkflowViewModel`, which does not observe nested `wizard` mutations.
private struct WizardStageContent: View {
@Bindable var model: WizardViewModel
var workflow: TargetWorkflowViewModel
@ObservedObject var model: WizardViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
switch model.stage {
+27 -29
View File
@@ -4,7 +4,7 @@ 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()
@StateObject var model = SettingsViewModel()
@Environment(\.dismiss) private var dismiss
private static let instruments: [(code: String, label: String)] = [
@@ -58,7 +58,7 @@ struct SettingsView: View {
Text($0.label).tag($0.code)
}
}
Text("Display-only — Stage 2's instrument select is used for actual runs.")
Text("Seeds Spot Read and Stage 3 when the instrument is plugged in. printtarg -i is still chosen on Stage 2.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -69,22 +69,24 @@ struct SettingsView: View {
}
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)
}
TextField(
"Good ΔE ≤",
value: $model.settings.deltaEGoodMax,
format: .number
)
.accessibilityIdentifier("settingsDeltaEGood")
TextField(
"Warning ΔE ≤",
value: $model.settings.deltaEWarningMax,
format: .number
)
.accessibilityIdentifier("settingsDeltaEWarning")
Text("Swatch and verify status use these as the green / amber cutoffs. Fail is anything above Warning.")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(model.validationErrors, id: \.self) { error in
Text(error)
.font(.caption)
@@ -93,16 +95,12 @@ struct SettingsView: View {
}
Section("Calibration") {
HStack {
Text("Stale after")
TextField(
"30",
value: $model.settings.calibrationStaleDays,
format: .number
)
.frame(width: 60)
Text("days")
}
TextField(
"Stale after (days)",
value: $model.settings.calibrationStaleDays,
format: .number
)
.accessibilityIdentifier("settingsCalStaleDays")
}
Section("Profile install") {
@@ -143,7 +141,7 @@ struct SettingsView: View {
}
}
}
.formStyle(.grouped)
.padding(.leading, 45)
Divider()
+6 -6
View File
@@ -1,4 +1,5 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
@@ -6,12 +7,11 @@ import ICCeryCore
/// validation; the log level is applied live via `LogSink` (#158) and a
/// `settingsDidChange` notification fans out to #20.
@MainActor
@Observable
final class SettingsViewModel {
final class SettingsViewModel: ObservableObject {
var settings: AppSettings
var validationErrors: [String] = []
var savedFlash = false
@Published var settings: AppSettings
@Published var validationErrors: [String] = []
@Published var savedFlash = false
private let store: SettingsStore
private let sink: LogSink
@@ -33,7 +33,7 @@ final class SettingsViewModel {
sink.applySettings(settings)
savedFlash = true
Task {
try? await Task.sleep(for: .seconds(1.5))
try? await Task.sleep(nanoseconds: 1_500_000_000)
savedFlash = false
}
return true
+136 -4
View File
@@ -4,15 +4,54 @@ import ICCeryCore
/// 270 pt sidebar (docs/21 §Shell): logo, settings/about buttons, preset
/// select, Calibrate Printer + status chip, and the 1–5 stepper.
struct SidebarView: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var model: WizardViewModel
@ObservedObject private var profile: ProfileWorkflowViewModel
@ObservedObject private var media: MediaLibraryViewModel
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var measurement: MeasurementWorkflowViewModel
@ObservedObject private var project: ProjectSession
var onOpenSettings: () -> Void
var onOpenAbout: () -> Void
@Binding var showingAllHelp: Bool
private var model: WizardViewModel { workflow.wizard }
init(
workflow: TargetWorkflowViewModel,
onOpenSettings: @escaping () -> Void,
onOpenAbout: @escaping () -> Void,
showingAllHelp: Binding<Bool>
) {
self.workflow = workflow
self._model = ObservedObject(wrappedValue: workflow.wizard)
self._profile = ObservedObject(wrappedValue: workflow.profile)
self._media = ObservedObject(wrappedValue: workflow.media)
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._measurement = ObservedObject(wrappedValue: workflow.measurement)
self._project = ObservedObject(wrappedValue: workflow.project)
self.onOpenSettings = onOpenSettings
self.onOpenAbout = onOpenAbout
self._showingAllHelp = showingAllHelp
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
presetBlock
mediaBlock
studioButtons
stepperAndProject
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
// Swift 5.7 (Xcode 14.2 CI runner) caps a ViewBuilder body at 10
// children (#146); these Group blocks are layout-transparent, so
// visual order, ids and the 270 pt column are unchanged.
private var header: some View {
Group {
HStack {
Image("ICCery-logo")
.resizable()
@@ -41,7 +80,11 @@ struct SidebarView: View {
.padding(12)
Divider().overlay(Theme.border)
}
}
private var presetBlock: some View {
Group {
// Preset select (`#presetSelect`) — issue #11. Selection
// applies the preset immediately; names render via Text only.
Picker("Preset", selection: Binding(
@@ -73,7 +116,65 @@ struct SidebarView: View {
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private var mediaBlock: some View {
Group {
// Media library (`#mediaSelect`) — issue #146. Selection
// applies the recipe immediately, like presets; names render
// via Text only (#114). Never reuses `presetSelect` (#137).
Picker("Media", selection: Binding(
get: { media.selectedRecipeID },
set: { media.selectRecipe($0) }
)) {
Text("No media recipe").tag("none")
ForEach(media.recipes) { recipe in
Text(recipe.name).tag(recipe.id)
}
}
.pickerStyle(.menu)
.accessibilityIdentifier("mediaSelect")
.padding(.horizontal, 12)
.padding(.vertical, 8)
.helpOverlay(
"Saved printer + paper + ink + .cal bound to a preset.",
showing: $showingAllHelp)
if let reasons = media.staleReasons[media.selectedRecipeID],
!reasons.isEmpty {
Text(reasons.contains(.printer)
? "Printer not installed" : "Calibration stale")
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 12)
.accessibilityIdentifier("mediaRecipeStale")
.helpOverlay(
"Re-run Stage 0 or pick a different recipe.",
showing: $showingAllHelp)
}
HStack(spacing: 8) {
Button("Capture") { media.beginCapture() }
.disabled(printSession.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnMediaLibraryCapture")
.helpOverlay(
"Select a printer in Stage 2 first",
showing: $showingAllHelp)
Button("Manage") { workflow.showingManageMedia = true }
.accessibilityIdentifier("btnMediaLibraryManage")
.helpOverlay(
"Apply or delete saved media recipes.",
showing: $showingAllHelp)
Spacer()
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
}
}
private var studioButtons: some View {
Group {
// Calibrate Printer (`#btnCalibratePrinter`).
Button(action: { model.enterCalibration() }) {
Label("Calibrate Printer", systemImage: "slider.horizontal.3")
@@ -88,9 +189,36 @@ struct SidebarView: View {
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.helpOverlay(
"View the profile gamut in 3D against sRGB.",
showing: $showingAllHelp)
.accessibilityIdentifier("btnViewGamut")
.padding(.horizontal, 12)
// Spot Read sheet (`#btnSpotRead`) — issue #148. Enabled
// only with a working folder (#59) and while no Stage 3
// chartread child is live; opening never kills
// `chartread_{basename}`.
Button(action: { workflow.showingSpotRead = true }) {
Label("Spot Read", systemImage: "eyedropper")
.frame(maxWidth: .infinity)
}
.controlSize(.large)
.disabled(model.workingDirectory == nil || measurement.isChartreadRunning)
.helpOverlay(
model.workingDirectory == nil
? "Set a working folder in Stage 1 first."
: (measurement.isChartreadRunning
? "Stop the Stage 3 chart read first."
: "Read a single patch as Lab/XYZ from the instrument."),
showing: $showingAllHelp)
.accessibilityIdentifier("btnSpotRead")
.padding(.horizontal, 12)
}
}
private var stepperAndProject: some View {
Group {
Divider().overlay(Theme.border)
.padding(.vertical, 8)
@@ -110,9 +238,13 @@ struct SidebarView: View {
.padding(.horizontal, 6)
Spacer()
// Project chip (issue #149) — a compact footer in the
// spacer's bottom, under the stepper. The 270 pt column
// cannot take four more large buttons (R10).
ProjectChip(project: project, showingAllHelp: $showingAllHelp)
.padding(8)
}
.frame(width: Theme.Metrics.sidebarWidth)
.background(Theme.panel)
}
}
+360
View File
@@ -0,0 +1,360 @@
import SwiftUI
import ICCeryCore
/// Spot Read sheet (issue #148) — one patch Lab/XYZ from the live
/// instrument. A `RootView` sheet, not a wizard stage and not a Stage 3
/// tab; all identifiers are `spot*` — Stage 3 `chartread` ids are never
/// reused here.
struct SpotReadView: View {
@ObservedObject var model: SpotReadViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
if !model.sidecarAvailable {
missingSidecar
} else {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
instrumentCard
promptLine
transport
lastSampleCard
historySection
}
}
}
footer
}
.padding(16)
.frame(width: 560, height: 640)
.background(Theme.background)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotReadView")
.onAppear { model.sheetOpened() }
.onDisappear { model.sheetClosed() }
}
// MARK: - Header / missing sidecar
private var header: some View {
HStack(alignment: .firstTextBaseline) {
Text("Spot Read")
.font(.title3)
.foregroundStyle(Theme.text)
Spacer()
if model.isRunning {
ProgressView()
.scaleEffect(0.8)
}
}
}
private var missingSidecar: some View {
VStack(alignment: .leading, spacing: 12) {
Text("spotread sidecar missing — run fetch-argyll")
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotSidecarMissing")
Spacer()
}
}
// MARK: - Instrument card (clones Stage 3 look, own ids)
private var instrumentCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Instrument")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Button(action: { model.detectInstruments() }) {
Image(systemName: "arrow.clockwise")
}
.disabled(!model.canDetect)
.accessibilityIdentifier("btnSpotDetectInstruments")
}
if let error = model.detectionError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotDetectError")
}
Picker("Instrument", selection: Binding(
get: { instrumentTag },
set: { newTag in
if newTag.isEmpty {
model.selectedInstrument = .auto
} else if let device = model.instruments.first(where: { "\($0.port)" == newTag }) {
model.selectedInstrument = .device(device)
}
}
)) {
Text("Auto (first available port)").tag("")
ForEach(model.instruments) { device in
Text(device.displayName).tag("\(device.port)")
}
}
.pickerStyle(.menu)
.disabled(model.isRunning)
.accessibilityIdentifier("spotInstrumentSelect")
if model.defaultMissing {
Text("Saved default instrument not present")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotDefaultMissing")
}
Toggle("Also set as default instrument", isOn: Binding(
get: { model.setAsDefault },
set: { model.applyDefaultToggle($0) }
))
.accessibilityIdentifier("spotSetDefault")
if model.selectedInstrument.isXY {
Text("XY tables use Stage 3. Spot Read is a handheld / reflective probe.")
.font(.caption)
.foregroundStyle(Theme.accent)
.accessibilityIdentifier("spotXYHint")
}
}
.padding(16)
.background(Theme.panel)
}
private var instrumentTag: String {
switch model.selectedInstrument {
case .auto:
return ""
case .device(let device):
return "\(device.port)"
}
}
// MARK: - Prompt line
private var promptLine: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Status")
.font(.headline)
.foregroundStyle(Theme.text)
Spacer()
Text(model.prompt)
.font(.callout)
.foregroundStyle(Theme.text)
.accessibilityIdentifier("spotPrompt")
}
if let error = model.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.accessibilityIdentifier("spotLastError")
.accessibilityValue(error)
}
if !model.log.isEmpty {
ProcessLogView(
lines: model.log,
minHeight: 60,
maxHeight: 100,
containerId: "spotLogContainer",
logId: "spotLog"
)
}
}
.padding(16)
.background(Theme.panel)
}
// MARK: - Transport
private var transport: some View {
HStack(spacing: 12) {
if !model.isRunning {
Button("Start") { model.start() }
.disabled(!model.canStart)
.accessibilityIdentifier("btnSpotStart")
} else {
switch model.state {
case .calibrating:
Button("Calibrate") { model.calibrate() }
.accessibilityIdentifier("btnSpotCalibrate")
case .awaitingStrip:
Button("Read") { model.trigger() }
.accessibilityIdentifier("btnSpotTrigger")
default:
EmptyView()
}
Button("Stop") { model.stopIfNeeded() }
.accessibilityIdentifier("btnSpotStop")
}
Spacer()
}
.padding(.horizontal, 4)
}
// MARK: - Last sample
@ViewBuilder
private var lastSampleCard: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Last sample")
.font(.headline)
.foregroundStyle(Theme.text)
if let sample = model.displayedSample {
HStack(spacing: 16) {
let rgb = LabColorMath.labToSRGB(sample.lab)
RoundedRectangle(cornerRadius: 4)
.fill(Color(red: rgb.r, green: rgb.g, blue: rgb.b))
.frame(width: 32, height: 32)
.overlay(RoundedRectangle(cornerRadius: 4).stroke(Theme.border))
.accessibilityIdentifier("spotSwatch")
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 12) {
Text(String(format: "L* %.1f", sample.lab.l))
.accessibilityIdentifier("spotLabL")
Text(String(format: "a* %.1f", sample.lab.a))
.accessibilityIdentifier("spotLabA")
Text(String(format: "b* %.1f", sample.lab.b))
.accessibilityIdentifier("spotLabB")
}
.font(.callout)
.foregroundStyle(Theme.text)
if let xyz = sample.xyz {
Text(String(format: "XYZ %.2f %.2f %.2f", xyz.x, xyz.y, xyz.z))
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotXYZ")
}
HStack(spacing: 8) {
Text(sample.port.map { "\(sample.instrumentName) · port \($0)" }
?? sample.instrumentName)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastInstrument")
if let de = model.displayedDeltaE {
HStack(spacing: 6) {
Circle()
.fill(deltaEColor)
.frame(width: 8, height: 8)
Text(String(format: "ΔE %.2f", de))
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotDeltaE")
}
}
if model.isDisplayedLabImplausible {
Text("Implausible L*")
.font(.caption)
.foregroundStyle(.orange)
.accessibilityIdentifier("spotLabImplausible")
}
}
Spacer()
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("spotLastSample")
} else {
Text("No readings yet.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotLastEmpty")
}
}
.padding(16)
.background(Theme.panel)
}
private var deltaEColor: Color {
switch model.deltaEClassification {
case .good, nil: return .green
case .warning: return .orange
case .bad: return .red
}
}
// MARK: - History
@ViewBuilder
private var historySection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("History")
.font(.headline)
.foregroundStyle(Theme.text)
if model.samples.isEmpty {
Text("No history.")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("spotHistoryEmpty")
} else {
List {
ForEach(Array(model.samples.enumerated()), id: \.element.id) { index, sample in
historyRow(index: index, sample: sample)
}
}
.frame(minHeight: 120)
.accessibilityIdentifier("spotHistoryTable")
}
}
.padding(16)
.background(Theme.panel)
}
private func historyRow(index: Int, sample: SpotReadSample) -> some View {
let previous = index + 1 < model.samples.count ? model.samples[index + 1] : nil
let deltaE = previous.map { ColorDifference.deltaE00($0.lab, sample.lab) }
return Button(action: { model.selectFromHistory(sample) }) {
HStack(spacing: 10) {
Text(sample.timestamp, style: .time)
.frame(width: 70, alignment: .leading)
Text(String(format: "%.1f", sample.lab.l))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.a))
.frame(width: 44, alignment: .trailing)
Text(String(format: "%.1f", sample.lab.b))
.frame(width: 44, alignment: .trailing)
Text(deltaE.map { String(format: "%.2f", $0) } ?? "")
.frame(width: 44, alignment: .trailing)
Text(sample.instrumentName)
.lineLimit(1)
Spacer()
}
.font(.caption)
.foregroundStyle(Theme.text)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("spotHistoryRow-\(sample.id.uuidString)")
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: 12) {
Button("Copy Lab") { model.copyLab() }
.disabled(model.displayedSample == nil)
.accessibilityIdentifier("btnSpotCopyLab")
Button("Export CSV…") { model.exportCsv() }
.disabled(model.samples.isEmpty)
.accessibilityIdentifier("btnSpotExportCsv")
Spacer()
Button("Close") { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("btnCloseSpotRead")
}
.padding(.top, 4)
}
}
+420
View File
@@ -0,0 +1,420 @@
import AppKit
import Combine
import Foundation
import ICCeryCore
/// Spot-read console state and interaction (issue #148).
///
/// Runs the bundled `spotread` sidecar under the single-lease process id
/// `spotread`; Stage 3 `chartread` is untouched. `defaultInstrument`
/// seeds the instrument picker on sheet open — it is never written into
/// `printtarg -i` or `targen` argv (R15).
@MainActor
final class SpotReadViewModel: ObservableObject {
let workflow: TargetWorkflowViewModel
let environment: AppEnvironment
private let fileDialogs = FileDialogService.shared
// MARK: - Instrument card
@Published var instruments: [InstrumentDevice] = []
@Published var selectedInstrument: InstrumentSelection = .auto
@Published var isDetecting = false
@Published var detectionError: String?
/// `spotDefaultMissing` — set when `defaultInstrument` is saved but
/// no detected device matches it.
@Published var defaultMissing = false
/// `spotSetDefault` toggle state.
@Published var setAsDefault = false
// MARK: - Session
@Published var isRunning = false
@Published var state: ChartreadState = .idle
@Published var prompt = "Press Start to open the instrument."
@Published var lastError: String?
@Published var log: [String] = []
// MARK: - Samples / history (in-memory, cap 50, newest first)
@Published private(set) var samples: [SpotReadSample] = []
@Published private(set) var displayedSample: SpotReadSample?
@Published private(set) var displayedDeltaE: Double?
private let historyLimit = 50
private var streamTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) {
self.workflow = workflow
self.environment = environment
}
// MARK: - Derived state
/// Whether the bundled `spotread` sidecar resolves to an executable.
/// `BinaryResolver` only — never `$PATH`, never `chartread`.
var sidecarAvailable: Bool {
let url = environment.runner.binaryResolver.resolve("spotread")
return environment.runner.binaryResolver.exists(url)
}
var isChartreadRunning: Bool { workflow.measurement.isChartreadRunning }
var canStart: Bool {
sidecarAvailable && !isDetecting && !isRunning && !isChartreadRunning
&& workflow.wizard.effectiveWorkingDirectory != nil
}
var canDetect: Bool { !isDetecting && !isRunning }
var deltaEClassification: SwatchClassification? {
guard let de = displayedDeltaE else { return nil }
let settings = environment.settingsStore.load()
return ColorDifference.classify(
deltaE: de,
goodMax: settings.deltaEGoodMax,
warningMax: settings.deltaEWarningMax)
}
/// `spotLabImplausible` — L* outside 0…100 still displays, unclamped.
var isDisplayedLabImplausible: Bool {
guard let l = displayedSample?.lab.l else { return false }
return l < 0 || l > 100
}
// MARK: - Sheet lifecycle
/// Called from `SpotReadView.onAppear`. Resets the in-memory session
/// and runs detection once; a missing sidecar gets a wizard notice.
func sheetOpened() {
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
lastError = nil
state = .idle
prompt = "Press Start to open the instrument."
guard sidecarAvailable else {
workflow.wizard.showNotice(
"spotread sidecar missing — run fetch-argyll", kind: .error)
return
}
detectInstruments()
}
/// Called from `onDisappear` *and* the sheet's `onDismiss` — clearing
/// the flag alone is not enough; a live child must be quit and
/// killed (R14).
func sheetClosed() {
stopIfNeeded()
samples = []
displayedSample = nil
displayedDeltaE = nil
log = []
defaultMissing = false
}
// MARK: - Detection
func detectInstruments() {
guard canDetect else { return }
isDetecting = true
detectionError = nil
Task { @MainActor [weak self] in
guard let self else { return }
// `instlist` is an exclusive lease (#116) — never spawn a
// second one; surface the busy state instead.
if await self.environment.runner.processManager.isRunning(ProcessID.instlist) {
self.detectionError = "Instrument detection is already running."
self.isDetecting = false
return
}
do {
let devices = try await self.environment.runner.detectInstruments()
self.instruments = devices
self.seedDefault(from: 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
}
}
/// Seed the picker from `AppSettings.defaultInstrument`; no match →
/// `.auto` + `spotDefaultMissing`.
private func seedDefault(from devices: [InstrumentDevice]) {
guard let code = environment.settingsStore.load().defaultInstrument,
!code.isEmpty else {
defaultMissing = false
return
}
if let match = devices.first(where: { Self.matches(code: code, device: $0) }) {
selectedInstrument = .device(match)
defaultMissing = false
} else {
selectedInstrument = .auto
defaultMissing = true
}
}
/// Whether an `instlist` device corresponds to a `printtarg -i` /
/// settings instrument code (`i1`, `CM`, `p3`, `SS`, `20`/`22`/`41`/`51`).
static func matches(code: String, device: InstrumentDevice) -> Bool {
let haystack = "\(device.name) \(device.type)".lowercased()
switch code {
case "i1": return haystack.contains("i1pro") && !haystack.contains("i1pro 3") && !haystack.contains("i1pro3")
case "p3": return haystack.contains("i1pro 3") || haystack.contains("i1pro3")
case "CM": return haystack.contains("colormunki")
case "SS": return haystack.contains("specbos") || haystack.contains("spectraval") || haystack.contains("spectroscan") || haystack.contains("spectro scan")
case "20": return haystack.contains("display 2")
case "22": return haystack.contains("display")
case "41": return haystack.contains("spyder 4") || haystack.contains("spyder 5") || haystack.contains("spyder4") || haystack.contains("spyder5")
case "51": return haystack.contains("spyder x")
default: return false
}
}
/// Reverse of `matches` — most specific codes first.
static func code(for device: InstrumentDevice) -> String? {
for code in ["p3", "51", "41", "22", "20", "CM", "SS", "i1"]
where matches(code: code, device: device) {
return code
}
return nil
}
/// `spotSetDefault` — writes `AppSettings.defaultInstrument` only.
/// Never touches `printtarg -i` or `targen`.
func applyDefaultToggle(_ on: Bool) {
setAsDefault = on
var settings = environment.settingsStore.load()
if on, case .device(let device) = selectedInstrument {
settings.defaultInstrument = Self.code(for: device)
} else if !on {
settings.defaultInstrument = nil
}
try? environment.settingsStore.save(settings)
}
// MARK: - Session control
func start() {
guard sidecarAvailable else {
lastError = "spotread sidecar missing — run fetch-argyll"
return
}
guard !isChartreadRunning else {
lastError = "Stop the Stage 3 chart read first."
return
}
guard let cwd = workflow.wizard.effectiveWorkingDirectory else {
lastError = "Set a working folder in Stage 1 first."
return
}
Task { @MainActor [weak self] in
guard let self else { return }
// `spotread` is an exclusive lease — a second Start while a
// child is live is an error, not a kill + respawn (#116).
if await self.environment.runner.processManager.isRunning(ProcessID.spotread) {
self.lastError = "A spotread session is already running."
return
}
self.begin(config: self.buildConfig(cwd: cwd))
}
}
private func buildConfig(cwd: URL) -> SpotReadConfig {
let port: Int?
let name: String
switch selectedInstrument {
case .auto:
port = nil
name = "Auto"
case .device(let device):
port = device.port
name = device.name
}
return SpotReadConfig(
workingDirectory: cwd,
selectedPort: selectedInstrument.chartreadPort,
enableLEDs: environment.settingsStore.load().enableI1Pro2Leds,
isXY: selectedInstrument.isXY,
instrumentName: name,
instrumentPort: port
)
}
private func begin(config: SpotReadConfig) {
isRunning = true
state = .idle
lastError = nil
prompt = "Waiting for a reading…"
let stream = environment.runner.runSpotread(config: config)
streamTask = Task { @MainActor [weak self] in
guard let self else { return }
for await event in stream {
self.handle(event: event)
}
self.isRunning = false
self.state = .idle
if self.lastError == nil {
self.prompt = "Press Start to open the instrument."
}
}
}
private func handle(event: SpotReadEvent) {
switch event {
case .prompt(let result):
state = result.state
prompt = promptText(for: result.state)
case .sample(let sample):
let previous = samples.first
samples.insert(sample, at: 0)
if samples.count > historyLimit {
samples.removeLast()
}
displayedSample = sample
displayedDeltaE = previous.map {
ColorDifference.deltaE00($0.lab, sample.lab)
}
case .log(let batch):
log.append(contentsOf: batch)
case .exit(let code):
if code != 0 {
lastError = "spotread exited with code \(code)"
}
case .failed(let error):
lastError = error.localizedDescription
}
}
private func promptText(for state: ChartreadState) -> String {
switch state {
case .calibrating:
return "Place the instrument on the calibration tile, then Calibrate."
case .awaitingStrip:
return "Place on the patch, then Read."
case .reading, .promptContinue:
return "Waiting for a reading…"
case .warning:
return "Instrument warning — stop and restart if it persists."
case .error:
return "Read error — Stop, then Start again."
default:
return "Waiting for a reading…"
}
}
// MARK: - Transport
/// `btnSpotCalibrate` — same bytes Stage 3 sends for calibrate.
func calibrate() {
send(.trigger)
}
/// `btnSpotTrigger` — the Read key (`" \n"`).
func trigger() {
send(.trigger)
}
private func send(_ input: ChartreadInput) {
Task { @MainActor [weak self] in
guard let self, self.isRunning else { return }
try? await self.environment.runner.sendSpotreadInput(input)
}
}
/// `btnSpotStop` / sheet dismiss: `q\n`, ~500 ms, then kill if the
/// child is still live.
func stopIfNeeded() {
guard isRunning else { return }
streamTask?.cancel()
streamTask = nil
let processManager = environment.runner.processManager
Task { @MainActor in
try? await processManager.sendStdin(
id: ProcessID.spotread, bytes: ChartreadInput.quit.bytes)
try? await Task.sleep(nanoseconds: 500_000_000)
await processManager.kill(id: ProcessID.spotread)
}
isRunning = false
state = .idle
prompt = "Press Start to open the instrument."
}
// MARK: - History / export
/// Click a history row: copies that sample into the last-sample card.
/// Never re-triggers the instrument.
func selectFromHistory(_ sample: SpotReadSample) {
displayedSample = sample
if let index = samples.firstIndex(of: sample), index + 1 < samples.count {
displayedDeltaE = ColorDifference.deltaE00(samples[index + 1].lab, sample.lab)
} else {
displayedDeltaE = nil
}
}
/// `btnSpotCopyLab` — `L* a* b*` of the displayed sample as plain
/// text (`50.0 1.2 -3.4`).
func copyLab() {
guard let sample = displayedSample else { return }
let text = String(format: "%.1f %.1f %.1f", sample.lab.l, sample.lab.a, sample.lab.b)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
/// `btnSpotExportCsv` — RFC-4180 via `selectCsvSavePath`. Cancel is
/// a no-op. Rows are newest-first, matching the history list.
func exportCsv() {
guard !samples.isEmpty else { return }
let url = UITestHooks.isEnabled
? UITestHooks.csvExportURL
: fileDialogs.selectCsvSavePath()
guard let url else { return }
var out = "timestamp,L,a,b,dE00,instrument,port\r\n"
for (index, sample) in samples.enumerated() {
let deltaE = index + 1 < samples.count
? String(format: "%.2f", ColorDifference.deltaE00(samples[index + 1].lab, sample.lab))
: ""
out += "\(csvField(iso8601(sample.timestamp))),\(f1(sample.lab.l)),\(f1(sample.lab.a)),\(f1(sample.lab.b)),\(deltaE),\(csvField(sample.instrumentName)),\(sample.port.map(String.init) ?? "")\r\n"
}
do {
try out.write(to: url, atomically: true, encoding: .utf8)
workflow.wizard.showNotice("Spot readings exported: \(url.lastPathComponent)")
} catch {
workflow.wizard.showNotice(
"Export failed: \(error.localizedDescription)", kind: .error)
}
}
private func f1(_ value: Double) -> String {
String(format: "%.1f", value)
}
private func iso8601(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
private func csvField(_ text: String) -> String {
guard text.contains(",") || text.contains("\"") || text.contains("\n") else {
return text
}
return "\"\(text.replacingOccurrences(of: "\"", with: "\"\""))\""
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import ICCeryCore
/// docs/08). All documented element ids are wired as accessibility
/// identifiers so the UI-test contract stays stable.
struct Stage1View: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
var body: some View {
ScrollView {
+146 -39
View File
@@ -5,7 +5,19 @@ import ICCeryCore
/// issues #9/#10, docs/09). Print controls are visible but inert —
/// real spooling lands in M3.
struct Stage2View: View {
@Bindable var workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: nested ObservableObjects are not tracked
/// through the parent's `objectWillChange`.
@ObservedObject private var printSession: PrintSessionViewModel
@ObservedObject private var wizard: WizardViewModel
@State private var printGenerationTask: Task<Void, Never>?
init(workflow: TargetWorkflowViewModel) {
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
self._wizard = ObservedObject(wrappedValue: workflow.wizard)
}
var body: some View {
ScrollView {
@@ -206,7 +218,7 @@ struct Stage2View: View {
}
}
// MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path
// MARK: - Raw print panel (#rawPrintPanel) — native spool path (#201)
private var printPanel: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -240,9 +252,10 @@ struct Stage2View: View {
}
.frame(maxWidth: 320)
.accessibilityIdentifier("printerSelect")
.onChange(of: workflow.print.selectedPrinter) { _, _ in
.onChange(of: workflow.print.selectedPrinter) { _ in
workflow.print.selectedTray = nil
workflow.print.selectedMediaType = nil
workflow.print.selectedQuality = nil
Task { @MainActor in await workflow.print.reloadSelectedCapabilities() }
}
if let selected = workflow.print.printers
@@ -253,6 +266,18 @@ struct Stage2View: View {
.background(Theme.background)
.clipShape(Capsule())
.accessibilityIdentifier("printerStatusBadge")
// AirPrint queues colour-manage the URF path —
// unmanaged colour cannot be guaranteed (#202).
if selected.isAirPrint {
Text("AirPrint queue — unmanaged colour "
+ "cannot be guaranteed.")
.font(.caption)
.foregroundStyle(.orange)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.12))
.clipShape(Capsule())
.accessibilityIdentifier("airPrintWarningBadge")
}
}
Button(action: workflow.print.refreshPrinters) {
Image(systemName: "arrow.clockwise")
@@ -267,10 +292,12 @@ struct Stage2View: View {
.accessibilityIdentifier("btnPrinterProperties")
}
// Tray / media / orientation — from queue capabilities.
// Tray / media / paper / quality / orientation — from
// queue capabilities. Extracted subviews keep every
// ViewBuilder ≤10 children (R13).
HStack(spacing: 14) {
if !workflow.print.printerCaps.trays.isEmpty {
Picker("Tray", selection: $workflow.print.selectedTray) {
Picker("Paper source", selection: $workflow.print.selectedTray) {
ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
@@ -289,48 +316,119 @@ struct Stage2View: View {
.accessibilityIdentifier("mediaTypeGroup")
.accessibilityIdentifier("printerMediaTypeSelect")
}
HStack(spacing: 0) {
Button("Portrait") { workflow.print.printOrientation = "portrait" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "portrait" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientPortrait")
Button("Landscape") { workflow.print.printOrientation = "landscape" }
.buttonStyle(.bordered)
.tint(workflow.print.printOrientation == "landscape" ? .accentColor : .gray)
.accessibilityIdentifier("btnOrientLandscape")
if !workflow.print.printerCaps.paperSizes.isEmpty {
paperSizeGroup
}
if !workflow.print.printerCaps.qualities.isEmpty {
qualityGroup
}
Picker("Orientation", selection: $workflow.print.printOrientation) {
Text("Portrait").tag("portrait")
Text("Landscape").tag("landscape")
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerOrientationSelect")
Spacer()
}
// Stage 1 owns the custom dimensions — the caption lives
// inside `paperSizeGroup` (#183).
HStack(spacing: 8) {
Button(action: {
if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result, pageSize: workflow.pageSize)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
systemImage: "printer")
}
.controlSize(.large)
.disabled(workflow.print.isPrinting
|| workflow.printtargResult == nil
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll")
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
.accessibilityIdentifier("btnAdvanceToStage3")
.disabled(workflow.printtargResult == nil
|| !workflow.wizard.isUnlocked(.measure))
}
printAllRow
}
.padding(12)
.background(Theme.panel)
.clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("rawPrintPanel")
.task(id: workflow.printtargResult?.pages.count) {
// Auto-enumerate once a manifest exists and whenever it
// changes (e.g. resume from .ti2).
.onAppear { schedulePrinterRefresh() }
.onChange(of: workflow.printtargResult?.pages.count) { _ in
schedulePrinterRefresh()
}
// Editable picker that re-mirrors Stage 1's pageSize (#183 E4).
.onChange(of: workflow.pageSize) { _ in
workflow.print.seedPaperSelection()
}
}
/// Print All + single-job granularity (#201 D5) + advance.
/// Extracted so every ViewBuilder body stays ≤10 children (R13 —
/// Xcode 14.2 has no `buildPartialBlock`).
private var printAllRow: some View {
HStack(spacing: 8) {
Button(action: {
if let result = workflow.printtargResult {
workflow.print.printAllPages(from: result)
}
}) {
Label(workflow.print.isPrinting ? "Printing…" : "Print All",
systemImage: "printer")
}
.controlSize(.large)
.disabled(workflow.print.isPrinting
|| workflow.printtargResult == nil
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintAll")
Toggle("Single spool job",
isOn: $workflow.print.singleJobForAllPages)
.help("Send all pages as one print job instead of "
+ "one job per page")
.accessibilityIdentifier("chkSingleSpoolJob")
Spacer()
Button("Advance to Stage 3") { workflow.advanceToStage3() }
.accessibilityIdentifier("btnAdvanceToStage3")
.disabled(workflow.printtargResult == nil
|| !workflow.wizard.isUnlocked(.measure))
}
}
/// Paper picker + custom-size caption under the `paperSizeGroup`
/// container (#183). `caps.paperSizes` plus the synthetic custom
/// entry (`id: 0`, shown as `Custom (W×H mm)`).
private var paperSizeGroup: some View {
VStack(alignment: .leading, spacing: 4) {
Picker("Paper", selection: $workflow.print.selectedPaperSize) {
ForEach(workflow.print.printerCaps.paperSizes, id: \.id) { size in
Text(size.id == 0
? "Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)"
: size.name)
.tag(Optional(size.id))
}
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerPaperSizeSelect")
if workflow.print.selectedPaperSize == 0 {
Text("Custom (\(Int(workflow.customPageW))×\(Int(workflow.customPageH)) mm)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("paperSizeGroup")
}
/// Quality picker — driver tokens with PPD-enriched labels (#183),
/// filtered to what the driver accepts for the selected media
/// (#214).
private var qualityGroup: some View {
VStack(alignment: .leading, spacing: 4) {
Picker("Quality", selection: $workflow.print.selectedQuality) {
ForEach(workflow.print.availableQualities, id: \.id) {
Text($0.name).tag(Optional($0.id))
}
}
.frame(maxWidth: 200)
.accessibilityIdentifier("printerQualitySelect")
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("qualityGroup")
}
/// Auto-enumerates printers once a manifest exists and whenever it
/// changes (e.g. resume from .ti2). The explicit task handle keeps
/// a superseded run from racing the next one.
private func schedulePrinterRefresh() {
printGenerationTask?.cancel()
printGenerationTask = Task { @MainActor in
if workflow.print.printers.isEmpty, workflow.printtargResult != nil {
workflow.print.refreshPrinters()
}
@@ -341,7 +439,16 @@ struct Stage2View: View {
/// One gallery cell: PNG preview + per-page Print button.
private struct GalleryPageView: View {
let page: GalleryPage
let workflow: TargetWorkflowViewModel
@ObservedObject var workflow: TargetWorkflowViewModel
/// Observed directly: `print` is a nested ObservableObject and its
/// `isPrinting`/`selectedPrinter` changes drive this cell's button.
@ObservedObject private var printSession: PrintSessionViewModel
init(page: GalleryPage, workflow: TargetWorkflowViewModel) {
self.page = page
self.workflow = workflow
self._printSession = ObservedObject(wrappedValue: workflow.print)
}
var body: some View {
VStack(spacing: 6) {
@@ -362,7 +469,7 @@ private struct GalleryPageView: View {
Text("\(page.page.patches) patches · " +
"\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm")
.font(.caption2).foregroundStyle(.secondary)
Button("Print") { workflow.print.printPage(page, pageSize: workflow.pageSize) }
Button("Print") { workflow.print.printPage(page) }
.disabled(workflow.print.isPrinting
|| workflow.print.selectedPrinter.isEmpty)
.accessibilityIdentifier("btnPrintPage-\(page.index)")
+9 -1
View File
@@ -4,7 +4,15 @@ import ICCeryCore
/// Stage 3 — measurement, live swatches, and multi-pass averaging.
struct Stage3View: View {
@Bindable var model: MeasurementWorkflowViewModel
@ObservedObject var model: MeasurementWorkflowViewModel
/// `model.basename`/`model.workingDirectory` delegate to `wizard`;
/// observe it directly so header updates propagate.
@ObservedObject private var wizard: WizardViewModel
init(model: MeasurementWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
+22 -10
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 4 — build an ICC/ICM profile from the canonical `.ti3`.
struct Stage4View: View {
@Bindable var model: ProfileWorkflowViewModel
@ObservedObject var model: ProfileWorkflowViewModel
/// Header reads `model.wizard.basename`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
@@ -130,16 +138,20 @@ struct Stage4View: View {
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCopyright")
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
// Nested VStack keeps the parent at the Swift 5.7 ViewBuilder
// 10-child limit (Xcode 14.2 / macOS 12 CI runner, #111).
VStack(alignment: .leading, spacing: 12) {
Toggle("Apply calibration curve", isOn: $model.applyCalibration)
.accessibilityIdentifier("colprofApplyCalibration")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
if model.applyCalibration {
HStack {
TextField("Calibration .cal file", text: $model.calibrationFile)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("colprofCalibrationFile")
Button("Browse…") { model.browseForCalibrationFile() }
.accessibilityIdentifier("btnBrowseCalibrationFile")
}
}
}
}
+9 -1
View File
@@ -3,7 +3,15 @@ import ICCeryCore
/// Stage 5 — verify the generated profile, track drift, and install.
struct Stage5View: View {
@Bindable var model: ProfileWorkflowViewModel
@ObservedObject var model: ProfileWorkflowViewModel
/// Header/buttons read `model.wizard.*`; observe the nested
/// ObservableObject directly.
@ObservedObject private var wizard: WizardViewModel
init(model: ProfileWorkflowViewModel) {
self.model = model
self._wizard = ObservedObject(wrappedValue: model.wizard)
}
var body: some View {
VStack(spacing: 0) {
+90 -59
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Stage 1/2 form state, runner orchestration, resume flow, and preset
@@ -10,8 +10,7 @@ import ICCeryCore
/// All process work runs through `ArgyllRunner` off `@MainActor`; only
/// coalesced log batches and completion hop back.
@MainActor
@Observable
final class TargetWorkflowViewModel {
final class TargetWorkflowViewModel: ObservableObject {
let wizard: WizardViewModel
let environment: AppEnvironment
@@ -19,95 +18,112 @@ final class TargetWorkflowViewModel {
// MARK: - Stage 1 form (targen)
var colourSpace: ColourSpace = .rgb {
@Published var colourSpace: ColourSpace = .rgb {
didSet {
guard colourSpace != oldValue else { return }
// CMYK black patches default to 0, RGB to 4 (docs/08).
blackPatches = colourSpace == .cmyk ? 0 : 4
}
}
var patchPreset: PatchCountPreset = .standard800
@Published var patchPreset: PatchCountPreset = .standard800
/// `#patchCountCustom` — used when `patchPreset == .custom`.
var customPatchCount = 2500
var whitePatches = 4
var blackPatches = 4
@Published var customPatchCount = 2500
@Published var whitePatches = 4
@Published var blackPatches = 4
// Advanced — each optional flag is enabled + value, so an untouched
// control emits nothing (#advanced fields are opt-in).
var greyStepsEnabled = false
var greySteps = 5
var singleChannelEnabled = false
var singleChannelSteps = 5
var neutralStepsEnabled = false
var neutralSteps = 3
var neutralConcEnabled = false
var neutralConcentration = 0.50
var preconditioningProfile: String?
var highQuality = false
var adaptationEnabled = false
var adaptation = 0.10
var algorithm: FullSpreadAlgorithm = .ofps
var inkLimitEnabled = false
var totalInkLimit = 320
var darkEmphasisEnabled = false
var darkEmphasis = 1.0
var devicePowerEnabled = false
var devicePower = 1.0
@Published var greyStepsEnabled = false
@Published var greySteps = 5
@Published var singleChannelEnabled = false
@Published var singleChannelSteps = 5
@Published var neutralStepsEnabled = false
@Published var neutralSteps = 3
@Published var neutralConcEnabled = false
@Published var neutralConcentration = 0.50
@Published var preconditioningProfile: String?
@Published var highQuality = false
@Published var adaptationEnabled = false
@Published var adaptation = 0.10
@Published var algorithm: FullSpreadAlgorithm = .ofps
@Published var inkLimitEnabled = false
@Published var totalInkLimit = 320
@Published var darkEmphasisEnabled = false
@Published var darkEmphasis = 1.0
@Published var devicePowerEnabled = false
@Published var devicePower = 1.0
/// `#targetBasename` — no placeholder is ever invented (#60).
var targetBasename = ""
@Published var targetBasename = ""
/// `#selectedPathDisplay` / resolved cwd.
var targetDirectory: URL?
@Published var targetDirectory: URL?
// MARK: - Stage 2 form (printtarg)
var instrument: PrintInstrument = .i1
var pageSize: PageSize = .a4
var customPageW = 210.0
var customPageH = 297.0
var bitDepth: TiffBitDepth = .eight
@Published var instrument: PrintInstrument = .i1
@Published var pageSize: PageSize = .a4
@Published var customPageW = 210.0
@Published var customPageH = 297.0
@Published var bitDepth: TiffBitDepth = .eight
/// `#tiffDpi` — two-way bound; presets can change it (150-DPI draft
/// regression must be visible here).
var tiffDpi = 300
var layoutOrder: LayoutOrder = .deterministic
var customSeed = 1
var labelIsCustom = false
var customLabel = ""
var metaPrinter = ""
var metaInkSet = ""
var metaDriverPaper = ""
var metaActualPaper = ""
@Published var tiffDpi = 300
@Published var layoutOrder: LayoutOrder = .deterministic
@Published var customSeed = 1
@Published var labelIsCustom = false
@Published var customLabel = ""
@Published var metaPrinter = ""
@Published var metaInkSet = ""
@Published var metaDriverPaper = ""
@Published var metaActualPaper = ""
// MARK: - Run state
var targenRunning = false
var targenLog: [String] = []
var printtargRunning = false
var printtargLog: [String] = []
var printtargResult: PrinttargResult?
@Published var targenRunning = false
@Published var targenLog: [String] = []
@Published var printtargRunning = false
@Published var printtargLog: [String] = []
@Published var printtargResult: PrinttargResult?
/// Sticky until the target changes: `.ti2` resume landed us on
/// Stage 3 (`#stage3LoadedTargetBanner` data).
var resumedFromTi2 = false
@Published var resumedFromTi2 = false
// MARK: - Presets
var presets: [ProfilingPreset] = []
var selectedPresetID = "none"
var showingSavePreset = false
var showingManagePresets = false
var savePresetName = ""
var savePresetDesc = ""
@Published var presets: [ProfilingPreset] = []
@Published var selectedPresetID = "none"
@Published var showingSavePreset = false
@Published var showingManagePresets = false
@Published var savePresetName = ""
@Published var savePresetDesc = ""
// MARK: - Media library (issue #146)
@Published var showingSaveMedia = false
@Published var showingManageMedia = false
// MARK: - Spot read (issue #148)
/// `RootView` sheet binding for the spot-read console.
@Published var showingSpotRead = false
/// Stage 3 measurement workflow, owned at the app level so it persists
/// across stage switches and can observe settings changes.
var measurement: MeasurementWorkflowViewModel
@Published var measurement: MeasurementWorkflowViewModel
/// Stage 4/5 profile workflow, owned at the app level so it persists
/// across stage switches and can observe preset values.
var profile: ProfileWorkflowViewModel
@Published var profile: ProfileWorkflowViewModel
/// Stage 0 calibration workflow.
var calibration: CalibrationViewModel!
@Published var calibration: CalibrationViewModel!
/// Stage 2 unmanaged print session.
var print: PrintSessionViewModel!
@Published var print: PrintSessionViewModel!
/// Media recipe library — needs a complete `self`.
@Published var media: MediaLibraryViewModel!
/// Spot-read console — needs `wizard` / `measurement`.
@Published var spotRead: SpotReadViewModel!
/// Project file session (issue #149), created last — needs `media`
/// for recipe apply and `spotRead` for the live-child check.
@Published var project: ProjectSession!
init(environment: AppEnvironment = .live()) {
self.environment = environment
@@ -121,12 +137,27 @@ final class TargetWorkflowViewModel {
environment: environment
)
self.print = PrintSessionViewModel(wizard: wizard, environment: environment)
// Paper-size seeding reads the Stage 1 form through this weak
// back-reference; the print side never writes it (#183).
self.print.workflow = self
self.calibration = nil
self.calibration = CalibrationViewModel(
workflow: self,
profile: self.profile,
environment: environment
)
self.media = MediaLibraryViewModel(
workflow: self,
environment: environment
)
self.spotRead = SpotReadViewModel(
workflow: self,
environment: environment
)
self.project = ProjectSession(
workflow: self,
environment: environment
)
reloadPresets()
}
+14 -15
View File
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import ICCeryCore
/// Wizard state machine + artefact gating (issue #4, docs/06).
@@ -10,47 +10,46 @@ import ICCeryCore
/// `wizard_state.json`; unlocks come from `ArtefactProbe.verify` —
/// navigation is disk, not buttons.
@MainActor
@Observable
final class WizardViewModel {
final class WizardViewModel: ObservableObject {
// MARK: - wizardState fields (persisted)
var stage: WizardStage {
@Published var stage: WizardStage {
didSet { if stage != oldValue { persist() } }
}
/// `wizardState.basename` — empty until a real artefact names it (#60).
var basename: String {
@Published var basename: String {
didSet { if basename != oldValue { refreshGating(); persist() } }
}
/// `wizardState.cwd` — resolved via `resolveSafeCwd` (#59).
var workingDirectory: URL? {
@Published var workingDirectory: URL? {
didSet { if workingDirectory != oldValue { refreshGating(); persist() } }
}
var printerName: String? {
@Published var printerName: String? {
didSet { if printerName != oldValue { persist() } }
}
var sessionMode: SessionMode {
@Published var sessionMode: SessionMode {
didSet { if sessionMode != oldValue { persist() } }
}
/// `profileBasename` may differ after a `.ti3` import (#94).
var profileBasename: String? {
@Published var profileBasename: String? {
didSet { if profileBasename != oldValue { persist() } }
}
/// Pre-`CAL_` basename, persisted so relaunch/Force Quit can restore it (#29).
var calibrationOriginalBasename: String {
@Published var calibrationOriginalBasename: String {
didSet { if calibrationOriginalBasename != oldValue { persist() } }
}
// MARK: - Ephemeral
/// Banner notice currently displayed (`#wizardNotification`).
var notice: Notice?
@Published var notice: Notice?
/// Current artefact probe result; recomputed on `refreshGating()`.
private(set) var artefacts = StageArtefacts()
@Published private(set) var artefacts = StageArtefacts()
/// Whether the 3D gamut viewer sheet is open (issue #28).
var showingGamutViewer = false
@Published var showingGamutViewer = false
/// Optional `.gam` URL to show alongside the sRGB reference.
var gamutProfileURL: URL?
@Published var gamutProfileURL: URL?
private let stateStore: WizardStateStore
private var noticeDismissTask: Task<Void, Never>?
@@ -201,7 +200,7 @@ final class WizardViewModel {
self.notice = notice
if let delay = notice.autoHideAfter {
noticeDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
if self?.notice?.id == notice.id {
self?.notice = nil
+13 -15
View File
@@ -1,27 +1,25 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("AppPaths")
struct AppPathsTests {
@Test func appDataDirUsesBundleID() {
#expect(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
final class AppPathsTests: XCTestCase {
func testAppDataDirUsesBundleID() {
XCTAssertTrue(AppPaths.appDataDir.path.contains("Library/Application Support/com.gronod.iccery2"))
}
@Test func logFileIsUnderLibraryLogs() {
#expect(AppPaths.logFile.lastPathComponent == "iccery.log")
#expect(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
func testLogFileIsUnderLibraryLogs() {
XCTAssertEqual(AppPaths.logFile.lastPathComponent, "iccery.log")
XCTAssertTrue(AppPaths.logFile.path.contains("Library/Logs/com.gronod.iccery2"))
}
@Test func bundledArgyllDirIsInsideResources() {
#expect(AppPaths.bundledArgyllDir.lastPathComponent == "Argyll")
func testBundledArgyllDirIsInsideResources() {
XCTAssertEqual(AppPaths.bundledArgyllDir.lastPathComponent, "Argyll")
}
}
@Suite("WizardStage")
struct WizardStageTests {
@Test func stepperOrderIsOneThroughFive() {
#expect(WizardStage.stepperStages.map(\.stepperIndex) == [1, 2, 3, 4, 5])
#expect(WizardStage.calibrate.stepperIndex == nil)
final class WizardStageTests: XCTestCase {
func testStepperOrderIsOneThroughFive() {
XCTAssertEqual(WizardStage.stepperStages.map(\.stepperIndex), [1, 2, 3, 4, 5])
XCTAssertNil(WizardStage.calibrate.stepperIndex)
}
}
@@ -1,22 +1,19 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ApplycalArgs")
struct ApplycalArgsTests {
final class ApplycalArgsTests: XCTestCase {
@Test("Apply argv")
func applyArgv() throws {
func testApplyArgv() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc")
)
let args = try ApplycalArgs.build(config: config)
#expect(args == ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
XCTAssertEqual(args, ["-v", "-a", "/tmp/cal.cal", "/tmp/profile.icc"])
}
@Test("Unapply is emitted when the caller explicitly sets it")
func unapplyEmittedWhenConfigSet() throws {
func testUnapplyEmittedWhenConfigSet() throws {
let config = ApplycalConfig(
calibrationPath: "/tmp/cal.cal",
inputProfileURL: URL(fileURLWithPath: "/tmp/profile.icc"),
@@ -25,6 +22,6 @@ struct ApplycalArgsTests {
let args = try ApplycalArgs.build(config: config)
// Builder emits -u only when the caller explicitly sets unapply.
// The UI layer never passes unapply: true in v2.0.
#expect(args == ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
XCTAssertEqual(args, ["-v", "-u", "/tmp/cal.cal", "/tmp/profile.icc"])
}
}
@@ -0,0 +1,39 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// ``ApproximateLab`` sanity tests (issue #147).
///
/// These are loose sanity checks on a fixed-matrix approximation — not
/// ColorSync goldens.
final class ApproximateLabTests: XCTestCase {
func testWhiteMapsToHighLStar() {
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 255, b: 255)
XCTAssertGreaterThan(lab.l, 95)
XCTAssertEqual(lab.a, 0, accuracy: 2)
XCTAssertEqual(lab.b, 0, accuracy: 2)
}
func testBlackMapsToZeroLStar() {
let lab = ApproximateLab.srgb8ToLab(r: 0, g: 0, b: 0)
XCTAssertEqual(lab.l, 0, accuracy: 1)
}
func testPureRedIsChromatic() {
let lab = ApproximateLab.srgb8ToLab(r: 255, g: 0, b: 0)
// sRGB red ≈ Lab D50 (54, 81, 70) — loose bounds only.
XCTAssertGreaterThan(lab.l, 40)
XCTAssertLessThan(lab.l, 65)
XCTAssertGreaterThan(lab.a, 60)
XCTAssertGreaterThan(lab.b, 40)
}
func testMidGreyIsNeutral() {
let lab = ApproximateLab.srgb8ToLab(r: 128, g: 128, b: 128)
XCTAssertGreaterThan(lab.l, 45)
XCTAssertLessThan(lab.l, 65)
XCTAssertEqual(lab.a, 0, accuracy: 1)
XCTAssertEqual(lab.b, 0, accuracy: 1)
}
}
+41 -55
View File
@@ -1,94 +1,80 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("ArgsBuilder")
struct ArgsBuilderTests {
final class ArgsBuilderTests: XCTestCase {
// MARK: - option
@Test("option: nil emits nothing")
func optionNil() {
#expect(ArgsBuilder.option("-f", nil) == [])
func testOptionNil() {
XCTAssertEqual(ArgsBuilder.option("-f", nil), [])
}
@Test("option: present value emits flag and value verbatim")
func optionPresent() {
#expect(ArgsBuilder.option("-f", "abc") == ["-f", "abc"])
#expect(ArgsBuilder.option("-f", "") == ["-f", ""])
#expect(ArgsBuilder.option("-f", " padded ") == ["-f", " padded "])
func testOptionPresent() {
XCTAssertEqual(ArgsBuilder.option("-f", "abc"), ["-f", "abc"])
XCTAssertEqual(ArgsBuilder.option("-f", ""), ["-f", ""])
XCTAssertEqual(ArgsBuilder.option("-f", " padded "), ["-f", " padded "])
}
// MARK: - optionIfNonEmpty
@Test("optionIfNonEmpty: nil and empty emit nothing")
func optionIfNonEmptyNilEmpty() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", nil) == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "") == [])
func testOptionIfNonEmptyNilEmpty() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", nil), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", ""), [])
}
@Test("optionIfNonEmpty: whitespace-only emits nothing")
func optionIfNonEmptyWhitespace() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " ") == [])
#expect(ArgsBuilder.optionIfNonEmpty("-d", " \t\n ") == [])
func testOptionIfNonEmptyWhitespace() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " "), [])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " \t\n "), [])
}
@Test("optionIfNonEmpty: trims surrounding whitespace")
func optionIfNonEmptyTrims() {
#expect(ArgsBuilder.optionIfNonEmpty("-d", " label ") == ["-d", "label"])
#expect(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n") == ["-d", "cal.cal"])
func testOptionIfNonEmptyTrims() {
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", " label "), ["-d", "label"])
XCTAssertEqual(ArgsBuilder.optionIfNonEmpty("-d", "\tcal.cal\n"), ["-d", "cal.cal"])
}
// MARK: - optionUnlessApprox
@Test("optionUnlessApprox: nil emits nothing")
func optionUnlessApproxNil() {
#expect(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50) == [])
func testOptionUnlessApproxNil() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", nil, skip: 0.50), [])
}
@Test("optionUnlessApprox: exact skip value emits nothing")
func optionUnlessApproxExactSkip() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0) == [])
func testOptionUnlessApproxExactSkip() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.50, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.0, skip: 1.0), [])
}
@Test("optionUnlessApprox: within epsilon emits nothing")
func optionUnlessApproxWithinEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50) == [])
#expect(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0) == [])
func testOptionUnlessApproxWithinEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.5005, skip: 0.50), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 0.9995, skip: 1.0), [])
}
@Test("optionUnlessApprox: outside epsilon emits flag")
func optionUnlessApproxOutsideEpsilon() {
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50) == ["-N", "0.75"])
#expect(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0) == ["-V", "1.50"])
#expect(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50) == ["-N", "0.50"])
func testOptionUnlessApproxOutsideEpsilon() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.75, skip: 0.50), ["-N", "0.75"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-V", 1.50, skip: 1.0), ["-V", "1.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-N", 0.498, skip: 0.50), ["-N", "0.50"])
}
@Test("optionUnlessApprox: POSIX formatting is locale-stable")
func optionUnlessApproxPOSIX() {
func testOptionUnlessApproxPOSIX() {
// 1234.5 must never produce a grouping separator or comma decimal.
#expect(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0) == ["-p", "1234.50"])
#expect(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0) == ["-p", "2.00"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 1234.5, skip: 1.0), ["-p", "1234.50"])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-p", 2.0, skip: 1.0), ["-p", "2.00"])
}
@Test("optionUnlessApprox: custom epsilon and format honoured")
func optionUnlessApproxCustom() {
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01) == [])
#expect(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f") == ["-x", "1.5"])
func testOptionUnlessApproxCustom() {
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.005, skip: 1.0, epsilon: 0.01), [])
XCTAssertEqual(ArgsBuilder.optionUnlessApprox("-x", 1.5, skip: 1.0, format: "%.1f"), ["-x", "1.5"])
}
// MARK: - flag
@Test("flag: true emits the bare flag")
func flagTrue() {
#expect(ArgsBuilder.flag("-G", when: true) == ["-G"])
#expect(ArgsBuilder.flag("-r", when: true) == ["-r"])
func testFlagTrue() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: true), ["-G"])
XCTAssertEqual(ArgsBuilder.flag("-r", when: true), ["-r"])
}
@Test("flag: false emits nothing")
func flagFalse() {
#expect(ArgsBuilder.flag("-G", when: false) == [])
#expect(ArgsBuilder.flag("-r", when: false) == [])
func testFlagFalse() {
XCTAssertEqual(ArgsBuilder.flag("-G", when: false), [])
XCTAssertEqual(ArgsBuilder.flag("-r", when: false), [])
}
}
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ArgyllRunner Calibration")
struct ArgyllRunnerCalibrationTests {
final class ArgyllRunnerCalibrationTests: XCTestCase {
private func makeRunner(processManager: ProcessManager = ProcessManager()) -> ArgyllRunner {
let binDir = URL(fileURLWithPath: #filePath)
@@ -23,8 +22,7 @@ struct ArgyllRunnerCalibrationTests {
return root
}
@Test("Calibration targen produces CAL_*.ti1")
func calibrationTargenProducesTi1() async throws {
func testCalibrationTargenProducesTi1() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let config = CalibrationTargenConfig(
@@ -36,13 +34,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_demo.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("Calibration targen from foo runs as process id targen_CAL_foo")
func calibrationTargenProcessId() async throws {
func testCalibrationTargenProcessId() async throws {
let testRoot = try makeTestDir()
let pm = ProcessManager()
let runner = makeRunner(processManager: pm)
@@ -65,13 +62,13 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runCalibrationTargen(config: config)
#expect(url.lastPathComponent == "CAL_foo.ti1")
#expect(await sawExit.value)
XCTAssertEqual(url.lastPathComponent, "CAL_foo.ti1")
let sawExitEvent = await sawExit.value
XCTAssertTrue(sawExitEvent)
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal captured run creates .cal")
func printcalProducesCal() async throws {
func testPrintcalProducesCal() async throws {
let testRoot = try makeTestDir()
let runner = makeRunner()
let output = testRoot.appendingPathComponent("CAL_demo.cal")
@@ -83,13 +80,12 @@ struct ArgyllRunnerCalibrationTests {
let url = try await runner.runPrintcal(config: config)
#expect(url.lastPathComponent == "CAL_demo.cal")
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertEqual(url.lastPathComponent, "CAL_demo.cal")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
try? FileManager.default.removeItem(at: testRoot)
}
@Test("printcal failure throws toolFailed")
func printcalFailureThrows() async throws {
func testPrintcalFailureThrows() async throws {
let testRoot = try makeTestDir()
defer { try? FileManager.default.removeItem(at: testRoot) }
@@ -117,9 +113,11 @@ struct ArgyllRunnerCalibrationTests {
outputURL: output
)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
_ = try await runner.runPrintcal(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "printcal", code: 1, logs: ["printcal mock failure\n"]))
}
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
final class LogHolder: @unchecked Sendable {
@@ -19,11 +19,9 @@ final class LogHolder: @unchecked Sendable {
}
}
@Suite("ArgyllRunner colprof")
struct ArgyllRunnerColprofTests {
final class ArgyllRunnerColprofTests: XCTestCase {
@Test("Mock colprof produces .icc")
func colprofProducesIcc() async throws {
func testColprofProducesIcc() async throws {
let binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
@@ -43,15 +41,14 @@ struct ArgyllRunnerColprofTests {
holder.append(batch)
}
#expect(url.lastPathComponent == "testrun.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains { $0.contains("Gamut mapping") })
XCTAssertEqual(url.lastPathComponent, "testrun.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains { $0.contains("Gamut mapping") })
try? FileManager.default.removeItem(at: testRoot)
}
@Test("Failing colprof throws toolFailed with code and logs")
func colprofFailureThrowsToolFailed() async throws {
func testColprofFailureThrowsToolFailed() async throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("colprof-fail-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -72,9 +69,11 @@ struct ArgyllRunnerColprofTests {
)
let config = ColprofConfig(basename: "failrun", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"])) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runColprof(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .toolFailed(
tool: "colprof", code: 4, logs: ["colprof broke"]))
}
}
}
@@ -1,5 +1,5 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// Focused contracts for the shared `runStreamingTool` loop (#79).
@@ -7,8 +7,7 @@ import Testing
/// Every test uses a per-test temporary directory, unique basenames,
/// and a fresh `ProcessManager` — no shared UI fixture scripts and no
/// process-environment mutation.
@Suite("ArgyllRunner streaming loop contracts")
struct ArgyllRunnerStreamingLoopTests {
final class ArgyllRunnerStreamingLoopTests: XCTestCase {
private func makeTempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
@@ -30,8 +29,7 @@ struct ArgyllRunnerStreamingLoopTests {
binaryResolver: BinaryResolver(bundledRoot: binDir, overrideDir: binDir))
}
@Test("Non-zero exit throws toolFailed retaining code and collected stdout/stderr lines")
func nonZeroExitThrowsToolFailed() async throws {
func testNonZeroExitThrowsToolFailed() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -47,21 +45,20 @@ struct ArgyllRunnerStreamingLoopTests {
do {
_ = try await runner.runTargen(config: config)
Issue.record("Expected toolFailed")
XCTFail("Expected toolFailed")
} catch let error as ArgyllRunnerError {
guard case .toolFailed(let tool, let code, let logs) = error else {
Issue.record("Expected toolFailed, got \(error)")
XCTFail("Expected toolFailed, got \(error)")
return
}
#expect(tool == "targen")
#expect(code == 3)
#expect(logs.contains("Generating patches..."))
#expect(logs.contains("targen: too few patches"))
XCTAssertEqual(tool, "targen")
XCTAssertEqual(code, 3)
XCTAssertTrue(logs.contains("Generating patches..."))
XCTAssertTrue(logs.contains("targen: too few patches"))
}
}
@Test("Exit 0 without expected artefact throws missingArtefact with the artefact path")
func zeroExitMissingArtefact() async throws {
func testZeroExitMissingArtefact() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -75,13 +72,14 @@ struct ArgyllRunnerStreamingLoopTests {
colourSpace: .rgb, patchCount: 800, whitePatches: 4,
blackPatches: 4, basename: "gone", workingDirectory: dir)
await #expect(throws: ArgyllRunnerError.missingArtefact(expectedPath)) {
await assertAsyncThrows(expectedType: ArgyllRunnerError.self) {
try await runner.runTargen(config: config)
} errorHandler: { error in
XCTAssertEqual(error, .missingArtefact(expectedPath))
}
}
@Test("Immediate exit after one stdout line still delivers the line and succeeds")
func immediateExitDeliversLine() async throws {
func testImmediateExitDeliversLine() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
try writeMock("targen", """
@@ -101,13 +99,12 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runTargen(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "quick.ti1")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("only line"))
XCTAssertEqual(url.lastPathComponent, "quick.ti1")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("only line"))
}
@Test("colprof unterminated progress fragment reaches onLogBatch before exit")
func colprofPartialLineFlush() async throws {
func testColprofPartialLineFlush() async throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }
// The fragment is printed without a newline, then the mock sleeps
@@ -129,13 +126,13 @@ struct ArgyllRunnerStreamingLoopTests {
let url = try await runner.runColprof(config: config) { batch in
holder.append(batch)
}
#expect(url.lastPathComponent == "frag.icc")
#expect(FileManager.default.fileExists(atPath: url.path))
#expect(holder.lines.contains("Doing gamut mapping"))
XCTAssertEqual(url.lastPathComponent, "frag.icc")
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(holder.lines.contains("Doing gamut mapping"))
}
@Test("toolFailed maps each tool to its user-facing description",
arguments: [
func testToolDescriptions() {
let cases: [(tool: String, expected: String)] = [
(tool: "chartread", expected: "Chartread failed: boom"),
(tool: "average", expected: "Averaging failed: boom"),
(tool: "colprof", expected: "Profile creation failed: boom"),
@@ -143,19 +140,19 @@ struct ArgyllRunnerStreamingLoopTests {
(tool: "applycal", expected: "Apply calibration failed: boom"),
(tool: "iccgamut", expected: "Gamut extraction failed: boom"),
(tool: "profcheck", expected: "Profile verification failed: boom"),
])
func toolDescriptions(tool: String, expected: String) {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
#expect(error.errorDescription == expected)
]
for (tool, expected) in cases {
let error = ArgyllRunnerError.toolFailed(tool: tool, code: 1, logs: ["boom"])
XCTAssertEqual(error.errorDescription, expected)
}
}
@Test("toolFailed falls back to a generic description for unmapped tools and empty logs")
func genericFallbacks() {
func testGenericFallbacks() {
let unknown = ArgyllRunnerError.toolFailed(tool: "targen", code: 7, logs: ["boom"])
#expect(unknown.errorDescription == "Process exited with code 7")
XCTAssertEqual(unknown.errorDescription, "Process exited with code 7")
let emptyLogs = ArgyllRunnerError.toolFailed(tool: "colprof", code: 2, logs: [])
#expect(emptyLogs.errorDescription
== "Profile creation failed: exited with code 2")
XCTAssertEqual(emptyLogs.errorDescription,
"Profile creation failed: exited with code 2")
}
}
+102 -60
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -10,9 +10,8 @@ private func tempURL(_ name: String) -> URL {
.appendingPathComponent(name)
}
@Suite("Ti2Header")
struct Ti2HeaderTests {
@Test func parsesKeywordsAndSibling() throws {
final class Ti2HeaderTests: XCTestCase {
func testParsesKeywordsAndSibling() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ti2-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
@@ -31,18 +30,18 @@ struct Ti2HeaderTests {
)
let h = Ti2Header.parse(dir.appendingPathComponent("job.ti2"))
#expect(h.instrument == "i1iO")
#expect(h.patchCount == 800)
#expect(h.pageCount == 3)
#expect(h.hasSiblingTi1)
XCTAssertEqual(h.instrument, "i1iO")
XCTAssertEqual(h.patchCount, 800)
XCTAssertEqual(h.pageCount, 3)
XCTAssertTrue(h.hasSiblingTi1)
}
@Test func missingFileYieldsEmptyHeader() {
func testMissingFileYieldsEmptyHeader() {
let h = Ti2Header.parse(URL(fileURLWithPath: "/nonexistent/x.ti2"))
#expect(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
XCTAssertTrue(h.instrument == nil && h.patchCount == nil && !h.hasSiblingTi1)
}
@Test func numberOfFieldsIsNotPatchCount() throws {
func testNumberOfFieldsIsNotPatchCount() throws {
let url = tempURL("t.ti2")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
@@ -50,12 +49,11 @@ struct Ti2HeaderTests {
try "NUMBER_OF_FIELDS 9\nNUMBER_OF_SETS 52\nBEGIN_DATA\n".write(
to: url, atomically: true, encoding: .utf8
)
#expect(Ti2Header.parse(url).patchCount == 52)
XCTAssertEqual(Ti2Header.parse(url).patchCount, 52)
}
}
@Suite("TiffPreview")
struct TiffPreviewTests {
final class TiffPreviewTests: XCTestCase {
/// Builds a real 2000×1000 TIFF in a temp dir via ImageIO.
private func makeTiff(width: Int = 2000, height: Int = 1000) throws -> URL {
let url = tempURL("big.tif")
@@ -81,50 +79,104 @@ struct TiffPreviewTests {
return url
}
@Test func producesCappedPNG() throws {
func testProducesCappedPNG() throws {
let tiff = try makeTiff()
let png = TiffPreview.previewPNG(tiff: tiff)
#expect(png != nil)
XCTAssertNotNil(png)
// PNG magic
#expect(png!.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
XCTAssertEqual(png!.prefix(8), Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
// Verify the cap by decoding the thumbnail header.
let src = CGImageSourceCreateWithData(png! as CFData, nil)!
let img = CGImageSourceCreateImageAtIndex(src, 0, nil)!
#expect(max(img.width, img.height) <= TiffPreview.maxEdge)
#expect(img.width == 1200)
XCTAssertTrue(max(img.width, img.height) <= TiffPreview.maxEdge)
XCTAssertEqual(img.width, 1200)
}
@Test func nonTiffReturnsNil() throws {
func testNonTiffReturnsNil() throws {
let url = tempURL("not-tiff.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
#expect(TiffPreview.previewPNG(tiff: url) == nil)
XCTAssertNil(TiffPreview.previewPNG(tiff: url))
}
}
@Suite("ArtefactFiles")
struct ArtefactFilesTests {
@Test func base64RoundTrip() throws {
final class AppInfoTests: XCTestCase {
// MARK: displayVersion (#189)
func testNoTagFallsBackToShortVersion() {
XCTAssertEqual(
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: ""),
"2.0.0"
)
}
func testWhitespaceTagFallsBackToShortVersion() {
XCTAssertEqual(
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: " "),
"2.0.0"
)
}
func testExactReleaseTagDedupes() {
XCTAssertEqual(
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "v2.0.0"),
"v2.0.0"
)
}
func testBareVersionTagDedupes() {
XCTAssertEqual(
ArtefactFiles.displayVersion(shortVersion: "2.0.0", releaseTag: "2.0.0"),
"2.0.0"
)
}
func testPrereleaseTagShowsMarketingInParens() {
XCTAssertEqual(
ArtefactFiles.displayVersion(
shortVersion: "2.0.0", releaseTag: "v2.0.0-pre2-grok"),
"v2.0.0-pre2-grok (2.0.0)"
)
}
func testDescribeStringShowsMarketingInParens() {
XCTAssertEqual(
ArtefactFiles.displayVersion(
shortVersion: "2.0.0", releaseTag: "v2.0.0-5-gdeadbee"),
"v2.0.0-5-gdeadbee (2.0.0)"
)
}
func testAppInfoUsesComposedVersion() {
// .main in the test host is the ICCery app under test; whatever it
// resolves to, build must be a non-empty digits-or-default string.
let info = ArtefactFiles.appInfo()
XCTAssertFalse(info.version.isEmpty)
XCTAssertFalse(info.build.isEmpty)
}
}
final class ArtefactFilesTests: XCTestCase {
func testBase64RoundTrip() throws {
let url = tempURL("a.txt")
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
try "hello".write(to: url, atomically: true, encoding: .utf8)
let b64 = try ArtefactFiles.readBase64(url)
#expect(Data(base64Encoded: b64) == Data("hello".utf8))
XCTAssertEqual(Data(base64Encoded: b64), Data("hello".utf8))
}
@Test func defaultWorkingDirExists() {
#expect(FileManager.default.fileExists(
func testDefaultWorkingDirExists() {
XCTAssertTrue(FileManager.default.fileExists(
atPath: ArtefactFiles.defaultWorkingDirectory().path
))
}
}
@Suite("ArtefactProbe profile resolve")
struct ArtefactProbeProfileTests {
final class ArtefactProbeProfileTests: XCTestCase {
private func makeDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("probe-\(UUID().uuidString)")
@@ -134,93 +186,83 @@ struct ArtefactProbeProfileTests {
// MARK: Basename probe matrix (#69)
@Test("basename probe: only .icc exists")
func onlyIcc() throws {
func testOnlyIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icc.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icc.path)
}
@Test("basename probe: only .icm exists")
func onlyIcm() throws {
func testOnlyIcm() throws {
let dir = try makeDir()
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path == icm.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(basename: "job", cwd: dir)?.path, icm.path)
}
@Test("basename probe prefers .icm")
func icmWins() throws {
func testIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let url = ArtefactProbe.resolveProfile(basename: "job", cwd: dir)
#expect(url?.path == icm.path)
XCTAssertEqual(url?.path, icm.path)
}
@Test("basename probe: neither exists returns nil")
func neitherExists() throws {
func testNeitherExists() throws {
let dir = try makeDir()
#expect(ArtefactProbe.resolveProfile(basename: "job", cwd: dir) == nil)
XCTAssertNil(ArtefactProbe.resolveProfile(basename: "job", cwd: dir))
}
// MARK: Explicit URL matrix (#69 / #83)
@Test("explicit existing .icc wins even when .icm exists")
func explicitIccWins() throws {
func testExplicitIccWins() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
try Data("icm".utf8).write(to: dir.appendingPathComponent("job.icm"))
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
}
@Test("explicit existing .icm wins even when .icc exists")
func explicitIcmWins() throws {
func testExplicitIcmWins() throws {
let dir = try makeDir()
try Data("icc".utf8).write(to: dir.appendingPathComponent("job.icc"))
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
#expect(ArtefactProbe.resolveProfile(icm).path == icm.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icm.path)
}
@Test("explicit missing .icc flips to sibling .icm")
func flipExtension() throws {
func testFlipExtension() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icm".utf8).write(to: icm)
let resolved = ArtefactProbe.resolveProfile(icc)
#expect(resolved.path == icm.path)
XCTAssertEqual(resolved.path, icm.path)
}
@Test("explicit missing .icm flips to sibling .icc")
func flipToIcc() throws {
func testFlipToIcc() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
let icm = dir.appendingPathComponent("job.icm")
try Data("icc".utf8).write(to: icc)
#expect(ArtefactProbe.resolveProfile(icm).path == icc.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(icm).path, icc.path)
}
@Test("explicit missing both returns the original URL")
func missingBoth() throws {
func testMissingBoth() throws {
let dir = try makeDir()
let icc = dir.appendingPathComponent("job.icc")
#expect(ArtefactProbe.resolveProfile(icc).path == icc.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(icc).path, icc.path)
}
@Test("unrelated extension is never rewritten")
func unrelatedExtension() throws {
func testUnrelatedExtension() throws {
let dir = try makeDir()
let mpp = dir.appendingPathComponent("job.mpp")
let icc = dir.appendingPathComponent("job.icc")
try Data("icc".utf8).write(to: icc)
// Even though a sibling .icc exists, a missing .mpp stays .mpp.
#expect(ArtefactProbe.resolveProfile(mpp).path == mpp.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(mpp).path, mpp.path)
let txt = dir.appendingPathComponent("job.txt")
#expect(ArtefactProbe.resolveProfile(txt).path == txt.path)
XCTAssertEqual(ArtefactProbe.resolveProfile(txt).path, txt.path)
}
}
+16 -17
View File
@@ -1,9 +1,8 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@Suite("BinaryResolver")
struct BinaryResolverTests {
final class BinaryResolverTests: XCTestCase {
private func makeTree(_ body: (URL) throws -> Void) throws -> URL {
let root = FileManager.default.temporaryDirectory
@@ -22,16 +21,16 @@ struct BinaryResolverTests {
}
}
@Test func overrideDirWinsWhenFileExists() throws {
func testOverrideDirWinsWhenFileExists() throws {
let override = try makeTree { root in
try touch(root.appendingPathComponent("targen"))
}
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen") == override.appendingPathComponent("targen"))
XCTAssertEqual(r.resolve("targen"), override.appendingPathComponent("targen"))
}
@Test func overrideFallsThroughWhenMissing() throws {
func testOverrideFallsThroughWhenMissing() throws {
let override = try makeTree { _ in }
let bundled = try makeTree { root in
let dir = root.appendingPathComponent("macos-universal")
@@ -39,10 +38,10 @@ struct BinaryResolverTests {
try touch(dir.appendingPathComponent("instlist"))
}
let r = BinaryResolver(bundledRoot: bundled, overrideDir: override)
#expect(r.resolve("targen").path.contains("macos-universal/targen"))
XCTAssertTrue(r.resolve("targen").path.contains("macos-universal/targen"))
}
@Test func universalPreferredWhenMarkerPresent() throws {
func testUniversalPreferredWhenMarkerPresent() throws {
let bundled = try makeTree { root in
for dir in ["macos-universal", "macos-x86_64"] {
let d = root.appendingPathComponent(dir)
@@ -51,10 +50,10 @@ struct BinaryResolverTests {
}
}
let r = BinaryResolver(bundledRoot: bundled)
#expect(r.platformDir() == "macos-universal")
XCTAssertEqual(r.platformDir(), "macos-universal")
}
@Test func fallsBackToArchDir() throws {
func testFallsBackToArchDir() throws {
let bundled = try makeTree { root in
let d = root.appendingPathComponent("macos-x86_64")
try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
@@ -64,20 +63,20 @@ struct BinaryResolverTests {
bundledRoot: bundled,
archDirs: ["macos-universal", "macos-x86_64"]
)
#expect(r.platformDir() == "macos-x86_64")
XCTAssertEqual(r.platformDir(), "macos-x86_64")
}
@Test func missingEverythingReturnsConstructedPath() throws {
func testMissingEverythingReturnsConstructedPath() throws {
let bundled = try makeTree { _ in }
let r = BinaryResolver(bundledRoot: bundled)
// v1 semantic: path is returned; spawn surfaces the error.
#expect(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
#expect(!r.exists(r.resolve("targen")))
XCTAssertTrue(r.resolve("targen").path.hasSuffix("macos-universal/targen"))
XCTAssertFalse(r.exists(r.resolve("targen")))
}
@Test func mockAndGamutPaths() throws {
func testMockAndGamutPaths() throws {
let r = BinaryResolver(bundledRoot: URL(fileURLWithPath: "/x"))
#expect(r.mock("chartread").path == "/x/mocks/chartread.mock")
#expect(r.referenceGamut("sRGB.gam").path == "/x/reference_gamuts/sRGB.gam")
XCTAssertEqual(r.mock("chartread").path, "/x/mocks/chartread.mock")
XCTAssertEqual(r.referenceGamut("sRGB.gam").path, "/x/reference_gamuts/sRGB.gam")
}
}
+37 -50
View File
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CGATS Parser & Writer")
struct CGATSParserTests {
final class CGATSParserTests: XCTestCase {
private static let canonicalCTI3 = """
CTI3
@@ -21,44 +20,40 @@ struct CGATSParserTests {
END_DATA
"""
@Test("Parses CTI3 with canonical field names")
func parseCTI3() throws {
func testParseCTI3() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
#expect(dataset.format == .cti3)
#expect(dataset.samples.count == 2)
#expect(dataset.colorRep == "RGB")
#expect(dataset.deviceClass == "DISPLAY")
#expect(dataset.samples[0].id == "1")
#expect(dataset.samples[0].loc == "A1")
#expect(dataset.samples[1].values["RGB_G"] == "50.0000")
XCTAssertEqual(dataset.format, .cti3)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.colorRep, "RGB")
XCTAssertEqual(dataset.deviceClass, "DISPLAY")
XCTAssertEqual(dataset.samples[0].id, "1")
XCTAssertEqual(dataset.samples[0].loc, "A1")
XCTAssertEqual(dataset.samples[1].values["RGB_G"], "50.0000")
}
@Test("Round-trips parse, write, reparse")
func roundTrip() throws {
func testRoundTrip() throws {
let first = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(first)
let second = try CGATSParser.parse(text)
#expect(second.format == first.format)
#expect(second.samples.count == first.samples.count)
#expect(second.colorRep == first.colorRep)
#expect(second.deviceClass == first.deviceClass)
XCTAssertEqual(second.format, first.format)
XCTAssertEqual(second.samples.count, first.samples.count)
XCTAssertEqual(second.colorRep, first.colorRep)
XCTAssertEqual(second.deviceClass, first.deviceClass)
}
@Test("Parses CSV with comma delimiters")
func parseCSV() throws {
func testParseCSV() throws {
let csv = """
SAMPLE_ID,SAMPLE_LOC,RGB_R,RGB_G,RGB_B,XYZ_X,XYZ_Y,XYZ_Z,LAB_L,LAB_A,LAB_B
1,A1,50,0,0,20,10,5,50,60,30
2,A2,0,50,0,10,30,5,60,-50,40
"""
let dataset = try CGATSParser.parse(csv, sourceURL: URL(fileURLWithPath: "/tmp/sample.csv"))
#expect(dataset.format == .csv)
#expect(dataset.samples.count == 2)
#expect(dataset.samples[0].values["RGB_R"] == "50.0000")
XCTAssertEqual(dataset.format, .csv)
XCTAssertEqual(dataset.samples.count, 2)
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "50.0000")
}
@Test("Converts 0-255 device values to 0-100")
func converts255To100() throws {
func testConverts255To100() throws {
let rgb = """
CTI3
COLOR_REP RGB
@@ -72,12 +67,11 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(rgb)
#expect(dataset.samples[0].values["RGB_R"] == "100.0000")
#expect(dataset.samples[0].values["RGB_G"] == "50.1961")
XCTAssertEqual(dataset.samples[0].values["RGB_R"], "100.0000")
XCTAssertEqual(dataset.samples[0].values["RGB_G"], "50.1961")
}
@Test("Synthesizes COLOR_REP and DEVICE_CLASS when missing")
func synthesizesMetadata() throws {
func testSynthesizesMetadata() throws {
let cmyk = """
CTI3
NUMBER_OF_FIELDS 6
@@ -90,19 +84,15 @@ struct CGATSParserTests {
END_DATA
"""
let dataset = try CGATSParser.parse(cmyk)
#expect(dataset.colorRep == "CMYK")
#expect(dataset.deviceClass == "PRINTER")
XCTAssertEqual(dataset.colorRep, "CMYK")
XCTAssertEqual(dataset.deviceClass, "PRINTER")
}
@Test("Rejects empty file")
func rejectsEmpty() {
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse("")
}
func testRejectsEmpty() {
XCTAssertThrowsError(try CGATSParser.parse(""))
}
@Test("Rejects malformed arity")
func rejectsArity() {
func testRejectsArity() {
let bad = """
CTI3
NUMBER_OF_FIELDS 2
@@ -114,21 +104,18 @@ struct CGATSParserTests {
1
END_DATA
"""
#expect(throws: (any Error).self) {
_ = try CGATSParser.parse(bad)
}
XCTAssertThrowsError(try CGATSParser.parse(bad))
}
@Test("Writer emits valid .ti3 with tabs and required keywords")
func writerFormat() throws {
func testWriterFormat() throws {
let dataset = try CGATSParser.parse(Self.canonicalCTI3)
let text = try CGATSWriter.write(dataset)
#expect(text.contains("CTI3"))
#expect(text.contains("BEGIN_DATA_FORMAT"))
#expect(text.contains("BEGIN_DATA"))
#expect(text.contains("END_DATA"))
#expect(text.contains("COLOR_REP"))
#expect(text.contains("DEVICE_CLASS"))
#expect(text.contains("\t"))
XCTAssertTrue(text.contains("CTI3"))
XCTAssertTrue(text.contains("BEGIN_DATA_FORMAT"))
XCTAssertTrue(text.contains("BEGIN_DATA"))
XCTAssertTrue(text.contains("END_DATA"))
XCTAssertTrue(text.contains("COLOR_REP"))
XCTAssertTrue(text.contains("DEVICE_CLASS"))
XCTAssertTrue(text.contains("\t"))
}
}
@@ -1,78 +1,67 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// Issue #83 — canonical `CAL_` / original-stem pairing.
@Suite("CalibrationIdentity")
struct CalibrationIdentityTests {
@Test("live foo, no persisted")
func livePlain() {
final class CalibrationIdentityTests: XCTestCase {
func testLivePlain() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live foo ignores stale persisted")
func livePlainIgnoresPersisted() {
func testLivePlainIgnoresPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "foo", persistedOriginal: "bar")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live CAL_foo, persisted foo")
func liveCalPersisted() {
func testLiveCalPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "foo")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("live CAL_foo, empty persisted strips prefix")
func liveCalNoPersist() {
func testLiveCalNoPersist() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("persisted original wins over CAL_ live")
func persistedWins() {
func testPersistedWins() {
let id = CalibrationIdentity.parse(liveBasename: "CAL_foo", persistedOriginal: "bar")
#expect(id.originalBasename == "bar")
#expect(id.calibrationBasename == "CAL_bar")
XCTAssertEqual(id.originalBasename, "bar")
XCTAssertEqual(id.calibrationBasename, "CAL_bar")
}
@Test("empty live yields empty identity even with persisted original")
func emptyLiveWithPersisted() {
func testEmptyLiveWithPersisted() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "foo")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
@Test("empty live, empty persisted")
func emptyLive() {
func testEmptyLive() {
let id = CalibrationIdentity.parse(liveBasename: "", persistedOriginal: "")
#expect(id.originalBasename.isEmpty)
#expect(id.calibrationBasename.isEmpty)
XCTAssertTrue(id.originalBasename.isEmpty)
XCTAssertTrue(id.calibrationBasename.isEmpty)
}
@Test("prefix is idempotent on already-prefixed input")
func alreadyPrefixed() {
#expect(CalibrationIdentity.prefix("CAL_foo") == "CAL_foo")
#expect(CalibrationIdentity.prefix("foo") == "CAL_foo")
func testAlreadyPrefixed() {
XCTAssertEqual(CalibrationIdentity.prefix("CAL_foo"), "CAL_foo")
XCTAssertEqual(CalibrationIdentity.prefix("foo"), "CAL_foo")
let id = CalibrationIdentity.parse(liveBasename: "CAL_CAL_foo", persistedOriginal: "")
#expect(id.originalBasename == "CAL_foo")
#expect(id.calibrationBasename == "CAL_foo")
XCTAssertEqual(id.originalBasename, "CAL_foo")
XCTAssertEqual(id.calibrationBasename, "CAL_foo")
}
@Test("prefix never invents a name from empty input")
func prefixEmpty() {
#expect(CalibrationIdentity.prefix("").isEmpty)
#expect(CalibrationIdentity.strip("foo") == "foo")
#expect(CalibrationIdentity.strip("CAL_foo") == "foo")
func testPrefixEmpty() {
XCTAssertTrue(CalibrationIdentity.prefix("").isEmpty)
XCTAssertEqual(CalibrationIdentity.strip("foo"), "foo")
XCTAssertEqual(CalibrationIdentity.strip("CAL_foo"), "foo")
}
@Test("runner process id for a calibration targen is targen_CAL_*")
func processIdMatches() {
func testProcessIdMatches() {
let cal = CalibrationIdentity.prefix("foo")
#expect(ProcessID.targen(cal) == "targen_CAL_foo")
XCTAssertEqual(ProcessID.targen(cal), "targen_CAL_foo")
}
}
@@ -1,9 +1,8 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationStore")
struct CalibrationStoreTests {
final class CalibrationStoreTests: XCTestCase {
private static let sampleCal = """
CTI3
@@ -23,8 +22,7 @@ struct CalibrationStoreTests {
END_DATA
"""
@Test("Loads metadata and curves from .cal")
func parseCal() async throws {
func testParseCal() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("test_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -33,17 +31,16 @@ struct CalibrationStoreTests {
try await store.load(url: url)
let data = await store.data
#expect(data?.colorRep == "RGB")
#expect(data?.descriptor == "Test printer")
#expect(data?.maxTac == 300)
#expect(data?.curves.count == 3)
XCTAssertEqual(data?.colorRep, "RGB")
XCTAssertEqual(data?.descriptor, "Test printer")
XCTAssertEqual(data?.maxTac, 300)
XCTAssertEqual(data?.curves.count, 3)
let r = data?.curves.first { $0.channel == "R" }
#expect(r?.output == [0, 64, 255])
XCTAssertEqual(r?.output, [0, 64, 255])
}
@Test("Staleness is true for a very old calibration")
func staleCalibration() async throws {
func testStaleCalibration() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("stale_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -51,11 +48,10 @@ struct CalibrationStoreTests {
let store = CalibrationStore(staleDays: 0)
try await store.load(url: url)
let stale = await store.isStale(comparedTo: "Other")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
@Test("Printer mismatch is flagged as stale")
func printerMismatch() async throws {
func testPrinterMismatch() async throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("mismatch_\(UUID().uuidString).cal")
try Self.sampleCal.write(to: url, atomically: true, encoding: .utf8)
@@ -64,6 +60,6 @@ struct CalibrationStoreTests {
try await store.load(url: url)
await store.setPrinterName("Printer A")
let stale = await store.isStale(comparedTo: "Printer B")
#expect(stale == true)
XCTAssertEqual(stale, true)
}
}
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("CalibrationTargenArgs")
struct CalibrationTargenArgsTests {
final class CalibrationTargenArgsTests: XCTestCase {
@Test("RGB baseline")
func rgbBaseline() throws {
func testRgbBaseline() throws {
let config = CalibrationTargenConfig(
colourSpace: .rgb,
steps: 21,
@@ -15,11 +13,10 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
XCTAssertEqual(args, ["-v", "-d", "2", "-s", "21", "-g", "21", "-e", "4", "-f", "0", "CAL_demo"])
}
@Test("CMYK baseline with ink limit and neutral emphasis")
func cmykWithOptions() throws {
func testCmykWithOptions() throws {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
steps: 25,
@@ -30,33 +27,26 @@ struct CalibrationTargenArgsTests {
workingDirectory: URL(fileURLWithPath: "/tmp")
)
let args = try CalibrationTargenArgs.build(config: config)
#expect(args == ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
XCTAssertEqual(args, ["-v", "-d", "4", "-s", "25", "-g", "25", "-e", "4", "-f", "0", "-n", "25", "-l", "320", "CAL_printer"])
}
@Test("Rejects out-of-range steps")
func rejectsBadSteps() {
func testRejectsBadSteps() {
let config = CalibrationTargenConfig(steps: 5, basename: "demo")
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Rejects bad CMYK ink limit")
func rejectsBadInkLimit() {
func testRejectsBadInkLimit() {
let config = CalibrationTargenConfig(
colourSpace: .cmyk,
inkLimit: 500,
basename: "demo"
)
#expect(throws: (any Error).self) {
_ = try CalibrationTargenArgs.build(config: config)
}
XCTAssertThrowsError(try CalibrationTargenArgs.build(config: config))
}
@Test("Does not double-prefix an existing CAL_ basename")
func noDoublePrefix() throws {
func testNoDoublePrefix() throws {
let config = CalibrationTargenConfig(basename: "CAL_test")
let args = try CalibrationTargenArgs.build(config: config)
#expect(args.last == "CAL_test")
XCTAssertEqual(args.last, "CAL_test")
}
}
+23 -32
View File
@@ -1,72 +1,63 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofArgs")
struct ColprofArgsTests {
final class ColprofArgsTests: XCTestCase {
@Test("Default algorithm and quality")
func defaults() throws {
func testDefaults() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "target"])
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "target"])
}
@Test("FWA bare -f when empty string")
func fwaBareFlag() throws {
func testFwaBareFlag() throws {
let config = ColprofConfig(fwa: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args == ["-v", "-a", "l", "-q", "m", "-f", "target"])
XCTAssertEqual(args, ["-v", "-a", "l", "-q", "m", "-f", "target"])
}
@Test("FWA D50 and D65 emit -f value")
func fwaD50() throws {
func testFwaD50() throws {
let config = ColprofConfig(fwa: "D50", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-f"))
#expect(args.contains("D50"))
#expect(args.last == "target")
XCTAssertTrue(args.contains("-f"))
XCTAssertTrue(args.contains("D50"))
XCTAssertEqual(args.last, "target")
}
@Test("FWA none is omitted")
func fwaNoneOmitted() throws {
func testFwaNoneOmitted() throws {
let config = ColprofConfig(fwa: "none", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-f"))
XCTAssertFalse(args.contains("-f"))
}
@Test("Viewing conditions skip none")
func viewingCondNoneSkipped() throws {
func testViewingCondNoneSkipped() throws {
let config = ColprofConfig(
inputViewingCond: "none",
outputViewingCond: "mt",
basename: "target"
)
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-c"))
#expect(args.contains("-d"))
#expect(args.contains("mt"))
XCTAssertFalse(args.contains("-c"))
XCTAssertTrue(args.contains("-d"))
XCTAssertTrue(args.contains("mt"))
}
@Test("Description falls back to basename when empty")
func descriptionFallback() throws {
func testDescriptionFallback() throws {
let config = ColprofConfig(description: "", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-D"))
XCTAssertFalse(args.contains("-D"))
}
@Test("Copyright only when non-empty")
func copyright() throws {
func testCopyright() throws {
let config = ColprofConfig(copyright: "Gronod 2026", basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(args.contains("-C"))
#expect(args.contains("Gronod 2026"))
XCTAssertTrue(args.contains("-C"))
XCTAssertTrue(args.contains("Gronod 2026"))
}
@Test("No -u passed")
func noProgressJsonFlag() throws {
func testNoProgressJsonFlag() throws {
let config = ColprofConfig(basename: "target")
let args = try ColprofArgs.build(config: config)
#expect(!args.contains("-u"))
XCTAssertFalse(args.contains("-u"))
}
}
@@ -1,24 +1,20 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("ColprofProgress")
struct ColprofProgressTests {
final class ColprofProgressTests: XCTestCase {
@Test("Classifies gamut mapping")
func gamutMapping() {
#expect(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress") == .gamutMapping)
func testGamutMapping() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Gamut mapping calculation in progress"), .gamutMapping)
}
@Test("Classifies fitting or clut")
func fitting() {
#expect(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points") == .fittingClut)
#expect(ColprofProgressClassifier.classify(line: "clut table") == .fittingClut)
func testFitting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Fitting cLUT grid points"), .fittingClut)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "clut table"), .fittingClut)
}
@Test("Classifies writing")
func writing() {
#expect(ColprofProgressClassifier.classify(line: "Writing ICC profile header") == .writingIcc)
#expect(ColprofProgressClassifier.classify(line: "icc profile written") == .writingIcc)
func testWriting() {
XCTAssertEqual(ColprofProgressClassifier.classify(line: "Writing ICC profile header"), .writingIcc)
XCTAssertEqual(ColprofProgressClassifier.classify(line: "icc profile written"), .writingIcc)
}
}
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@testable import ICCery
@@ -6,48 +6,75 @@ import AppKit
import ApplicationServices
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
@Suite("CupsOptionsFilter")
struct CupsOptionsFilterTests {
final class CupsOptionsFilterTests: XCTestCase {
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
func dropsReserved() {
func testDropsReserved() {
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
+ "collate=true copies=1 job-sheets=none,none "
+ "pserrorhandler-requested=standard "
+ "MediaType=PhotographicGlossy"
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
XCTAssertEqual(CupsOptionsFilter.filter(raw), "MediaType=PhotographicGlossy")
}
@Test("Keeps relevant driver keys, order preserved")
func keepsRelevant() {
func testKeepsRelevant() {
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
+ "Resolution=600x600dpi Duplex=None"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Permissive: unknown non-com.* keys survive")
func keepsUnknown() {
/// #180 — a captured `EPIJ_Qual` (and the other canonical quality
/// keys) survives the filter so it reaches the Stage 2 mirror.
func testKeepsQualityKeys() {
let raw = "EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
+ "cupsPrintQuality=High Quality=Best "
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0"
XCTAssertEqual(CupsOptionsFilter.filter(raw),
"EPIJ_Qual=304 CNIJPrintQuality=3 PrintQuality=2 "
+ "cupsPrintQuality=High Quality=Best")
}
func testKeepsUnknown() {
let raw = "VendorFooBar=baz MediaType=Plain"
#expect(CupsOptionsFilter.filter(raw) == raw)
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
@Test("Drops empty keys and values")
func dropsEmpty() {
func testDropsEmpty() {
let raw = "=noval MediaType= InputSlot=Rear"
// "MediaType=" has an empty value → dropped; "=noval" empty key.
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
XCTAssertEqual(CupsOptionsFilter.filter(raw), "InputSlot=Rear")
}
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
func extractMedia() {
#expect(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7") == "7")
#expect(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4") == nil)
func testExtractMedia() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "MediaType=Photo EPIJ_Medi=1"), "Photo")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=7"), "7")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
/// #218 — vendor paper-source keys survive the filter, and an
/// empty `AP_D_InputSlot=` is still dropped.
func testKeepsTrayKeys() {
let raw = "InputSlot=Rear MediaSource=Front EPIJ_FdSo=Rear "
+ "EPIJ_PSrc=Front CNIJMediaSupply=1 AP_D_InputSlot=Auto"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
let emptyAlias = "AP_D_InputSlot= InputSlot=Rear"
XCTAssertEqual(CupsOptionsFilter.filter(emptyAlias), "InputSlot=Rear")
}
func testExtractTray() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo"), "Rear")
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto InputSlot=Rear",
preferredKey: "InputSlot"), "Rear")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
}
}
@@ -55,9 +82,8 @@ struct CupsOptionsFilterTests {
/// `@convention(c)` closures can't capture, so recording goes through
/// a file-scope recorder keyed by global state; no private symbols are
/// touched.
@Suite("ColorSyncSuppressor")
@MainActor
struct ColorSyncSuppressorTests {
final class ColorSyncSuppressorTests: XCTestCase {
/// Fake PMPrintSession — the injected resolver never dereferences it.
private var fakeSession: PMPrintSession {
@@ -78,10 +104,14 @@ struct ColorSyncSuppressorTests {
s.modeResolver = { name in
if Self.missing.contains(name) { return nil }
Self.currentSymbol = name
// `Self` inside a @convention(c) closure is a dynamic-Self
// capture — spell the (final) class name instead.
return { _, modeArg in
Self.recorded.append((Self.currentSymbol, modeArg as String))
if let ok = Self.succeeding,
Self.currentSymbol == ok.0, (modeArg as String) == ok.1 {
ColorSyncSuppressorTests.recorded.append(
(ColorSyncSuppressorTests.currentSymbol, modeArg as String))
if let ok = ColorSyncSuppressorTests.succeeding,
ColorSyncSuppressorTests.currentSymbol == ok.0,
(modeArg as String) == ok.1 {
return 0
}
return 1
@@ -90,55 +120,50 @@ struct ColorSyncSuppressorTests {
return s
}
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
func attemptOrder() {
func testAttemptOrder() {
Self.recorded = []
Self.succeeding = nil
Self.missing = ["PMSessionSetColorMatchingModeLock"]
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
// Lock is unresolvable → skipped; the rest plays out in order.
#expect(Self.recorded.map { "\($0.0)|\($0.1)" }
== ColorMatchingAttempts.attempts
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, ColorMatchingAttempts.attempts
.filter { $0.symbol != "PMSessionSetColorMatchingModeLock" }
.map { "\($0.symbol)|\($0.mode)" })
}
@Test("First zero wins — later symbols/modes not called")
func firstZeroWins() {
func testFirstZeroWins() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"AP_ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded.map { "\($0.0)|\($0.1)" }, [
"PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching",
])
}
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
func modeFallback() {
func testModeFallback() {
Self.recorded = []
Self.succeeding = ("PMSessionSetColorMatchingModeLock",
"ApplicationColorMatching")
Self.missing = []
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession))
#expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[0].1 == "AP_ApplicationColorMatching")
#expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock")
#expect(Self.recorded[1].1 == "ApplicationColorMatching")
#expect(Self.recorded.count == 2)
XCTAssertTrue(s.applySPIMode(to: fakeSession))
XCTAssertEqual(Self.recorded[0].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[0].1, "AP_ApplicationColorMatching")
XCTAssertEqual(Self.recorded[1].0, "PMSessionSetColorMatchingModeLock")
XCTAssertEqual(Self.recorded[1].1, "ApplicationColorMatching")
XCTAssertEqual(Self.recorded.count, 2)
}
@Test("All symbols missing → false, no calls")
func allMissing() {
func testAllMissing() {
Self.recorded = []
Self.succeeding = nil
Self.missing = Set(ColorMatchingAttempts.symbols)
let s = makeSuppressor()
#expect(s.applySPIMode(to: fakeSession) == false)
#expect(Self.recorded.isEmpty)
XCTAssertEqual(s.applySPIMode(to: fakeSession), false)
XCTAssertTrue(Self.recorded.isEmpty)
}
}
+629 -67
View File
@@ -1,11 +1,10 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
@Suite("CupsParsers")
struct CupsParsersTests {
final class CupsParserTests: XCTestCase {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -31,115 +30,678 @@ struct CupsParsersTests {
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope
ColorModel/Output Mode: *RGB Gray
Duplex/Duplex: *None DuplexNoTumble DuplexTumble
cupsPrintQuality/cupsPrintQuality: Draft *Normal High
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
"""
@Test("lpstat -e: one destination per line; empty = success")
func destinations() {
#expect(CupsParsers.lpstatDestinations(lpstatE) == [
func testDestinations() {
XCTAssertEqual(CupsParsers.lpstatDestinations(lpstatE), [
"Canon_Pro9500_II_series_XPS",
"Epson_XP_55_LPD",
"EPSON_XP_55_Series",
])
#expect(CupsParsers.lpstatDestinations("") == [])
XCTAssertEqual(CupsParsers.lpstatDestinations(""), [])
}
@Test("lpstat -p: idle / now-printing / disabled statuses")
func statuses() {
func testStatuses() {
let s = CupsParsers.lpstatStatuses(lpstatP)
#expect(s["Canon_Pro9500_II_series_XPS"] == .idle)
#expect(s["Epson_XP_55_LPD"] == .printing)
#expect(s["EPSON_XP_55_Series"] == .stopped)
XCTAssertEqual(s["Canon_Pro9500_II_series_XPS"], .idle)
XCTAssertEqual(s["Epson_XP_55_LPD"], .printing)
XCTAssertEqual(s["EPSON_XP_55_Series"], .stopped)
}
@Test("lpstat -d: default destination or none")
func defaultDestination() {
#expect(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n")
== "Canon_Pro9500_II_series_XPS")
#expect(CupsParsers.lpstatDefault("no system default destination\n") == nil)
func testDefaultDestination() {
XCTAssertEqual(CupsParsers.lpstatDefault(
"system default destination: Canon_Pro9500_II_series_XPS\n"), "Canon_Pro9500_II_series_XPS")
XCTAssertNil(CupsParsers.lpstatDefault("no system default destination\n"))
}
@Test("lpoptions -p: quoted printer-info, bare flags ignored")
func displayName() {
#expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series")
#expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil)
func testDisplayName() {
XCTAssertEqual(CupsParsers.lpoptionsDisplayName(lpoptionsP), "EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsDisplayName("printer-type=42\n"))
}
@Test("lpoptions -l: key/label split, * marks the default")
func optionListings() {
func testOptionListings() {
let listings = CupsParsers.lpoptionsList(lpoptionsL)
#expect(listings.count == 6)
XCTAssertEqual(listings.count, 6)
let page = listings[0]
#expect(page.key == "PageSize")
#expect(page.label == "Media Size")
#expect(page.defaultChoice == "A4")
#expect(page.choices.contains("Custom.WIDTHxHEIGHT"))
#expect(!page.choices.contains("*A4"))
XCTAssertEqual(page.key, "PageSize")
XCTAssertEqual(page.label, "Media Size")
XCTAssertEqual(page.defaultChoice, "A4")
XCTAssertTrue(page.choices.contains("Custom.WIDTHxHEIGHT"))
XCTAssertFalse(page.choices.contains("*A4"))
let slot = listings[1]
#expect(slot.key == "InputSlot")
#expect(slot.choices == ["Auto", "Main", "Photo", "Rear"])
#expect(slot.defaultChoice == "Main")
XCTAssertEqual(slot.key, "InputSlot")
XCTAssertEqual(slot.choices, ["Auto", "Main", "Photo", "Rear"])
XCTAssertEqual(slot.defaultChoice, "Main")
}
@Test("capabilities: trays/sizes index 1-based, media uses detected key")
func capabilities() {
func testCapabilities() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
#expect(caps.trays == [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Main", name: "Main"),
PrinterTray(id: 3, token: "Photo", name: "Photo"),
PrinterTray(id: 4, token: "Rear", name: "Rear"),
])
#expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5"))
#expect(caps.paperSizes.count == 10)
#expect(caps.mediaTypes.map(\.id) == [
XCTAssertEqual(caps.trayKey, "InputSlot")
XCTAssertEqual(caps.trayDefault, "Main")
XCTAssertEqual(caps.paperSizes.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [
"Stationery", "PhotographicHighGloss", "Photographic",
"PhotographicMatte", "Envelope",
])
#expect(caps.supportsOrientation)
XCTAssertTrue(caps.supportsOrientation)
}
@Test("PPD enrichment maps id → human label")
func ppdLabels() {
func testPpdLabels() {
let ppd = """
*CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<</MediaType(42)>>"
*CNIJMediaType 0/Plain Paper: ""
*en_US.CNIJMediaType 13/Envelope: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
#expect(labels["42"] == "Photo Paper Plus Semi-gloss")
#expect(labels["0"] == "Plain Paper")
#expect(labels["13"] == "Envelope")
XCTAssertEqual(labels["42"], "Photo Paper Plus Semi-gloss")
XCTAssertEqual(labels["0"], "Plain Paper")
XCTAssertEqual(labels["13"], "Envelope")
}
@Test("detectMediaTypeKey prefers vendor keys in order")
func mediaTypeKey() {
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType")
#expect(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]) == "MediaType")
#expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil)
func testMediaTypeKey() {
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["MediaType", "CNIJMediaType"]), "CNIJMediaType")
XCTAssertEqual(CupsParsers.detectMediaTypeKey(
optionKeys: ["PageSize", "MediaType"]), "MediaType")
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
}
@Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat")
func driverBypass() {
// MARK: - #183 quality key + option extraction
func testQualityKeyRosterOrder() {
// Vendor keys beat the generic ones; OutputMode/Resolution sit
// last (they are colour-ish keys on some drivers — #183/#180).
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["EPIJ_Qual", "Quality", "OutputMode"]), "EPIJ_Qual")
// A full Epson key set — EPIJ_Qual wins over the colour-mode
// key, the generic keys, and Resolution (#180, R11).
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["EPIJ_Qual", "OutputMode", "Resolution",
"cupsPrintQuality", "PrintQuality",
"ColorModel"]), "EPIJ_Qual")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["Quality", "OutputMode", "Resolution"]), "Quality")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["cupsPrintQuality", "CNIJQuality"]),
"CNIJQuality")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["OutputMode", "Resolution"]), "OutputMode")
XCTAssertEqual(CupsParsers.detectQualityKey(
optionKeys: ["Resolution"]), "Resolution")
XCTAssertNil(CupsParsers.detectQualityKey(optionKeys: ["PageSize"]))
}
func testCapabilitiesQuality() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList(lpoptionsL)
let caps = service.capabilities(from: listings, ppd: nil)
// EPIJ_Qual is the roster member — all seven Epson codes
// enumerate in the driver's own (non-sorted) order (#180).
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(caps.qualityDefault, "303")
}
func testCapabilitiesQualityPpdLabels() {
// Epson XP-55 PPD fragment — the seven `*EPIJ_Qual id/Label`
// lines in the driver's own order (#180).
let ppd = """
*OpenUI *EPIJ_Qual/Print Quality: PickOne
*DefaultEPIJ_Qual: 303
*EPIJ_Qual 301/Fast Economy: ""
*EPIJ_Qual 302/Economy: ""
*EPIJ_Qual 303/Normal: ""
*EPIJ_Qual 308/Draft: ""
*EPIJ_Qual 304/Fine: ""
*EPIJ_Qual 305/Quality: ""
*EPIJ_Qual 307/Best Quality: ""
*CloseUI: *EPIJ_Qual
"""
let service = CupsService()
let listings = CupsParsers.lpoptionsList(
"EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n")
let caps = service.capabilities(from: listings, ppd: ppd)
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities, [
PrinterQuality(id: "301", name: "Fast Economy"),
PrinterQuality(id: "302", name: "Economy"),
PrinterQuality(id: "303", name: "Normal"),
PrinterQuality(id: "308", name: "Draft"),
PrinterQuality(id: "304", name: "Fine"),
PrinterQuality(id: "305", name: "Quality"),
PrinterQuality(id: "307", name: "Best Quality"),
])
XCTAssertEqual(caps.qualityDefault, "303")
}
/// #180 — the Epson listing also carries `OutputMode` (a colour
/// mode) and `Resolution`; detection must still pick `EPIJ_Qual`.
func testCapabilitiesQualityEpsonDetection() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
PageSize/Media Size: *A4 Letter
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
OutputMode/Color Mode: *Color Mono
Resolution/Resolution: *360dpi 720dpi
""")
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.qualityKey, "EPIJ_Qual")
XCTAssertEqual(caps.qualities.count, 7)
XCTAssertEqual(caps.qualityDefault, "303")
XCTAssertFalse(caps.qualities.contains { $0.id == "Color" })
}
func testExtractOption() {
let options = "PageSize=A4 EPIJ_Qual=303 printer-info='EPSON XP-55'"
XCTAssertEqual(CupsParsers.extractOption(
named: "PageSize", fromOptionsString: options), "A4")
// Case-insensitive key match.
XCTAssertEqual(CupsParsers.extractOption(
named: "epij_qual", fromOptionsString: options), "303")
// Quoted values come back unquoted.
XCTAssertEqual(CupsParsers.extractOption(
named: "printer-info", fromOptionsString: options), "EPSON XP-55")
XCTAssertNil(CupsParsers.extractOption(
named: "InputSlot", fromOptionsString: options))
}
func testExtractQualityAndOrientation() {
let options = "orientation-requested=4 OutputMode=Gray EPIJ_Qual=305"
XCTAssertEqual(CupsParsers.extractQuality(
fromOptionsString: options), "305")
XCTAssertEqual(CupsParsers.extractOrientation(
fromOptionsString: options), "landscape")
XCTAssertNil(CupsParsers.extractQuality(
fromOptionsString: "PageSize=A4"))
XCTAssertNil(CupsParsers.extractOrientation(
fromOptionsString: "PageSize=A4"))
}
// MARK: - #186 capture-return
/// A captured `k=v` string maps to all four `PrintOptions`
/// fields — `PageSize`, the detected quality key,
/// `orientation-requested`, and a vendor media key (#186).
func testCapturedStringMapsAllFields() {
let captured =
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
XCTAssertEqual(CupsParsers.extractOption(
named: "PageSize", fromOptionsString: captured), "A4")
XCTAssertEqual(CupsParsers.extractQuality(
fromOptionsString: captured), "305")
XCTAssertEqual(CupsParsers.extractOrientation(
fromOptionsString: captured), "landscape")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: captured), "Photo")
}
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
/// (#186). `MediaType` still wins when present alongside them.
func testExtractMediaTypeRosterFallback() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
// `MediaType` keeps first precedence (docs/11 §tests).
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
"Plain")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys)
.map { "\($0.key)=\($0.value)" }
}
#expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4")
#expect(pair(["CNIJIntent"]) == "CNIJIntent=4")
#expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0")
#expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3")
#expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected")
#expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected")
#expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off")
#expect(pair(["PageSize"]) == nil)
XCTAssertEqual(pair(["CNIJIntent2", "CNIJIntent"]), "CNIJIntent2=4")
XCTAssertEqual(pair(["CNIJIntent"]), "CNIJIntent=4")
XCTAssertEqual(pair(["EPIJ_CCor", "EPIJ_CMat"]), "EPIJ_CCor=0")
XCTAssertEqual(pair(["EPIJ_CMat"]), "EPIJ_CMat=3")
XCTAssertEqual(pair(["StpColorCorrection"]), "StpColorCorrection=Uncorrected")
XCTAssertEqual(pair(["ColorCorrection"]), "ColorCorrection=Uncorrected")
XCTAssertEqual(pair(["EpsonColorMode"]), "EpsonColorMode=Off")
XCTAssertNil(pair(["PageSize"]))
}
// MARK: - #218 paper-source roster
func testTrayKeyPrecedence() {
// Generic keys first; `EPIJ_FdSo` preferred over `EPIJ_PSrc`.
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"InputSlot", "EPIJ_FdSo", "EPIJ_PSrc"]), "InputSlot")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"MediaSource", "EPIJ_FdSo", "EPIJ_PSrc"]), "MediaSource")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_FdSo", "EPIJ_PSrc"]), "EPIJ_FdSo")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"EPIJ_PSrc", "CNIJMediaSupply"]), "EPIJ_PSrc")
XCTAssertEqual(CupsParsers.detectTrayKey(optionKeys: [
"CNIJMediaSupply"]), "CNIJMediaSupply")
XCTAssertNil(CupsParsers.detectTrayKey(optionKeys: ["PageSize"]))
}
func testExtractTrayPreferredKeyAndRoster() {
let preferred = CupsParsers.extractTray(
fromOptionsString: "EPIJ_FdSo=Rear EPIJ_PSrc=Front",
preferredKey: "EPIJ_FdSo")
XCTAssertEqual(preferred, "Rear")
let fallback = CupsParsers.extractTray(
fromOptionsString: "EPIJ_PSrc=Front", preferredKey: "EPIJ_FdSo")
XCTAssertEqual(fallback, "Front")
let alias = CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot=Auto")
XCTAssertEqual(alias, "Auto")
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "PageSize=A4"))
XCTAssertNil(CupsParsers.extractTray(
fromOptionsString: "AP_D_InputSlot="))
}
func testTrayExtractionCaseInsensitive() {
XCTAssertEqual(CupsParsers.extractTray(
fromOptionsString: "epij_fdso=Rear", preferredKey: "EPIJ_FdSo"),
"Rear")
}
func testCapabilitiesTraysEpson() {
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
EPIJ_PSrc/Page Setup: *Front Rear Cassette
EPIJ_FdSo/Paper Source: Auto *Rear Front
""")
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trayKey, "EPIJ_FdSo")
XCTAssertEqual(caps.trayDefault, "Rear")
XCTAssertEqual(caps.trays.map(\.token), ["Auto", "Rear", "Front"])
XCTAssertEqual(caps.trays.map(\.name), ["Auto", "Rear", "Front"])
}
func testCapabilitiesTraysCanon() {
let ppd = """
*CNIJMediaSupply 1/Cassette (Upper): ""
*CNIJMediaSupply 2/Manual Feed: ""
*CNIJMediaSupply 4/Rear Tray: ""
"""
let service = CupsService()
let listings = CupsParsers.lpoptionsList("""
CNIJMediaSupply/Media Supply: *1 2 4
""")
let caps = service.capabilities(from: listings, ppd: ppd)
XCTAssertEqual(caps.trayKey, "CNIJMediaSupply")
XCTAssertEqual(caps.trays.map(\.token), ["1", "2", "4"])
XCTAssertEqual(caps.trays.map(\.name), [
"Cassette (Upper)", "Manual Feed", "Rear Tray"
])
}
// MARK: - #181 Canon media locale precedence + PPD encoding
/// The 18 Canon Pro9500 media types named in the issue — the ids
/// are the numeric codes the driver enumerates via `lpoptions -l`.
private let canonMedia: [(id: String, label: String)] = [
("0", "Plain Paper"),
("1", "Photo Paper Plus Glossy II"),
("2", "Photo Paper Pro Platinum N"),
("3", "Photo Paper Pro Platinum"),
("4", "Photo Paper Pro Luster"),
("5", "Photo Paper Plus Semi-gloss"),
("6", "Matte Photo Paper"),
("7", "Fine Art \"Photo Rag\""),
("8", "Fine Art \"Museum Etching\""),
("9", "Photo Paper Pro Premium Matte"),
("10", "Fine Art Premium Matte"),
("11", "Other Fine Art Paper"),
("12", "Canvas"),
("13", "Board Paper"),
("14", "Ink Jet Hagaki"),
("15", "Hagaki"),
("16", "Printable disc"),
("17", "Printable disc (bleed-proof)"),
]
/// Canon Pro9500-shaped fragment: the unqualified base block comes
/// early and the `th.` block trails at the end — the ordering that
/// let Thai overwrite English under last-write-wins (#181).
private var canonPPD: String {
var lines = [
"*OpenUI *CNIJMediaType/Media Type: PickOne",
"*DefaultCNIJMediaType: 0",
]
for media in canonMedia {
lines.append(
"*CNIJMediaType \(media.id)/\(media.label): \"\"")
}
lines.append("*CloseUI: *CNIJMediaType")
for media in canonMedia {
lines.append(
"*th.CNIJMediaType \(media.id)/กระดาษ\(media.id): \"\"")
}
return lines.joined(separator: "\n")
}
func testPpdLabelsUnqualifiedSurvivesTrailingThai() {
let labels = CupsParsers.ppdChoiceLabels(
canonPPD, key: "CNIJMediaType")
XCTAssertEqual(labels["0"], "Plain Paper")
XCTAssertEqual(labels["17"], "Printable disc (bleed-proof)")
}
func testPpdLabelsUnqualifiedWinsRegardlessOfOrder() {
// `th.` block first — precedence is deterministic, not
// positional (#181, E3).
let ppd = """
*th.CNIJMediaType 0/กระดาษธรรมดา: ""
*CNIJMediaType 0/Plain Paper: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
XCTAssertEqual(labels["0"], "Plain Paper")
}
func testPpdLabelsQualifiedFallbackOrder() {
// en_US > en > first-qualified-seen (#181, E3).
let ppd = """
*en.CNIJMediaType 1/English Label: ""
*en_US.CNIJMediaType 1/US English Label: ""
*th.CNIJMediaType 1/กระดาษ: ""
*fr.CNIJMediaType 2/Français: ""
*de.CNIJMediaType 2/Deutsch: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
XCTAssertEqual(labels["1"], "US English Label")
// A qualified-only id still gets its first-seen qualified
// label — never left unlabeled (R9).
XCTAssertEqual(labels["2"], "Français")
}
func testPpdLabelsHexEscapeDecoding() {
let ppd = """
*CNIJMediaType 3/Photo Paper Plus Glossy<2F>Matte: ""
*CNIJMediaType 4/Plain<20>Paper: ""
*CNIJMediaType 5/Bad<ZZ>Escape: ""
"""
let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType")
XCTAssertEqual(labels["3"], "Photo Paper Plus Glossy/Matte")
XCTAssertEqual(labels["4"], "Plain Paper")
XCTAssertEqual(labels["5"], "Bad<ZZ>Escape")
}
/// Every `CNIJMediaType` choice enumerated by `lpoptions -l` gets a
/// non-Thai label (E1 — the true count is the hardware gate's, so
/// no count is hardcoded here); the 18 named AC labels are
/// spot-checked.
func testCapabilitiesCanonMediaAllNonThai() {
var choices = canonMedia.map(\.id)
choices[0] = "*\(choices[0])"
let listings = CupsParsers.lpoptionsList(
"CNIJMediaType/Media Type: \(choices.joined(separator: " "))\n")
let caps = CupsService().capabilities(from: listings, ppd: canonPPD)
XCTAssertEqual(caps.mediaTypes.count, canonMedia.count)
for type in caps.mediaTypes {
XCTAssertFalse(type.name.unicodeScalars.contains {
(0x0E00...0x0E7F).contains($0.value)
}, "Thai label leaked into \(type.id): \(type.name)")
}
for media in canonMedia {
XCTAssertEqual(
caps.mediaTypes.first { $0.id == media.id }?.name,
media.label)
}
}
/// UTF-8 PPD carrying Thai labels decodes intact — the English
/// base block wins precedence and no mojibake leaks through (#181,
/// R10). Exercises `loadPPD` through `capabilities(for:)`.
func testLoadPPDUtf8ThaiSurvivesDecode() async throws {
let (service, root) = try makeCupsService(
ppdData: Data(canonPPD.utf8),
listing: "CNIJMediaType/Media Type: *0 1")
defer { try? FileManager.default.removeItem(at: root) }
let caps = try await service.capabilities(for: "Canon_Test")
XCTAssertEqual(caps.mediaTypes.map(\.name),
["Plain Paper", "Photo Paper Plus Glossy II"])
}
/// A PPD that is not valid UTF-8 (lone `0xE9` for `é`) falls back
/// to ISO-Latin-1 instead of yielding nil → raw ids (#181, R10).
func testLoadPPDLatin1Fallback() async throws {
let ppd = "*CNIJMediaType 0/Papier Couché: \"\"\n"
let (service, root) = try makeCupsService(
ppdData: ppd.data(using: .isoLatin1)!,
listing: "CNIJMediaType/Media Type: *0")
defer { try? FileManager.default.removeItem(at: root) }
let caps = try await service.capabilities(for: "Canon_Test")
XCTAssertEqual(caps.mediaTypes,
[PrinterMediaType(id: "0", name: "Papier Couché")])
}
/// Fixture `lpoptions` + `ppdDir` so `capabilities(for:)` reaches
/// the private `loadPPD` — same mock style as
/// `MediaLibraryViewModelTests.installMockCups`.
private func makeCupsService(
ppdData: Data,
listing: String,
queue: String = "Canon_Test"
) throws -> (CupsService, URL) {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ppd-\(UUID().uuidString)")
let bin = root.appendingPathComponent("bin")
let ppdDir = root.appendingPathComponent("ppd")
for dir in [bin, ppdDir] {
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
}
let lpoptions = """
#!/bin/sh
printf '%s\\n' '\(listing)'
"""
let scriptURL = bin.appendingPathComponent("lpoptions")
try lpoptions.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: scriptURL.path)
try ppdData.write(to: ppdDir.appendingPathComponent("\(queue).ppd"))
return (CupsService(
processManager: ProcessManager(),
binaryDir: bin, ppdDir: ppdDir), root)
}
// MARK: - #202 AirPrint detection
/// `lpstat -v` — `device for <name>: <uri>` lines; a `network`
/// remote stub carries no URI and is skipped.
private let lpstatV = """
device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB
device for Epson_XP_55_LPD: lpd://192.168.1.50/queue
device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./
device for Office_IPPS: ipps://print.example.com/ipp/print
network Remote_Queue
"""
func testLpstatDeviceURIs() {
let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV)
XCTAssertEqual(uris.count, 4)
XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"],
"usb://Canon/PRO-9500%20II%20series?serial=1234AB")
XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue")
XCTAssertEqual(uris["EPSON_XP_55_Series"],
"ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./")
XCTAssertEqual(uris["Office_IPPS"],
"ipps://print.example.com/ipp/print")
XCTAssertNil(uris["Remote_Queue"])
XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:])
}
func testLpoptionsMakeAndModel() {
XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel(
output: lpoptionsP), "EPSON EPSON XP-55 Series")
XCTAssertNil(CupsParsers.lpoptionsMakeAndModel(
output: "printer-type=42\n"))
}
/// Rule 1 — an `apple-airprint://` device URI is AirPrint on its
/// own; PPD and make-and-model are irrelevant.
func testAirPrintRuleAppleAirPrintScheme() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./",
makeAndModel: nil, ppd: ""))
}
/// Rule 2 — the PPD declares `*APAirPrint: True`.
func testAirPrintRulePPDFlag() {
let ppd = """
*PPD-Adobe: "4.3"
*APAirPrint: True
*OpenUI *PageSize/Media Size: PickOne
"""
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd))
}
/// Rule 3 — make-and-model contains "Apple" and "AirPrint".
func testAirPrintRuleMakeAndModel() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "Apple AirPrint", ppd: ""))
// Both tokens are required.
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "Apple LaserWriter", ppd: ""))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "socket://10.0.0.9/",
makeAndModel: "HP AirPrint-Ready", ppd: ""))
}
/// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint"
/// case-insensitively. `ipps://` alone is not enough.
func testAirPrintRuleIPPSWithPPDMention() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "ipps://print.example.com/ipp/print",
makeAndModel: nil,
ppd: "*Foo: \"AIRPRINT enabled\"\n"))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipps://print.example.com/ipp/print",
makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n"))
// An unencrypted ipp:// URI does not satisfy rule 4.
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipp://print.example.com/ipp/print",
makeAndModel: nil,
ppd: "*Foo: \"airprint enabled\"\n"))
}
/// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS
/// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name
/// without the subtype is not AirPrint.
func testAirPrintRuleMDNSSubtype() {
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./",
makeAndModel: nil, ppd: ""))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./",
makeAndModel: nil, ppd: ""))
}
/// Rule 6 — a `*cupsFilter2` rule whose destination MIME is
/// `image/urf` (the AirPrint-only raster). A PWG-raster filter is
/// not AirPrint.
func testAirPrintRuleCupsFilter2URF() {
let ppd = """
*cupsFilter2: "application/pdf image/urf 0 -"
*cupsFilter2: "image/urf image/urf 100 -"
"""
XCTAssertTrue(CupsParsers.detectAirPrint(
deviceURI: nil, makeAndModel: nil, ppd: ppd))
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: nil, makeAndModel: nil,
ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n"))
}
/// Negative — a standard USB raster-driver queue (Epson XP-55)
/// matches none of the six rules.
func testAirPrintNegativeUSBRaster() {
let ppd = """
*PPD-Adobe: "4.3"
*EPIJ_Qual 303/Normal: ""
*cupsFilter: "application/vnd.cups-raster 0 rastertoepson"
"""
XCTAssertFalse(CupsParsers.detectAirPrint(
deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123",
makeAndModel: "EPSON XP-55 Series", ppd: ppd))
}
/// `listPrinters` survives a failing `lpstat -v` — the failure is
/// tolerated, enumeration proceeds, every queue reports
/// `isAirPrint == false`, and there is **no** fallback respawn
/// (exactly one `-v` invocation, #202).
func testListPrintersToleratesLpstatVFailure() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-airprint-\(UUID().uuidString)")
let bin = root.appendingPathComponent("bin")
try FileManager.default.createDirectory(
at: bin, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let log = root.appendingPathComponent("lpstat.log")
let lpstat = """
#!/bin/sh
printf '%s\\n' "$1" >> '\(log.path)'
case "$1" in
-e) printf 'Mock_Epson\\n' ;;
-p) printf 'printer Mock_Epson is idle.\\n' ;;
-d) printf 'no system default destination\\n' ;;
-v) exit 1 ;;
esac
exit 0
"""
let lpoptions = """
#!/bin/sh
printf "printer-info='Mock'\\n"
"""
for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] {
let url = bin.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
let service = CupsService(
processManager: ProcessManager(), binaryDir: bin,
ppdDir: root.appendingPathComponent("ppd"))
let printers = try await service.listPrinters()
XCTAssertEqual(printers.map(\.name), ["Mock_Epson"])
XCTAssertFalse(printers[0].isAirPrint)
let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "")
.split(separator: "\n")
XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1,
"lpstat -v must be spawned exactly once: \(calls)")
}
}
+20 -30
View File
@@ -1,65 +1,57 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("DriftAlert")
struct DriftAlertTests {
final class DriftAlertTests: XCTestCase {
@Test("No alert with fewer than two poor results")
func notEnough() {
func testNotEnough() {
let records = [
record(avg: 4.0, at: 1000)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Alert on two poor results one hour apart")
func oneHourApart() {
func testOneHourApart() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 4600)
]
#expect(DriftAlert.compute(from: records) != nil)
XCTAssertNotNil(DriftAlert.compute(from: records))
}
@Test("No alert if same day and under one hour")
func sameDayUnderHour() {
func testSameDayUnderHour() {
let records = [
record(avg: 4.0, at: 1000),
record(avg: 5.0, at: 2000)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Alert on distinct days")
func distinctDays() {
func testDistinctDays() {
let day1 = record(avg: 4.0, at: 0)
let day2 = record(avg: 5.0, at: 86400 + 1000)
#expect(DriftAlert.compute(from: [day1, day2]) != nil)
XCTAssertNotNil(DriftAlert.compute(from: [day1, day2]))
}
@Test("Non-poor records do not trigger")
func nonPoor() {
func testNonPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 1.5, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Non-poor records break the consecutive poor run")
func nonPoorBreaksRun() {
func testNonPoorBreaksRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 86400), // poor, far apart
record(avg: 1.0, at: 90000), // good — breaks the run
record(avg: 4.0, at: 92000) // poor, recent but close to previous poor
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Only the final consecutive poor run is considered")
func onlySuffixRun() {
func testOnlySuffixRun() {
let records = [
record(avg: 4.0, at: 0), // poor
record(avg: 4.5, at: 18000), // poor, > 1h from first
@@ -67,26 +59,24 @@ struct DriftAlertTests {
record(avg: 4.0, at: 25000), // poor
record(avg: 4.5, at: 26000) // poor, < 1h and same day
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
@Test("Final consecutive poor run alerts when far apart")
func suffixRunAlerts() {
func testSuffixRunAlerts() {
let records = [
record(avg: 1.0, at: 0), // good
record(avg: 4.0, at: 1000), // poor
record(avg: 4.5, at: 4600) // poor, 1h after previous
]
#expect(DriftAlert.compute(from: records) != nil)
XCTAssertNotNil(DriftAlert.compute(from: records))
}
@Test("A single final poor record after good records does not alert")
func singleFinalPoor() {
func testSingleFinalPoor() {
let records = [
record(avg: 1.0, at: 0),
record(avg: 4.0, at: 86400)
]
#expect(DriftAlert.compute(from: records) == nil)
XCTAssertNil(DriftAlert.compute(from: records))
}
private func record(avg: Double, at offset: TimeInterval) -> VerificationRecord {
+35 -38
View File
@@ -1,4 +1,4 @@
import Testing
import XCTest
import Foundation
@testable import ICCeryCore
@@ -13,91 +13,88 @@ private func touch(_ url: URL, _ contents: String = "x") throws {
try contents.write(to: url, atomically: true, encoding: .utf8)
}
@Suite("PathSecurity")
struct PathSecurityTests {
@Test func rejectsTraversalAndSeparators() {
final class PathSecurityTests: XCTestCase {
func testRejectsTraversalAndSeparators() {
for bad in ["a/b", "a\\b", "..", "a/../b", "", "..x"] {
#expect(!PathSecurity.isValidBasename(bad))
#expect(throws: PathSecurity.Error.self) {
try PathSecurity.sanitizeBasename(bad)
XCTAssertFalse(PathSecurity.isValidBasename(bad))
XCTAssertThrowsError(try PathSecurity.sanitizeBasename(bad)) { error in
XCTAssertTrue(error is PathSecurity.Error)
}
}
}
@Test func acceptsNormalNames() {
func testAcceptsNormalNames() {
for good in ["target", "My Target 01", "écheneau-ümläut", "a.b"] {
#expect(PathSecurity.isValidBasename(good))
XCTAssertTrue(PathSecurity.isValidBasename(good))
}
}
@Test func resolveSafeCwdPrefersExplicit() throws {
func testResolveSafeCwdPrefersExplicit() throws {
let dir = try tempDir()
#expect(PathSecurity.resolveSafeCwd(dir) == dir)
XCTAssertEqual(PathSecurity.resolveSafeCwd(dir), dir)
}
@Test func resolveSafeCwdNeverReturnsNil() {
func testResolveSafeCwdNeverReturnsNil() {
let missing = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)")
let resolved = PathSecurity.resolveSafeCwd(missing)
#expect(FileManager.default.fileExists(atPath: resolved.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: resolved.path))
}
}
@Suite("AtomicFileWriter")
struct AtomicFileWriterTests {
@Test func writesAndLeavesNoTmp() throws {
final class AtomicFileWriterTests: XCTestCase {
func testWritesAndLeavesNoTmp() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("state.json")
try AtomicFileWriter.write(Data("{\"a\":1}".utf8), to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "{\"a\":1}")
#expect(!FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "{\"a\":1}")
XCTAssertFalse(FileManager.default.fileExists(atPath: url.appendingPathExtension("tmp").path))
}
@Test func overwritesExistingAtomically() throws {
func testOverwritesExistingAtomically() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("f.txt")
try AtomicFileWriter.write("one", to: url)
try AtomicFileWriter.write("two-longer", to: url)
#expect(try String(contentsOf: url, encoding: .utf8) == "two-longer")
XCTAssertEqual(try String(contentsOf: url, encoding: .utf8), "two-longer")
}
@Test func createsParentDirs() throws {
func testCreatesParentDirs() throws {
let dir = try tempDir()
let url = dir.appendingPathComponent("a/b/c/deep.json")
try AtomicFileWriter.write("{}", to: url)
#expect(FileManager.default.fileExists(atPath: url.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: url.path))
}
}
@Suite("ArtefactProbe")
struct ArtefactProbeTests {
@Test func verifyProgression() throws {
final class ArtefactProbeTests: XCTestCase {
func testVerifyProgression() throws {
let dir = try tempDir()
var v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v == StageArtefacts())
XCTAssertEqual(v, StageArtefacts())
try touch(dir.appendingPathComponent("t.ti1"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
XCTAssertTrue(v.stage1Complete && !v.stage2Complete && !v.stage3Complete)
try touch(dir.appendingPathComponent("t.ti2"))
try touch(dir.appendingPathComponent("t.ti3"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
XCTAssertTrue(v.stage2Complete && v.stage3Complete && !v.stage4Complete)
try touch(dir.appendingPathComponent("t.icc"))
v = ArtefactProbe.verify(basename: "t", cwd: dir)
#expect(v.stage4Complete && v.profilePath?.pathExtension == "icc")
XCTAssertTrue(v.stage4Complete && v.profilePath?.pathExtension == "icc")
}
@Test func icmWinsOverIcc() throws {
func testIcmWinsOverIcc() throws {
let dir = try tempDir()
try touch(dir.appendingPathComponent("p.icc"))
try touch(dir.appendingPathComponent("p.icm"))
let profile = ArtefactProbe.resolveProfile(basename: "p", cwd: dir)
#expect(profile?.pathExtension == "icm")
XCTAssertEqual(profile?.pathExtension, "icm")
}
@Test func enumeratesPassesPagesAndCAL() throws {
func testEnumeratesPassesPagesAndCAL() throws {
let dir = try tempDir()
for name in [
"t.ti1", "t.ti2", "t.tif", "t.2.tif", "t_03.tif",
@@ -115,15 +112,15 @@ struct ArtefactProbeTests {
"t.ti3", "t_pass1.ti3", "t_pass2.ti3",
"t.icc", "t.gam", "CAL_t.ti1", "CAL_t.cal",
] {
#expect(names.contains(expected), "missing \(expected)")
XCTAssertTrue(names.contains(expected), "missing \(expected)")
}
#expect(!names.contains("other.ti1"))
#expect(!names.contains("t.txt"))
#expect(!names.contains("CAL_other.ti1"))
XCTAssertFalse(names.contains("other.ti1"))
XCTAssertFalse(names.contains("t.txt"))
XCTAssertFalse(names.contains("CAL_other.ti1"))
}
@Test func emptyDirReturnsEmpty() throws {
func testEmptyDirReturnsEmpty() throws {
let dir = try tempDir()
#expect(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
XCTAssertTrue(ArtefactProbe.existingArtefacts(basename: "x", cwd: dir).isEmpty)
}
}
@@ -0,0 +1,85 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// ``GamutGeometry`` containment and volume goldens on the bundled
/// `sRGB.gam` reference mesh (issue #147). No GPU involved.
final class GamutContainmentTests: XCTestCase {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
let bundle = Bundle.main
let resource = bundle.resourceURL ?? bundle.bundleURL
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
private func loadSRGB() throws -> GamutMesh {
try GamutMeshParser.parse(url: bundledSRGBGamURL)
}
func testNeutralMidGreyIsInsideSRGB() throws {
let mesh = try loadSRGB()
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.inside)
}
func testSaturatedColourIsOutsideSRGB() throws {
let mesh = try loadSRGB()
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 80, b: 80), in: mesh),
.outside)
}
func testVolumeIsFiniteAndPositiveOnBundledSRGB() throws {
let mesh = try loadSRGB()
let volume = GamutGeometry.volume(of: mesh)
XCTAssertTrue(volume.isFinite)
XCTAssertGreaterThan(volume, 0)
}
func testVertexOnlyMeshReportsUnknown() {
let mesh = GamutMesh(
vertices: [
GamutVertex(lab: LabColor(l: 50, a: 0, b: 0), rgb: DisplayRGB(r: 0.5, g: 0.5, b: 0.5)),
],
faces: [])
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.unknown)
XCTAssertEqual(GamutGeometry.volume(of: mesh), 0)
}
func testKnownCubeFixture() throws {
// Unit cube centred at Lab (50, 0, 0): a*,b* ∈ ±10, L* ∈ 40...60.
// Two triangles per face, outward winding.
let lab = { (l: Double, a: Double, b: Double) in
GamutVertex(lab: LabColor(l: l, a: a, b: b), rgb: DisplayRGB(r: 0, g: 0, b: 0))
}
// Corners in (a, L, b) space.
let c = [
lab(40, -10, -10), lab(40, 10, -10), lab(40, 10, 10), lab(40, -10, 10), // bottom
lab(60, -10, -10), lab(60, 10, -10), lab(60, 10, 10), lab(60, -10, 10), // top
]
let quad = { (a: UInt32, b: UInt32, c: UInt32, d: UInt32) in
[GamutTriangle(a: a, b: b, c: c), GamutTriangle(a: a, b: c, c: d)]
}
var faces: [GamutTriangle] = []
faces += quad(0, 3, 2, 1) // bottom (y=40)
faces += quad(4, 5, 6, 7) // top (y=60)
faces += quad(0, 1, 5, 4) // z=-10
faces += quad(3, 7, 6, 2) // z=+10
faces += quad(1, 2, 6, 5) // x=+10
faces += quad(0, 4, 7, 3) // x=-10
let mesh = GamutMesh(vertices: c, faces: faces)
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 0, b: 0), in: mesh),
.inside)
XCTAssertEqual(
GamutGeometry.containment(of: LabColor(l: 50, a: 20, b: 0), in: mesh),
.outside)
// 20 × 20 × 20 Lab-cube.
XCTAssertEqual(GamutGeometry.volume(of: mesh), 8000, accuracy: 1)
}
}
@@ -1,15 +1,13 @@
import Testing
import XCTest
import SceneKit
import ICCeryCore
@testable import ICCery
/// ``GamutSceneGeometryBuilder`` edge-case tests.
@Suite("Gamut scene geometry builder")
@MainActor
struct GamutGeometryBuilderTests {
final class GamutGeometryBuilderTests: XCTestCase {
@Test("Drops out-of-bounds faces from the element without crashing")
func dropsOutOfBoundsFaces() {
func testDropsOutOfBoundsFaces() {
let white = GamutVertex(
lab: LabColor(l: 100, a: 0, b: 0),
rgb: DisplayRGB(r: 1, g: 1, b: 1)
@@ -28,6 +26,6 @@ struct GamutGeometryBuilderTests {
let (_, element) = GamutSceneGeometryBuilder.geometry(for: mesh)
#expect(element.primitiveCount == 1, "Only the in-bounds face should be in the index buffer")
XCTAssertEqual(element.primitiveCount, 1, "Only the in-bounds face should be in the index buffer")
}
}
@@ -1,10 +1,9 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
/// ``GamutMeshParser`` acceptance + edge-case tests.
@Suite("Gamut mesh parser")
struct GamutMeshParserTests {
final class GamutMeshParserTests: XCTestCase {
/// Returns the bundled real `sRGB.gam` in `Resources/Argyll/reference_gamuts`.
private var bundledSRGBGamURL: URL {
@@ -13,16 +12,14 @@ struct GamutMeshParserTests {
return resource.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
}
@Test("Parses bundled sRGB.gam")
func parsesBundledSRGB() throws {
func testParsesBundledSRGB() throws {
let mesh = try GamutMeshParser.parse(url: bundledSRGBGamURL)
#expect(mesh.vertices.count == 448, "sRGB.gam has 448 vertices")
#expect(mesh.faces.count == 892, "sRGB.gam has 892 faces")
XCTAssertEqual(mesh.vertices.count, 448, "sRGB.gam has 448 vertices")
XCTAssertEqual(mesh.faces.count, 892, "sRGB.gam has 892 faces")
}
@Test("Discards VERTEX_NO and uses push-order indices")
func discardsVertexNo() throws {
func testDiscardsVertexNo() throws {
let text = """
GAMUT
NUMBER_OF_FIELDS 4
@@ -49,14 +46,13 @@ struct GamutMeshParserTests {
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 4)
#expect(mesh.faces.count == 2)
#expect(mesh.vertices[0].lab == LabColor(l: 10, a: 20, b: 30))
#expect(mesh.vertices[3].lab == LabColor(l: 40, a: 50, b: 60))
XCTAssertEqual(mesh.vertices.count, 4)
XCTAssertEqual(mesh.faces.count, 2)
XCTAssertEqual(mesh.vertices[0].lab, LabColor(l: 10, a: 20, b: 30))
XCTAssertEqual(mesh.vertices[3].lab, LabColor(l: 40, a: 50, b: 60))
}
@Test("Ignores comments and blank lines")
func ignoresComments() throws {
func testIgnoresComments() throws {
let text = """
# Header comment
NUMBER_OF_FIELDS 4
@@ -81,12 +77,11 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.count == 2)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.vertices.count, 2)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Remaps coordinates to x=a*, y=L*, z=b*")
func remapsCoordinates() throws {
func testRemapsCoordinates() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -99,11 +94,10 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.vertices.first?.position == SIMD3<Float>(-20, 50, 80))
XCTAssertEqual(mesh.vertices.first?.position, SIMD3<Float>(-20, 50, 80))
}
@Test("Computes per-vertex sRGB colour")
func computesVertexColor() throws {
func testComputesVertexColor() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -116,14 +110,13 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
let white = try #require(mesh.vertices.first).rgb
#expect(white.r > 0.95)
#expect(white.g > 0.95)
#expect(white.b > 0.95)
let white = try XCTUnwrap(mesh.vertices.first).rgb
XCTAssertTrue(white.r > 0.95)
XCTAssertTrue(white.g > 0.95)
XCTAssertTrue(white.b > 0.95)
}
@Test("Drops out-of-bounds face indices")
func dropsOutOfBoundsFaces() throws {
func testDropsOutOfBoundsFaces() throws {
let text = """
NUMBER_OF_FIELDS 4
BEGIN_DATA_FORMAT
@@ -146,21 +139,23 @@ struct GamutMeshParserTests {
"""
let mesh = try GamutMeshParser.parse(text: text)
#expect(mesh.faces.count == 1)
XCTAssertEqual(mesh.faces.count, 1)
}
@Test("Throws on empty file")
func throwsOnEmptyFile() {
#expect(throws: GamutMeshParseError.noDataBlock) {
_ = try GamutMeshParser.parse(text: "")
func testThrowsOnEmptyFile() {
XCTAssertThrowsError(try GamutMeshParser.parse(text: "")) { error in
guard case GamutMeshParseError.noDataBlock = error else {
return XCTFail("Expected GamutMeshParseError.noDataBlock, got \(error)")
}
}
}
@Test("Throws when file is missing")
func throwsWhenMissing() {
func testThrowsWhenMissing() {
let url = URL(fileURLWithPath: "/nonexistent/path/to/mesh.gam")
#expect(throws: GamutMeshParseError.missingFile) {
_ = try GamutMeshParser.parse(url: url)
XCTAssertThrowsError(try GamutMeshParser.parse(url: url)) { error in
guard case GamutMeshParseError.missingFile = error else {
return XCTFail("Expected GamutMeshParseError.missingFile, got \(error)")
}
}
}
}
@@ -0,0 +1,137 @@
import Foundation
import Metal
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #147 — `GamutViewModel` compare-slot behaviour: failed or
/// missing compare meshes are info, never fatal (#24); sRGB always stays.
@MainActor
final class GamutViewModelTests: XCTestCase {
/// Bundled root that has `reference_gamuts/sRGB.gam` but **no** tool
/// binaries, so `iccgamut` spawns deterministically fail.
private func bundledRootWithoutTools() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-vm-\(UUID().uuidString)")
let gamutDir = root.appendingPathComponent("reference_gamuts")
try FileManager.default.createDirectory(
at: gamutDir, withIntermediateDirectories: true)
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
try FileManager.default.copyItem(
at: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"),
to: gamutDir.appendingPathComponent("sRGB.gam"))
return root
}
private func makeViewModel(root: URL? = nil) throws -> GamutViewModel {
let env = try TestAppEnvironment.make(bundledArgyllRoot: root)
return GamutViewModel(environment: env.environment)
}
func testInitialLoadHasSRGBAndFacesStatus() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertTrue(vm.status.contains("faces"), "status: \(vm.status)")
}
/// #147 — `viewerUnavailable` is decided before any `SCNView` is
/// mounted: it must exactly mirror Metal presence on this host.
func testViewerUnavailableMirrorsMetalAvailability() async throws {
let vm = try makeViewModel()
XCTAssertEqual(
vm.viewerUnavailable,
MTLCreateSystemDefaultDevice() == nil)
}
func testMissingCompareGamLeavesSRGBAndSetsNotice() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
await vm.loadCompareGam(
url: URL(fileURLWithPath: "/nonexistent/compare.gam"))
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.noticeText)
}
func testFailedIccgamutLeavesSRGBAndSetsNotice() async throws {
// bundledRootWithoutTools has no macos-universal/iccgamut.
let vm = try makeViewModel(root: bundledRootWithoutTools())
await vm.awaitInitialLoad()
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
let profile = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-icc-\(UUID().uuidString).icc")
try Data("MOCK_ICC".utf8).write(to: profile)
defer { try? FileManager.default.removeItem(at: profile) }
await vm.loadCompareProfile(url: profile)
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.noticeText)
}
func testThirdProfileReplacesCompareSlot() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
let srgb = bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam")
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-gamut-cmp-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
let first = dir.appendingPathComponent("first.gam")
let second = dir.appendingPathComponent("second.gam")
try FileManager.default.copyItem(at: srgb, to: first)
try FileManager.default.copyItem(at: srgb, to: second)
defer { try? FileManager.default.removeItem(at: dir) }
await vm.loadCompareGam(url: first)
XCTAssertNil(vm.noticeText)
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "first")
await vm.loadCompareGam(url: second)
XCTAssertEqual(vm.layer(id: GamutViewModel.compareLayerID)?.displayName, "second")
XCTAssertEqual(
vm.layers.filter { $0.role == .profileB }.count, 1,
"compare slot holds one profile")
XCTAssertNotNil(vm.noticeText)
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
}
func testRemoveCompareLeavesSRGB() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
let bundle = Bundle.main.resourceURL ?? Bundle.main.bundleURL
await vm.loadCompareGam(
url: bundle.appendingPathComponent("Argyll/reference_gamuts/sRGB.gam"))
XCTAssertNotNil(vm.layer(id: GamutViewModel.compareLayerID))
vm.removeCompare()
XCTAssertNil(vm.layer(id: GamutViewModel.compareLayerID))
XCTAssertNotNil(vm.layer(id: GamutViewModel.srgbLayerID))
}
func testInspectLabRunsContainmentPerLayer() async throws {
let vm = try makeViewModel()
await vm.awaitInitialLoad()
vm.labEntryL = "50"
vm.labEntryA = "0"
vm.labEntryB = "0"
XCTAssertTrue(vm.canInspectLab)
vm.inspectEnteredLab()
let srgb = vm.inspectResults.first { $0.id == GamutViewModel.srgbLayerID }
XCTAssertEqual(srgb?.containment, .inside)
XCTAssertTrue(vm.inspectIsApproximate)
XCTAssertNotNil(vm.inspectSwatch)
}
}
@@ -0,0 +1,203 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #149 — `.icceryproj` schema: snake_case keys, strict
/// `schema_version == 1`, and save-time refusal for empty/illegal
/// basename and empty/unsafe cwd (#59/#60/R11).
final class ICCeryProjectTests: XCTestCase {
private func tempDir() throws -> URL {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-proj-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: dir, withIntermediateDirectories: true)
return dir
}
private func makeProject(
basename: String = "run-1",
cwd: String = "/tmp"
) -> ICCeryProject {
ICCeryProject(
name: "Run One",
basename: basename,
cwd: cwd,
profileBasename: "run-1",
printerID: "Mock_Queue",
printerDisplayName: "Mock Queue",
mediaRecipeID: "recipe-1",
presetID: "preset-std-rgb",
calibrationURL: "/tmp/r.cal",
lastVerification: VerificationSnapshot(
date: Date(timeIntervalSince1970: 1_700_000_000),
avgDE00: 0.8, maxDE00: 2.1,
status: "excellent", profileFilename: "run-1.icc"))
}
// MARK: - Round trip / keys
func testSnakeCaseRoundTrip() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
let project = makeProject()
try project.save(to: url)
let loaded = try ICCeryProject.load(from: url)
XCTAssertEqual(loaded.schemaVersion, 1)
XCTAssertEqual(loaded.basename, "run-1")
XCTAssertEqual(loaded.cwd, "/tmp")
XCTAssertEqual(loaded.profileBasename, "run-1")
XCTAssertEqual(loaded.printerID, "Mock_Queue")
XCTAssertEqual(loaded.mediaRecipeID, "recipe-1")
XCTAssertEqual(loaded.presetID, "preset-std-rgb")
XCTAssertEqual(loaded.calibrationURL, "/tmp/r.cal")
XCTAssertEqual(loaded.lastVerification?.avgDE00, 0.8)
XCTAssertEqual(loaded.lastVerification?.maxDE00, 2.1)
XCTAssertEqual(loaded.lastVerification?.status, "excellent")
XCTAssertEqual(loaded.lastVerification?.profileFilename, "run-1.icc")
let raw = try String(contentsOf: url, encoding: .utf8)
for key in [
"\"schema_version\"", "\"profile_basename\"",
"\"printer_id\"", "\"printer_display_name\"",
"\"media_recipe_id\"", "\"preset_id\"",
"\"calibration_url\"", "\"last_verification\"",
"\"avg_de00\"", "\"max_de00\"", "\"profile_filename\"",
] {
XCTAssertTrue(raw.contains(key), "missing key \(key)")
}
}
func testOptionalFieldsDecodeWhenAbsent() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("min.icceryproj")
let json = """
{
"schema_version": 1,
"name": "Minimal",
"basename": "abc",
"cwd": "/tmp",
"updated": "2026-09-13T00:00:00Z"
}
"""
try json.write(to: url, atomically: true, encoding: .utf8)
let loaded = try ICCeryProject.load(from: url)
XCTAssertEqual(loaded.basename, "abc")
XCTAssertNil(loaded.mediaRecipeID)
XCTAssertNil(loaded.presetID)
XCTAssertNil(loaded.lastVerification)
XCTAssertNil(loaded.calibrationURL)
}
// MARK: - Schema gate
func testSchemaVersion2Throws() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("v2.icceryproj")
try """
{"schema_version": 2, "name": "x", "basename": "abc",
"cwd": "/tmp", "future_field": [1, 2]}
""".write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
guard case ICCeryProject.ValidationError.unsupportedSchema(2)
= error else {
return XCTFail("expected unsupportedSchema, got \(error)")
}
XCTAssertEqual(
error.localizedDescription,
"This project file is not schema 1.")
}
}
func testMissingSchemaVersionThrows() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("noversion.icceryproj")
try "{\"basename\": \"abc\", \"cwd\": \"/tmp\"}"
.write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url))
}
// MARK: - Validation
func testEmptyBasenameRefusesSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
XCTAssertThrowsError(
try makeProject(basename: "").save(to: url)
) { error in
XCTAssertEqual(
error as? ICCeryProject.ValidationError, .emptyBasename)
}
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
func testIllegalBasenameRefusesSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
for bad in ["a/b", "a\\b", "..", "x..y"] {
XCTAssertThrowsError(
try makeProject(basename: bad).save(
to: dir.appendingPathComponent("p.icceryproj"))
) { error in
guard case ICCeryProject.ValidationError.invalidBasename
= error else {
return XCTFail("expected invalidBasename, got \(error)")
}
}
}
}
func testEmptyAndRelativeCwdRefuseSave() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("p.icceryproj")
XCTAssertThrowsError(try makeProject(cwd: "").save(to: url)) { error in
XCTAssertEqual(
error as? ICCeryProject.ValidationError, .emptyCwd)
}
XCTAssertThrowsError(
try makeProject(cwd: "relative/dir").save(to: url)
) { error in
guard case ICCeryProject.ValidationError.unsafeCwd = error else {
return XCTFail("expected unsafeCwd, got \(error)")
}
}
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
}
func testIllegalBasenameRefusesOpen() throws {
let dir = try tempDir()
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("bad.icceryproj")
try """
{"schema_version": 1, "basename": "a/b", "cwd": "/tmp"}
""".write(to: url, atomically: true, encoding: .utf8)
XCTAssertThrowsError(try ICCeryProject.load(from: url)) { error in
guard case ICCeryProject.ValidationError.invalidBasename
= error else {
return XCTFail("expected invalidBasename, got \(error)")
}
}
}
func testEmptyNameFallsBackToBasename() throws {
let project = try ICCeryProject(
name: "", basename: "stem", cwd: "/tmp").validated()
XCTAssertEqual(project.name, "stem")
}
func testMissingFileThrowsOnOpen() {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("gone-\(UUID().uuidString).icceryproj")
XCTAssertThrowsError(try ICCeryProject.load(from: url))
}
}
@@ -1,16 +1,14 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("IccgamutArgs")
struct IccgamutArgsTests {
final class IccgamutArgsTests: XCTestCase {
@Test("Density is 10 and not a directory")
func densityNotDirectory() throws {
func testDensityNotDirectory() throws {
let config = IccgamutConfig(
profileURL: URL(fileURLWithPath: "/tmp/MyProfile.icc")
)
let args = try IccgamutArgs.build(config: config)
#expect(args == ["-v", "-d", "10", "/tmp/MyProfile.icc"])
XCTAssertEqual(args, ["-v", "-d", "10", "/tmp/MyProfile.icc"])
}
}
+14 -21
View File
@@ -1,26 +1,23 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("JSONFileStore")
struct JSONFileStoreTests {
final class JSONFileStoreTests: XCTestCase {
private func tempURL() -> URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("json-store-\(UUID().uuidString).json")
}
@Test("Missing file returns default")
func missingFileDefaults() throws {
func testMissingFileDefaults() throws {
let store = JSONFileStore<AppSettings>(
fileURL: tempURL(),
corrupt: .throwCorrupt,
defaultValue: { .default }
)
#expect(try store.load() == .default)
XCTAssertEqual(try store.load(), .default)
}
@Test("Corrupt file with replaceWithDefault returns default and leaves bytes")
func corruptDefaults() throws {
func testCorruptDefaults() throws {
let url = tempURL()
try "{ not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<AppSettings>(
@@ -28,13 +25,12 @@ struct JSONFileStoreTests {
corrupt: .replaceWithDefault,
defaultValue: { .default }
)
#expect(try store.load() == .default)
XCTAssertEqual(try store.load(), .default)
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "{ not json")
XCTAssertEqual(kept, "{ not json")
}
@Test("Corrupt file with throwCorrupt throws and leaves bytes")
func corruptThrows() throws {
func testCorruptThrows() throws {
let url = tempURL()
try "not json".write(to: url, atomically: true, encoding: .utf8)
let store = JSONFileStore<[Int]>(
@@ -42,15 +38,12 @@ struct JSONFileStoreTests {
corrupt: .throwCorrupt,
defaultValue: { [] }
)
#expect(throws: DecodingError.self) {
_ = try store.load()
}
XCTAssertThrowsError(try store.load()) { error in XCTAssertTrue(error is DecodingError) }
let kept = try String(contentsOf: url, encoding: .utf8)
#expect(kept == "not json")
XCTAssertEqual(kept, "not json")
}
@Test("Pretty sorted keys")
func prettySorted() throws {
func testPrettySorted() throws {
let url = tempURL()
let store = JSONFileStore<AppSettings>(
fileURL: url,
@@ -59,8 +52,8 @@ struct JSONFileStoreTests {
)
try store.save(.default)
let text = try String(contentsOf: url, encoding: .utf8)
#expect(text.contains("\n"))
#expect(text.contains("\"delta_e_good_max\""))
XCTAssertTrue(text.contains("\n"))
XCTAssertTrue(text.contains("\"delta_e_good_max\""))
// Lexical key sorting: ascending order of top-level keys.
let keys = [
"ask_before_overwrite_profile",
@@ -75,7 +68,7 @@ struct JSONFileStoreTests {
var lastIndex = text.startIndex
for key in keys {
guard let range = text.range(of: "\"\(key)\"", range: lastIndex..<text.endIndex) else {
Issue.record("missing or out-of-order key \(key)")
XCTFail("missing or out-of-order key \(key)")
return
}
lastIndex = range.upperBound
-131
View File
@@ -1,131 +0,0 @@
import Testing
import Foundation
@testable import ICCeryCore
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
/// flag order, captured-option precedence, and sanitisation.
@Suite("LpArgs")
struct LpArgsTests {
private let tiff = "/tmp/work/target_001.tif"
private let queue = "EPSON_XP_55_Series"
private func build(
options: PrintOptions = PrintOptions(),
optionKeys: Set<String> = []
) throws -> [String] {
try LpArgs.build(
queue: queue, tiffPath: tiff,
options: options, optionKeys: optionKeys)
}
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
func header() throws {
let argv = try build()
#expect(Array(argv[0...1]) == ["-d", queue])
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
#expect(Array(argv[4...5])
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(Array(argv[6...7])
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
#expect(argv.last == tiff)
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
}
@Test("Never emits -o raw; captured raw= is dropped")
func neverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
#expect(argv[i + 1] != "raw")
#expect(argv[i + 1] != "raw=true")
}
#expect(!argv.contains { $0.hasPrefix("raw=") })
#expect(argv.contains("MediaType=Photo"))
}
@Test("Captured options replayed after AP_* headers")
func capturedReplay() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear MediaType=Photo"))
let rear = argv.firstIndex(of: "InputSlot=Rear")!
let apFirst = argv.firstIndex(of:
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
#expect(rear > apFirst)
}
@Test("Captured wins: media key present → derived media skipped")
func capturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
#expect(argv.contains("MediaType=Glossy"))
#expect(!argv.contains("MediaType=Plain"))
}
@Test("Media emitted via detected key when not captured")
func mediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
#expect(argv.contains("CNIJMediaType=SemiGloss"))
#expect(!argv.contains("MediaType=SemiGloss"))
}
@Test("Driver bypass emitted when absent, skipped when captured")
func bypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
#expect(withBypass.contains("EPIJ_CMat=3"))
let captured = try build(
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
optionKeys: ["EPIJ_CMat"])
// Captured value kept, detection not re-applied.
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
== ["EPIJ_CMat=1"])
}
@Test("Orientation: portrait=3 landscape=4; captured wins")
func orientation() throws {
#expect(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
#expect(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
#expect(!capturedOrients.contains("orientation-requested=4"))
#expect(capturedOrients.contains("orientation-requested=5"))
}
@Test("PageSize emitted unless captured")
func pageSize() throws {
#expect(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
#expect(!capturedSize.contains("PageSize=A4"))
#expect(capturedSize.contains("PageSize=Letter"))
}
@Test("Sanitise rejects `;`, newline, and shell metachars")
func sanitise() throws {
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
}
#expect(throws: LpArgsError.self) {
_ = try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))
}
}
}
+93 -128
View File
@@ -1,12 +1,10 @@
import Foundation
import Testing
import XCTest
@testable import ICCeryCore
@Suite("InstrumentParser")
struct InstrumentParserTests {
final class InstrumentParserTests: XCTestCase {
@Test("Parses pretty-printed instlist JSON")
func json() throws {
func testJson() throws {
let json = """
{
"event": "instruments",
@@ -18,134 +16,118 @@ struct InstrumentParserTests {
}
"""
let devices = try InstrumentParser.parse(json)
#expect(devices.count == 3)
#expect(devices[0].port == 1)
#expect(devices[0].name == "X-Rite i1Pro")
#expect(devices[2].port == 3)
XCTAssertEqual(devices.count, 3)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[0].name, "X-Rite i1Pro")
XCTAssertEqual(devices[2].port, 3)
}
@Test("Falls back to regex for legacy instlist text")
func regexFallback() throws {
func testRegexFallback() throws {
let text = """
1: 'X-Rite i1Pro' on usb
2: 'ColorMunki Smile'
""" + "\n"
let devices = try InstrumentParser.parse(text)
#expect(devices.count == 2)
#expect(devices[0].port == 1)
#expect(devices[1].name == "ColorMunki Smile")
XCTAssertEqual(devices.count, 2)
XCTAssertEqual(devices[0].port, 1)
XCTAssertEqual(devices[1].name, "ColorMunki Smile")
}
@Test("Empty output returns no devices")
func empty() throws {
#expect(try InstrumentParser.parse("").isEmpty)
func testEmpty() throws {
XCTAssertTrue(try InstrumentParser.parse("").isEmpty)
}
}
@Suite("ChartreadArgs")
struct ChartreadArgsTests {
final class ChartreadArgsTests: XCTestCase {
@Test("Baseline argv and port 1 omits -c")
func baseline() throws {
func testBaseline() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 1)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "target"])
XCTAssertEqual(args, ["-v", "-u", "target"])
}
@Test("Port > 1 emits -c")
func portArgument() throws {
func testPortArgument() throws {
let config = ChartreadConfig(basename: "target", selectedPort: 3)
let args = try ChartreadArgs.build(config: config)
#expect(args == ["-v", "-u", "-c", "3", "target"])
XCTAssertEqual(args, ["-v", "-u", "-c", "3", "target"])
}
@Test("LEDs emit -Y l")
func leds() throws {
func testLeds() throws {
let config = ChartreadConfig(
basename: "target",
selectedPort: 2,
enableLEDs: true
)
let args = try ChartreadArgs.build(config: config)
#expect(args.contains("-Y"))
#expect(args.contains("l"))
XCTAssertTrue(args.contains("-Y"))
XCTAssertTrue(args.contains("l"))
}
@Test("Auto omits -c")
func autoPort() throws {
func testAutoPort() throws {
let config = ChartreadConfig(basename: "target")
let args = try ChartreadArgs.build(config: config)
#expect(!args.contains("-c"))
XCTAssertFalse(args.contains("-c"))
}
}
@Suite("ChartreadClassifier")
struct ChartreadClassifierTests {
final class ChartreadClassifierTests: XCTestCase {
@Test("Calibration prompt")
func calibration() {
func testCalibration() {
let r = ChartreadClassifier.classify(
line: "Place instrument on calibration tile and hit [Space] to calibrate.",
previousState: .idle
)
#expect(r.state == .calibrating)
XCTAssertEqual(r.state, .calibrating)
}
@Test("Strip awaiting")
func awaitingStrip() {
func testAwaitingStrip() {
let r = ChartreadClassifier.classify(
line: "Hit [Space] to read strip A",
previousState: .calibrating
)
#expect(r.state == .awaitingStrip)
XCTAssertEqual(r.state, .awaitingStrip)
}
@Test("Done prompt")
func done() {
func testDone() {
let r = ChartreadClassifier.classify(
line: "'d' if/when done",
previousState: .awaitingStrip
)
#expect(r.state == .allStripsRead)
XCTAssertEqual(r.state, .allStripsRead)
}
@Test("XY place sheet")
func placeSheet() {
func testPlaceSheet() {
let r = ChartreadClassifier.classify(
line: "Please place sheet 1 of 2 on the table",
previousState: .idle
)
#expect(r.state == .tablePlaceSheet)
#expect(r.sheetNumber == 1)
#expect(r.sheetTotal == 2)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.sheetNumber, 1)
XCTAssertEqual(r.sheetTotal, 2)
}
@Test("XY locate patch")
func locatePatch() {
func testLocatePatch() {
let r = ChartreadClassifier.classify(
line: "locate patch A1 with the sight,",
previousState: .tablePlaceSheet
)
#expect(r.state == .tableAlign)
#expect(r.alignmentPatch == "A1")
XCTAssertEqual(r.state, .tableAlign)
XCTAssertEqual(r.alignmentPatch, "A1")
}
@Test("Remove sheet notice preserves state")
func removeNotice() {
func testRemoveNotice() {
let r = ChartreadClassifier.classify(
line: "Please remove last sheet from table",
previousState: .tablePlaceSheet
)
#expect(r.state == .tablePlaceSheet)
#expect(r.isRemoveSheetNotice == true)
XCTAssertEqual(r.state, .tablePlaceSheet)
XCTAssertEqual(r.isRemoveSheetNotice, true)
}
}
@Suite("ChartreadRow")
struct ChartreadRowTests {
final class ChartreadRowTests: XCTestCase {
@Test("Decodes row JSON")
func decode() throws {
func testDecode() throws {
let json = """
{"event": "row_complete", "row_id": "A", "row_index": 0, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -155,13 +137,12 @@ struct ChartreadRowTests {
]}
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
#expect(row.rowId == "A")
#expect(row.patchCount == 1)
#expect(row.patches[0].measured.lab?.l == 51)
XCTAssertEqual(row.rowId, "A")
XCTAssertEqual(row.patchCount, 1)
XCTAssertEqual(row.patches[0].measured.lab?.l, 51)
}
@Test("Decodes a row carrying both XYZ and Lab arrays")
func decodeXYZAndLab() throws {
func testDecodeXYZAndLab() throws {
let json = """
{"event": "row_complete", "row_id": "B", "row_index": 1, "total_rows": 2,
"patch_count": 1, "patches": [
@@ -171,88 +152,78 @@ struct ChartreadRowTests {
"""
let row = try JSONDecoder().decode(ChartreadRow.self, from: Data(json.utf8))
let measured = row.patches[0].measured
#expect(measured.xyz == CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
#expect(measured.lab == CIELab(l: 63.4, a: 2.5, b: -8.2))
XCTAssertEqual(measured.xyz, CIEXYZ(x: 30.5, y: 32.1, z: 25.9))
XCTAssertEqual(measured.lab, CIELab(l: 63.4, a: 2.5, b: -8.2))
}
@Test("XYZColor/CIEXYZ encode as an unkeyed three-number array")
func xyzWireEncoding() throws {
func testXyzWireEncoding() throws {
for color in [XYZColor(x: 1.5, y: 2.5, z: 3.5), CIEXYZ(x: 1.5, y: 2.5, z: 3.5)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [1.5, 2.5, 3.5])
XCTAssertEqual(value as? [Double], [1.5, 2.5, 3.5])
}
}
@Test("LabColor/CIELab encode as an unkeyed three-number array")
func labWireEncoding() throws {
func testLabWireEncoding() throws {
for color in [LabColor(l: 50, a: -1, b: 2), CIELab(l: 50, a: -1, b: 2)] {
let value = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color))
#expect(value as? [Double] == [50, -1, 2])
XCTAssertEqual(value as? [Double], [50, -1, 2])
}
}
@Test("PatchColor keeps the XYZ and Lab keys over unkeyed arrays")
func patchColorKeys() throws {
func testPatchColorKeys() throws {
let color = PatchColor(
xyz: CIEXYZ(x: 10, y: 20, z: 30),
lab: CIELab(l: 55, a: 1, b: -2))
let object = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(color)) as? [String: Any]
#expect(object?["XYZ"] as? [Double] == [10, 20, 30])
#expect(object?["Lab"] as? [Double] == [55, 1, -2])
#expect(object?["spectral"] == nil)
XCTAssertEqual(object?["XYZ"] as? [Double], [10, 20, 30])
XCTAssertEqual(object?["Lab"] as? [Double], [55, 1, -2])
XCTAssertNil(object?["spectral"])
}
}
@Suite("ColourMath")
struct ColourMathTests {
final class ColourMathTests: XCTestCase {
@Test("White XYZ to Lab")
func whiteLab() {
func testWhiteLab() {
let white = XYZColor(x: 96.4212, y: 100.0, z: 82.5188)
let lab = LabColorMath.xyzToLab(white)
#expect(abs(lab.l - 100) < 0.5)
#expect(abs(lab.a) < 0.5)
#expect(abs(lab.b) < 0.5)
XCTAssertTrue(abs(lab.l - 100) < 0.5)
XCTAssertTrue(abs(lab.a) < 0.5)
XCTAssertTrue(abs(lab.b) < 0.5)
}
@Test("Lab to sRGB roundtrip is clamped")
func labToSRGB() {
func testLabToSRGB() {
let red = LabColor(l: 55, a: 80, b: 70)
let rgb = LabColorMath.labToSRGB(red)
#expect(rgb.r > 0.8)
#expect(rgb.g < 0.2)
#expect(rgb.b < 0.2)
XCTAssertTrue(rgb.r > 0.8)
XCTAssertTrue(rgb.g < 0.2)
XCTAssertTrue(rgb.b < 0.2)
}
@Test("Pad white returns DisplayRGB")
func padWhite() {
func testPadWhite() {
let white = LabColor(l: 95, a: 0, b: 0)
let rgb = LabColorMath.labToSRGB(white)
#expect(rgb.r > 0.9)
#expect(rgb.g > 0.9)
#expect(rgb.b > 0.9)
XCTAssertTrue(rgb.r > 0.9)
XCTAssertTrue(rgb.g > 0.9)
XCTAssertTrue(rgb.b > 0.9)
}
@Test("Standard CIEDE2000 vector (Sharma)")
func ciede2000() {
func testCiede2000() {
let a = LabColor(l: 50, a: -1.3802, b: -84.2814)
let b = LabColor(l: 50, a: 0.0000, b: -82.7485)
#expect(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
XCTAssertTrue(abs(ColorDifference.deltaE00(a, b) - 1.00) < 0.001)
}
@Test("Classification respects thresholds")
func classify() {
#expect(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0) == .good)
#expect(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0) == .warning)
#expect(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0) == .bad)
func testClassify() {
XCTAssertEqual(ColorDifference.classify(deltaE: 0.5, goodMax: 2.0, warningMax: 5.0), .good)
XCTAssertEqual(ColorDifference.classify(deltaE: 3.0, goodMax: 2.0, warningMax: 5.0), .warning)
XCTAssertEqual(ColorDifference.classify(deltaE: 6.0, goodMax: 2.0, warningMax: 5.0), .bad)
}
}
@Suite("MeasurementArtefacts")
struct MeasurementArtefactTests {
final class MeasurementArtefactTests: XCTestCase {
private func makeCwd() throws -> URL {
let url = FileManager.default.temporaryDirectory
@@ -261,8 +232,7 @@ struct MeasurementArtefactTests {
return url
}
@Test("Discovers passes in order")
func discovery() throws {
func testDiscovery() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -271,11 +241,10 @@ struct MeasurementArtefactTests {
try "C".write(to: cwd.appendingPathComponent("target_pass10.ti3"), atomically: true, encoding: .utf8)
let passes = MeasurementArtefacts.passSnapshots(basename: "target", cwd: cwd)
#expect(passes.map(\.lastPathComponent) == ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
XCTAssertEqual(passes.map(\.lastPathComponent), ["target_pass1.ti3", "target_pass3.ti3", "target_pass10.ti3"])
}
@Test("Snapshot and promote are atomic")
func snapshotPromote() throws {
func testSnapshotPromote() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -283,16 +252,15 @@ struct MeasurementArtefactTests {
try "canonical".write(to: canonical, atomically: true, encoding: .utf8)
let pass = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass.lastPathComponent == "target_pass1.ti3")
#expect(!FileManager.default.fileExists(atPath: canonical.path))
XCTAssertEqual(pass.lastPathComponent, "target_pass1.ti3")
XCTAssertFalse(FileManager.default.fileExists(atPath: canonical.path))
let promoted = try MeasurementArtefacts.promotePass(pass: pass, basename: "target", cwd: cwd)
#expect(promoted.lastPathComponent == "target.ti3")
#expect(FileManager.default.fileExists(atPath: promoted.path))
XCTAssertEqual(promoted.lastPathComponent, "target.ti3")
XCTAssertTrue(FileManager.default.fileExists(atPath: promoted.path))
}
@Test("Pass collisions handled")
func collision() throws {
func testCollision() throws {
let cwd = try makeCwd()
defer { try? FileManager.default.removeItem(at: cwd) }
@@ -302,28 +270,25 @@ struct MeasurementArtefactTests {
try "v2".write(to: canonical, atomically: true, encoding: .utf8)
let pass2 = try MeasurementArtefacts.snapshotPass(basename: "target", cwd: cwd)
#expect(pass2.lastPathComponent == "target_pass2.ti3")
XCTAssertEqual(pass2.lastPathComponent, "target_pass2.ti3")
}
}
@Suite("AverageArgs")
struct AverageArgsTests {
final class AverageArgsTests: XCTestCase {
@Test("Requires at least two pass files")
func passCount() {
func testPassCount() {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
basename: "target",
passFiles: [URL(fileURLWithPath: "target_pass1.ti3")]
)
#expect(throws: AverageArgError.self) {
_ = try AverageArgs.build(config: config)
XCTAssertThrowsError(try AverageArgs.build(config: config)) { error in
XCTAssertTrue(error is AverageArgError)
}
}
@Test("Output is last and inputs are relative")
func ordering() throws {
func testOrdering() throws {
let cwd = URL(fileURLWithPath: "/tmp")
let config = AverageConfig(
workingDirectory: cwd,
@@ -334,8 +299,8 @@ struct AverageArgsTests {
]
)
let args = try AverageArgs.build(config: config)
#expect(args.first == "-v")
#expect(args.last == "target.ti3")
#expect(args == ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
XCTAssertEqual(args.first, "-v")
XCTAssertEqual(args.last, "target.ti3")
XCTAssertEqual(args, ["-v", "target_pass1.ti3", "target_pass2.ti3", "target.ti3"])
}
}
@@ -0,0 +1,96 @@
import Foundation
import XCTest
@testable import ICCeryCore
/// Issue #146 — `MediaLibraryStore` persistence contract.
final class MediaLibraryStoreTests: XCTestCase {
private var url: URL!
override func setUp() {
url = FileManager.default.temporaryDirectory
.appendingPathComponent("media-lib-\(UUID().uuidString).json")
}
override func tearDown() {
try? FileManager.default.removeItem(at: url)
url = nil
}
private func recipe(id: String, name: String = "R") -> MediaRecipe {
MediaRecipe(
id: id, name: name, printerID: "q",
colourSpace: "rgb", presetID: "preset-std-rgb")
}
func testRoundTrip() async throws {
let store = MediaLibraryStore(url: url)
let r = recipe(id: "recipe-1", name: "Epson Rag")
try await store.upsert(r)
let loaded = try await store.load()
XCTAssertEqual(loaded, [r])
}
func testCorruptFileThrowsAndPreservesBytes() async throws {
try "not json".write(to: url, atomically: true, encoding: .utf8)
let before = try Data(contentsOf: url)
let store = MediaLibraryStore(url: url)
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.load()
}
// upsert must also propagate — a corrupt file is never wiped.
await assertAsyncThrows(expectedType: DecodingError.self) {
try await store.upsert(recipe(id: "x"))
}
XCTAssertEqual(try Data(contentsOf: url), before)
}
func testDelete() async throws {
let store = MediaLibraryStore(url: url)
try await store.upsert(recipe(id: "a"))
try await store.upsert(recipe(id: "b"))
let removed = try await store.delete(id: "a")
XCTAssertTrue(removed)
let remaining = try await store.load().map(\.id)
XCTAssertEqual(remaining, ["b"])
let again = try await store.delete(id: "a")
XCTAssertFalse(again)
}
func testCapacityReached() async throws {
let store = MediaLibraryStore(url: url, capacity: 2)
try await store.upsert(recipe(id: "1"))
try await store.upsert(recipe(id: "2"))
await assertAsyncThrows(
expectedType: MediaLibraryStore.MediaLibraryError.self
) {
try await store.upsert(recipe(id: "3"))
} errorHandler: {
XCTAssertEqual($0, .capacityReached(2))
}
let stored = try await store.load()
XCTAssertEqual(stored.count, 2)
}
func testUpsertPreservesCreatedBumpsUpdated() async throws {
let store = MediaLibraryStore(url: url)
var r = recipe(id: "recipe-1")
r.created = Date(timeIntervalSince1970: 1_000_000)
r.updated = Date(timeIntervalSince1970: 1_000_000)
try await store.upsert(r)
var edit = r
edit.name = "Renamed"
edit.updated = Date(timeIntervalSince1970: 2_000_000)
try await store.upsert(edit)
let loaded = try await store.load()
XCTAssertEqual(loaded.count, 1)
XCTAssertEqual(loaded[0].name, "Renamed")
XCTAssertEqual(loaded[0].created, r.created)
XCTAssertGreaterThan(loaded[0].updated, r.updated)
}
}
@@ -0,0 +1,406 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness
/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir.
@MainActor
final class MediaLibraryViewModelTests: XCTestCase {
// CTI3 fixture mirrored from CalibrationStoreTests.
private static let sampleCal = """
CTI3
DESCRIPTOR "Test printer"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
MAX_TAC "300"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
/// Same fixture plus an old CREATED keyword so the age check fires.
private static let staleCal = """
CTI3
DESCRIPTOR "Test printer"
CREATED "2020-01-01T00:00:00Z"
COLOR_REP "RGB"
DEVICE_CLASS "OUTPUT"
NUMBER_OF_FIELDS 5
NUMBER_OF_SETS 3
BEGIN_DATA_FORMAT
SAMPLE_ID INPUT_VALUE R G B
END_DATA_FORMAT
BEGIN_DATA
1 0 0 0 0
2 128 64 64 64
3 255 255 255 255
END_DATA
"""
private var env: TestAppEnvironment!
private var workflow: TargetWorkflowViewModel!
private var media: MediaLibraryViewModel!
override func setUp() async throws {
env = try TestAppEnvironment.make()
try installMockCups()
workflow = TargetWorkflowViewModel(environment: env.environment)
media = workflow.media
await media.reloadAsync()
}
override func tearDown() async throws {
env?.cleanup()
env = nil
workflow = nil
media = nil
}
/// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the
/// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues:
/// `Mock_Queue` (default) and `Other_Queue`.
private func installMockCups() throws {
let bin = env.root.appendingPathComponent("cups-bin")
try FileManager.default.createDirectory(
at: bin, withIntermediateDirectories: true)
let lpstat = """
#!/bin/sh
case "$1" in
-e) printf 'Mock_Queue\\nOther_Queue\\n' ;;
-p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;;
-d) printf 'system default destination: Mock_Queue\\n' ;;
esac
exit 0
"""
let lpoptions = """
#!/bin/sh
list=0
queue=""
for arg in "$@"; do
case "$arg" in
-l) list=1 ;;
-p) ;;
*) queue="$arg" ;;
esac
done
if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: *A4 Letter\\n'
printf 'MediaType/Media Type: *Stationery Glossy\\n'
exit 0
fi
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
exit 0
"""
for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] {
let url = bin.appendingPathComponent(name)
try body.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
}
private func writeCal(
_ contents: String = MediaLibraryViewModelTests.sampleCal,
named name: String = "recipe.cal"
) throws -> String {
let url = env.root.appendingPathComponent(name)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url.path
}
private func makeRecipe(
id: String = "recipe-t1",
printerID: String = "Mock_Queue",
colourSpace: String = "rgb",
presetID: String = "preset-std-rgb",
calibrationURL: String? = nil,
applyCalibration: Bool = false
) -> MediaRecipe {
MediaRecipe(
id: id,
name: "Test Recipe",
printerID: printerID,
printerDisplayName: "Mock Queue Display",
paperName: "Rag",
inkSet: "PK",
colourSpace: colourSpace,
presetID: presetID,
calibrationURL: calibrationURL,
applyCalibration: applyCalibration)
}
private func seed(_ recipe: MediaRecipe) async throws {
try await env.environment.mediaStore.upsert(recipe)
await media.reloadAsync()
}
// MARK: - Apply
func testApplyHappyPath() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue")
XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display")
XCTAssertTrue(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb")
XCTAssertEqual(
workflow.buildPrinttargConfig().calibrationFile, calPath)
}
func testApplyMissingPresetRefuses() async throws {
let r = makeRecipe(presetID: "preset-nonexistent")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertEqual(workflow.selectedPresetID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("no longer exists") == true)
}
func testApplyColourSpaceMismatchRefuses() async throws {
let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(media.selectedRecipeID, "none")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("colour space") == true)
}
func testApplyMissingCalFileFails() async throws {
let missing = env.root.appendingPathComponent("gone.cal").path
let r = makeRecipe(
calibrationURL: missing, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, missing)
XCTAssertEqual(workflow.wizard.notice?.kind, .error)
XCTAssertEqual(media.selectedRecipeID, "none")
}
func testApplyCalPrefixedCalCannotArmK() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
let applied = await media.apply(r)
// Success with warning — the refusal is permanent, re-clicking
// cannot unstick it (decision 6).
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertEqual(workflow.profile.calibrationFile, calPath)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
XCTAssertEqual(media.selectedRecipeID, r.id)
XCTAssertEqual(workflow.wizard.notice?.kind, .warning)
}
func testApplyLiveCalBasenameBlocks() async throws {
let calPath = try writeCal()
let r = makeRecipe(
calibrationURL: calPath, applyCalibration: true)
try await seed(r)
workflow.wizard.basename = "CAL_live"
let applied = await media.apply(r)
XCTAssertTrue(applied)
XCTAssertFalse(workflow.profile.applyCalibration)
XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile)
}
func testApplyMissingQueueLeavesQueueUntouched() async throws {
workflow.print.selectedPrinter = "Other_Queue"
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
let applied = await media.apply(r)
XCTAssertFalse(applied)
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue")
XCTAssertTrue(
workflow.wizard.notice?.text.contains("is not installed") == true)
XCTAssertEqual(media.selectedRecipeID, "none")
}
// MARK: - Capture
func testCaptureCopiesPrinterAndPreset() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "My Recipe"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "PK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertEqual(stored[0].printerID, "Mock_Queue")
XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display")
XCTAssertEqual(stored[0].presetID, "preset-std-rgb")
XCTAssertEqual(stored[0].colourSpace, "rgb")
XCTAssertEqual(media.selectedRecipeID, stored[0].id)
}
func testCaptureNoPresetAutoSnapshots() async throws {
workflow.print.printers = [
Printer(name: "Mock_Queue", displayName: "Mock Queue Display")
]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "none"
media.saveMediaName = "Snap"
media.saveMediaPaper = "Rag"
media.saveMediaInk = "MK"
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored.count, 1)
XCTAssertTrue(stored[0].presetID.hasPrefix("custom-"))
XCTAssertTrue(
env.environment.presetStore.customs()
.contains { $0.id == stored[0].presetID })
XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID)
}
func testCaptureRequiresPrinter() async {
workflow.print.selectedPrinter = ""
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
let saved = await media.captureFromSession()
XCTAssertFalse(saved)
XCTAssertNotNil(media.saveMediaError)
}
func testCaptureForcesOffCalToggleForCalFile() async throws {
let calPath = try writeCal(named: "CAL_target.cal")
workflow.profile.calibrationFile = calPath
workflow.profile.applyCalibration = true
workflow.print.printers = [Printer(name: "Mock_Queue")]
workflow.print.selectedPrinter = "Mock_Queue"
workflow.selectedPresetID = "preset-std-rgb"
media.saveMediaName = "n"
media.saveMediaPaper = "p"
media.saveMediaInk = "i"
media.saveMediaApplyCal = true // forced off by calApplyable
XCTAssertFalse(media.calApplyable)
let saved = await media.captureFromSession()
XCTAssertTrue(saved)
let stored = await env.environment.mediaStore.all()
XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim
XCTAssertFalse(stored[0].applyCalibration)
}
// MARK: - Staleness
func testStaleCalFlagged() async throws {
let calPath = try writeCal(
MediaLibraryViewModelTests.staleCal, named: "old.cal")
let r = makeRecipe(calibrationURL: calPath)
try await seed(r)
workflow.print.printers = [Printer(name: "Mock_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.calibration) == true)
XCTAssertNotNil(media.calAgeDays[r.id])
}
func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws {
let r = makeRecipe(printerID: "No_Such_Queue")
try await seed(r)
// Un-enumerated list is indeterminate → no flag.
workflow.print.printers = []
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
// Absent from a non-empty list → .printer.
workflow.print.printers = [Printer(name: "Other_Queue")]
await media.refreshStalenessAsync()
XCTAssertTrue(
media.staleReasons[r.id]?.contains(.printer) == true)
// Present but unselected → no flag.
workflow.print.printers = [
Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"),
]
workflow.print.selectedPrinter = "Other_Queue"
await media.refreshStalenessAsync()
XCTAssertNil(media.staleReasons[r.id])
}
// MARK: - Delete
func testDeleteResetsSelection() async throws {
let r = makeRecipe()
try await seed(r)
media.selectedRecipeID = r.id
media.delete(r)
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(media.selectedRecipeID, "none")
let stored = await env.environment.mediaStore.all()
XCTAssertTrue(stored.isEmpty)
}
// MARK: - Corrupt library
func testCorruptLibraryKeepsFileAndWarns() async throws {
// Fresh environment so the store's `loaded` flag is still false.
let env2 = try TestAppEnvironment.make()
defer { env2.cleanup() }
try "garbage".write(
to: env2.mediaLibraryURL, atomically: true, encoding: .utf8)
let workflow2 = TargetWorkflowViewModel(
environment: env2.environment)
await workflow2.media.reloadAsync()
XCTAssertTrue(workflow2.media.recipes.isEmpty)
XCTAssertEqual(workflow2.media.selectedRecipeID, "none")
XCTAssertEqual(workflow2.wizard.notice?.kind, .warning)
XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL),
"garbage".data(using: .utf8))
}
}

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