Compare commits

...
46 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
61 changed files with 7234 additions and 667 deletions
+20 -1
View File
@@ -17,9 +17,16 @@ jobs:
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: |
@@ -51,6 +58,10 @@ jobs:
- 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' \
@@ -59,7 +70,10 @@ jobs:
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
@@ -191,6 +205,10 @@ jobs:
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).
@@ -202,6 +220,7 @@ jobs:
- 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 }}
+20 -1
View File
@@ -30,9 +30,16 @@ jobs:
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: |
@@ -64,6 +71,10 @@ jobs:
- 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' \
@@ -72,7 +83,10 @@ jobs:
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
@@ -191,6 +205,10 @@ jobs:
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).
@@ -202,6 +220,7 @@ jobs:
- 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 }}
+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
+113 -3
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,10 +43,49 @@ 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="$(uname -m)"
@@ -50,7 +93,74 @@ 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.
+4 -3
View File
@@ -1,6 +1,6 @@
# BUILD-PLAN.md — ICCery v2 Mac
Spec snapshot: `docs/`. Source of tickets: Gitea milestones M1–M6 + M10 (id 32) + 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.
@@ -20,11 +20,12 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| 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 |
M8 and M9 merged to `develop` via PR #104 / #145; M10 starts from `800c980`.
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, or M10 exit gate.**
Issue **16 is not an M3, M6, M10, or M11 exit gate.**
## Branch taxonomy
@@ -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))"
}
}
@@ -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
}
@@ -18,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)" }
}
+41 -9
View File
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|---|---|
| Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 |
| 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 |
@@ -25,7 +25,8 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| 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: gamut compare (#147), Spot Read (#148), project files (#149) shipped on `develop`; media library (#146) is in the tree, issue still open |
| 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
@@ -125,7 +126,35 @@ 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 `ICCery-${VERSION}-${BUILD_NUM}.dmg` with `dmgbuild==1.6.7`.
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.
@@ -145,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
@@ -176,15 +207,16 @@ not in this tree.
leases. Quit path: `q\n`, ~500 ms, kill; `killAll` on terminate.
- **Argv builders** in ICCeryCore (`TargenArgs`, `PrinttargArgs`,
`ChartreadArgs`, `ColprofArgs`, `ApplycalArgs`, `IccgamutArgs`,
`ProfcheckArgs`, `LpArgs`, `SpotReadArgs`, …). UI must not concatenate flags.
`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 `lp` with ColorSync suppression
(`AP_ColorMatchingMode` / `AP.ColorMatchingMode`). Captured `NSPrintPanel`
options win over derived CUPS keys. Never `lp -o raw`.
- **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).
@@ -269,8 +301,8 @@ fix/<issue>-<slug>
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
branch is used only while that milestone is assembling; `milestone/m10-studio`
has been merged and deleted. Do not open umbrella “bugfix” branches that mix
tickets.
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`
+35 -5
View File
@@ -24,6 +24,7 @@ struct AppEnvironment: Sendable {
.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)
@@ -34,6 +35,11 @@ struct AppEnvironment: Sendable {
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(),
@@ -46,7 +52,7 @@ struct AppEnvironment: Sendable {
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir),
binaryDir: cupsDir, ppdDir: ppdDir),
historyStore: VerificationHistoryStore(),
mediaStore: MediaLibraryStore(),
recentProjectsStore: RecentProjectsStore()
@@ -106,12 +112,14 @@ enum UITestHooks {
// 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
@@ -125,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:
+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
}
}
+322 -27
View File
@@ -2,26 +2,90 @@ import Combine
import Foundation
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
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?
@Published var printers: [Printer] = []
@Published var selectedPrinter = ""
@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?
@Published var selectedMediaType: String?
@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>?
@@ -72,25 +136,142 @@ final class PrintSessionViewModel: ObservableObject {
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,
@@ -99,18 +280,64 @@ final class PrintSessionViewModel: ObservableObject {
)
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).",
@@ -122,17 +349,38 @@ final class PrintSessionViewModel: ObservableObject {
}
}
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` 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(
@@ -156,13 +404,13 @@ final class PrintSessionViewModel: ObservableObject {
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 }
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).",
@@ -180,21 +428,68 @@ final class PrintSessionViewModel: ObservableObject {
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)
}
}
+110 -33
View File
@@ -218,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) {
@@ -255,6 +255,7 @@ struct Stage2View: View {
.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
@@ -265,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")
@@ -279,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))
}
@@ -301,39 +316,24 @@ 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)
@@ -344,6 +344,83 @@ struct Stage2View: View {
.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
@@ -392,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)")
@@ -137,6 +137,9 @@ final class TargetWorkflowViewModel: ObservableObject {
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,
@@ -102,6 +102,62 @@ final class TiffPreviewTests: XCTestCase {
}
}
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")
@@ -24,6 +24,17 @@ final class CupsOptionsFilterTests: XCTestCase {
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
}
/// #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"
XCTAssertEqual(CupsOptionsFilter.filter(raw), raw)
@@ -43,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase {
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"))
}
}
/// Issue 14 — the dlsym attempt order and first-success semantics.
+579 -6
View File
@@ -4,7 +4,7 @@ import Foundation
/// Issue 12 — CUPS enumeration parsers on recorded fixtures
/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here.
final class CupsParsersTests: XCTestCase {
final class CupsParserTests: XCTestCase {
// Recorded on an Epson XP-55 + Canon Pro9500 host.
private let lpstatE = """
@@ -30,7 +30,7 @@ final class CupsParsersTests: XCTestCase {
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
"""
func testDestinations() {
@@ -83,11 +83,13 @@ final class CupsParsersTests: XCTestCase {
let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"),
PrinterTray(id: 2, name: "Main"),
PrinterTray(id: 3, name: "Photo"),
PrinterTray(id: 4, name: "Rear"),
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"),
])
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), [
@@ -117,6 +119,157 @@ final class CupsParsersTests: XCTestCase {
XCTAssertNil(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]))
}
// 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)
@@ -131,4 +284,424 @@ final class CupsParsersTests: XCTestCase {
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)")
}
}
-118
View File
@@ -1,118 +0,0 @@
import XCTest
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.
final class LpArgsTests: XCTestCase {
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)
}
func testHeader() throws {
let argv = try build()
XCTAssertEqual(Array(argv[0...1]), ["-d", queue])
XCTAssertEqual(Array(argv[2...3]), ["-t", "ICCery Target - target_001.tif"])
XCTAssertEqual(Array(argv[4...5]), ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(Array(argv[6...7]), ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
XCTAssertEqual(argv.last, tiff)
XCTAssertFalse(argv.contains { $0 == "raw" || $0 == "-o raw" })
}
func testNeverRaw() throws {
let argv = try build(options: PrintOptions(
cupsOptions: "raw=true MediaType=Photo"))
for (i, arg) in argv.enumerated() where arg == "-o" {
XCTAssertNotEqual(argv[i + 1], "raw")
XCTAssertNotEqual(argv[i + 1], "raw=true")
}
XCTAssertFalse(argv.contains { $0.hasPrefix("raw=") })
XCTAssertTrue(argv.contains("MediaType=Photo"))
}
func testCapturedReplay() 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")!
XCTAssertTrue(rear > apFirst)
}
func testCapturedWinsMedia() throws {
let argv = try build(
options: PrintOptions(
mediaType: "Plain",
cupsOptions: "MediaType=Glossy"),
optionKeys: ["MediaType"])
XCTAssertTrue(argv.contains("MediaType=Glossy"))
XCTAssertFalse(argv.contains("MediaType=Plain"))
}
func testMediaDerived() throws {
let argv = try build(
options: PrintOptions(mediaType: "SemiGloss"),
optionKeys: ["CNIJMediaType", "MediaType"])
// CNIJMediaType wins over MediaType in detection order.
XCTAssertTrue(argv.contains("CNIJMediaType=SemiGloss"))
XCTAssertFalse(argv.contains("MediaType=SemiGloss"))
}
func testBypassRules() throws {
let withBypass = try build(
optionKeys: ["EPIJ_CMat"])
XCTAssertTrue(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.
XCTAssertEqual(captured.filter { $0.hasPrefix("EPIJ_CMat") }, ["EPIJ_CMat=1"])
}
func testOrientation() throws {
XCTAssertTrue(try build(options: PrintOptions(orientation: "portrait"))
.contains("orientation-requested=3"))
XCTAssertTrue(try build(options: PrintOptions(orientation: "landscape"))
.contains("orientation-requested=4"))
let capturedOrients = try build(options: PrintOptions(
orientation: "landscape",
cupsOptions: "orientation-requested=5"))
XCTAssertFalse(capturedOrients.contains("orientation-requested=4"))
XCTAssertTrue(capturedOrients.contains("orientation-requested=5"))
}
func testPageSize() throws {
XCTAssertTrue(try build(options: PrintOptions(paperSize: "A4"))
.contains("PageSize=A4"))
let capturedSize = try build(options: PrintOptions(
paperSize: "A4", cupsOptions: "PageSize=Letter"))
XCTAssertFalse(capturedSize.contains("PageSize=A4"))
XCTAssertTrue(capturedSize.contains("PageSize=Letter"))
}
func testSanitise() throws {
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear;rm -rf /"))) { error in
XCTAssertTrue(error is LpArgsError)
}
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))) { error in
XCTAssertTrue(error is LpArgsError)
}
XCTAssertThrowsError(try build(options: PrintOptions(
cupsOptions: "InputSlot=$(whoami)"))) { error in
XCTAssertTrue(error is LpArgsError)
}
}
}
@@ -0,0 +1,412 @@
import XCTest
import Foundation
@testable import ICCeryCore
/// #214 — media → allowed-quality resolution: generic PPD
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
/// injected — no installed drivers touched.
final class MediaQualityConstraintsTests: XCTestCase {
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
/// with defaults 2/2/1.
private var epsonListings: [CupsOptionListing] {
CupsParsers.lpoptionsList("""
PageSize/Media Size: 4x6 5x7 *A4 Letter
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
EPIJ_PSrc/Page Setup: *2 3 25
EPIJ_FdSo/Paper Source: *2 3 12
EPIJ_Ink_/Grayscale: *1 0
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
""")
}
private var epsonQualityIDs: Set<String> {
["301", "302", "303", "304", "305", "307", "308"]
}
// MARK: - ppdKeyword
func testPPDKeywordQuotedAndBare() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
*CNIJTableID: 354
*CNIJTableIDFoo: 999
"""
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
"/Library/Printers/EPSON/InkjetPrinter2")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
"EP14C0605W.data")
XCTAssertEqual(
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
}
// MARK: - Path derivation
func testEPIJPDEDataPath() {
let ppd = """
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
XCTAssertEqual(
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
ppd: "*CNIJTableID: 354\n"))
}
func testCNIJTablePath() {
let ppd = """
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
*CNIJTableID: 354
"""
XCTAssertEqual(
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
ppd: "*EPIJMachineBundleName: \"x\"\n"))
}
// MARK: - Generic PPD UIConstraints
func testPPDUIConstraintsBothOrders() {
let ppd = """
*UIConstraints: *MediaType Glossy *PrintQuality Draft
*UIConstraints: *PrintQuality Draft *MediaType Matte
*Constraints: *MediaType Glossy *PrintQuality Low
*UIConstraints: *InputSlot Rear *PrintQuality Draft
"""
let forbidden = MediaQualityConstraints.ppdUIConstraints(
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
mediaIDs: ["Glossy", "Matte"],
qualityIDs: ["Draft", "Low", "High"])
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
XCTAssertEqual(forbidden["Matte"], ["Draft"])
}
// MARK: - Epson EPIJUIConstraint
/// Realistic XP-55 fragment: pure-media rules plus conditional
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
func testEPIJConstraintsAgainstDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
// additionally requires Ink_=0 — default is 1, so inert too
// (302 survives).
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
// 999 is not a listed quality id — ignored outright.
XCTAssertEqual(forbidden["92"], ["301"])
}
/// A conditional rule whose extra terms all match the defaults
/// fires like a pure-media rule.
func testEPIJConditionalRuleFiresAtDefaults() {
let dat = """
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_PSrc": "2"])
XCTAssertEqual(forbidden["0"], ["304"])
}
/// A rule with no media term applies to every listed media; a term
/// on a key the queue does not advertise counts as satisfied
/// (conservative-forbid — #214 semantics).
func testEPIJGlobalAndUnknownKeyRules() {
let dat = """
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
let forbidden = MediaQualityConstraints.epijUIConstraints(
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
defaults: ["EPIJ_Ink_": "1"])
// Global rule: Ink_=1 matches default → 305 forbidden on both.
XCTAssertTrue(forbidden["0"]!.contains("305"))
XCTAssertTrue(forbidden["15"]!.contains("305"))
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
XCTAssertTrue(forbidden["0"]!.contains("307"))
}
/// The end-to-end XP-55 expectation through `resolve` — the exact
/// sets the user reported from the driver PDE.
func testResolveEpsonProducesDriverSets() {
let dat = """
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
// Plain paper: Fast Economy / Economy / Normal / Fine.
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
// Premium Semigloss: Quality / Best Quality / Draft.
XCTAssertEqual(map["15"], ["305", "307", "308"])
// Ultra Glossy drops Fast Economy…Fine and Draft.
XCTAssertEqual(map["92"], ["305", "307"])
// Unconstrained media (no rules) produce no entry.
XCTAssertNil(map["13"])
}
// MARK: - #218 tray-aware quality constraints
/// A conditional rule keyed by the selected tray/token must use the
/// supplied override rather than the queue's lpoptions default.
func testResolveWithTrayOverride() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
*EPIJUIConstraint: *EPIJ_FdSo 12 *EPIJ_Medi 0|*EPIJ_Qual 307
"""
// The queue default is EPIJ_FdSo=2 (from `epsonListings`), so
// the default map has no forbidden qualities for media "0".
let defaultMap = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil)
XCTAssertNil(defaultMap["0"])
// Override to tray "3" -> 305 forbidden; override to tray "12"
// -> 307 forbidden.
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
let readFile: (URL) -> Data? = {
$0 == datURL ? dat.data(using: .utf8) : nil
}
let tray3 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
XCTAssertEqual(tray3["0"], epsonQualityIDs.subtracting(["305"]))
let tray12 = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(tray12["0"], epsonQualityIDs.subtracting(["307"]))
}
func testResolveTrayOverrideCacheReadOnlyOnce() {
let dat = """
*EPIJUIConstraint: *EPIJ_FdSo 3 *EPIJ_Medi 0|*EPIJ_Qual 305
"""
let ppd = """
*EPIJDriverBasePath: "/drivers/epson"
*EPIJMachineBundleName: "EP14C0605W.data"
"""
let datURL = URL(fileURLWithPath:
"/drivers/epson/Machine/EP14C0605W.data/"
+ "Contents/Resources/PDEData.dat")
var reads = 0
var cache: [URL: Data] = [:]
let readFile: (URL) -> Data? = {
if let cached = cache[$0] { return cached }
reads += 1
let data = $0 == datURL ? dat.data(using: .utf8) : nil
cache[$0] = data
return data
}
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "3"], readFile: readFile)
_ = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
optionOverrides: ["EPIJ_FdSo": "12"], readFile: readFile)
XCTAssertEqual(reads, 1)
}
// MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
private func canonRecord(
family: UInt16 = 3, flag: UInt16 = 0,
media: UInt32, pad: UInt16 = 0, quality: UInt16
) -> Data {
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
d.append(contentsOf: [
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
return d
}
func testCNBTableScan() {
var data = Data()
// Junk prefix — even length keeps the records 2-byte aligned.
data.append(contentsOf: [0xFF, 0x00])
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
for q: UInt16 in [10, 15, 20, 0] {
data.append(canonRecord(media: 0, quality: q))
}
data.append(canonRecord(flag: 1, media: 0, quality: 10))
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
for q: UInt16 in [0, 5, 10] {
data.append(canonRecord(media: 50, quality: q))
}
data.append(canonRecord(media: 0x10032, quality: 0))
// Noise: unknown media, unknown quality, nonzero pad, and a
// different record family that must lose the modal vote.
data.append(canonRecord(media: 999, quality: 5))
data.append(canonRecord(media: 50, quality: 7))
data.append(canonRecord(media: 50, pad: 1, quality: 15))
data.append(canonRecord(family: 9, media: 50, quality: 15))
data.append(Data([0x11, 0x22, 0x33]))
let map = MediaQualityConstraints.cnijMediaQualityTable(
data,
mediaIDs: ["0", "50", "42"],
qualityIDs: ["0", "5", "10", "15", "20"])
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
XCTAssertEqual(map["50"], ["0", "5", "10"])
XCTAssertNil(map["42"])
}
func testCNBGarbageFailsOpen() {
let map = MediaQualityConstraints.cnijMediaQualityTable(
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
XCTAssertTrue(map.isEmpty)
}
// MARK: - resolve ordering / fallback
func testResolvePrefersPPDConstraints() {
let ppd = """
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
*EPIJDriverBasePath: "/should/not/be/read"
*EPIJMachineBundleName: "x.data"
"""
var reads = 0
let map = MediaQualityConstraints.resolve(
listings: epsonListings, ppd: ppd,
readFile: { _ in reads += 1; return nil })
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
// The Epson path is never touched once PPD constraints hit.
XCTAssertEqual(reads, 0)
}
func testResolveNoPPDOrKeysFailsOpen() {
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: epsonListings, ppd: nil).isEmpty)
// No media key in the roster → no map.
XCTAssertTrue(MediaQualityConstraints.resolve(
listings: CupsParsers.lpoptionsList(
"PageSize/Media Size: *A4 Letter\n"),
ppd: "*CNIJTableID: 354\n").isEmpty)
}
// MARK: - PrinterCapabilities accessors
func testCapabilitiesAccessorFallbacks() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
PrinterMediaType(id: "15", name: "Semigloss"),
],
qualities: ["301", "302", "303", "308", "304", "305", "307"]
.map { PrinterQuality(id: $0, name: "Q\($0)") })
// No map → everything, in driver order (#180).
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
caps.qualityIDsByMediaType = [
"0": ["301", "302", "303", "304"],
"15": ["305", "307", "308"],
]
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
["301", "302", "303", "304"])
// Driver order preserved — 308 sits before 304 in lpoptions.
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
["308", "305", "307"])
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
// Unknown media → unconstrained.
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
}
func testTraySpecificQualityMap() {
var caps = PrinterCapabilities(
mediaTypes: [
PrinterMediaType(id: "0", name: "Plain"),
],
qualities: ["301", "302", "303", "304", "305"]
.map { PrinterQuality(id: $0, name: "Q\($0)") },
qualityIDsByMediaType: ["0": ["301", "302", "303"]])
caps.qualityIDsByMediaTypeByTray = [
"Rear": ["0": ["301", "302", "303"]],
"Front": ["0": ["303", "304", "305"]],
]
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Rear")
.map { $0.id }, ["301", "302", "303"])
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Front")
.map { $0.id }, ["303", "304", "305"])
// Unknown tray falls back to the default map.
XCTAssertEqual(
caps.qualities(forMediaType: "0", trayToken: "Unknown")
.map { $0.id }, ["301", "302", "303"])
// No tray token also falls back.
XCTAssertEqual(
caps.qualities(forMediaType: "0").map { $0.id },
["301", "302", "303"])
XCTAssertTrue(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Front"))
XCTAssertFalse(caps.allowsQuality(
"304", forMediaType: "0", trayToken: "Rear"))
}
}
@@ -0,0 +1,269 @@
import AppKit
import CoreGraphics
import Foundation
import XCTest
@testable import ICCery
/// Issue #201 Phase 3 — real `NSPrintOperation` with
/// `jobDisposition = .save`: proves the canvas paginates at paper size,
/// draws the raster 1:1 at the top-left, and survives end-to-end
/// without a printer. Skippable via `ICCERY_SKIP_PRINT_PDF=1` for
/// hosts where the print system cannot run (R8).
@MainActor
final class NativeSpoolPDFTests: XCTestCase {
private var tempRoot: URL!
/// A4 in points.
private let paperSize = NSSize(width: 595.28, height: 841.89)
/// Known top-left patch colour — solid fill across the fixture.
private let patchBytes: [UInt8] = [230, 40, 50]
override func setUpWithError() throws {
tempRoot = try TargetTestFixtures.makeTempDirectory()
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempRoot)
}
func testSavePDFDrawsPagesOneToOne() throws {
try XCTSkipIf(
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
== "1",
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
// 72×72 px @ 72 dpi → a 72×72 pt solid block at the top-left.
let tiff = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 72, height: 72), dpi: 72,
components: 3, bitsPerComponent: 8,
pixelBytes: patchBytes, in: tempRoot)
let raster = try TargetRasterLoader.load(tiff: tiff)
let pdfURL = tempRoot.appendingPathComponent("spool.pdf")
let info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView(
pages: [raster, raster], paperSize: paperSize)
let operation = NSPrintOperation(view: canvas, printInfo: info)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.canSpawnSeparateThread = false
XCTAssertTrue(operation.run(),
"NSPrintOperation.save failed")
XCTAssertTrue(
FileManager.default.fileExists(atPath: pdfURL.path))
let document = try XCTUnwrap(
CGPDFDocument(pdfURL as CFURL))
XCTAssertEqual(document.numberOfPages, 2)
let page = try XCTUnwrap(document.page(at: 1))
let mediaBox = page.getBoxRect(.mediaBox)
XCTAssertEqual(mediaBox.width, paperSize.width, accuracy: 0.5)
XCTAssertEqual(mediaBox.height, paperSize.height, accuracy: 0.5)
try assertTopLeftPatch(on: page, mediaBox: mediaBox)
}
/// Render the page at 1 px/pt and check the fixture's solid block
/// landed at the top-left at its point size — the coarse 1:1 +
/// no-scaling check.
private func assertTopLeftPatch(
on page: CGPDFPage, mediaBox: CGRect
) throws {
let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height))
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap(
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
return (color.redComponent,
color.greenComponent,
color.blueComponent)
}
func isPatch(_ p: (Double, Double, Double)) -> Bool {
abs(p.0 - Double(patchBytes[0]) / 255) < 0.04
&& abs(p.1 - Double(patchBytes[1]) / 255) < 0.04
&& abs(p.2 - Double(patchBytes[2]) / 255) < 0.04
}
// The print system insets the view by the queue's unprintable
// margin (R10) — host-dependent, ~18 pt on this machine — so
// the block's anchor is the painted region's top-left, not
// (0,0). Find the painted origin by the first opaque pixel.
var originX = width, originY = height
for y in 0..<height {
for x in 0..<width
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
originX = min(originX, x)
originY = min(originY, y)
}
}
XCTAssertLessThan(originX, width, "PDF page rendered empty")
XCTAssertLessThan(originY, height, "PDF page rendered empty")
// 10 px inside the 72 pt block on each axis → patch colour.
XCTAssertTrue(try isPatch(pixel(originX + 10, originY + 10)),
"top-left block is not the fixture colour")
// Measure the block's painted width on a row through it:
// 72 pt at 1 px/pt — the 1:1 / no-scaling assertion.
var blockWidth = 0
var x = originX
while x < width,
isPatch(try pixel(x, originY + 10)) {
blockWidth += 1
x += 1
}
XCTAssertEqual(blockWidth, 72, accuracy: 2)
// Past the block edge the canvas's white page fill shows.
let outside = try pixel(originX + 80, originY + 80)
XCTAssertEqual(outside.0, 1, accuracy: 0.04)
XCTAssertEqual(outside.1, 1, accuracy: 0.04)
XCTAssertEqual(outside.2, 1, accuracy: 0.04)
}
/// #211 — a top/bottom banded block proves the page is drawn
/// upright AND anchored at the paper's top edge: a mirrored draw
/// swaps the bands; a bottom-anchored draw leaves white above the
/// block instead of below.
func testSavePDFDrawsPageUprightTopAnchored() throws {
try XCTSkipIf(
ProcessInfo.processInfo.environment["ICCERY_SKIP_PRINT_PDF"]
== "1",
"ICCERY_SKIP_PRINT_PDF=1 — print system unavailable")
// 144×144 px @ 72 dpi → a 144×144 pt block, top half red,
// bottom half blue.
let topBytes: [UInt8] = [230, 40, 50]
let bottomBytes: [UInt8] = [40, 50, 230]
let tiff = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 144, height: 144), dpi: 72,
components: 3, bitsPerComponent: 8,
pixelBytes: topBytes,
bottomHalfPixelBytes: bottomBytes, in: tempRoot)
let raster = try TargetRasterLoader.load(tiff: tiff)
let pdfURL = tempRoot.appendingPathComponent("bands.pdf")
let info = makeSaveInfo(pdfURL: pdfURL)
let canvas = TargetPageCanvasView(
pages: [raster], paperSize: paperSize)
let operation = NSPrintOperation(view: canvas, printInfo: info)
operation.showsPrintPanel = false
operation.showsProgressPanel = false
operation.canSpawnSeparateThread = false
XCTAssertTrue(operation.run(),
"NSPrintOperation.save failed")
let document = try XCTUnwrap(
CGPDFDocument(pdfURL as CFURL))
let page = try XCTUnwrap(document.page(at: 1))
let mediaBox = page.getBoxRect(.mediaBox)
let rep = try renderPageToBitmap(page, mediaBox: mediaBox)
func pixel(_ x: Int, _ y: Int) throws -> (Double, Double, Double) {
let color = try XCTUnwrap(
rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB))
return (color.redComponent,
color.greenComponent,
color.blueComponent)
}
func matches(
_ p: (Double, Double, Double), _ rgb: [UInt8]
) -> Bool {
abs(p.0 - Double(rgb[0]) / 255) < 0.04
&& abs(p.1 - Double(rgb[1]) / 255) < 0.04
&& abs(p.2 - Double(rgb[2]) / 255) < 0.04
}
// Painted region = the page fill inset by the queue's
// unprintable margin (R10); minimum opaque y is its top edge
// in the rendered bitmap (same convention as the 1:1 test).
let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height))
var topX = width, topY = height
for y in 0..<height {
for x in 0..<width
where (rep.colorAt(x: x, y: y)?.alphaComponent ?? 0) > 0.5 {
topX = min(topX, x)
topY = min(topY, y)
}
}
XCTAssertLessThan(topY, height, "PDF page rendered empty")
// The 144 pt block hangs from the page's top edge: top-band
// colour for the first 72 pt, bottom-band colour for the
// next 72 pt, then the white page fill.
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 10), topBytes),
"block top is not the top-band colour — mirrored draw?")
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 82), bottomBytes),
"block bottom is not the bottom-band colour — mirrored draw?")
XCTAssertTrue(
try matches(pixel(topX + 10, topY + 134), bottomBytes),
"block does not reach 144 pt below the painted top edge")
let below = try pixel(topX + 10, topY + 160)
XCTAssertEqual(below.0, 1, accuracy: 0.04)
XCTAssertEqual(below.1, 1, accuracy: 0.04)
XCTAssertEqual(below.2, 1, accuracy: 0.04)
}
/// Print settings shared by the save-PDF harness tests: A4, zero
/// margins, `.clip` pagination, 1:1, no centring, `.save` to
/// `pdfURL`.
private func makeSaveInfo(pdfURL: URL) -> NSPrintInfo {
let info = NSPrintInfo()
info.paperSize = paperSize
info.orientation = .portrait
info.topMargin = 0
info.bottomMargin = 0
info.leftMargin = 0
info.rightMargin = 0
info.horizontalPagination = .clip
info.verticalPagination = .clip
info.scalingFactor = 1.0
info.isHorizontallyCentered = false
info.isVerticallyCentered = false
info.jobDisposition = .save
info.dictionary()[
NSPrintInfo.AttributeKey.jobSavingURL] = pdfURL
return info
}
/// Render a saved-PDF page into a bitmap at 1 px/pt.
private func renderPageToBitmap(
_ page: CGPDFPage, mediaBox: CGRect
) throws -> NSBitmapImageRep {
let width = Int(ceil(mediaBox.width))
let height = Int(ceil(mediaBox.height))
let rep = try XCTUnwrap(NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: width,
pixelsHigh: height,
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0))
let context = try XCTUnwrap(
NSGraphicsContext(bitmapImageRep: rep))
let cg = context.cgContext
let transform = page.getDrawingTransform(
.mediaBox,
rect: CGRect(x: 0, y: 0, width: width, height: height),
rotate: 0, preserveAspectRatio: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
cg.concatenate(transform)
cg.drawPDFPage(page)
NSGraphicsContext.restoreGraphicsState()
return rep
}
}
+98 -1
View File
@@ -44,14 +44,55 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear"),
]
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays, preferredTrayKey: "InputSlot")
XCTAssertEqual(result?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
}
}
/// #183 — the stub parses paper size / quality / orientation out of
/// `ICCERY_TEST_PANEL_OPTIONS` so UI tests can verify apply-back.
func testOkResultExtractsNewFields() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS":
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.options.paperSize, "Letter")
XCTAssertEqual(result?.options.quality, "305")
XCTAssertEqual(result?.options.orientation, "landscape")
}
}
/// #186 — a vendor media key in the captured string reaches
/// `options.mediaType` through the detection roster
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
func testOkResultExtractsVendorMediaKey() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS":
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.options.paperSize, "A4")
XCTAssertEqual(result?.options.orientation, "landscape")
XCTAssertEqual(result?.options.mediaType, "Photo")
}
}
func testOkDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
@@ -64,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase {
XCTAssertNil(result?.options.cupsOptions)
}
}
/// #218 — vendor tray keys and the `AP_D_InputSlot` capture alias
/// map back through `paperSource` when the token matches the roster.
func testOkResultExtractsTrayToken() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Rear", name: "Rear Tray"),
PrinterTray(id: 3, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "PageSize=A4 EPIJ_FdSo=Rear",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays,
preferredTrayKey: "EPIJ_FdSo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.cupsOptions,
"PageSize=A4 EPIJ_FdSo=Rear")
}
}
func testOkResultExtractsAPDInputSlotAlias() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, token: "Manual", name: "Manual Feed"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "AP_D_InputSlot=Manual",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertEqual(result?.options.paperSource, 2)
}
}
func testOkResultUnknownTrayNoPaperSource() throws {
let trays = [
PrinterTray(id: 1, token: "Auto", name: "Auto"),
]
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "InputSlot=Unknown",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(
forQueue: "q", trays: trays)
XCTAssertNil(result?.options.paperSource)
}
}
}
@@ -0,0 +1,180 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
@testable import ICCeryCore
/// Issue #217 — the panel's pre-modal seam
/// (`PrintPanelService.configureBoundPanel`) must rehydrate the
/// captured `PrintTicket` before the Stage 2 writes, validate the
/// result, and refuse a foreign-queue ticket — all without driving
/// the modal or needing a real queue (`printer: nil` exercises
/// restore → selections → validate on a session-less `NSPrintInfo`).
@MainActor
final class PrintPanelTicketTests: XCTestCase {
private let service = PrintPanelService()
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else a synthetic token for a
/// queue-less environment (same convention as PrintTicketTests).
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
private func makeTicket(
queue: String,
key: String = "EPIJ_Qual",
value: String = "305"
) throws -> PrintTicket {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
value, forKey: key, locked: false,
in: PMTicketBridge.settings(source),
context: "PrintPanelTicketTests"))
return try PMTicketBridge.serialise(source, queue: queue)
}
private func request(
ticket: PrintTicket?,
selections: PrintPanelInitialSelections =
PrintPanelInitialSelections(),
trays: [PrinterTray] = []
) -> PrintPanelRequest {
PrintPanelRequest(
queue: "Panel_Q", displayName: nil, optionKeys: [],
initialSelections: selections, ticket: ticket, trays: trays)
}
/// RC1 — a captured vendor key is present in the configured
/// printInfo before the modal would open.
func testTicketRestoredPreModal() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"305")
}
/// D6 parity — Stage 2 selections win over the restored ticket.
func testStage2WinsOverTicket() throws {
let printInfo = NSPrintInfo()
let ticket = try makeTicket(queue: boundQueue(of: printInfo))
var selections = PrintPanelInitialSelections()
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
service.configureBoundPanel(
printInfo,
request: request(ticket: ticket, selections: selections),
printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"303")
}
/// R3 — a ticket captured for another queue is refused; the
/// destination's settings stay untouched and nothing throws.
func testCrossQueueTicketRefusedAtPanel() throws {
let printInfo = NSPrintInfo()
let bound = boundQueue(of: printInfo)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(printInfo),
context: "PrintPanelTicketTests"))
let ticket = try makeTicket(queue: foreign)
service.configureBoundPanel(
printInfo, request: request(ticket: ticket), printer: nil)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(printInfo)),
"999")
}
/// #218 — the Stage 2 tray selection lands after `PageSize` and
/// before the media key in the configured `NSPrintInfo`.
func testStage2TrayWrittenPreModal() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.trayKey = "EPIJ_FdSo"
selections.tray = "Rear"
selections.mediaType = "Photo"
var req = request(ticket: nil, selections: selections)
req = PrintPanelRequest(
queue: req.queue, displayName: req.displayName,
optionKeys: ["MediaType"], initialSelections: selections,
ticket: nil, trays: [])
service.configureBoundPanel(
printInfo, request: req, printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "PageSize", in: settings),
"A4")
XCTAssertEqual(
PMTicketBridge.stringValue(forKey: "EPIJ_FdSo", in: settings),
"Rear")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "MediaType", in: settings), "Photo")
}
/// First-ever open — no ticket, selections still land and the
/// post-selections validate keeps them readable.
func testNoTicketKeepsSelections() {
let printInfo = NSPrintInfo()
var selections = PrintPanelInitialSelections()
selections.paperSize = "A4"
selections.qualityKey = "EPIJ_Qual"
selections.quality = "303"
selections.orientation = "landscape"
service.configureBoundPanel(
printInfo,
request: request(ticket: nil, selections: selections),
printer: nil)
let settings = PMTicketBridge.settings(printInfo)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PageSize", in: settings), "A4")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual", in: settings), "303")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "orientation-requested", in: settings), "4")
// The suppression writes still land on the ticket so the PDE
// sees the locked AP_* pair (③) and Quartz vocabulary (⑤′).
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "AP_ColorMatchingMode", in: settings),
"AP_ApplicationColorMatching")
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "PMColorMatchingMode", in: settings),
"APCustomColorMatching")
}
}
@@ -0,0 +1,671 @@
import Foundation
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #183 — Stage 2 paper-size / quality selection: seeding from
/// Stage 1 `pageSize`, the synthetic `Custom.<pt>x<pt>` entry, re-mirror
/// triggers, and `TargetPrintOverrides` wiring into the resolved
/// ticket writes (#201 — the `RecordingTargetSpooler` seam replaces
/// the deleted `lp` argv log).
/// `lpoptions` is a mock script in the test env's `cups-bin` — no
/// live CUPS is touched.
@MainActor
final class PrintSessionViewModelTests: XCTestCase {
private var env: TestAppEnvironment!
private var spoolLogURL: URL!
override func setUp() async throws {
env = try TestAppEnvironment.make()
spoolLogURL = env.root.appendingPathComponent("spool.log")
try writeCupsFixtures()
}
override func tearDown() async throws {
env?.cleanup()
env = nil
spoolLogURL = nil
}
private var binDir: URL {
env.root.appendingPathComponent("cups-bin")
}
/// Mock `lpoptions -l` advertises paper sizes + a quality key —
/// the queue's option-key roster feeds media/quality detection.
private func writeCupsFixtures() throws {
try FileManager.default.createDirectory(
at: binDir, withIntermediateDirectories: true)
let lpoptions = """
#!/bin/sh
list=0
queue=""
for arg in "$@"; do
case "$arg" in
-l) list=1 ;;
-*) ;;
*) queue="$arg" ;;
esac
done
if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
printf 'InputSlot/Media Source: Auto Main *Rear\\n'
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
exit 0
fi
printf "printer-info='Mock %s' printer-type=42\\n" "$queue"
exit 0
"""
let url = binDir.appendingPathComponent("lpoptions")
try lpoptions.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755], ofItemAtPath: url.path)
}
private func makeWorkflow() -> TargetWorkflowViewModel {
TargetWorkflowViewModel(environment: env.environment)
}
private func loadCaps(
_ vm: PrintSessionViewModel, queue: String = "Mock_Q"
) async {
vm.selectedPrinter = queue
await vm.reloadSelectedCapabilities()
}
private func waitForFile(
_ url: URL, timeout: TimeInterval = 10
) async -> String {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let text = try? String(contentsOf: url, encoding: .utf8),
!text.isEmpty {
return text
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
}
// MARK: - Seeding / mirror
/// `pageSize = .a4` matches the capability named "A4" → its id.
func testSeedMatchesPageSizeRawValue() async {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.pageSize, .a4)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
// Tray seeds from the `*` default on caps load, not the first
// list element (#218).
XCTAssertEqual(workflow.print.selectedTray, 3)
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Quality seeds from the `*` default on caps load.
XCTAssertEqual(workflow.print.selectedQuality, "303")
// All seven Epson codes enumerate in driver order (#180).
XCTAssertEqual(workflow.print.printerCaps.qualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
}
/// `.custom` → synthetic `id: 0` entry whose token is the
/// dimensions in **points** (mm × 72/25.4): 210×297 → `Custom.595x842`.
func testSeedCustomPageSizeSyntheticEntry() async {
let workflow = makeWorkflow()
workflow.pageSize = .custom
workflow.customPageW = 210
workflow.customPageH = 297
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 0)
let synthetic = workflow.print.printerCaps.paperSizes
.first { $0.id == 0 }
XCTAssertEqual(synthetic?.name, "Custom.595x842")
XCTAssertEqual(workflow.print.selectedPaperSizeToken, "Custom.595x842")
}
/// A `workflow.pageSize` change re-mirrors the picker.
func testReseedOnPageSizeChange() async {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
workflow.pageSize = .letter
workflow.print.seedPaperSelection()
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
}
/// A user pick survives unrelated publishes — re-seed only fires
/// on pageSize / printer / caps triggers (#183, R14).
func testUserEditPreservedAcrossUnrelatedPublishes() async {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.selectedPaperSize = 5
workflow.print.selectedTray = 2
workflow.print.printOrientation = "landscape"
workflow.print.printNotice = Notice(kind: .info, text: "x")
XCTAssertEqual(workflow.print.selectedPaperSize, 5)
}
/// A printer change re-seeds from `workflow.pageSize` after the
/// capabilities reload (the picker re-mirrors, not guesses).
func testPrinterChangeReseeds() async {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.selectedPaperSize = 5
workflow.print.selectedQuality = "301"
// The view nils quality on printer change before reloading —
// the VM re-seeds `when nil` only (#183 contract).
workflow.print.selectedPrinter = "Other_Q"
workflow.print.selectedQuality = nil
await workflow.print.reloadSelectedCapabilities()
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// A pageSize with no capability match leaves the pick nil —
/// never a guessed id.
func testSeedNoMatchLeavesNil() async {
let workflow = makeWorkflow()
workflow.pageSize = .a2
await loadCaps(workflow.print)
XCTAssertNil(workflow.print.selectedPaperSize)
XCTAssertNil(workflow.print.selectedPaperSizeToken)
}
// MARK: - Spool wiring
/// `spool` resolves the Stage 2 paper token and quality into the
/// ticket writes (`PageSize=`, `<qualityKey>=`) recorded by the
/// DEBUG spool seam (#201 D8).
func testSpoolPassesPaperTokenAndQuality() async throws {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.selectedPaperSize = 4 // Letter
workflow.print.selectedQuality = "301"
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
let result = PrinttargResult(
ti2URL: env.root.appendingPathComponent("target.ti2"),
manifest: PrinttargManifest(pages: [page.page]),
pages: [page])
workflow.print.printAllPages(from: result)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
}
/// #201 — the stubbed panel never produces a `PrintTicket`, so a
/// spool with `capturedTickets` empty must still resolve every
/// Stage 2 write (the default UI-test path).
func testSpoolWithoutTicketResolvesStage2Writes() async throws {
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
XCTAssertTrue(workflow.print.capturedTickets.isEmpty)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
let result = PrinttargResult(
ti2URL: env.root.appendingPathComponent("target.ti2"),
manifest: PrinttargManifest(pages: [page.page]),
pages: [page])
workflow.print.printAllPages(from: result)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("pages=1"), log)
XCTAssertTrue(log.contains("PageSize=A4"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
XCTAssertTrue(log.contains("MediaType=Stationery"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
XCTAssertTrue(log.contains("orientation-requested=3"), log)
XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains(
"PMColorMatchingMode=APCustomColorMatching"), log)
}
// MARK: - Panel apply-back (stubbed NSPrintPanel)
/// The stubbed panel's captured `PageSize=`/`EPIJ_Qual=` apply back
/// to `selectedPaperSize`/`selectedQuality` (#183 capture-return).
func testPanelResultAppliesBackSelections() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS",
"PageSize=Letter EPIJ_Qual=305", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.openPrinterPreferences()
let deadline = Date().addingTimeInterval(10)
while Date() < deadline,
workflow.print.selectedPaperSize != 4
|| workflow.print.selectedQuality != "305" {
try? await Task.sleep(nanoseconds: 100_000_000)
}
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.selectedQuality, "305")
}
/// #186 — the captured `orientation-requested`/media token apply
/// back to `printOrientation`/`selectedMediaType`, and a dialog
/// result never mutates `workflow.pageSize` (printtarg layout).
func testPanelResultAppliesBackOrientationAndMedia() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS",
"orientation-requested=4 MediaType=Glossy", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.printOrientation, "portrait")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
XCTAssertEqual(workflow.pageSize, .a4)
}
/// #218 — a captured `InputSlot=` (or any roster key) maps back to
/// `selectedTray` and populates `PrintOptions.paperSource`.
func testPanelResultAppliesBackTray() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=Auto", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedTray, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedTray, 1)
}
/// #218 — a captured tray token absent from the roster is appended
/// as a synthetic entry and selected.
func testPanelResultUnknownTrayAppended() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "InputSlot=OnlyInDialog", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
let originalCount = workflow.print.printerCaps.trays.count
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printerCaps.trays.count,
originalCount + 1)
XCTAssertEqual(workflow.print.selectedTrayToken, "OnlyInDialog")
}
/// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
}
/// #186 — a stub result pointing at another queue in `printers`
/// switches `selectedPrinter` and reloads its capabilities.
func testPanelResultSwitchesToKnownQueue() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [
Printer(name: "Mock_Q", isDefault: true),
Printer(name: "Other_Q"),
]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Other_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
// Caps reloaded for the new queue: paper re-seeded, then the
// captured PageSize applied back onto the new caps.
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
"PageSize=Letter")
}
/// #186 — a stub result naming a queue absent from `printers`
/// leaves the selection on the opened queue.
func testPanelResultGhostQueueIgnored() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Mock_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
}
/// #186 — cancel returns `nil`: info notice, no field changes.
func testPanelCancelLeavesSelections() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.printOrientation = "landscape"
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "cancelled")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
XCTAssertEqual(workflow.print.selectedQuality, "303")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
}
// MARK: - Media-aware quality filtering (#214)
/// Constrains the loaded fixture caps: `Stationery` allows
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
private func constrainCaps(_ vm: PrintSessionViewModel) {
var caps = vm.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303", "304"],
"Glossy": ["305", "307"],
]
vm.printerCaps = caps
}
/// Media switch → an invalid quality pick re-seeds to the first
/// allowed choice; the picker source shrinks to the media's set.
func testMediaChangeClampsInvalidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// A still-valid pick survives a media switch; an unconstrained
/// media keeps the pick too.
func testMediaChangeKeepsValidQuality() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedQuality = "302"
workflow.print.selectedMediaType = "Matte" // unconstrained
XCTAssertEqual(workflow.print.selectedQuality, "302")
workflow.print.selectedMediaType = "Glossy" // 302 invalid
XCTAssertEqual(workflow.print.selectedQuality, "305")
workflow.print.selectedQuality = "307"
workflow.print.selectedMediaType = "Stationery" // 307 invalid
// Driver default 303 is allowed there → preferred over first.
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// #218 — a per-tray quality map is selected when the current tray
/// token has an entry; otherwise it falls back to the default map.
func testTraySpecificQualityMapSelected() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
var caps = workflow.print.printerCaps
caps.qualityIDsByMediaType = [
"Stationery": ["301", "302", "303"],
"Glossy": ["305", "307"],
]
caps.qualityIDsByMediaTypeByTray = [
"Auto": ["Stationery": ["301", "302"]],
"Rear": ["Stationery": ["301", "302", "303", "304"]],
]
workflow.print.printerCaps = caps
XCTAssertEqual(workflow.print.selectedTrayToken, "Rear")
// Non-empty tray map for Rear wins over the default media map.
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303", "304"])
workflow.print.selectedTray = 1 // Auto
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302"])
workflow.print.selectedTray = 999 // invalid -> fallback
XCTAssertEqual(workflow.print.availableQualities.map { $0.id },
["301", "302", "303"])
}
/// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture
/// `PDEData.dat` (#214).
func testQualitySeedRespectsMediaMap() async throws {
let ppdDir = env.root.appendingPathComponent("ppd")
let epsonRoot = env.root.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
atomically: true, encoding: .utf8)
// Stationery forbids everything except 305/307 — including the
// `lpoptions` default 303.
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
var environment = env.environment
environment = AppEnvironment(
stateStore: environment.stateStore,
settingsStore: environment.settingsStore,
presetStore: environment.presetStore,
runner: environment.runner,
cupsService: CupsService(
processManager: ProcessManager(),
binaryDir: binDir, ppdDir: ppdDir),
historyStore: environment.historyStore,
mediaStore: environment.mediaStore,
recentProjectsStore: environment.recentProjectsStore)
let workflow = TargetWorkflowViewModel(environment: environment)
workflow.print.selectedPrinter = "Mock_Q"
await workflow.print.reloadSelectedCapabilities()
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printerCaps
.qualityIDsByMediaType["Stationery"], ["305", "307"])
// 303 is the driver default but invalid on Stationery → 305.
XCTAssertEqual(workflow.print.selectedQuality, "305")
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["305", "307"])
}
/// No constraint map → every quality stays selectable on every
/// media (the pre-#214 behaviour, by design for unknown drivers).
func testUnconstrainedDriverKeepsAllQualities() async {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
["301", "302", "303", "308", "304", "305", "307"])
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// A captured quality invalid for the captured media is dropped —
/// the clamped selection stands.
func testPanelResultInvalidQualityDropped() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print) // Stationery forbids 305
XCTAssertEqual(workflow.print.selectedQuality, "303")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedQuality, "303")
}
/// `makeRequest` substitutes a stale-invalid quality before the
/// ticket is written — the last gate before the driver (#214).
func testSpoolSubstitutesStaleQuality() async throws {
let workflow = makeWorkflow()
await loadCaps(workflow.print)
constrainCaps(workflow.print)
workflow.print.selectedMediaType = "Glossy"
workflow.print.selectedQuality = "308" // stale, invalid
workflow.print.spooler = RecordingTargetSpooler(
logURL: spoolLogURL)
let tiff = env.root.appendingPathComponent("page1.tif")
try Data([0x49, 0x49]).write(to: tiff)
let page = GalleryPage(
index: 0,
page: PrinttargPage(
filename: "page1.tif", patches: 10,
widthMm: 210, heightMm: 297),
fileURL: tiff, previewPNG: nil, previewError: nil)
workflow.print.printPage(page)
let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
}
/// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice(
_ vm: PrintSessionViewModel,
containing fragment: String,
timeout: TimeInterval = 10
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let text = vm.printNotice?.text, text.contains(fragment) {
return
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
XCTFail("Timed out waiting for notice containing '\(fragment)'")
}
}
@@ -0,0 +1,121 @@
import AppKit
import Foundation
import XCTest
@testable import ICCery
/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte
/// round-trip, cross-queue refusal (R3), plist fallback, and the
/// `takeRetainedValue` ownership canary.
@MainActor
final class PrintTicketTests: XCTestCase {
/// The queue a destination `NSPrintInfo` will report as bound —
/// the session's current printer, else the Cocoa printer name,
/// else a synthetic token for a queue-less environment.
private func boundQueue(of printInfo: NSPrintInfo) -> String {
PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo),
fallback: "UnboundQueue")
}
func testRoundTripRetainsVendorValue() throws {
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
"305", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(source),
context: "PrintTicketTests"))
let target = NSPrintInfo()
let ticket = try PMTicketBridge.serialise(
source, queue: boundQueue(of: target))
try PMTicketBridge.restore(ticket, into: target)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(target)),
"305")
}
func testPrintSettingsDataIsXmlPlist() throws {
let ticket = try PMTicketBridge.serialise(
NSPrintInfo(), queue: "AnyQueue")
XCTAssertFalse(ticket.printSettings.isEmpty)
XCTAssertFalse(ticket.pageFormat.isEmpty)
XCTAssertEqual(
String(decoding: ticket.printSettings.prefix(5),
as: UTF8.self),
"<?xml")
}
func testCrossQueueReplayIsRefused() throws {
let target = NSPrintInfo()
let bound = boundQueue(of: target)
let foreign = "DefinitelyNot_\(bound)"
try XCTSkipIf(
bound == foreign,
"destination resolved to the foreign queue")
// Sentinel on the destination — a refused restore must leave
// the target's settings untouched.
XCTAssertTrue(
PMTicketBridge.setValue(
"999", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(target),
context: "PrintTicketTests"))
let source = NSPrintInfo()
XCTAssertTrue(
PMTicketBridge.setValue(
"305", forKey: "EPIJ_Qual", locked: false,
in: PMTicketBridge.settings(source),
context: "PrintTicketTests"))
let ticket = try PMTicketBridge.serialise(
source, queue: foreign)
try PMTicketBridge.restore(ticket, into: target)
XCTAssertEqual(
PMTicketBridge.stringValue(
forKey: "EPIJ_Qual",
in: PMTicketBridge.settings(target)),
"999")
}
func testPrintInfoPlistFallbackRoundTrips() throws {
let source = NSPrintInfo()
// The `com.apple.print.PrintSettings` entry only exists once
// something writes into the attribute dictionary — the
// ColorSync mirror does this on the real panel path.
source.dictionary()[
NSPrintInfo.AttributeKey("com.apple.print.PrintSettings")] =
["PMColorMatchingMode": "APCustomColorMatching"]
as NSDictionary
let ticket = try PMTicketBridge.serialise(
source, queue: "AnyQueue")
let data = try XCTUnwrap(ticket.printInfoPlist)
let object = try PropertyListSerialization.propertyList(
from: data, options: [], format: nil)
let dictionary = try XCTUnwrap(object as? [String: Any])
XCTAssertNotNil(
dictionary["com.apple.print.PrintSettings"])
}
/// 200× serialise loop — a `takeRetainedValue` over/under-release
/// canary. Byte counts must stay stable; ASan + Malloc Scribble
/// catches an actual imbalance.
func testSerialiseIsStableAcrossIterations() throws {
let source = NSPrintInfo()
var firstCount: Int?
for _ in 0..<200 {
let ticket = try PMTicketBridge.serialise(
source, queue: "AnyQueue")
if let first = firstCount {
XCTAssertEqual(ticket.printSettings.count, first)
} else {
firstCount = ticket.printSettings.count
}
}
XCTAssertNotNil(firstCount)
}
}
@@ -0,0 +1,187 @@
import AppKit
import CoreGraphics
import Foundation
import XCTest
@testable import ICCery
/// Issue #201 Phase 3 — `TargetPageCanvasView` bottom-up page stacking,
/// snapped top-left anchoring, and the draw-time interpolation /
/// antialias contract asserted via `drawProbe`.
@MainActor
final class TargetCanvasGeometryTests: XCTestCase {
/// A4-ish paper, points.
private let paperSize = NSSize(width: 595.28, height: 841.89)
private func raster(
pixelWidth: Int = 2480,
pixelHeight: Int = 3508,
dpi: Double = 300
) -> TargetPageRaster {
let image = TargetTestFixtures.makeImage(
px: CGSize(width: 8, height: 8),
components: 3, bitsPerComponent: 8)!
return TargetPageRaster(
sourceURL: URL(fileURLWithPath: "/fixture.tiff"),
cgImage: image,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
dpiX: dpi, dpiY: dpi,
componentCount: 3,
bitsPerComponent: 8,
expectedWidthMm: nil,
expectedHeightMm: nil)
}
// MARK: - Page stacking
/// Bottom-up space: page 1 is the BOTTOM band of the frame — the
/// non-flipped pagination convention (#211).
func testRectForPageStacksBottomUp() {
let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()],
paperSize: paperSize)
XCTAssertFalse(view.isFlipped)
let page1 = view.rectForPage(1)
let page3 = view.rectForPage(3)
XCTAssertEqual(page1.minY, 0, accuracy: 0.001)
XCTAssertEqual(page3.minY, 2 * paperSize.height, accuracy: 0.001)
XCTAssertEqual(page1.size, paperSize)
XCTAssertEqual(page3.size, paperSize)
}
func testKnowsPageRangeAndFrame() {
let view = TargetPageCanvasView(
pages: [raster(), raster()],
paperSize: paperSize)
var range = NSRange()
XCTAssertTrue(view.knowsPageRange(&range))
XCTAssertEqual(range, NSRange(location: 1, length: 2))
XCTAssertEqual(
view.frame.height, 2 * paperSize.height, accuracy: 0.001)
XCTAssertEqual(view.frame.width, paperSize.width, accuracy: 0.001)
}
// MARK: - Destination rect
func testDestinationRectAnchorsTopLeftAndSnaps() {
let view = TargetPageCanvasView(
pages: [raster(), raster()],
paperSize: paperSize)
// Near-full-page raster: in bottom-up space the page's top
// edge is `maxY`, so the top-anchored origin is ~0.03 pt
// negative — the overflow clips at the BOTTOM of the sheet.
let first = view.destinationRect(forPage: 1)
XCTAssertEqual(first.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(first.origin.y, -0.03, accuracy: 0.001)
XCTAssertEqual(first.width, 595.2, accuracy: 0.01)
XCTAssertEqual(first.height, 841.92, accuracy: 0.01)
let second = view.destinationRect(forPage: 2)
XCTAssertEqual(
second.origin.y,
2 * paperSize.height - first.height, accuracy: 0.001)
XCTAssertEqual(second.size, first.size)
// A raster smaller than the paper hangs from the page's TOP
// edge — minY would anchor it at the bottom (#211 follow-up).
let small = TargetPageCanvasView(
pages: [raster(pixelWidth: 72, pixelHeight: 72, dpi: 72)],
paperSize: paperSize)
let smallRect = small.destinationRect(forPage: 1)
XCTAssertEqual(smallRect.origin.x, 0, accuracy: 0.001)
XCTAssertEqual(
smallRect.origin.y, paperSize.height - 72, accuracy: 0.001)
XCTAssertEqual(smallRect.size, CGSize(width: 72, height: 72))
for rect in [first, second, smallRect] {
XCTAssertEqual(
rect.origin.x * 1000,
(rect.origin.x * 1000).rounded(),
accuracy: 1e-9)
XCTAssertEqual(
rect.origin.y * 1000,
(rect.origin.y * 1000).rounded(),
accuracy: 1e-9)
}
}
// MARK: - Draw flags
/// Render the canvas into an offscreen bitmap context and assert —
/// from inside `drawProbe` — that interpolation and antialiasing
/// are off and the drawn rect is `destinationRect(forPage:)`.
func testDrawDisablesInterpolationAndAntialias() throws {
let pages = [raster(), raster()]
let view = TargetPageCanvasView(
pages: pages, paperSize: paperSize)
let rep = try XCTUnwrap(NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(ceil(view.frame.width)),
pixelsHigh: Int(ceil(view.frame.height)),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0))
let context = try XCTUnwrap(
NSGraphicsContext(bitmapImageRep: rep))
var probed: [(page: Int, rect: NSRect)] = []
view.drawProbe = { ctx, page, rect in
XCTAssertEqual(ctx.imageInterpolation, .none,
"page \(page): interpolation not .none")
XCTAssertFalse(ctx.shouldAntialias,
"page \(page): antialiasing still on")
probed.append((page, rect))
}
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
view.draw(view.bounds)
NSGraphicsContext.restoreGraphicsState()
XCTAssertEqual(probed.count, pages.count)
for (page, rect) in probed {
XCTAssertEqual(
rect, view.destinationRect(forPage: page),
"page \(page): probe rect mismatch")
}
}
/// A dirty rect covering only page 2's band must draw only page 2.
func testDrawSkipsPagesOutsideDirtyRect() throws {
let view = TargetPageCanvasView(
pages: [raster(), raster(), raster()],
paperSize: paperSize)
let rep = try XCTUnwrap(NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(ceil(view.frame.width)),
pixelsHigh: Int(ceil(view.frame.height)),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0))
let context = try XCTUnwrap(
NSGraphicsContext(bitmapImageRep: rep))
var probedPages: [Int] = []
view.drawProbe = { _, page, _ in probedPages.append(page) }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = context
view.draw(view.rectForPage(2))
NSGraphicsContext.restoreGraphicsState()
XCTAssertEqual(probedPages, [2])
}
}
@@ -0,0 +1,145 @@
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
/// Shared TIFF fixture builder for the #201 raster / canvas / PDF
/// harness tests. Writes real TIFFs via `CGImageDestination` so the
/// loader exercises the same decode path as production.
enum TargetTestFixtures {
enum FixtureError: Error {
case imageNotCreated
case destinationNotCreated
case finalizeFailed
}
/// Fresh per-test directory under the temp root.
static func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-raster-\(UUID().uuidString)")
try FileManager.default.createDirectory(
at: url, withIntermediateDirectories: true)
return url
}
/// A solid-fill (or repeating-pattern) `CGImage` with a device
/// colour space for 1/3/4 components at 8 or 16 bpc. `alpha: true`
/// appends one alpha sample per pixel (`last`). `pixelBytes` is the
/// repeating per-pixel pattern (big-endian for 16 bpc).
/// `bottomHalfPixelBytes`, when set, is the pattern for the bottom
/// half of the rows — a vertical asymmetry that lets tests catch a
/// mirrored draw (#211).
static func makeImage(
px: CGSize,
components: Int,
bitsPerComponent: Int,
alpha: Bool = false,
pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil
) -> CGImage? {
let width = Int(px.width)
let height = Int(px.height)
let bytesPerComponent = bitsPerComponent / 8
let samples = components + (alpha ? 1 : 0)
let bytesPerPixel = samples * bytesPerComponent
let bytesPerRow = width * bytesPerPixel
func buildRow(_ pattern: [UInt8]) -> [UInt8] {
var row = [UInt8](repeating: 0, count: bytesPerRow)
for offset in stride(
from: 0, to: bytesPerRow, by: pattern.count) {
for (index, byte) in pattern.enumerated()
where offset + index < bytesPerRow {
row[offset + index] = byte
}
}
return row
}
let topRow = buildRow(
pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
let bottomRow = buildRow(bottomHalfPixelBytes ?? pixelBytes
?? Array(0..<bytesPerPixel).map {
UInt8(($0 * 37 + 11) & 0xff)
})
var bytes = [UInt8]()
bytes.reserveCapacity(bytesPerRow * height)
for rowIndex in 0..<height {
bytes.append(
contentsOf: rowIndex < height / 2 ? topRow : bottomRow)
}
let data = Data(bytes)
let space: CGColorSpace
switch components {
case 1: space = CGColorSpaceCreateDeviceGray()
case 3: space = CGColorSpaceCreateDeviceRGB()
case 4: space = CGColorSpaceCreateDeviceCMYK()
default: return nil
}
var info = CGBitmapInfo()
if bitsPerComponent == 16 {
info.insert(.byteOrder16Big)
}
if alpha {
info = CGBitmapInfo(
rawValue: info.rawValue | CGImageAlphaInfo.last.rawValue)
}
guard let provider = CGDataProvider(data: data as CFData)
else { return nil }
return CGImage(
width: width, height: height,
bitsPerComponent: bitsPerComponent,
bitsPerPixel: bitsPerComponent * samples,
bytesPerRow: bytesPerRow,
space: space,
bitmapInfo: info,
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent)
}
/// Writes `makeImage` output to a single-page TIFF. `dpi` nil
/// produces a TIFF with no resolution tags (the 72-fallback path).
/// `bottomHalfPixelBytes` passes through to `makeImage` (#211).
static func makeTIFF(
px: CGSize,
dpi: Double?,
components: Int = 3,
bitsPerComponent: Int = 8,
alpha: Bool = false,
pixelBytes: [UInt8]? = nil,
bottomHalfPixelBytes: [UInt8]? = nil,
in directory: URL
) throws -> URL {
guard let image = makeImage(
px: px, components: components,
bitsPerComponent: bitsPerComponent,
alpha: alpha, pixelBytes: pixelBytes,
bottomHalfPixelBytes: bottomHalfPixelBytes)
else { throw FixtureError.imageNotCreated }
let url = directory.appendingPathComponent(
"fixture-\(UUID().uuidString).tiff")
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL, UTType.tiff.identifier as CFString, 1, nil)
else { throw FixtureError.destinationNotCreated }
var properties: [CFString: Any] = [:]
if let dpi {
properties[kCGImagePropertyDPIWidth] = dpi
properties[kCGImagePropertyDPIHeight] = dpi
properties[kCGImagePropertyTIFFDictionary] = [
kCGImagePropertyTIFFXResolution: dpi,
kCGImagePropertyTIFFYResolution: dpi,
kCGImagePropertyTIFFResolutionUnit: 2, // inches
]
}
CGImageDestinationAddImage(
destination, image, properties as CFDictionary)
guard CGImageDestinationFinalize(destination)
else { throw FixtureError.finalizeFailed }
return url
}
}
@@ -0,0 +1,241 @@
import CoreGraphics
import Foundation
import ImageIO
import XCTest
@testable import ICCery
/// Issue #201 Phase 3 — `TargetRasterLoader` DPI handling, device
/// re-tag (D10), unsupported-layout rejection, and manifest drift (D9).
final class TargetRasterTests: XCTestCase {
private var tempRoot: URL!
override func setUpWithError() throws {
tempRoot = try TargetTestFixtures.makeTempDirectory()
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: tempRoot)
}
private func load(_ url: URL) throws -> TargetPageRaster {
try TargetRasterLoader.load(tiff: url)
}
/// Decode index 0 the same way the loader does, for provider-byte
/// comparisons that prove the re-tag did not resample.
private func decoded(_ url: URL) throws -> CGImage {
let source = try XCTUnwrap(
CGImageSourceCreateWithURL(url as CFURL, nil))
return try XCTUnwrap(
CGImageSourceCreateImageAtIndex(source, 0, nil))
}
// MARK: - pointSize / physicalMm
func testPointSizeFromDPI() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 2480, height: 3508), dpi: 300,
in: tempRoot)
let raster = try load(url)
XCTAssertEqual(raster.pointSize.width, 595.2, accuracy: 0.01)
XCTAssertEqual(raster.pointSize.height, 841.92, accuracy: 0.01)
XCTAssertEqual(raster.physicalMm.width, 209.97, accuracy: 0.01)
XCTAssertEqual(raster.physicalMm.height, 297.01, accuracy: 0.01)
}
func testPointSizeAt600dpi() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 1200, height: 1800), dpi: 600,
in: tempRoot)
let raster = try load(url)
XCTAssertEqual(raster.pointSize.width, 144, accuracy: 0.01)
XCTAssertEqual(raster.pointSize.height, 216, accuracy: 0.01)
}
func testMissingDPIFallsBackTo72() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 100, height: 80), dpi: nil,
in: tempRoot)
let raster = try load(url)
XCTAssertEqual(raster.dpiX, TargetRasterLoader.fallbackDPI)
XCTAssertEqual(raster.dpiY, TargetRasterLoader.fallbackDPI)
XCTAssertEqual(raster.pointSize.width, 100, accuracy: 0.01)
XCTAssertEqual(raster.pointSize.height, 80, accuracy: 0.01)
}
func testMissingFileThrows() {
let url = tempRoot.appendingPathComponent("nope.tiff")
XCTAssertThrowsError(try load(url)) { error in
XCTAssertEqual(
error as? TargetRasterError,
.tiffMissing(url.path))
}
}
func testGarbageFileThrowsUndecodable() throws {
let url = tempRoot.appendingPathComponent("junk.tiff")
try Data([0x00, 0x01, 0x02, 0x03]).write(to: url)
XCTAssertThrowsError(try load(url)) { error in
XCTAssertEqual(
error as? TargetRasterError,
.undecodable(url.path))
}
}
// MARK: - deviceTagged (D10)
func testDeviceTaggedRGB8() throws {
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
px: CGSize(width: 16, height: 16),
components: 3, bitsPerComponent: 8))
let tagged = try XCTUnwrap(
TargetRasterLoader.deviceTagged(source))
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
XCTAssertEqual(tagged.bitsPerComponent, 8)
XCTAssertFalse(tagged.shouldInterpolate)
XCTAssertEqual(
tagged.dataProvider?.data as? Data,
source.dataProvider?.data as? Data)
}
func testDeviceTaggedCMYK8() throws {
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
px: CGSize(width: 16, height: 16),
components: 4, bitsPerComponent: 8))
let tagged = try XCTUnwrap(
TargetRasterLoader.deviceTagged(source))
XCTAssertEqual(tagged.colorSpace?.model, .cmyk)
XCTAssertEqual(tagged.bitsPerComponent, 8)
XCTAssertEqual(
tagged.dataProvider?.data as? Data,
source.dataProvider?.data as? Data)
}
func testDeviceTaggedGray8() throws {
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
px: CGSize(width: 16, height: 16),
components: 1, bitsPerComponent: 8))
let tagged = try XCTUnwrap(
TargetRasterLoader.deviceTagged(source))
XCTAssertEqual(tagged.colorSpace?.model, .monochrome)
XCTAssertEqual(tagged.bitsPerComponent, 8)
XCTAssertEqual(
tagged.dataProvider?.data as? Data,
source.dataProvider?.data as? Data)
}
func testDeviceTaggedRGB16KeepsBitDepth() throws {
let source = try XCTUnwrap(TargetTestFixtures.makeImage(
px: CGSize(width: 16, height: 16),
components: 3, bitsPerComponent: 16))
let tagged = try XCTUnwrap(
TargetRasterLoader.deviceTagged(source))
XCTAssertEqual(tagged.colorSpace?.model, .rgb)
XCTAssertEqual(tagged.bitsPerComponent, 16)
XCTAssertEqual(
tagged.dataProvider?.data as? Data,
source.dataProvider?.data as? Data)
}
/// The loaded raster reuses the decoder's provider verbatim — byte
/// equality is the no-resample guarantee (R5).
func testLoadedRasterSharesDecodedBytes() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 32, height: 24), dpi: 300,
in: tempRoot)
let raster = try load(url)
let decoded = try decoded(url)
XCTAssertEqual(
raster.cgImage.dataProvider?.data as? Data,
decoded.dataProvider?.data as? Data)
XCTAssertEqual(raster.cgImage.colorSpace?.model, .rgb)
}
// MARK: - Rejections
func testAlphaTIFFThrowsUnsupportedLayout() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 16, height: 16), dpi: 300,
components: 3, alpha: true, in: tempRoot)
XCTAssertThrowsError(try load(url)) { error in
guard case .unsupportedLayout(_, _, _, let hasAlpha) =
error as? TargetRasterError
else {
return XCTFail("expected unsupportedLayout, got \(error)")
}
XCTAssertTrue(hasAlpha)
}
}
func testDeviceTaggedRejectsFloat() throws {
var info = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
info.insert(.floatComponents)
info.insert(.byteOrder32Little)
var bytes = [Float](repeating: 0.5, count: 4 * 4 * 3)
let floatImage = bytes.withUnsafeMutableBytes { buffer -> CGImage? in
CGImage(
width: 4, height: 4,
bitsPerComponent: 32, bitsPerPixel: 96,
bytesPerRow: 4 * 3 * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: info,
provider: CGDataProvider(data: Data(buffer) as CFData)!,
decode: nil, shouldInterpolate: false,
intent: .defaultIntent)
}
bytes = []
let image = try XCTUnwrap(floatImage)
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
}
func testDeviceTaggedRejectsIndexed() throws {
var table = [UInt8](repeating: 0, count: 256 * 3)
let indexed = try XCTUnwrap(CGColorSpace(
indexedBaseSpace: CGColorSpaceCreateDeviceRGB(),
last: 255, colorTable: &table))
let image = try XCTUnwrap(CGImage(
width: 8, height: 8,
bitsPerComponent: 8, bitsPerPixel: 8,
bytesPerRow: 8,
space: indexed,
bitmapInfo: CGBitmapInfo(),
provider: CGDataProvider(
data: Data(repeating: 7, count: 64) as CFData)!,
decode: nil, shouldInterpolate: false,
intent: .defaultIntent))
XCTAssertNil(TargetRasterLoader.deviceTagged(image))
}
/// No 2-component `CGImage` is fabricatable through public API —
/// the rejection lives in `deviceSpace(for:)`, asserted here.
func testDeviceSpaceRejectsTwoAndFiveComponents() {
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 2))
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 0))
XCTAssertNil(TargetRasterLoader.deviceSpace(for: 5))
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 1))
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 3))
XCTAssertNotNil(TargetRasterLoader.deviceSpace(for: 4))
}
// MARK: - Manifest drift (D9)
func testManifestDriftWhenMismatch() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 2480, height: 3508), dpi: 300,
in: tempRoot)
let raster = try TargetRasterLoader.load(
tiff: url, expectedWidthMm: 200, expectedHeightMm: 297)
XCTAssertNotNil(raster.manifestDrift)
}
func testManifestDriftNilWhenMatching() throws {
let url = try TargetTestFixtures.makeTIFF(
px: CGSize(width: 2480, height: 3508), dpi: 300,
in: tempRoot)
// 2480 px @300 dpi = 209.97 mm — inside the 0.5 mm band.
let raster = try TargetRasterLoader.load(
tiff: url, expectedWidthMm: 210, expectedHeightMm: 297)
XCTAssertNil(raster.manifestDrift)
}
}
@@ -0,0 +1,226 @@
import XCTest
@testable import ICCeryCore
@testable import ICCery
/// Issue #201 Phase 4 / #218 — the resolved ticket-write list that
/// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2
/// always wins), `raw` can never appear.
final class TicketWriteResolverTests: XCTestCase {
private func resolve(
overrides: TargetPrintOverrides = TargetPrintOverrides(),
optionKeys: Set<String> = []
) -> ResolvedTicketWrites {
TicketWriteResolver.resolve(
overrides: overrides, optionKeys: optionKeys)
}
private func keys(_ r: ResolvedTicketWrites) -> [String] {
r.writes.map(\.key)
}
private func value(
_ key: String, in r: ResolvedTicketWrites
) -> String? {
r.writes.first { $0.key == key }?.value
}
/// Both AP_* spellings are always present and locked; all three
/// Quartz keys are always present (D2 dual vocabulary).
func testColourKeysAlwaysPresent() {
let r = resolve()
let ap = r.writes.filter {
ColorMatchingAttempts.printSettingsKeys.contains($0.key)
}
XCTAssertEqual(ap.count, 2)
XCTAssertTrue(ap.allSatisfy {
$0.locked
&& $0.value == "AP_ApplicationColorMatching"
})
XCTAssertEqual(value("PMColorMatchingMode", in: r),
"APCustomColorMatching")
XCTAssertEqual(value("PMCustomColorMatchingProfile", in: r), "")
XCTAssertEqual(
value("com.apple.print.PrintSettings.PMColorMatchingMode",
in: r),
"APCustomColorMatching")
// The Quartz mode key is locked; profile + legacy key are not.
XCTAssertTrue(r.writes.first { $0.key == "PMColorMatchingMode" }!
.locked)
XCTAssertFalse(r.writes.first {
$0.key == "PMCustomColorMatchingProfile" }!.locked)
XCTAssertFalse(r.writes.first {
$0.key == "com.apple.print.PrintSettings.PMColorMatchingMode"
}!.locked)
}
/// `raw` can never appear — there is no `lp` and the resolver has
/// no captured-options input that could smuggle it in (#92).
func testRawNeverAppears() {
for optionKeys in [Set<String>(), ["raw"],
["MediaType", "raw"]] {
let r = resolve(
overrides: TargetPrintOverrides(
paperSize: "raw", mediaType: "raw",
qualityKey: "raw", quality: "raw",
trayKey: "raw", tray: "raw",
orientation: "raw"),
optionKeys: optionKeys)
XCTAssertFalse(r.writes.contains {
$0.key.lowercased() == "raw"
})
}
}
/// D6 inversion — the deleted argv builder suppressed the Stage 2
/// value when the captured string already carried the key (the old
/// media branch at `build`'s line 81). There is no captured-wins
/// path any more:
/// the explicit Stage 2 value is emitted unconditionally.
func testStage2MediaTypeAlwaysWins() {
let r = resolve(
overrides: TargetPrintOverrides(mediaType: "Matte"),
optionKeys: ["MediaType"])
XCTAssertEqual(value("MediaType", in: r), "Matte")
// …and likewise for a vendor key — nothing consults captured
// options before writing.
let epson = resolve(
overrides: TargetPrintOverrides(mediaType: "Matte"),
optionKeys: ["EPIJ_Medi"])
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Matte")
}
/// Vendor media-key selection follows `CupsParsers.mediaTypeKeys`:
/// `EPIJ_Medi` is picked when present; `CNIJMediaType` beats
/// `MediaType`.
func testVendorMediaKeySelection() {
let epson = resolve(
overrides: TargetPrintOverrides(mediaType: "Photo"),
optionKeys: ["EPIJ_Medi", "MediaType"])
XCTAssertEqual(value("EPIJ_Medi", in: epson), "Photo")
XCTAssertNil(value("MediaType", in: epson))
let canon = resolve(
overrides: TargetPrintOverrides(mediaType: "Photo"),
optionKeys: ["CNIJMediaType", "MediaType"])
XCTAssertEqual(value("CNIJMediaType", in: canon), "Photo")
XCTAssertNil(value("MediaType", in: canon))
}
/// Driver "no colour adjustment" bypass — Canon, Epson (CCor
/// preferred over CMat), Gutenprint — always unlocked and never
/// gated on `ppdUncorrectedPassthrough`.
func testDriverBypass() {
let canon = resolve(optionKeys: ["CNIJIntent2"])
XCTAssertEqual(value("CNIJIntent2", in: canon), "4")
XCTAssertFalse(canon.writes.first { $0.key == "CNIJIntent2" }!
.locked)
let epson = resolve(optionKeys: ["EPIJ_CCor", "EPIJ_CMat"])
XCTAssertEqual(value("EPIJ_CCor", in: epson), "0")
XCTAssertNil(value("EPIJ_CMat", in: epson))
let epsonMat = resolve(optionKeys: ["EPIJ_CMat"])
XCTAssertEqual(value("EPIJ_CMat", in: epsonMat), "3")
let gutenprint = resolve(optionKeys: ["StpColorCorrection"])
XCTAssertEqual(value("StpColorCorrection", in: gutenprint),
"Uncorrected")
XCTAssertNil(value("EPIJ_CMat", in: resolve()))
}
/// Orientation: landscape → 4, portrait → 3, nil → no write.
func testOrientation() {
XCTAssertEqual(
value("orientation-requested", in: resolve(
overrides: TargetPrintOverrides(
orientation: "landscape"))),
"4")
XCTAssertEqual(
value("orientation-requested", in: resolve(
overrides: TargetPrintOverrides(
orientation: "portrait"))),
"3")
XCTAssertNil(value("orientation-requested", in: resolve()))
}
/// A `nil` override emits no write for that key; quality and tray
/// need **both** the detected key and the value.
func testNilOverridesEmitNoWrite() {
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
XCTAssertNil(value("PageSize", in: r))
XCTAssertNil(value("MediaType", in: r))
XCTAssertNil(value("EPIJ_Qual", in: r))
XCTAssertNil(value("orientation-requested", in: r))
XCTAssertNil(r.paperToken)
XCTAssertNil(r.orientation)
let keyOnly = resolve(
overrides: TargetPrintOverrides(qualityKey: "EPIJ_Qual"),
optionKeys: ["EPIJ_Qual"])
XCTAssertNil(value("EPIJ_Qual", in: keyOnly))
let valueOnly = resolve(
overrides: TargetPrintOverrides(quality: "305"),
optionKeys: ["EPIJ_Qual"])
XCTAssertNil(value("EPIJ_Qual", in: valueOnly))
// An empty paper token emits no write and no paperToken.
let emptyPaper = resolve(
overrides: TargetPrintOverrides(paperSize: ""))
XCTAssertNil(value("PageSize", in: emptyPaper))
XCTAssertNil(emptyPaper.paperToken)
// Tray: key or value alone, or an empty value, emits no write.
let trayKeyOnly = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayKeyOnly))
let trayValueOnly = resolve(
overrides: TargetPrintOverrides(tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: trayValueOnly))
let emptyTray = resolve(
overrides: TargetPrintOverrides(trayKey: "EPIJ_FdSo", tray: ""),
optionKeys: ["EPIJ_FdSo"])
XCTAssertNil(value("EPIJ_FdSo", in: emptyTray))
}
/// #218 paper source is emitted with its canonical key and the raw
/// token; the display label must never reach the ticket.
func testTrayWrite() {
let r = resolve(
overrides: TargetPrintOverrides(
trayKey: "EPIJ_FdSo", tray: "Rear"),
optionKeys: ["EPIJ_FdSo"])
XCTAssertEqual(value("EPIJ_FdSo", in: r), "Rear")
XCTAssertEqual(r.writes.first { $0.key == "EPIJ_FdSo" }?.locked,
false)
XCTAssertNil(value("InputSlot", in: r))
}
/// The write list equals the locked 1–11 order, exactly.
func testLockedWriteOrder() {
let r = resolve(
overrides: TargetPrintOverrides(
paperSize: "A4", mediaType: "Photo",
qualityKey: "EPIJ_Qual", quality: "305",
trayKey: "EPIJ_FdSo", tray: "Rear",
orientation: "landscape"),
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
XCTAssertEqual(keys(r), [
"AP_ColorMatchingMode",
"AP.ColorMatchingMode",
"PMColorMatchingMode",
"PMCustomColorMatchingProfile",
"com.apple.print.PrintSettings.PMColorMatchingMode",
"PageSize",
"EPIJ_FdSo",
"MediaType",
"EPIJ_Qual",
"EPIJ_CMat",
"orientation-requested",
])
XCTAssertEqual(r.paperToken, "A4")
XCTAssertEqual(r.orientation, "landscape")
}
}
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# Mock lp for Milestone3UITests. Appends its full argv to
# ICCERY_TEST_LP_ARGV so the test can assert flag order and option
# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection).
{
printf 'lp'
for arg in "$@"; do printf ' %s' "$arg"; done
printf '\n'
} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}"
if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then
echo "mock lp failure" >&2
exit "$ICCERY_MOCK_LP_EXIT"
fi
exit 0
+6 -3
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# Mock lpoptions for Milestone3UITests. `-p <q>` prints printer-info;
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys.
# `-p <q> -l` prints Key/Label listings incl. Epson bypass keys and the
# recorded XP-55 EPIJ_Qual line — the driver's own non-sorted order
# (308 sits between 303 and 304, #180).
queue=""
list=0
for arg in "$@"; do
@@ -13,11 +15,12 @@ for arg in "$@"; do
done
if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n'
printf 'InputSlot/Media Source: Auto *Main Rear\n'
printf 'EPIJ_FdSo/Paper Source: Auto *Main Rear\n'
printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\n'
printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
printf 'ColorModel/Output Mode: *RGB Gray\n'
exit 0
fi
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue"
exit 0
+6
View File
@@ -12,6 +12,12 @@ case "$1" in
-d)
printf 'system default destination: Mock_Epson_7450\n'
;;
-v)
# Mock_Canon_Pro is the AirPrint fixture queue (#202): local
# unencrypted ipp:// resolved via the _universal mDNS subtype.
printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n'
printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n'
;;
*)
exit 1
;;
+14 -3
View File
@@ -1,6 +1,8 @@
#!/bin/sh
# Mock printtarg for Milestone2UITests. Writes one 2x2 TIFF, a pretty
# Mock printtarg for Milestone2UITests. Writes N 2x2 TIFFs, a pretty
# manifest on stdout, and a fake .ti2 next to the basename (last argv).
# N defaults to 1; ICCERY_MOCK_PRINTTARG_PAGES raises it for the
# single-spool-job granularity test (#201 D5).
# Exit code is overridable via ICCERY_MOCK_PRINTTARG_EXIT.
last=""
for arg in "$@"; do last="$arg"; done
@@ -8,7 +10,16 @@ if [ "${ICCERY_MOCK_PRINTTARG_EXIT:-0}" -ne 0 ]; then
echo "mock printtarg failure" >&2
exit "$ICCERY_MOCK_PRINTTARG_EXIT"
fi
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page1.tif"
printf '{\n "event": "manifest",\n "pages": [\n {"filename": "page1.tif", "patches": 4, "width_mm": 210, "height_mm": 297}\n ]\n}\n'
pages="${ICCERY_MOCK_PRINTTARG_PAGES:-1}"
i=1
printf '{\n "event": "manifest",\n "pages": [\n'
while [ "$i" -le "$pages" ]; do
echo 'SUkqAAgAAAAKAAABAwABAAAAAgAAAAEBAwABAAAAAgAAAAIBAwABAAAACAAAAAMBAwABAAAAAQAAAAYBAwABAAAAAQAAABEBBAABAAAAhgAAABUBAwABAAAAAQAAABYBAwABAAAAAgAAABcBBAABAAAABAAAABwBAwABAAAAAQAAAAAAAAA8eLTw' | /usr/bin/base64 -D > "page$i.tif"
sep=","
if [ "$i" -eq "$pages" ]; then sep=""; fi
printf ' {"filename": "page%s.tif", "patches": 4, "width_mm": 210, "height_mm": 297}%s\n' "$i" "$sep"
i=$((i + 1))
done
printf ' ]\n}\n'
touch "$last.ti2"
exit 0
@@ -0,0 +1,365 @@
import XCTest
/// Milestone 11 UI tests — issue #183 Stage 2 paper size + print
/// quality pickers. Runs against the same mock CUPS fixture binaries
/// as `Milestone3UITests`; the `NSPrintPanel` stays stubbed through
/// `ICCERY_TEST_PRINT_PANEL` (XCUITest cannot drive the system modal).
@MainActor
final class Milestone11PrintSettingsUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var spoolLogURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui11-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
spoolLogURL = testRoot.appendingPathComponent("spool.log")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id]
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
/// Drive the app through targen + printtarg so the print panel is
/// live with a manifest.
private func reachPrintPanel() {
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
_ = waitFor("btnCreateLayout", timeout: 25)
app.buttons["btnCreateLayout"].click()
_ = waitFor("galleryPage-0", timeout: 25)
}
private func spoolLogLines() -> [String] {
((try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? "")
.split(separator: "\n").map(String.init)
}
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let lines = spoolLogLines()
if !lines.isEmpty { return lines.joined(separator: "\n") }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return spoolLogLines().joined(separator: "\n")
}
/// Poll until the spool log holds at least `count` lines.
@discardableResult
private func waitForSpoolLineCount(
_ count: Int, timeout: TimeInterval = 15
) -> [String] {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let lines = spoolLogLines()
if lines.count >= count { return lines }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return spoolLogLines()
}
// MARK: - Tests
/// The paper-size and quality pickers exist with their new ids;
/// the existing tray / media / orientation ids are unchanged (#183).
func testPaperAndQualityPickersExist() throws {
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
XCTAssertTrue(element("printerPaperSizeSelect").exists)
XCTAssertTrue(element("printerQualitySelect").exists)
XCTAssertTrue(element("paperSizeGroup").exists)
XCTAssertTrue(element("qualityGroup").exists)
// Existing ids untouched. (`mediaTypeGroup` is not asserted —
// stacked `.accessibilityIdentifier` modifiers collapse to the
// last one, so it never resolved even before this change.)
XCTAssertTrue(element("printerSelect").exists)
XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnPrinterProperties").exists)
}
/// The displayed selection of a picker — `AXTitle` for a popup
/// button, falling back to label/value depending on how AppKit
/// exposes the current item.
private func selection(of id: String) -> String {
let el = element(id)
for candidate in [el.title, el.label, el.value as? String ?? ""] {
if !candidate.isEmpty, candidate != el.identifier {
return candidate
}
}
return el.title
}
/// The paper picker seeds from Stage 1's `pageSize` (A4 default)
/// and the quality picker from the driver's `*` default choice.
func testPickersSeedFromStage1AndDriverDefault() throws {
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
_ = waitFor("printerPaperSizeSelect")
_ = waitFor("printerQualitySelect")
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "A4")
XCTAssertEqual(selection(of: "printerQualitySelect"), "303")
}
/// #180 — the quality picker lists all seven Epson `EPIJ_Qual`
/// codes in the driver's own order (308 between 303 and 304); no
/// PPD is injected under UI testing so items show raw tokens.
func testQualityPickerListsAllSevenDriverOptions() throws {
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
let picker = app.popUpButtons["printerQualitySelect"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
picker.click()
let expected = ["301", "302", "303", "308", "304", "305", "307"]
for token in expected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
let titles = app.menuItems.allElementsBoundByIndex
.map(\.title)
.filter { expected.contains($0) }
XCTAssertEqual(titles, expected)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
}
/// The stubbed panel result's captured `PageSize=`/`EPIJ_Qual=`
/// apply back into the Stage 2 pickers and reach the recorded
/// ticket writes (#201 — the real modal is never driven).
func testPanelResultAppliesBackToPickers() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"PageSize=Letter EPIJ_Qual=305"
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
element("btnPrinterProperties").click()
let notice = element("printNotificationText")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Settings captured"))
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
XCTAssertEqual(selection(of: "printerQualitySelect"), "305")
app.buttons["btnPrintAll"].click()
let log = waitForSpoolLine()
XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
}
/// #186 — the stubbed panel result's `orientation-requested=` /
/// `MediaType=` apply back to the Stage 2 selections and reach the
/// recorded ticket writes via the Stage 2 overrides (#201 D6).
func testPanelResultAppliesBackOrientationAndMedia() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
element("btnPrinterProperties").click()
let notice = element("printNotificationText")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Settings captured"))
// `printerMediaTypeSelect` is the group's id — the popup is a
// descendant (stacked identifiers collapse to the container).
// The popup's AX title lags the binding — poll for the
// apply-back value.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
var mediaSelection = ""
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
mediaSelection = [
mediaPopup.title, mediaPopup.label,
mediaPopup.value as? String ?? "",
].first { !$0.isEmpty } ?? ""
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
app.buttons["btnPrintAll"].click()
let log = waitForSpoolLine()
XCTAssertTrue(log.contains("orientation-requested=4"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
}
/// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via
/// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the
/// media's allowed set; switching media re-filters and clamps the
/// selection.
func testQualityPickerFiltersByMediaConstraints() throws {
let ppdDir = testRoot.appendingPathComponent("ppd")
let epsonRoot = testRoot.appendingPathComponent("epson-driver")
let datDir = epsonRoot.appendingPathComponent(
"Machine/M.data/Contents/Resources")
try FileManager.default.createDirectory(
at: ppdDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(
at: datDir, withIntermediateDirectories: true)
try """
*EPIJDriverBasePath: "\(epsonRoot.path)"
*EPIJMachineBundleName: "M.data"
""".write(
to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"),
atomically: true, encoding: .utf8)
try """
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304
""".write(
to: datDir.appendingPathComponent("PDEData.dat"),
atomically: true, encoding: .utf8)
app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
let qualityPopup = app.popUpButtons["printerQualitySelect"]
XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10))
// Default media Stationery → {301,302,303,304} only.
qualityPopup.click()
let stationeryExpected = ["301", "302", "303", "304"]
for token in stationeryExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
XCTAssertFalse(app.menuItems["307"].exists)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// Switch to PhotographicGlossy → {305,307,308} in driver
// order (308 first — lpoptions order), selection clamped.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
mediaPopup.click()
let glossyItem = app.menuItems["PhotographicGlossy"]
XCTAssertTrue(glossyItem.waitForExistence(timeout: 5))
glossyItem.click()
qualityPopup.click()
let glossyExpected = ["308", "305", "307"]
for token in glossyExpected {
XCTAssertTrue(
app.menuItems[token].waitForExistence(timeout: 5),
"Missing quality menu item \(token)")
}
let titles = app.menuItems.allElementsBoundByIndex
.map(\.title)
.filter { glossyExpected.contains($0) }
XCTAssertEqual(titles, glossyExpected)
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
// The stale pick (303) clamped to the first allowed token.
let deadline = Date().addingTimeInterval(5)
while Date() < deadline,
selection(of: "printerQualitySelect") != "308" {
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(selection(of: "printerQualitySelect"), "308")
}
/// #201 D5 — per-page mode records one line per page; the
/// `chkSingleSpoolJob` toggle collapses the job into a single
/// request logged once with `pages=N`.
func testSingleSpoolJobTogglesGranularity() throws {
app.launchEnvironment["ICCERY_MOCK_PRINTTARG_PAGES"] = "2"
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
XCTAssertTrue(element("chkSingleSpoolJob").exists)
// Default off → one request per page → one line per page.
app.buttons["btnPrintAll"].click()
var lines = waitForSpoolLineCount(2)
XCTAssertEqual(lines.count, 2)
XCTAssertTrue(lines.allSatisfy { $0.contains("pages=1") },
lines.joined(separator: "\n"))
// Toggle on → one request for all pages → a single line.
element("chkSingleSpoolJob").click()
app.buttons["btnPrintAll"].click()
lines = waitForSpoolLineCount(3)
XCTAssertEqual(lines.count, 3)
XCTAssertEqual(
lines.filter { $0.contains("pages=2") }.count, 1,
lines.joined(separator: "\n"))
}
private func launchAppWithDefaults() {
app.launch()
app.activate()
}
}
@@ -0,0 +1,132 @@
import XCTest
/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock
/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted
/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS
/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster
/// queue — so the badge must track the picker selection.
@MainActor
final class Milestone12AirPrintUITests: XCTestCase {
private var app: XCUIApplication!
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var spoolLogURL: URL!
override func setUp() async throws {
continueAfterFailure = false
testRoot = FileManager.default.temporaryDirectory
.appendingPathComponent("iccery-ui12-\(UUID().uuidString)")
binDir = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
spoolLogURL = testRoot.appendingPathComponent("spool.log")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
app = XCUIApplication()
app.launchEnvironment = [
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_ROOT": testRoot.path,
"ICCERY_ARGYLL_BINARY_DIR": binDir.path,
"ICCERY_CUPS_BIN_DIR": binDir.path,
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
]
}
override func tearDown() async throws {
app?.terminate()
app = nil
if let testRoot {
try? FileManager.default.removeItem(at: testRoot)
}
testRoot = nil
}
private func element(_ id: String) -> XCUIElement {
let inApp = app.descendants(matching: .any)[id]
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id]
}
private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let el = element(id)
if el.exists { return el }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
let el = element(id)
XCTAssertTrue(el.exists, "Expected element \(id)")
return el
}
/// Poll until `id` no longer resolves — the badge is conditionally
/// rendered, so absence is only meaningful after a settle window.
private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if !element(id).exists { return }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
XCTAssertFalse(element(id).exists, "Element \(id) should be absent")
}
/// Drive the app through targen + printtarg so the print panel is
/// live with a manifest.
private func reachPrintPanel() {
app.buttons["btnBrowse"].click()
app.buttons["btnGenerate"].click()
_ = waitFor("btnCreateLayout", timeout: 25)
app.buttons["btnCreateLayout"].click()
_ = waitFor("galleryPage-0", timeout: 25)
}
/// Select the printer-picker menu item whose title contains
/// `needle` (display names come from the `lpoptions` fixture).
private func selectPrinter(containing needle: String) {
let picker = app.popUpButtons["printerSelect"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
picker.click()
let item = app.menuItems
.matching(NSPredicate(format: "title CONTAINS %@", needle))
.firstMatch
XCTAssertTrue(item.waitForExistence(timeout: 5),
"No printer menu item containing \(needle)")
item.click()
}
// MARK: - Tests
/// The badge appears while the AirPrint fixture queue is selected
/// and is absent for the USB Epson — in both directions.
func testAirPrintBadgeTracksSelectedQueue() throws {
app.launch()
app.activate()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
// Default queue is the USB Epson — no badge.
waitForAbsence("airPrintWarningBadge")
selectPrinter(containing: "Canon")
let badge = element("airPrintWarningBadge")
XCTAssertTrue(badge.waitForExistence(timeout: 10))
// StaticText exposes its content via AXValue, not the label.
let badgeText = [badge.value as? String, badge.label, badge.title]
.compactMap { $0 }
.first { !$0.isEmpty } ?? ""
XCTAssertTrue(
badgeText.contains(
"AirPrint queue — unmanaged colour cannot be guaranteed."),
"Unexpected badge text: \(badgeText)")
selectPrinter(containing: "Epson")
waitForAbsence("airPrintWarningBadge")
}
}
+59 -67
View File
@@ -3,9 +3,10 @@ import XCTest
/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock
/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a
/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL`
/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends
/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the
/// evidence that captured options are replayed (docs/11 §tests).
/// returns a canned `PrintPropertiesResult` instead. The DEBUG
/// `RecordingTargetSpooler` appends one resolved-ticket line per
/// request to `ICCERY_TEST_SPOOL_LOG` (#201 D8) — that file is the
/// evidence that the Stage 2 selections reach the spool.
@MainActor
final class Milestone3UITests: XCTestCase {
@@ -13,7 +14,7 @@ final class Milestone3UITests: XCTestCase {
private var testRoot: URL!
private var binDir: URL!
private var workDir: URL!
private var lpArgvURL: URL!
private var spoolLogURL: URL!
override func setUp() async throws {
continueAfterFailure = false
@@ -23,7 +24,7 @@ final class Milestone3UITests: XCTestCase {
.deletingLastPathComponent()
.appendingPathComponent("Fixtures/bin")
workDir = testRoot.appendingPathComponent("work")
lpArgvURL = testRoot.appendingPathComponent("lp-argv.log")
spoolLogURL = testRoot.appendingPathComponent("spool.log")
try FileManager.default.createDirectory(
at: workDir, withIntermediateDirectories: true)
@@ -36,7 +37,7 @@ final class Milestone3UITests: XCTestCase {
"ICCERY_TEST_SAVE_TARGET":
workDir.appendingPathComponent("mytarget.ti1").path,
"ICCERY_TEST_WORKDIR": workDir.path,
"ICCERY_TEST_LP_ARGV": lpArgvURL.path,
"ICCERY_TEST_SPOOL_LOG": spoolLogURL.path,
]
}
@@ -99,32 +100,32 @@ final class Milestone3UITests: XCTestCase {
_ = waitFor("galleryPage-0", timeout: 25)
}
private func recordedLpArgv() -> String {
(try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? ""
private func recordedSpoolLog() -> String {
(try? String(contentsOf: spoolLogURL, encoding: .utf8)) ?? ""
}
private func waitForLpLine(_ timeout: TimeInterval = 10) -> String {
private func waitForSpoolLine(_ timeout: TimeInterval = 10) -> String {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let out = recordedLpArgv()
let out = recordedSpoolLog()
if !out.isEmpty { return out }
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return recordedLpArgv()
return recordedSpoolLog()
}
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
/// moves up, clear of the Dock collision zone at the window's
/// bottom edge (#132).
/// Scrolls `stage-2` with the synthesized scroll wheel so
/// `identifier`'s button moves up, clear of the Dock collision
/// zone at the window's bottom edge (#132).
///
/// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
/// this LazyVGrid, so the scroll is a real drag on the gallery
/// cell's content. A stale/off-screen AX frame resolves to a screen
/// point that can be a Dock icon — a coordinate click there once
/// opened Calendar instead of Print. Callers must click only when
/// the returned element `isHittable`; never coordinate-click a
/// stale frame.
/// `app.scrollBars` — and a click-drag does not scroll a macOS
/// ScrollView (content-drag scrolling is iOS-only); the scroll
/// wheel is the mechanism the platform supports (#215). A
/// stale/off-screen AX frame resolves to a screen point that can
/// be a Dock icon — a coordinate click there once opened Calendar
/// instead of Print. Callers must click only when the returned
/// element `isHittable`; never coordinate-click a stale frame.
@discardableResult
private func scrollStage2UntilHittable(
_ identifier: String,
@@ -133,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
let scrollView = app.scrollViews["stage-2"]
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
@@ -141,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
button.frame.maxY < windowBottom - 80 {
return button
}
// Grab the upper half of the cell (the TIFF, not the Print
// button / Dock) and drag toward the top of the window.
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
// the Dock zone.
if cell.isHittable {
let start = cell.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.25))
let end = start.withOffset(CGVector(dx: 0, dy: -280))
start.press(forDuration: 0.15, thenDragTo: end)
} else {
// Cell not hit-testable: drag the stage-2 content
// directly — still content, still never scrollBars.
let scrollView = app.scrollViews["stage-2"]
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55))
.press(forDuration: 0.15, thenDragTo:
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
// Wheel-down inside the stage-2 viewport: content moves
// UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
// toward the document bottom (#215).
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.5))
.scroll(byDeltaX: 0, deltaY: -60)
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier]
}
@@ -182,8 +172,7 @@ final class Milestone3UITests: XCTestCase {
.waitForExistence(timeout: 10))
XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
}
@@ -203,13 +192,16 @@ final class Milestone3UITests: XCTestCase {
XCTAssertEqual(element("printNotificationIcon").value as? String, "info")
}
/// Preferences OK → captured options are replayed verbatim in the
/// `lp` argv alongside the two mandatory AP_* headers (issue 17's
/// acceptance test: "captured options replayed in argv").
func testCapturedOptionsReplayedInLpArgv() throws {
/// Preferences OK → the captured media selection applies back to
/// Stage 2 and reaches the recorded ticket writes alongside the
/// mandatory colour keys (issue 17's acceptance test, ported to
/// the native spool seam in #201). The captured `InputSlot` is no
/// longer replayed — captured vendor state lives inside the
/// `PrintTicket`, which the stub deliberately does not produce.
func testPanelSelectionsApplyBackToSpoolWrites() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"InputSlot=Rear MediaType=PhotographicGlossy"
"EPIJ_FdSo=Rear MediaType=PhotographicGlossy"
launchApp()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
@@ -221,20 +213,18 @@ final class Milestone3UITests: XCTestCase {
.contains("Settings captured"))
app.buttons["btnPrintAll"].click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv)
XCTAssertTrue(argv.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv)
XCTAssertTrue(argv.contains("InputSlot=Rear"), argv)
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
let log = waitForSpoolLine()
XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log)
// Detected bypass for the mock queue (EPIJ_CMat present in
// lpoptions -l) is appended when not captured.
XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv)
XCTAssertTrue(argv.contains("orientation-requested=3"), argv)
// Last token is the TIFF.
XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines)
.hasSuffix("page1.tif"), argv)
// lpoptions -l) is always written.
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
XCTAssertTrue(log.contains("orientation-requested=3"), log)
XCTAssertTrue(log.contains("page=page1.tif"), log)
}
/// Per-page print uses the same spool path (btnPrintPage-N).
@@ -262,14 +252,16 @@ final class Milestone3UITests: XCTestCase {
return
}
printPage.click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv)
XCTAssertTrue(argv.contains("page1.tif"), argv)
let log = waitForSpoolLine()
XCTAssertTrue(log.contains("AP_ColorMatchingMode"), log)
XCTAssertTrue(log.contains("page=page1.tif"), log)
}
/// lp failure surfaces in the in-panel notice, not the wizard banner.
func testLpFailureShowsPrintNotice() throws {
app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1"
/// A spool failure surfaces in the in-panel notice, not the
/// wizard banner (`ICCERY_TEST_SPOOL_FAIL` makes the recording
/// spooler throw `TargetSpoolError.operationFailed`).
func testSpoolFailureShowsPrintNotice() throws {
app.launchEnvironment["ICCERY_TEST_SPOOL_FAIL"] = "1"
launchApp()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
@@ -290,7 +282,7 @@ final class Milestone3UITests: XCTestCase {
_ = waitFor("printerStatusBadge")
app.buttons["btnPrintAll"].click()
_ = waitForLpLine()
_ = waitForSpoolLine()
let stateURL = testRoot
.appendingPathComponent("AppData")
.appendingPathComponent("wizard_state.json")
+8 -4
View File
@@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Meaning |
|-------|------|---------|
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix: 1-based index of the `InputSlot` / `MediaSource` value (not a PPD code). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). |
| `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. |
| `token` | `String` | **v2** (#218): the exact CUPS/PPD machine value (`Auto`, `Rear`, `1`, ...). |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix/macOS: PPD-enriched human label; if no PPD label is available, the raw token is used for display. |
### `PrinterPaperSize` (mod.rs:22–25)
@@ -71,11 +72,14 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| `media_types` | `Vec<PrinterMediaType>` | `#[serde(default)]`. Frontend fills `#printerMediaTypeSelect`. |
| `supports_orientation` | `bool` | Always `true` on both Windows and Unix. |
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq`
### `PrintOptions` (mod.rs:42–55) — `Default` + `PartialEq + Eq` — historical v1
> The `lp`/`-o` columns below describe the v0.8.5 spool contract. v2.0
> replaced the macOS `lp` path with a headless `NSPrintOperation` (#201).
| Field | Type | Windows | Linux | macOS |
|-------|------|---------|-------|-------|
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | **ignored** unless present in captured `cups_options` |
| `paper_source` | `Option<u16>` | `dmDefaultSource` (tray id) | **ignored** (unix `build_lp_args` never emits `InputSlot`) | v2 (#218): 1-based tray id driven by the Stage 2 picker; written to the detected tray key (`InputSlot`/`MediaSource`/`EPIJ_FdSo`/`EPIJ_PSrc`/`CNIJMediaSupply`) on the native ticket. |
| `orientation` | `Option<String>` | `"landscape"` → `DMORIENT_LANDSCAPE` (2), else portrait (1) | `-o orientation-requested=4` (landscape) or `=3` (portrait) | same, skipped if `cups_options` already has the key |
| `paper_size` | `Option<String>` | **ignored** | `-o PageSize=<value>` | same, skipped if `pagesize` already added |
| `media_type` | `Option<String>` | parsed as `u32` → `dmMediaType` | `-o MediaType=<value>` (generic key only) | uses `detect_media_type_key` (`CNIJMediaType` / `EPIJ_Medi` / `StpMediaType` / `MediaType`); skipped if any of those keys already in `cups_options` |
+98 -49
View File
@@ -19,7 +19,7 @@ This is the most implementation-sensitive chapter. A rewrite that opens System S
- `NSWorkspace` open of the printer
- `lpoptions` GUI
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later `lp` invocation.
Linux/Windows do not share this panel. Default button title is **"Use Settings"** (macos.rs:439) — this is a settings-capture dialog, not a print-now dialog. Actual spooling is a later, separate step — in v2.0 a headless `NSPrintOperation` (#201; v1 used `lp`).
Must run on the Cocoa main thread. `show_printer_properties` (macos.rs:689-710):
@@ -68,34 +68,31 @@ if pm_printer was created from ID:
`PMPrinter` from `PMPrinterCreateFromPrinterID` is released with `PMRelease` on all exit paths (cancel, error, success). The NSPrinter fallback path leaves `pm_printer` null so no release.
### ColorSync suppression strategy — UI click to `lp`
### ColorSync suppression strategy — UI click to `NSPrintOperation`
End-to-end, **six independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS.
End-to-end, **seven independent layers**. All of them exist because no single Apple API is sufficient across Epson PDE / Canon PDE / `cgpdftoraster` / CUPS. v2.0 (#201) replaced the `lp` tail with a headless `NSPrintOperation` that replays a captured `PMPrintSettings`/`PMPageFormat` ticket — layer ⑦ is new.
```
[Preferences click]
show_printer_properties
run_on_main_thread
run_native_print_panel
① PMSessionSetCurrentPMPrinter bind queue
PrintSessionViewModel.openPrinterPreferences
PrintPanelService.showProperties
runNativePanel
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter bind queue
② set_session_color_matching_mode SPI gray out PDE Color Matching
③ PMPrintSettingsSetValue AP_ColorMatchingMode + dotted
④ detect_driver_color_bypass → SetValue pre-select Canon/Epson/Gutenprint "off"
⑤ NSPrintInfo.printSettings dictionary same keys for AppKit PDEs
NSPrintPanel.runModalWithPrintInfo
⑤′ ColorSyncSuppressor.applyQuartzMode PMColorMatchingMode + legacy + nested mirror
NSPrintPanel.runModal("Use Settings")
user picks media / quality (color locked)
⑥ PMPrintSettingsToOptions → filter → PrintPropertiesResult
⑥ PMPrintSettingsToOptions → filter → Stage 2 mirror
⑦ PMTicketBridge.serialise(printInfo) → PrintTicket ticket capture
[frontend]
capturedCupsOptions[printer] = cups_options
capturedCupsOptions[queue] = mirror + capturedTickets[queue] = ticket
[Print Target]
print_target_native → macos::print_target → build_lp_args
ALWAYS -o AP_ColorMatchingMode=AP_ApplicationColorMatching
ALWAYS -o AP.ColorMatchingMode=AP_ApplicationColorMatching
THEN captured cups_options as -o k=v
THEN media_type if not already present (detected key)
THEN detect_driver_color_bypass if no color-bypass key yet
THEN orientation / PageSize if not already present
lp -d <queue> -t "ICCery Target - …" … <tiff>
PrintSessionViewModel → TargetPrintRequest → NativeTargetSpooler.spool
restore ticket → Stage 2 writes → NSPrintOperation.runOperation
(S1–S14 below — 1:1, device colour space, panels off)
```
Linux uses `-o raw` instead of AP_* flags. macOS **does not** use `-o raw`: a raw queue would skip the raster filter that actually understands `AP_ColorMatchingMode`. The macOS strategy is "tell the filter the application already matched color", not "skip the filter".
@@ -161,7 +158,7 @@ AGENTS.md:118, macos.rs:82-88:
| `AP_ColorSyncMatching` | **Avoided.** ColorSync applies the printer/display profile. Patches become color-managed. |
| `AP_VendorColorMatching` | **Avoided.** Epson/Canon driver color engine (ICM inside the PDE). Same corruption. |
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. Do not mix the two dictionaries.
ICCery-CPU uses a **different** vocabulary (`APCustomColorMatching` / `APColorSync` / `APPrinterExtension` on `PMColorMatchingMode`). See the CPU section. v1 kept the two dictionaries on separate paths (`lp` vs Quartz); v2.0 (#201, D2) has a single native path that writes **both** vocabularies — layer ⑤′ below.
### Layer ③ — `PMPrintSettingsSetValue` (macos.rs:357-375)
@@ -204,6 +201,18 @@ print_settings.insert(<bypass_key>, <bypass_val>)
`NSString` is transmuted to `&AnyObject` for the dictionary (`macos.rs:414-416`).
### Layer ⑤′ — Quartz vocabulary (v2.0, #201 D2)
With `lp` gone there is a single native path, and it carries **both**
dictionaries. `ColorSyncSuppressor.applyQuartzMode` additionally writes the
Quartz/`NSPrintOperation` vocabulary (docs/14 §7):
- `PMColorMatchingMode` = `APCustomColorMatching`
- `PMCustomColorMatchingProfile` = `""`
- `com.apple.print.PrintSettings.PMColorMatchingMode` (legacy)
- the same keys inside the nested `com.apple.print.printSettings`
sub-dictionary of `printInfo.dictionary()`
### Panel options (macos.rs:434-449)
```
@@ -227,17 +236,36 @@ After OK:
4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string` → `cups_options`.
6. `extract_media_type_from_options` → `media_type`.
7. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
7. `extract_tray_from_options` → the selected tray token and a 1-based `paper_source` id when it matches the roster.
8. Return `PrintPropertiesResult { selected_printer, options: PrintOptions { media_type, cups_options, paper_source, ppd_uncorrected_passthrough: Some(true), ..Default } }`.
Failure of `PMPrintSettingsToOptions` is a hard `Err`.
### Layer ⑦ — ticket serialise/restore (v2.0, #201 D3)
The layer-⑥ flattening is what lost the ticket (#201 root cause 1): it kept
only a `key=value` string and `CupsOptionsFilter` drops every `com.apple.*`
key, so `com.apple.print.PrintSettings` never survived. After the layer-⑥
capture, `PMTicketBridge.serialise(printInfo, queue:)` now snapshots the
whole ticket:
1. `PMPrintSettingsCreateDataRepresentation(settings, &data, kPMDataFormatXMLDefault)` → `PrintTicket.printSettings`.
2. `PMPageFormatCreateDataRepresentation` → `PrintTicket.pageFormat`.
3. Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered → `PrintTicket.printInfoPlist` — fallback only, never the primary restore path.
`PrintTicket` is in-memory, session-only, keyed by queue. On spool,
`PMTicketBridge.restore` replays it into the job's `NSPrintInfo`:
`PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` →
`updateFromPM*`. A ticket captured for queue A is never replayed onto
queue B.
### `RELEVANT_CUPS_OPTION_KEYS` (macos.rs:142-174)
Forwarded from the panel to `lp`:
Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot
Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply
Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
@@ -255,31 +283,39 @@ Duplex: Duplex, sides
- Drops `collate`, `copies`, `pserrorhandler-requested`, `job-sheets`
- **Keeps unknown non-`com.*` keys** (permissive: unknown driver keys survive)
### `build_lp_args` (macos.rs:518-643)
### Native spool — S1–S14 (v2.0, #201)
Always, even with `options=None`:
`NativeTargetSpooler.spool(_:)` (app target, `Sources/ICCery/Print/` — `ICCeryCore` stays AppKit-free, D1). One `TargetPrintRequest` per page, or one per run when `singleJobForAllPages` is on (D5):
```
lp -d <printer> -t "ICCery Target - <filename>"
-o AP_ColorMatchingMode=AP_ApplicationColorMatching
-o AP.ColorMatchingMode=AP_ApplicationColorMatching
… captured / detected options …
<tiff_path>
S1 NSPrintInfo()
S2 printInfo.printer = NSPrinter(name: queue) ?? NSPrinter(name: displayName) [best effort]
S3 PMTicketBridge.makePrinter(queue:) → bind(printer:to:) ① + PMSessionDefault*
S4 ticket != nil → PMTicketBridge.restore(ticket, into: printInfo) ⑦′
S5 TicketWriteResolver.resolve(...) → apply to PMPrintSettings + mirror dict ③④⑤+D2
S6 paper override → PMTicketBridge.applyPaper(token:…)
S7 orientation → printInfo.orientation + orientation-requested
S8 suppressor.applySPIMode(session) ②
S9 Cocoa geometry: margins 0, pagination .clip, scaling 1.0, centering off,
jobDisposition .spool (or .save + jobSavingURL under the PDF harness)
S10 TargetRasterLoader.load(each page) → device-tagged CGImage + pointSize
S11 TargetPageCanvasView(pages:paperSize: printInfo.paperSize)
S12 NSPrintOperation(view:printInfo:) — panels off, jobTitle set
S13 operation.runOperation() → false ⇒ throw TargetSpoolError.operationFailed
S14 PMRelease the printer on every path (defer)
```
Order after the two AP_* flags:
1. Parse `opts.cups_options` into `-o k=v`, record lowercased keys in `added_keys`.
2. If `media_type` set and none of `mediatype` / `cnijmediatype` / `epij_medi` / `stpmediatype` already added: `detect_media_type_key(lpoptions)` and add it.
3. If no color-bypass key yet (`cnijintent2`, `cnijintent`, `epij_cmat`, `epij_ccor`, `epij_oscolmat`, `colorcorrection`, `stpcolorcorrection`, `epsoncolormode`): `detect_driver_color_bypass` and add. **Not gated on `ppd_uncorrected_passthrough`.**
4. Orientation → `orientation-requested=4|3` unless already present.
5. `PageSize=` unless `pagesize` already present.
`ppd_uncorrected_passthrough` is stored from the panel but **does not change macOS lp flags**. There is no `-o raw` on macOS.
### `print_target` (macos.rs:646-677)
Exists-check, `build_lp_args`, `Command::new("lp").args(&args).output()`. Error wrapping same pattern as Unix (`"macOS CUPS print job failed: …"`).
`TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
`PageSize` → the detected tray key → the detected media key → the detected
quality key → the driver colour bypass → `orientation-requested`. Stage 2
overrides **always win**
over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
`raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
false); a multi-page job is one `TargetPageCanvasView` driven by
`knowsPageRange` / `rectForPage`, drawing each page 1:1 top-left anchored
with interpolation and antialiasing disabled (D9, docs/14 §6).
### Cancellation as `None`
@@ -295,19 +331,26 @@ The rewrite:
- `display_name` fallback for `NSPrinter::printerWithName`.
- Private SPI to lock Color Matching.
- Dual AP_* keys (underscore + dotted).
- Driver-specific PPD bypass pre-selected and re-applied on `lp`.
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions`.
- Driver-specific PPD bypass pre-selected in the panel and re-applied on the spool ticket (v1 re-applied it on `lp`).
- Capture via `PMPrintSettingsToOptions` into `capturedCupsOptions` (Stage 2 mirror) plus the `PrintTicket` serialise (layer ⑦, v2.0).
`PMPrinter` lifetime is explicit `PMRelease` on every path. SPI is `dlsym`'d so missing symbols on old OS X do not prevent launch. Panel **must** be main-thread (`MainThreadMarker::new().ok_or("Print panel must be invoked on the main thread")`).
### macOS tests (macos.rs:712-863 + tests.rs:278-318)
### macOS tests — historical v1 (macos.rs:712-863 + tests.rs:278-318)
- Filter drops `com.apple.*`, `collate`, `copies`, `AP_ColorMatchingMode`, empty `AP_D_InputSlot`; keeps `MediaType`, `EPIJ_CMat`, `PageSize`, `CNIJIntent2`, `ColorCorrection`.
- `extract_media_type_from_options` prefers `MediaType` then `EPIJ_Medi`.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path.
- `build_lp_args` always contains both AP_* flags; captured options win over explicit `media_type` / orientation / auto color-bypass; last arg is the TIFF path. (**Historical v1** — the captured-wins behaviour recorded here is #201 root cause 2; v2.0 inverts it: Stage 2 always wins, D6.)
- `detect_driver_color_bypass` Canon `4`, Epson `3`, Gutenprint `Uncorrected`.
- Missing TIFF errors.
v2.0 replacements (`TicketWriteResolverTests`, `PrintTicketTests`,
`TargetRasterTests`, `TargetCanvasGeometryTests`, `NativeSpoolPDFTests`):
both AP_* keys locked + all three Quartz keys always present; Stage 2
always wins (D6); `raw` never emitted; ticket serialise/restore
round-trips bytes; 1:1 geometry and draw flags asserted against a real
`.save`-to-PDF `NSPrintOperation`.
---
## ColorSync suppression — complete key/SPI/flag roster
@@ -338,7 +381,12 @@ PMRelease
AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPanelOptions::all` + `ShowsPageSetupAccessory`.
### CUPS / lp flags
### CUPS / lp flags — historical v1
> No `lp` invocation exists on the v2.0 target-print path (#201 — `LpArgs`,
> `CupsService.printTarget` and the `lp` fixture are deleted). This table
> records the v1 `lp -o` contract for reference only; the live write list is
> `TicketWriteResolver`'s locked order (§native spool above).
| Flag | Platform | When |
|------|----------|------|
@@ -361,8 +409,9 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan
### PPD keys (detection)
```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, PageSize, MediaSize
Tray: InputSlot, MediaSource, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply (EPIJ_FdSo preferred over EPIJ_PSrc when both present)
Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
```
---
+4 -1
View File
@@ -53,7 +53,10 @@ First-match order:
`CNIJMediaType` > `EPIJ_Medi` > `StpMediaType` > `MediaType`.
### Linux `build_lp_args` / `print_target` (unix.rs:362-457)
### Linux `build_lp_args` / `print_target` (unix.rs:362-457) — historical v1
> v0.8.5 Linux spool contract, kept for reference. ICCery v2 is macOS-only
> and its native path spools via `NSPrintOperation`, not `lp` (#201).
```
lp -d <printer> -t "ICCery Target - <filename>"
+10 -10
View File
@@ -8,7 +8,7 @@ Separate native macOS AppKit app. Spec: `/tmp/ICCery-CPU/SPEC.md`. Binary name `
### Why it exists
ICCery's Tauri path spools TIFF via `lp` and never goes through Quartz. That is correct for "don't let ColorSync touch the file", but:
ICCery's Tauri path spooled TIFF via `lp` and never went through Quartz (v1; v2.0 spools via a headless `NSPrintOperation` in ICCery proper — #201). That is correct for "don't let ColorSync touch the file", but:
- No 1:1 physical-size preview
- Windows-style `StretchDIBits` scaler (macOS `lp` may still scale inside the filter)
@@ -100,7 +100,7 @@ Injected into:
- `printInfo.dictionary()["com.apple.print.PrintSettings.PMColorMatchingMode"]` (legacy)
- nested `com.apple.print.printSettings` dictionary, same keys
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. ICCery talks to the CUPS `lp` ticket / `cgpdftoraster`. A rewrite that unifies them must keep both vocabularies or prove one is honored on both paths.
**This is not `AP_ApplicationColorMatching`.** TargetPrint talks to Quartz/`NSPrintOperation`. v1 ICCery talked to the CUPS `lp` ticket / `cgpdftoraster`; a rewrite that unifies them must keep both vocabularies or prove one is honored on both paths. **v2.0 (#201, D2) resolved this:** the Quartz vocabulary in this section is now live in ICCery proper — `ColorSyncSuppressor.applyQuartzMode` / `TicketWriteResolver` write `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, the legacy `com.apple.print.PrintSettings.PMColorMatchingMode` and the nested `com.apple.print.printSettings` mirror **alongside** the locked AP_* keys on the single native spool path.
Panel policy (`ColorMatching.configurePanel`):
@@ -125,7 +125,7 @@ static inline const char *TPCupsGetPPD(const char *name) {
- AirPrint (SPEC §10.2) if any of: URI `apple-airprint://`; PPD `*APAirPrint: True`; make Apple + model contains AirPrint; `ipps://` **and** PPD text contains `airprint`. Persistent warning badge; unmanaged color cannot be trusted. Tests in `AirPrintTests.swift`.
- Vendor bypass (SPEC §10.3) — **different keys from ICCery's lpoptions detector:**
| Vendor | TargetPrint keys | ICCery macOS `lp` keys |
| Vendor | TargetPrint keys | ICCery macOS `lpoptions`-detected keys (v1: `lp -o`) |
|--------|------------------|------------------------|
| Epson | `ColorModel=RGB`, `EPSONColorControls=Off` | `EPIJ_CMat=3` / `EPIJ_CCor=0` / `EpsonColorMode=Off` |
| Canon | `CNColorMatching=None` | `CNIJIntent2=4` / `CNIJIntent=4` |
@@ -176,9 +176,9 @@ Command::new("/Applications/TargetPrint.app/Contents/MacOS/TargetPrint")
CI publishes `vendor-iccery.zip` with `macos-x86_64` / `macos-aarch64` / `macos-universal` app bundles to drop into `src-tauri/targetprint/`.
Suggested ICCery integration:
Suggested ICCery integration (**superseded** — v2.0 took option 2's rendering model in-process instead; #201 adopted Quartz/`NSPrintOperation` spooling inside ICCery proper and removed `lp` entirely. The `--job` companion-app contract remains the v2.1 plan for `ICCeryPrintKit`, #16):
1. Keep current `lp` path as the headless/fast path (and the only path on Linux).
1. ~~Keep current `lp` path as the headless/fast path (and the only path on Linux).~~
2. On macOS, Preferences / Print can spawn TargetPrint with a `TargetJob` built from `PrintOptions` + TIFF list + `forceUnmanagedColor: true` + `lockColorManagement: true`.
3. Do not `CREATE_NO_WINDOW` (macOS); do not `wait()`. Cleanup of the JSON is ICCery's job after process exit, or leave in `/tmp` as an audit trail (SPEC §13).
@@ -186,14 +186,14 @@ Suggested ICCery integration:
| Concern | ICCery `macos.rs` | TargetPrint | Rewrite recommendation |
|---------|-------------------|-------------|------------------------|
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Keep `lp` for unattended; TargetPrint for preview+panel |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** if using NSPrintOperation; keep AP_* on `lp` |
| Spool | `lp` TIFF | Quartz `NSPrintOperation` | Quartz `NSPrintOperation` — **adopted in v2.0 via #201** (`NativeTargetSpooler`; `lp` removed from the target-print path) |
| ColorSync ticket | `AP_ApplicationColorMatching` (+ dotted) | `PMColorMatchingMode=APCustomColorMatching` | Set **both** — **adopted in v2.0 via #201** (D2: the single native path carries AP_* and the Quartz §7 vocabulary) |
| Lock PDE UI | private `PMSessionSetColorMatchingMode*` SPI | strip Color Matching accessories | Use SPI **and** strip; accessories API misses driver PDEs (the #188 failure mode) |
| Canon off | `CNIJIntent2=4` | `CNColorMatching=None` | Apply both |
| Epson off | `EPIJ_CMat=3` / `EPIJ_CCor=0` | `EPSONColorControls=Off` + `ColorModel=RGB` | Apply both; prefer captured panel values |
| Geometry | none (filter decides) | 1:1 pt from DPI | TargetPrint (or do not scale in GDI/`lp`) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges |
| AirPrint | none | detected + warned | Port detector into ICCery printer list |
| Geometry | none (filter decides) | 1:1 pt from DPI | 1:1 pt from DPI — **adopted in v2.0 via #201** (`TargetRasterLoader`/`TargetPageCanvasView`, docs/14 §6) |
| Interpolation | n/a (file passthrough) | explicitly disabled | Required for patch edges — **adopted in v2.0 via #201** (interpolation/antialias off in `TargetPageCanvasView.draw`) |
| AirPrint | none | detected + warned | Ported into ICCery — **adopted in v2.0 via #201/#202** (`lpstat -v` + PPD §10.2 rules, Stage 2 `airPrintWarningBadge`) |
| Linux | `-o raw` | n/a (macOS only) | Keep raw + PPD fallback |
| Windows | GDI ICM_OFF | n/a | Keep GDI; do not route through TargetPrint |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -130,7 +130,7 @@ Grouped by subsystem. Issue numbers are always cited.
**Sister-product traps (ICCery-CPU `#1`, `#2`, `#5`) that also apply to any CUPS rewrite:**
- PPD `ChoiceName/TranslationString` — UI must show the title, send the code (`MediaType=13` not `MediaType=Epson Premium Glossy`).
- Tray keywords are vendor-specific (`InputSlot` vs Epson `EPIJ_FdSo` vs Canon `CNIJMediaSupply`). Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Tray keywords are vendor-specific (`InputSlot`/`MediaSource` vs Epson `EPIJ_FdSo`/`EPIJ_PSrc` vs Canon `CNIJMediaSupply`). When both Epson keys are present, `EPIJ_FdSo` is the feed-source control and takes precedence over `EPIJ_PSrc`. Empty arrays are not `nil` — coalescing `?? ["Auto"]` does not fire.
- Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
---
+1 -1
View File
@@ -85,7 +85,7 @@ Lock these before rewriting UI:
4. `parseProfcheckReport` JSON + legacy + empty→warning
5. Color bypass detector: Canon/Epson/Gutenprint samples
6. `filter_cups_options_string` drops `com.apple.*`, keeps `EPIJ_CMat`
7. `build_lp_args` always emits both AP_* keys
7. `build_lp_args` always emits both AP_* keys (**historical v1** — superseded by `TicketWriteResolverTests`, #201)
8. Threshold validation `good < warning`
9. `snapshot_ti3` 1-based and removes canonical
10. DEVMODE round-trip size
+9 -3
View File
@@ -29,6 +29,12 @@
## Locked product decisions
> **Errata (M12, #201):** item 4's `lp` spool was replaced in v2.0 by a
> headless `NSPrintOperation` replaying the captured `PMPrintSettings`
> ticket, and the Quartz vocabulary of item 5 is now written **alongside**
> AP_* on the single native path (D2). "Never mix" no longer applies inside
> ICCery proper; it still governs the future `ICCeryPrintKit` boundary.
1. **Stack:** SwiftUI (`@Observable`, `@MainActor` view models) + AppKit for printing/panels. No Tauri, no Rust, no WebView.
2. **Floor:** macOS 14.0, universal `arm64` + `x86_64`.
3. **AGPL:** never link Argyll. Spawn with piped stdio + `ARGYLL_NOT_INTERACTIVE=1` on **every** child (streaming and captured).
@@ -120,7 +126,7 @@ Issues 1–6.
**Hardware:** none.
Issues 7–11.
### M3 — macOS unmanaged printing (`lp` path)
### M3 — macOS unmanaged printing (`lp` path) — historical v1, superseded by #201
**CI/mock:** `lpstat`/`lpoptions` parsers; `build_lp_args` golden vectors (both `AP_*` always present); option filter; cancel → nil.
**Hardware:** Preferences opens **driver PDE** on a real Epson or Canon queue; colour matching off/grayed; printed TIFF measures unmanaged (no ColorSync transform).
@@ -376,7 +382,7 @@ Six layers (spec [11](11-print-macos.md) roster):
- Deps: 12, 13.
- Test CI: injectable dlsym order; filter fixtures. Hardware: PDE colour grayed/off on Epson **and** Canon.
**Issue 15 — `lp` spool path**
**Issue 15 — `lp` spool path — historical v1, superseded by #201 native spool**
Labels: `Feature/Backend`, `Priority/High`
Milestone: M3
@@ -583,7 +589,7 @@ Milestone: **Later**
- Separate Swift package `ICCeryPrintKit`. **Zero** deps on wizard types.
- Public API: `TargetJob` v1 JSON + `--job` CLI (fire-and-forget) **and** in-process `NSPrintOperation`. Preserve extractability to a standalone app.
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`.**
- ColorSync vocabulary is **not** the `lp` path: `PMColorMatchingMode=APCustomColorMatching`, `PMCustomColorMatchingProfile=""`, legacy `com.apple.print.PrintSettings.PMColorMatchingMode`. **Never mix with `AP_ApplicationColorMatching`** — amended by #201 (D2): with `lp` gone, ICCery's single native path writes **both** vocabularies; this constraint now governs only the future `ICCeryPrintKit` boundary.
- Vendor keys (separate table from issue 14): Epson `ColorModel=RGB` + `EPSONColorControls=Off`; Canon `CNColorMatching=None`; HP `ColorModel=RGB` + `HPColorControl=Off`.
- Geometry: 72pt=1in, no `backingScaleFactor`, interpolation `.none`, antialias off, pixel-integrity seam test. Resolve SPEC contradiction: job JSON `"centered": true` vs draw “no centering” — **lock “no centering, scale 1.0” for profiling targets.**
- AirPrint detection → persistent warning.
+4 -1
View File
@@ -43,6 +43,8 @@ Do not install Argyll to `$PATH`. ICCery never searches `$PATH` for binaries.
| `NOTARIZE_APPLE_ID` | Apple ID for `notarytool` |
| `NOTARIZE_PASSWORD` | App-specific password for `notarytool` |
| `APPLE_TEAM_ID` | Team ID for `notarytool` |
| `RELEASE_TAG` | Optional. Simulates a tag build (`vX.Y.Z[-suffix]`); stamped into the bundle and DMG name |
| `BUILD_NUMBER` | Optional `CFBundleVersion` override; default `git rev-list --count HEAD` |
Invocation for a signed, notarized DMG:
@@ -56,7 +58,8 @@ export APPLE_TEAM_ID="TEAMID"
scripts/package-release.sh
```
The script will emit `ICCery-<version>-<build>.dmg`. After mounting, verify Gatekeeper acceptance:
The script will emit `ICCery-<version>-<build>.dmg` (or `ICCery-<tag>-<build>.dmg`
when `RELEASE_TAG`/a `v*` git tag applies). After mounting, verify Gatekeeper acceptance:
```
spctl -a -t open --context context:primary-signature -v ICCery-*.dmg
+105
View File
@@ -0,0 +1,105 @@
# Issue #201 — Printer quality and media type ignored when printing target
> Analysis doc referenced by issue **[Bug/Critical] Printer quality and media
> type settings ignored when printing target — lp path missing PMPrintSettings
> ticket** (#201). Line numbers refer to `develop` @ `0513b27` (pre-M12 code,
> verified 2026-09-17). The fix landed on `milestone/m12-native-spool` — see
> "Resolution" below.
## 0. Lifecycle trace — what the code did (pre-#201, historical v1)
```
Stage2View "btnPrinterProperties"
→ PrintSessionViewModel.openPrinterPreferences() :141
→ PrintPanelService.showProperties() :59
→ runNativePanel() :85
① PMPrinterCreateFromPrinterID + PMSessionSetCurrentPMPrinter :96–113
▸ applyInitialSelections (PMPrintSettingsSetValue ×4) :216
▸ applyPaperPageFormat (PMPaper match → PMCopyPageFormat) :258
②–⑤ ColorSyncSuppressor SPI / AP_* / bypass / mirror :152–163
NSPrintPanel.runModal("Use Settings") :173
⑥ PMPrintSettingsToOptions → CupsOptionsFilter → String :185–191
✗ THE TICKET IS DISCARDED HERE — only the flattened `k=v` string survives
→ capturedCupsOptions[queue] = String :174
Stage2View "btnPrintAll"
→ PrintSessionViewModel.printAllPages(from:) :205
→ spool(page, index:) :263
→ CupsService.printTarget() CupsService.swift:170
→ LpArgs.build() LpArgs.swift:42
→ ProcessManager.runCaptured("/usr/bin/lp", argv)
```
## Root causes (pre-#201, historical v1)
Two defects, both confirmed by reading the code:
### 1. Ticket loss
`ColorSyncSuppressor.captureOptions` (:133) was the only capture path. It
funneled the whole `PMPrintSettings` object through `PMPrintSettingsToOptions`
→ a space-separated `key=value` string, then `CupsOptionsFilter.filter`
**dropped every `com.apple.*` key** (`CupsOptionsFilter.swift:49`) — i.e. it
deliberately discarded `com.apple.print.PrintSettings`, the ticket the
Epson/Canon raster filter reads. `lp` cannot reconstruct it, so the driver
fell back to plain paper / normal quality.
### 2. Override inversion
`LpArgs.build` recorded every captured key in `addedKeys` (:67–73) and then
*suppressed* the explicit Stage 2 value when the key was already present —
media (:81), quality (:90), bypass (:102), orientation (:109), `PageSize`
(:117). A Stage 2 dropdown change after a panel capture was silently ignored.
### Dismissed hypothesis
**argv tokenisation is safe** — `ProcessManager.runCaptured` hands an argv
array to `Process`, never a shell. Space-containing `-o` values were
preserved; this was checked and dismissed in the issue investigation and
re-verified during the M12 audit.
## Locked decisions (D1–D13)
Decisions taken by the M12 megaplan; **locked — do not re-derive.**
(`lp`/`LpArgs` mentions in this table name the deleted historical v1 path.)
| # | Decision | Consequence |
|---|----------|-------------|
| D1 | Spooler lives in the **app target** (`Sources/ICCery/Print/`), not in `CupsService` | `ICCeryCore` stays AppKit-free (AGENTS §Package layout). `CupsService.printTarget` is **deleted**; `CupsService` keeps enumeration/capabilities/PPD only. The issue body's "in `CupsService`" is superseded — posted as errata on #201 |
| D2 | Write **both** ColorSync vocabularies on the native path | AP_* (locked) **and** Quartz `PMColorMatchingMode=APCustomColorMatching` + `PMCustomColorMatchingProfile=""` + legacy `com.apple.print.PrintSettings.PMColorMatchingMode` + the nested `com.apple.print.printSettings` mirror. AGENTS §"Private ColorSync SPI — never mix" is **amended**: with `lp` gone there is one path and it carries both dictionaries (docs/14 decision table, "Set both if using NSPrintOperation") |
| D3 | Ticket = `PMPrintSettings` **Data** + `PMPageFormat` **Data** + an `NSPrintInfo.dictionary()` plist fallback | `kPMDataFormatXMLDefault`. Restored via `PM*CreateWithDataRepresentation` → `PMCopy*` → `PMSessionValidate*` → `updateFromPM*`. Byte round-trip is unit-testable |
| D4 | Delete `lp`; **keep** `CupsParsers` + `CupsOptionsFilter` | Parsers still drive capabilities (#183/#180/#181), media/quality key detection, driver-bypass detection, and the panel→Stage-2 apply-back mirror (#186). Only `LpArgs` dies |
| D5 | Job granularity is **user-selectable**, default **one job per page** | New session-only `@Published var singleJobForAllPages = false`; Stage 2 checkbox `chkSingleSpoolJob`. Default preserves today's per-page notices and error attribution |
| D6 | **Stage 2 always wins** over the rehydrated ticket | Unconditional overwrite of paper / media / quality / orientation. Mitigation for vendor companion-key desync: only write when the value differs from the ticket's current value, and log both (R4) |
| D7 | Spool **silently** | `showsPrintPanel = false`, `showsProgressPanel = false`, `canSpawnSeparateThread = false`. Feedback stays on the existing `isPrinting` + `Notice`. No new system modal → XCUITest unaffected |
| D8 | Verification = **recorder seam + PDF harness** | DEBUG `RecordingTargetSpooler` writes resolved ticket lines to `ICCERY_TEST_SPOOL_LOG` (replaces `ICCERY_TEST_LP_ARGV`); a real `NSPrintOperation` with `jobDisposition = .save` produces a PDF for geometry assertions |
| D9 | Never scale; **warn + spool** | 1:1 always, anchored at the paper's top-left. `warn` when the DPI-derived size disagrees with the manifest `width_mm`/`height_mm` by >0.5 mm; warning `Notice` when the image exceeds the paper |
| D10 | Device colour spaces implemented **and verified** for 1/3/4 channels at 8 and 16 bpc | Re-tag the decoded `CGImage` into `DeviceGray`/`DeviceRGB`/`DeviceCMYK` reusing the source `dataProvider` — **no resample, no bit-depth change**. Hardware gate covers RGB-8, RGB-16, DeviceGray-8 and CMYK-16 |
| D11 | AirPrint detection + Stage 2 banner **in scope**, as its own issue | docs/14 §10.2 rules. Blocked-by #201 → filed as #202 |
| D12 | Extract a **shared** `PMTicketBridge` | Single `@MainActor enum` owning every `PM*` call, with one documented `PMRelease` rule. `PrintPanelService` migrates onto it |
| D13 | New milestone **M12 — Native print spool** | `milestone/m12-native-spool` from `develop`; #201 moves off the shipped M11 (id 34) |
## Resolution
Implemented on `milestone/m12-native-spool` (Phases 1–5, PRs #203–#207).
(`LpArgs`/`lp` references below name deleted historical v1 artefacts.)
- `PMTicketBridge` owns every `PM*` call; `PrintPanelService.showProperties`
returns `PanelCaptureResult` carrying a `PrintTicket` (layer ⑦ serialise).
- `NativeTargetSpooler` rehydrates the ticket into a fresh `NSPrintInfo`
(S1–S14, docs/11 §native spool) and spools a headless `NSPrintOperation`
over `TargetPageCanvasView` — 1:1, top-left anchored, interpolation off.
- `TicketWriteResolver` replaces `LpArgs.build`: Stage 2 always wins (D6),
both ColorSync vocabularies written (D2), no `raw` can ever appear.
- `LpArgs`, `CupsService.printTarget`, the `lp` fixture and
`ICCERY_TEST_LP_ARGV` are deleted; `RecordingTargetSpooler` writes to
`ICCERY_TEST_SPOOL_LOG` for tests (D8).
- AirPrint detection + Stage 2 warning badge shipped as #202 (D11).
## References
- `Sources/ICCery/Print/{PMTicketBridge,PrintTicket,NativeTargetSpooler,TicketWriteResolver,TargetRaster,TargetPageCanvasView}.swift`
- `Packages/ICCeryCore/Sources/ICCeryCore/Print/{CupsService,CupsParsers,CupsOptionsFilter,PrinterModels,ColorMatchingAttempts}.swift`
- `docs/11-print-macos.md` §ColorSync suppression — UI click to `NSPrintOperation`
- `docs/14-iccery-cpu-targetprint.md` §6–§7 (geometry + Quartz vocabulary)
- M12 megaplan (`plan-be303e6f3f5e89da`)
+17 -1
View File
@@ -42,6 +42,15 @@ targets:
echo "note: Vendor/Argyll absent — run scripts/fetch-argyll.sh"
fi
basedOnDependencyAnalysis: false
- name: Stamp release tag
# GENERATE_INFOPLIST_FILE writes Info.plist after script phases and
# only honours Apple's INFOPLIST_KEY_* allowlist — so the tag ships as
# a bundled resource read by ArtefactFiles.appInfo (#189).
script: |
DEST="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
mkdir -p "$DEST"
printf '%s' "${ICCERY_RELEASE_TAG:-}" > "$DEST/ICCeryReleaseTag"
basedOnDependencyAnalysis: false
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.gronod.iccery2
@@ -52,8 +61,15 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0"
MARKETING_VERSION: "2.0.4"
CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version.
# Written to the bundled ICCeryReleaseTag resource by the
# "Stamp release tag" phase — GENERATE_INFOPLIST_FILE drops custom
# INFOPLIST_KEY_* names and generates the plist after script phases.
# Tag builds hard-fail when the tag's X.Y.Z != MARKETING_VERSION above.
ICCERY_RELEASE_TAG: ""
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_ENTITLEMENTS: Resources/ICCery.entitlements
CODE_SIGN_IDENTITY: "-"
+19 -2
View File
@@ -45,6 +45,13 @@ DEST="platform=macOS"
IDENTITY="${CODESIGN_IDENTITY:--}"
DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-}"
# Resolve the version triple from the release tag / git state. On tag builds
# (RELEASE_TAG=v*) this hard-fails if the tag's X.Y.Z != project.yml
# MARKETING_VERSION, so a mis-tag cannot ship mismatched metadata (#189).
echo "==> Resolving version"
eval "$(scripts/version.sh)"
echo "version=$MARKETING_VERSION build=$CURRENT_PROJECT_VERSION tag=$ICCERY_RELEASE_TAG"
echo "==> Building universal Release app"
BUILD_EXTRA=""
if [ -n "$DEVELOPMENT_TEAM" ]; then
@@ -60,6 +67,9 @@ xcodebuild \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=YES \
CODE_SIGN_IDENTITY="$IDENTITY" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$CURRENT_PROJECT_VERSION" \
ICCERY_RELEASE_TAG="$ICCERY_RELEASE_TAG" \
$BUILD_EXTRA \
build
@@ -140,9 +150,16 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
fi
echo "==> Building DMG"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg"
# Tagged/described builds carry the tag in the DMG name so prerelease
# artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain
# release tag keeps the classic ICCery-<ver>-<build>.dmg form.
TAG_NAME="${ICCERY_RELEASE_TAG#v}"
case "$TAG_NAME" in
''|"$VERSION"|dev) DMG="ICCery-${VERSION}-${BUILD_NUM}.dmg" ;;
*) DMG="ICCery-${TAG_NAME}-${BUILD_NUM}.dmg" ;;
esac
VOLUME_NAME="ICCery ${VERSION}"
DMG_APP="$APP" \
+88
View File
@@ -0,0 +1,88 @@
#!/bin/sh
# scripts/version.sh
#
# Resolve the ICCery version triple and print KEY=value lines for eval:
#
# ICCERY_RELEASE_TAG release tag / describe string stamped into the
# bundle's ICCeryReleaseTag Info.plist key and shown
# in the About dialog.
# MARKETING_VERSION CFBundleShortVersionString — strict X.Y.Z only
# (Apple forbids suffixes; tag payload never lands here).
# CURRENT_PROJECT_VERSION CFBundleVersion — monotonically increasing integer.
# macOS/App-Store convention: never reset per version.
#
# Resolution:
# RELEASE_TAG env matching 'v[0-9]*' wins (CI tag builds; branch pushes pass
# the branch name and are ignored). Otherwise `git describe` on the worktree.
# Marketing version = first three numeric components of the tag core; a tag
# with no numeric core falls back to project.yml's MARKETING_VERSION.
# Build number = BUILD_NUMBER env override, else `git rev-list --count HEAD`.
#
# Hard fails (release-tag builds only): tag core not 1-3 numeric components,
# or parsed X.Y.Z != project.yml MARKETING_VERSION — bump project.yml or fix
# the tag before packaging.
#
# Usage: eval "$(scripts/version.sh)"
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)"
cd "$ROOT"
YML_MARKETING="$(sed -n 's/^ *MARKETING_VERSION: *"\(.*\)"/\1/p' project.yml | head -n 1)"
TAG="${RELEASE_TAG:-}"
case "$TAG" in
v[0-9]*) ;; # real release tag
*) TAG="" ;; # branch name (develop, feat/...) or empty
esac
IS_RELEASE_TAG=0
[ -n "$TAG" ] && IS_RELEASE_TAG=1
if [ -z "$TAG" ]; then
TAG="$(git describe --tags --always --dirty --match 'v[0-9]*' 2>/dev/null || true)"
fi
[ -n "$TAG" ] || TAG="dev"
# Marketing version: strip leading v, cut at first '-', keep first 3 dot
# components. "v2.0.0-pre2" -> "2.0.0"; "v2.0.0.0-x" -> "2.0.0";
# "v2.0.0-5-gsha" -> "2.0.0"; bare sha/"dev" -> no numeric core -> fallback.
CORE="${TAG#v}"
CORE="${CORE%%-*}"
MV=""
case "$CORE" in
*[!0-9.]*|'') ;; # non-numeric core — not a version tag
*)
MV="$(printf '%s' "$CORE" | cut -d. -f1-3)"
# Every kept component must be non-empty digits.
case "$MV" in
*[!0-9.]*|''|*..*|.*|*.) MV="" ;;
esac
;;
esac
if [ "$IS_RELEASE_TAG" -eq 1 ]; then
if [ -z "$MV" ]; then
echo "error: release tag '$TAG' has no X.Y.Z numeric core" >&2
exit 1
fi
if [ -n "$YML_MARKETING" ] && [ "$MV" != "$YML_MARKETING" ]; then
echo "error: tag '$TAG' resolves to $MV but project.yml MARKETING_VERSION=$YML_MARKETING; bump project.yml or fix the tag" >&2
exit 1
fi
fi
[ -n "$MV" ] || MV="$YML_MARKETING"
[ -n "$MV" ] || MV="0.0.0"
BUILD="${BUILD_NUMBER:-}"
if [ -z "$BUILD" ]; then
BUILD="$(git rev-list --count HEAD 2>/dev/null || true)"
fi
case "$BUILD" in
''|*[!0-9]*) BUILD="1" ;;
esac
[ "$BUILD" -gt 0 ] 2>/dev/null || BUILD="1"
printf 'ICCERY_RELEASE_TAG=%s\n' "$TAG"
printf 'MARKETING_VERSION=%s\n' "$MV"
printf 'CURRENT_PROJECT_VERSION=%s\n' "$BUILD"