Compare commits

..
Author SHA1 Message Date
gronod f5163bccca docs: add printing-workflow roadmap candidates
macOS CI / build-and-test (push) Successful in 8s
macOS CI / package (push) Successful in 3m17s
Outline features, defects, UI tweaks, and backend work that can
follow the shipped printer path. Display profiling and issue #16
stay out of scope.
2026-09-23 08:45:37 +01:00
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
30 changed files with 2234 additions and 196 deletions
+51
View File
@@ -93,6 +93,57 @@ codesign -dvv <sidecar>
``` ```
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging. 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 ## Private ColorSync SPI
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`. 2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`. Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
@@ -17,7 +17,8 @@ public enum CupsOptionsFilter {
// Media // Media
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
// Tray // Tray
"InputSlot", "AP_D_InputSlot", "InputSlot", "MediaSource", "AP_D_InputSlot",
"EPIJ_FdSo", "EPIJ_PSrc", "CNIJMediaSupply",
// Size // Size
"PageSize", "PageSize",
// Colour bypass // Colour bypass
@@ -353,6 +353,55 @@ public enum CupsParsers {
.map { $0 == "4" ? "landscape" : "portrait" } .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 /// Driver "no colour adjustment" key=value for `lpoptions -l` keys
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`; /// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`; /// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
@@ -122,8 +122,28 @@ public struct CupsService: Sendable {
let listings = try await optionListings(for: queue) let listings = try await optionListings(for: queue)
let ppd = loadPPD(for: queue) let ppd = loadPPD(for: queue)
var caps = capabilities(from: listings, ppd: ppd) 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( caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
listings: listings, ppd: ppd) 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 return caps
} }
@@ -132,16 +152,12 @@ public struct CupsService: Sendable {
from listings: [CupsOptionListing], from listings: [CupsOptionListing],
ppd: String? ppd: String?
) -> PrinterCapabilities { ) -> PrinterCapabilities {
var trays: [PrinterTray] = [] let optionKeys = Set(listings.map(\.key))
var sizes: [PrinterPaperSize] = [] var sizes: [PrinterPaperSize] = []
var media: [PrinterMediaType] = [] var media: [PrinterMediaType] = []
for listing in listings { for listing in listings {
switch listing.key { switch listing.key {
case "InputSlot", "MediaSource":
trays = listing.choices.enumerated().map {
PrinterTray(id: $0.offset + 1, name: $0.element)
}
case "PageSize", "MediaSize": case "PageSize", "MediaSize":
sizes = listing.choices.enumerated().map { sizes = listing.choices.enumerated().map {
PrinterPaperSize(id: $0.offset + 1, name: $0.element) PrinterPaperSize(id: $0.offset + 1, name: $0.element)
@@ -159,12 +175,32 @@ public struct CupsService: Sendable {
} }
} }
// 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 // Print quality — the detected roster key's listing maps to
// `PrinterQuality` with PPD labels and the `*` default (#183). // `PrinterQuality` with PPD labels and the `*` default (#183).
var qualities: [PrinterQuality] = [] var qualities: [PrinterQuality] = []
var qualityDefault: String? var qualityDefault: String?
let qualityKey = CupsParsers.detectQualityKey( let qualityKey = CupsParsers.detectQualityKey(optionKeys: optionKeys)
optionKeys: Set(listings.map(\.key)))
if let qualityKey, if let qualityKey,
let listing = listings.first(where: { $0.key == qualityKey }) { let listing = listings.first(where: { $0.key == qualityKey }) {
let labels = ppd.map { let labels = ppd.map {
@@ -178,7 +214,8 @@ public struct CupsService: Sendable {
return PrinterCapabilities( return PrinterCapabilities(
trays: trays, paperSizes: sizes, mediaTypes: media, trays: trays, paperSizes: sizes, mediaTypes: media,
qualityKey: qualityKey, qualities: qualities, trayKey: trayKey, qualityKey: qualityKey,
qualities: qualities, trayDefault: trayDefault,
qualityDefault: qualityDefault) qualityDefault: qualityDefault)
} }
@@ -32,6 +32,7 @@ public enum MediaQualityConstraints {
public static func resolve( public static func resolve(
listings: [CupsOptionListing], listings: [CupsOptionListing],
ppd: String?, ppd: String?,
optionOverrides: [String: String] = [:],
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) } readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
) -> [String: Set<String>] { ) -> [String: Set<String>] {
let optionKeys = Set(listings.map(\.key)) let optionKeys = Set(listings.map(\.key))
@@ -46,9 +47,12 @@ public enum MediaQualityConstraints {
else { return [:] } else { return [:] }
let mediaIDs = Set(mediaListing.choices) let mediaIDs = Set(mediaListing.choices)
let qualityIDs = Set(qualityListing.choices) let qualityIDs = Set(qualityListing.choices)
let defaults = Dictionary( var defaults = Dictionary(
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } }, listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
uniquingKeysWith: { first, _ in first }) uniquingKeysWith: { first, _ in first })
for (key, value) in optionOverrides {
defaults[key] = value
}
guard let ppd else { return [:] } guard let ppd else { return [:] }
let ppdMap = allowedMap( let ppdMap = allowedMap(
@@ -48,13 +48,17 @@ public struct Printer: Codable, Equatable, Sendable {
} }
/// Paper source. `id` is the 1-based index of the `InputSlot` / /// 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 struct PrinterTray: Codable, Equatable, Sendable {
public var id: Int public var id: Int
public var token: String
public var name: String public var name: String
public init(id: Int, name: String) { public init(id: Int, token: String, name: String) {
self.id = id self.id = id
self.token = token
self.name = name self.name = name
} }
} }
@@ -101,10 +105,16 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// Always `true` on macOS (spec parity — CUPS honours /// Always `true` on macOS (spec parity — CUPS honours
/// `orientation-requested`). /// `orientation-requested`).
public var supportsOrientation: Bool 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 /// The queue's detected quality enumeration key
/// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183). /// (`CupsParsers.detectQualityKey`), e.g. `EPIJ_Qual` (#183).
public var qualityKey: String? public var qualityKey: String?
public var qualities: [PrinterQuality] 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. /// The `*`-marked default choice from `lpoptions -l`, if any.
public var qualityDefault: String? public var qualityDefault: String?
/// Media-type id → the quality ids the driver accepts for it /// Media-type id → the quality ids the driver accepts for it
@@ -112,50 +122,81 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
/// exposes no per-media quality matrix; a missing entry means /// exposes no per-media quality matrix; a missing entry means
/// "unconstrained" — show everything. /// "unconstrained" — show everything.
public var qualityIDsByMediaType: [String: Set<String>] 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( public init(
trays: [PrinterTray] = [], trays: [PrinterTray] = [],
paperSizes: [PrinterPaperSize] = [], paperSizes: [PrinterPaperSize] = [],
mediaTypes: [PrinterMediaType] = [], mediaTypes: [PrinterMediaType] = [],
supportsOrientation: Bool = true, supportsOrientation: Bool = true,
trayKey: String? = nil,
qualityKey: String? = nil, qualityKey: String? = nil,
qualities: [PrinterQuality] = [], qualities: [PrinterQuality] = [],
trayDefault: String? = nil,
qualityDefault: String? = nil, qualityDefault: String? = nil,
qualityIDsByMediaType: [String: Set<String>] = [:] qualityIDsByMediaType: [String: Set<String>] = [:],
qualityIDsByMediaTypeByTray: [String: [String: Set<String>]] = [:]
) { ) {
self.trays = trays self.trays = trays
self.paperSizes = paperSizes self.paperSizes = paperSizes
self.mediaTypes = mediaTypes self.mediaTypes = mediaTypes
self.supportsOrientation = supportsOrientation self.supportsOrientation = supportsOrientation
self.trayKey = trayKey
self.qualityKey = qualityKey self.qualityKey = qualityKey
self.qualities = qualities self.qualities = qualities
self.trayDefault = trayDefault
self.qualityDefault = qualityDefault self.qualityDefault = qualityDefault
self.qualityIDsByMediaType = qualityIDsByMediaType self.qualityIDsByMediaType = qualityIDsByMediaType
self.qualityIDsByMediaTypeByTray = qualityIDsByMediaTypeByTray
} }
/// Qualities valid for `mediaID`, preserving the driver's listing /// The quality map for the requested tray token, if any.
/// order (#180). Falls back to the full list when the driver has /// Prefers the tray-specific map; otherwise falls back to the
/// no per-media data, the media is absent from the map, or the /// queue-default map. An explicitly empty tray map means the tray
/// filter would empty the picker (#214). /// is unconstrained, so `nil` is returned only when neither map
public func qualities(forMediaType mediaID: String?) -> [PrinterQuality] { /// 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, guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID], let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty !allowed.isEmpty
else { return qualities } else { return qualities }
let filtered = qualities.filter { allowed.contains($0.id) } let filtered = qualities.filter { allowed.contains($0.id) }
return filtered.isEmpty ? qualities : filtered return filtered.isEmpty ? qualities : filtered
} }
/// Whether `qualityID` is usable with `mediaID` — `true` whenever /// Whether `qualityID` is usable with `mediaID` and an optional
/// the driver exposes no constraint for that media (#214). A /// tray token (#218). A quality absent from `qualities` entirely
/// quality absent from `qualities` entirely is still allowed: a /// is still allowed: a driver-captured token can be legitimate
/// driver-captured token can be legitimate even when `lpoptions` /// even when `lpoptions` never listed it.
/// never listed it.
public func allowsQuality( public func allowsQuality(
_ qualityID: String, forMediaType mediaID: String? _ qualityID: String,
forMediaType mediaID: String?,
trayToken: String? = nil
) -> Bool { ) -> Bool {
guard let mediaID, guard let mediaID,
let allowed = qualityIDsByMediaType[mediaID], let allowed = qualityMap(forTrayToken: trayToken)?[mediaID],
!allowed.isEmpty !allowed.isEmpty
else { return true } else { return true }
return allowed.contains(qualityID) return allowed.contains(qualityID)
+1 -1
View File
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|---|---| |---|---|
| Product | ICCery v2 for macOS | | Product | ICCery v2 for macOS |
| Bundle | `com.gronod.iccery2` | | Bundle | `com.gronod.iccery2` |
| Version | 2.0.0 | | Version | 2.0.3 |
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` | | Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) | | Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
| CI | Gitea Actions `macos-12` runner | | CI | Gitea Actions `macos-12` runner |
+13 -1
View File
@@ -133,17 +133,29 @@ enum UITestHooks {
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and /// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the /// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for). /// 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"] { switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok": case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap { let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0 $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( return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap { selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0 $0.isEmpty ? nil : $0
} ?? queue, } ?? queue,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: options.flatMap { orientation: options.flatMap {
CupsParsers.extractOrientation(fromOptionsString: $0) CupsParsers.extractOrientation(fromOptionsString: $0)
}, },
+30 -15
View File
@@ -181,29 +181,44 @@ struct ColorSyncSuppressor {
// MARK: - Layer ⑥ capture // MARK: - Layer ⑥ capture
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType,
/// The malloc'd C string is freed after copying. /// 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( func captureOptions(
from settings: PMPrintSettings from settings: PMPrintSettings,
) -> (cupsOptions: String?, mediaType: String?) { trayKey: String?
guard let toOptions = optionsResolver() else { ) -> (cupsOptions: String?, mediaType: String?, tray: String?) {
log("ColorSync: PMPrintSettingsToOptions unavailable — " guard let unfiltered = rawOptionsDump(from: settings) else {
+ "panel options not captured") return (nil, nil, nil)
return (nil, nil)
} }
var raw: UnsafeMutablePointer<CChar>?
guard toOptions(settings, &raw) == 0, let raw else {
return (nil, nil)
}
defer { free(raw) }
let unfiltered = String(cString: raw)
let filtered = CupsOptionsFilter.filter(unfiltered) let filtered = CupsOptionsFilter.filter(unfiltered)
return ( return (
filtered.isEmpty ? nil : filtered, 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 // MARK: - dlsym
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
+189 -60
View File
@@ -21,6 +21,10 @@ struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1). /// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String? 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`. /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String? var qualityKey: String?
/// The selected quality token. /// The selected quality token.
@@ -32,6 +36,23 @@ struct PrintPanelInitialSelections {
var orientation: String? 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 /// Preferences → native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11). /// queue (issue 13, docs/11).
/// ///
@@ -58,11 +79,16 @@ struct PrintPanelService {
displayName: String?, displayName: String?,
cupsService: CupsService, cupsService: CupsService,
initialSelections: PrintPanelInitialSelections = initialSelections: PrintPanelInitialSelections =
PrintPanelInitialSelections() PrintPanelInitialSelections(),
ticket: PrintTicket? = nil,
trays: [PrinterTray] = []
) async throws -> PanelCaptureResult? { ) async throws -> PanelCaptureResult? {
#if DEBUG #if DEBUG
if UITestHooks.printPanelStubbed { if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue).map { return UITestHooks.printPanelResult(
forQueue: queue, trays: trays,
preferredTrayKey: initialSelections.trayKey
).map {
PanelCaptureResult(properties: $0, ticket: nil) PanelCaptureResult(properties: $0, ticket: nil)
} }
} }
@@ -75,83 +101,140 @@ struct PrintPanelService {
let optionKeys = (try? await cupsService.optionKeys(for: queue)) let optionKeys = (try? await cupsService.optionKeys(for: queue))
?? [] ?? []
return try runNativePanel( return try runNativePanel(
queue: queue, displayName: display, optionKeys: optionKeys, PrintPanelRequest(
initialSelections: initialSelections) queue: queue, displayName: display,
optionKeys: optionKeys,
initialSelections: initialSelections, ticket: ticket,
trays: trays))
} }
// MARK: - Panel // MARK: - Panel
private func runNativePanel( /// The pre-modal half of `runNativePanel` — a fully configured
queue: String, /// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
displayName: String?, /// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
optionKeys: Set<String>, /// assert ticket restore / Stage 2 precedence without driving the
initialSelections: PrintPanelInitialSelections /// modal (#217). The caller owns `printer` — it must stay alive
) throws -> PanelCaptureResult? { /// through the modal.
func makePanelPrintInfo(
for request: PrintPanelRequest,
boundTo printer: PMPrinter?
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
let printInfo = NSPrintInfo() let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// ① Bind the session to the selected CUPS queue (docs/11). // ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMTicketBridge.makePrinter(queue: queue) { if let printer {
pmPrinter = printer
let session = PMTicketBridge.session(printInfo)
let settings = PMTicketBridge.settings(printInfo)
do {
try PMTicketBridge.bind(printer: printer, to: printInfo) try PMTicketBridge.bind(printer: printer, to: printInfo)
} catch { configureBoundPanel(
PMTicketBridge.release(printer) printInfo, request: request, printer: printer)
throw error
}
// Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue // Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect). // (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn( AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — " "Print panel: PM binding unavailable for "
+ "'\(request.queue)' — "
+ "falling back to NSPrinter(displayName)") + "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName = request.displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
throw PrintPanelError.noPrinterFound( throw PrintPanelError.noPrinterFound(
displayName ?? queue) request.displayName ?? request.queue)
} }
printInfo.printer = nsPrinter printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues() printInfo.setUpPrintOperationDefaultValues()
return (printInfo, false)
} }
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))")
}
}
// 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 { defer {
if let printer = pmPrinter { if let printer = pmPrinter {
PMTicketBridge.release(printer) PMTicketBridge.release(printer)
} }
} }
// ②–⑤ ColourSync suppression — only on the PM path: the SPI let (printInfo, boundViaPM) = try makePanelPrintInfo(
// and PMPrintSettingsSetValue need a session with a current for: request, boundTo: pmPrinter)
// printer to attach to.
var settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "pre-modal")
var driverBypass: (key: String, value: String)?
if boundViaPM {
let session = PMTicketBridge.session(printInfo)
suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
driverBypass = suppressor.applyDriverBypass( // ④
to: settings, optionKeys: optionKeys)
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
}
let panel = NSPrintPanel() let panel = NSPrintPanel()
panel.options = [ panel.options = [
@@ -173,27 +256,35 @@ struct PrintPanelService {
// (#183); the PDE may rewrite or drop them (R12). // (#183); the PDE may rewrite or drop them (R12).
var cupsOptions: String? var cupsOptions: String?
var mediaType: String? var mediaType: String?
var trayToken: String?
if boundViaPM { if boundViaPM {
settings = PMTicketBridge.settings(printInfo) debugDump(printInfo, when: "post-OK")
let captured = suppressor.captureOptions(from: settings) let captured = suppressor.captureOptions(
from: PMTicketBridge.settings(printInfo),
trayKey: request.initialSelections.trayKey)
cupsOptions = captured.cupsOptions cupsOptions = captured.cupsOptions
mediaType = captured.mediaType mediaType = captured.mediaType
trayToken = captured.tray
} }
let capturedOptions = cupsOptions ?? "" let capturedOptions = cupsOptions ?? ""
let resolvedQueue = boundViaPM let resolvedQueue = boundViaPM
? PMTicketBridge.currentPrinterID( ? PMTicketBridge.currentPrinterID(
session: PMTicketBridge.session(printInfo), session: PMTicketBridge.session(printInfo),
fallback: queue) fallback: request.queue)
: queue : request.queue
// ⑦ Serialise the native ticket — the payload `lp -o` could // ⑦ Serialise the native ticket — the payload `lp -o` could
// never carry (#201). Warn-only via `try?`: a serialise // never carry (#201). Warn-only via `try?`: a serialise
// failure must not lose the Stage 2 mirror above. // failure must not lose the Stage 2 mirror above.
let ticket = try? PMTicketBridge.serialise( let ticket = try? PMTicketBridge.serialise(
printInfo, queue: resolvedQueue) printInfo, queue: resolvedQueue)
let paperSource = request.trays.first(where: {
$0.token == trayToken
}).map(\.id)
return PanelCaptureResult( return PanelCaptureResult(
properties: PrintPropertiesResult( properties: PrintPropertiesResult(
selectedPrinter: boundViaPM ? resolvedQueue : nil, selectedPrinter: boundViaPM ? resolvedQueue : nil,
options: PrintOptions( options: PrintOptions(
paperSource: paperSource,
orientation: CupsParsers.extractOrientation( orientation: CupsParsers.extractOrientation(
fromOptionsString: capturedOptions), fromOptionsString: capturedOptions),
paperSize: CupsParsers.extractOption( paperSize: CupsParsers.extractOption(
@@ -207,10 +298,10 @@ struct PrintPanelService {
ticket: ticket) ticket: ticket)
} }
/// Initial-selection `PMPrintSettings` writes — paper, quality, /// Initial-selection `PMPrintSettings` writes — paper, tray,
/// media type, orientation. All warn-only: a driver that ignores /// quality, media type, orientation. All warn-only: a driver that
/// a key must not keep the panel from opening (R12 surfaces via /// ignores a key must not keep the panel from opening (R12 surfaces
/// the capture echo instead). /// via the capture echo instead).
private func applyInitialSelections( private func applyInitialSelections(
_ selections: PrintPanelInitialSelections, _ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings, to settings: PMPrintSettings,
@@ -221,6 +312,13 @@ struct PrintPanelService {
paperSize, forKey: "PageSize", locked: false, paperSize, forKey: "PageSize", locked: false,
in: settings, context: "Print panel") 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 { if let key = selections.qualityKey, let value = selections.quality {
PMTicketBridge.setValue( PMTicketBridge.setValue(
value, forKey: key, locked: false, value, forKey: key, locked: false,
@@ -243,4 +341,35 @@ struct PrintPanelService {
} }
} }
/// `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
}
} }
@@ -24,7 +24,15 @@ final class PrintSessionViewModel: ObservableObject {
} }
} }
@Published var printerCaps = PrinterCapabilities() @Published var printerCaps = PrinterCapabilities()
@Published var selectedTray: Int? @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? { @Published var selectedMediaType: String? {
didSet { didSet {
// A media switch can invalidate the current quality pick — // A media switch can invalidate the current quality pick —
@@ -128,8 +136,11 @@ final class PrintSessionViewModel: ObservableObject {
if selectedMediaType == nil { if selectedMediaType == nil {
selectedMediaType = printerCaps.mediaTypes.first?.id selectedMediaType = printerCaps.mediaTypes.first?.id
} }
if selectedTray == nil { if selectedTray == nil || !printerCaps.trays
selectedTray = printerCaps.trays.first?.id .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 // Quality seeds inside the selected media's allowed set
// (#214): driver default when valid there, else the first // (#214): driver default when valid there, else the first
@@ -152,17 +163,26 @@ final class PrintSessionViewModel: ObservableObject {
// MARK: - Paper / quality selection (#183) // MARK: - Paper / quality selection (#183)
/// The quality picker's source (#214): `printerCaps.qualities` /// The raw tray token for the currently selected source (#218).
/// filtered to what the driver accepts for `selectedMediaType`. var selectedTrayToken: String? {
/// Equals the full list whenever the queue exposes no per-media guard let id = selectedTray else { return nil }
/// quality matrix — and never empty while `qualities` is not. 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] { var availableQualities: [PrinterQuality] {
printerCaps.qualities(forMediaType: selectedMediaType) printerCaps.qualities(
forMediaType: selectedMediaType,
trayToken: selectedTrayToken)
} }
/// Keep `selectedQuality` inside the allowed set for the current /// Keep `selectedQuality` inside the allowed set for the current
/// media: keep the pick when still valid, else the driver default /// media and tray: keep the pick when still valid, else the driver
/// when valid, else the first allowed choice (#214). /// default when valid, else the first allowed choice (#214/#218).
private func clampQualityToMedia() { private func clampQualityToMedia() {
let allowed = availableQualities let allowed = availableQualities
guard !allowed.isEmpty else { return } guard !allowed.isEmpty else { return }
@@ -178,6 +198,15 @@ final class PrintSessionViewModel: ObservableObject {
} }
} }
/// 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`: /// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
/// a capability whose name matches `pageSize.rawValue` → its id; /// a capability whose name matches `pageSize.rawValue` → its id;
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`); /// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
@@ -227,17 +256,22 @@ final class PrintSessionViewModel: ObservableObject {
let cups = environment.cupsService let cups = environment.cupsService
let selections = PrintPanelInitialSelections( let selections = PrintPanelInitialSelections(
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
mediaType: selectedMediaType, mediaType: selectedMediaType,
orientation: printOrientation) orientation: printOrientation)
let ticket = capturedTickets[queue]
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
.showProperties( .showProperties(
queue: queue, displayName: displayName, queue: queue, displayName: displayName,
cupsService: cups, cupsService: cups,
initialSelections: selections) initialSelections: selections,
ticket: ticket,
trays: printerCaps.trays)
else { else {
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
@@ -262,6 +296,28 @@ final class PrintSessionViewModel: ObservableObject {
if let media = result.properties.options.mediaType { if let media = result.properties.options.mediaType {
selectedMediaType = media 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/ // Capture-return (#183/#186): a dialog paper/quality/
// orientation change updates the Stage 2 selections — // orientation change updates the Stage 2 selections —
// never `workflow.pageSize` (printtarg layout is // never `workflow.pageSize` (printtarg layout is
@@ -410,7 +466,9 @@ final class PrintSessionViewModel: ObservableObject {
// the last gate before the driver). // the last gate before the driver).
var quality = selectedQuality var quality = selectedQuality
if let q = quality, if let q = quality,
!printerCaps.allowsQuality(q, forMediaType: selectedMediaType) { !printerCaps.allowsQuality(
q, forMediaType: selectedMediaType,
trayToken: selectedTrayToken) {
quality = availableQualities.first?.id ?? q quality = availableQualities.first?.id ?? q
} }
return TargetPrintRequest( return TargetPrintRequest(
@@ -429,6 +487,8 @@ final class PrintSessionViewModel: ObservableObject {
mediaType: selectedMediaType, mediaType: selectedMediaType,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: quality, quality: quality,
trayKey: printerCaps.trayKey,
tray: selectedTrayToken,
orientation: printOrientation), orientation: printOrientation),
optionKeys: optionKeys) optionKeys: optionKeys)
} }
+18 -8
View File
@@ -8,6 +8,8 @@ struct TargetPrintOverrides: Equatable {
var mediaType: String? var mediaType: String?
var qualityKey: String? // PrinterCapabilities.qualityKey var qualityKey: String? // PrinterCapabilities.qualityKey
var quality: String? var quality: String?
var trayKey: String? // PrinterCapabilities.trayKey
var tray: String? // raw tray token (#218)
var orientation: String? // "portrait" | "landscape" var orientation: String? // "portrait" | "landscape"
} }
@@ -42,10 +44,11 @@ enum TicketWriteResolver {
/// 5 `com.apple.print.PrintSettings.PMColorMatchingMode` /// 5 `com.apple.print.PrintSettings.PMColorMatchingMode`
/// = APCustomColorMatching (unlocked) /// = APCustomColorMatching (unlocked)
/// 6 `PageSize` = overrides.paperSize /// 6 `PageSize` = overrides.paperSize
/// 7 detectMediaTypeKey(optionKeys) = overrides.mediaType /// 7 overrides.trayKey = overrides.tray
/// 8 overrides.qualityKey = overrides.quality /// 8 detectMediaTypeKey(optionKeys) = overrides.mediaType
/// 9 detectDriverColorBypass(optionKeys) (unlocked) /// 9 overrides.qualityKey = overrides.quality
/// 10 `orientation-requested` = 3 | 4 /// 10 detectDriverColorBypass(optionKeys) (unlocked)
/// 11 `orientation-requested` = 3 | 4
/// ///
/// Invariants: `raw` can never appear (there is no `lp`); nothing /// Invariants: `raw` can never appear (there is no `lp`); nothing
/// is suppressed because a captured key already exists (D6 — the /// is suppressed because a captured key already exists (D6 — the
@@ -87,7 +90,14 @@ enum TicketWriteResolver {
key: "PageSize", value: paperSize, locked: false)) key: "PageSize", value: paperSize, locked: false))
} }
// 7 — media type via the queue's detected vendor key. // 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, if let mediaType = overrides.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey( let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -95,7 +105,7 @@ enum TicketWriteResolver {
key: mediaKey, value: mediaType, locked: false)) key: mediaKey, value: mediaType, locked: false))
} }
// 8 — print quality; the detected enumeration key travels in // 9 — print quality; the detected enumeration key travels in
// the overrides (`PrinterCapabilities.qualityKey`, #183). // the overrides (`PrinterCapabilities.qualityKey`, #183).
if let qualityKey = overrides.qualityKey, if let qualityKey = overrides.qualityKey,
let quality = overrides.quality { let quality = overrides.quality {
@@ -103,7 +113,7 @@ enum TicketWriteResolver {
key: qualityKey, value: quality, locked: false)) key: qualityKey, value: quality, locked: false))
} }
// 9 — driver "no colour adjustment" bypass. Never gated on // 10 — driver "no colour adjustment" bypass. Never gated on
// `ppdUncorrectedPassthrough` — macOS always bypasses. // `ppdUncorrectedPassthrough` — macOS always bypasses.
if let bypass = CupsParsers.detectDriverColorBypass( if let bypass = CupsParsers.detectDriverColorBypass(
optionKeys: optionKeys) { optionKeys: optionKeys) {
@@ -111,7 +121,7 @@ enum TicketWriteResolver {
key: bypass.key, value: bypass.value, locked: false)) key: bypass.key, value: bypass.value, locked: false))
} }
// 10 — orientation, CUPS IPP codes portrait=3 / landscape=4. // 11 — orientation, CUPS IPP codes portrait=3 / landscape=4.
if let orientation = overrides.orientation { if let orientation = overrides.orientation {
writes.append(TicketWrite( writes.append(TicketWrite(
key: "orientation-requested", key: "orientation-requested",
+6 -10
View File
@@ -297,7 +297,7 @@ struct Stage2View: View {
// ViewBuilder ≤10 children (R13). // ViewBuilder ≤10 children (R13).
HStack(spacing: 14) { HStack(spacing: 14) {
if !workflow.print.printerCaps.trays.isEmpty { 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) { ForEach(workflow.print.printerCaps.trays, id: \.id) {
Text($0.name).tag(Optional($0.id)) Text($0.name).tag(Optional($0.id))
} }
@@ -322,16 +322,12 @@ struct Stage2View: View {
if !workflow.print.printerCaps.qualities.isEmpty { if !workflow.print.printerCaps.qualities.isEmpty {
qualityGroup qualityGroup
} }
HStack(spacing: 0) { Picker("Orientation", selection: $workflow.print.printOrientation) {
Button("Portrait") { workflow.print.printOrientation = "portrait" } Text("Portrait").tag("portrait")
.buttonStyle(.bordered) Text("Landscape").tag("landscape")
.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")
} }
.frame(maxWidth: 200)
.accessibilityIdentifier("printerOrientationSelect")
Spacer() Spacer()
} }
// Stage 1 owns the custom dimensions — the caption lives // Stage 1 owns the custom dimensions — the caption lives
@@ -54,6 +54,28 @@ final class CupsOptionsFilterTests: XCTestCase {
XCTAssertNil(CupsParsers.extractMediaType( XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4")) 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. /// Issue 14 — the dlsym attempt order and first-success semantics.
+82 -4
View File
@@ -83,11 +83,13 @@ final class CupsParserTests: XCTestCase {
let caps = service.capabilities(from: listings, ppd: nil) let caps = service.capabilities(from: listings, ppd: nil)
XCTAssertEqual(caps.trays, [ XCTAssertEqual(caps.trays, [
PrinterTray(id: 1, name: "Auto"), PrinterTray(id: 1, token: "Auto", name: "Auto"),
PrinterTray(id: 2, name: "Main"), PrinterTray(id: 2, token: "Main", name: "Main"),
PrinterTray(id: 3, name: "Photo"), PrinterTray(id: 3, token: "Photo", name: "Photo"),
PrinterTray(id: 4, name: "Rear"), 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.first, PrinterPaperSize(id: 1, name: "3.5x5"))
XCTAssertEqual(caps.paperSizes.count, 10) XCTAssertEqual(caps.paperSizes.count, 10)
XCTAssertEqual(caps.mediaTypes.map(\.id), [ XCTAssertEqual(caps.mediaTypes.map(\.id), [
@@ -283,6 +285,82 @@ final class CupsParserTests: XCTestCase {
XCTAssertNil(pair(["PageSize"])) 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 // MARK: - #181 Canon media locale precedence + PPD encoding
/// The 18 Canon Pro9500 media types named in the issue — the ids /// The 18 Canon Pro9500 media types named in the issue — the ids
@@ -194,6 +194,73 @@ final class MediaQualityConstraintsTests: XCTestCase {
XCTAssertNil(map["13"]) 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 // MARK: - Canon cnb table
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`, /// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
@@ -308,4 +375,38 @@ final class MediaQualityConstraintsTests: XCTestCase {
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7) XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil)) 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"))
}
} }
+63 -1
View File
@@ -44,10 +44,16 @@ final class PrintPanelStubTests: XCTestCase {
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear", "ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue", "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?.selectedPrinter, "Other_Queue")
XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear") XCTAssertEqual(result?.options.cupsOptions, "MediaType=Photo InputSlot=Rear")
XCTAssertEqual(result?.options.mediaType, "Photo") XCTAssertEqual(result?.options.mediaType, "Photo")
XCTAssertEqual(result?.options.paperSource, 2)
XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true) XCTAssertEqual(result?.options.ppdUncorrectedPassthrough, true)
} }
} }
@@ -99,4 +105,60 @@ final class PrintPanelStubTests: XCTestCase {
XCTAssertNil(result?.options.cupsOptions) 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")
}
}
@@ -50,7 +50,7 @@ final class PrintSessionViewModelTests: XCTestCase {
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n' printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal Custom.WIDTHxHEIGHT\\n'
printf 'InputSlot/Media Source: Auto *Main Rear\\n' printf 'InputSlot/Media Source: Auto Main *Rear\\n'
printf 'MediaType/Media Type: *Stationery Glossy Matte\\n' printf 'MediaType/Media Type: *Stationery Glossy Matte\\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n' printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\\n'
exit 0 exit 0
@@ -99,6 +99,10 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) XCTAssertEqual(workflow.pageSize, .a4)
XCTAssertEqual(workflow.print.selectedPaperSize, 3) 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. // Quality seeds from the `*` default on caps load.
XCTAssertEqual(workflow.print.selectedQuality, "303") XCTAssertEqual(workflow.print.selectedQuality, "303")
// All seven Epson codes enumerate in driver order (#180). // All seven Epson codes enumerate in driver order (#180).
@@ -209,6 +213,7 @@ final class PrintSessionViewModelTests: XCTestCase {
let log = await waitForFile(spoolLogURL) let log = await waitForFile(spoolLogURL)
XCTAssertTrue(log.contains("PageSize=Letter"), log) XCTAssertTrue(log.contains("PageSize=Letter"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=301"), log) XCTAssertTrue(log.contains("EPIJ_Qual=301"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
} }
/// #201 — the stubbed panel never produces a `PrintTicket`, so a /// #201 — the stubbed panel never produces a `PrintTicket`, so a
@@ -241,6 +246,7 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertTrue(log.contains("PageSize=A4"), log) XCTAssertTrue(log.contains("PageSize=A4"), log)
XCTAssertTrue(log.contains("EPIJ_Qual=303"), log) XCTAssertTrue(log.contains("EPIJ_Qual=303"), log)
XCTAssertTrue(log.contains("MediaType=Stationery"), log) XCTAssertTrue(log.contains("MediaType=Stationery"), log)
XCTAssertTrue(log.contains("InputSlot=Rear"), log)
XCTAssertTrue(log.contains("orientation-requested=3"), log) XCTAssertTrue(log.contains("orientation-requested=3"), log)
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP_ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP_ColorMatchingMode=AP_ApplicationColorMatching"), log)
@@ -307,6 +313,52 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.pageSize, .a4) 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 /// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id). /// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws { func testPanelResultUnknownPaperLeavesSelection() async throws {
@@ -456,6 +508,35 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.print.selectedQuality, "303") 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 /// Seeding lands inside the allowed set even when the driver
/// default quality is invalid for the first media — end to end /// default quality is invalid for the first media — end to end
/// through `CupsService.capabilities(for:)` + a fixture /// through `CupsService.capabilities(for:)` + a fixture
@@ -2,9 +2,9 @@ import XCTest
@testable import ICCeryCore @testable import ICCeryCore
@testable import ICCery @testable import ICCery
/// Issue #201 Phase 4 — the resolved ticket-write list that replaces /// Issue #201 Phase 4 / #218 — the resolved ticket-write list that
/// the `lp` argv goldens: locked order 1–10, D6 (Stage 2 always /// replaces the `lp` argv goldens: locked order 1–11, D6 (Stage 2
/// wins), `raw` can never appear. /// always wins), `raw` can never appear.
final class TicketWriteResolverTests: XCTestCase { final class TicketWriteResolverTests: XCTestCase {
private func resolve( private func resolve(
@@ -63,6 +63,7 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "raw", mediaType: "raw", paperSize: "raw", mediaType: "raw",
qualityKey: "raw", quality: "raw", qualityKey: "raw", quality: "raw",
trayKey: "raw", tray: "raw",
orientation: "raw"), orientation: "raw"),
optionKeys: optionKeys) optionKeys: optionKeys)
XCTAssertFalse(r.writes.contains { XCTAssertFalse(r.writes.contains {
@@ -144,8 +145,8 @@ final class TicketWriteResolverTests: XCTestCase {
XCTAssertNil(value("orientation-requested", in: resolve())) XCTAssertNil(value("orientation-requested", in: resolve()))
} }
/// A `nil` override emits no write for that key; quality needs /// A `nil` override emits no write for that key; quality and tray
/// **both** the detected key and the value. /// need **both** the detected key and the value.
func testNilOverridesEmitNoWrite() { func testNilOverridesEmitNoWrite() {
let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"]) let r = resolve(optionKeys: ["MediaType", "EPIJ_Qual"])
XCTAssertNil(value("PageSize", in: r)) XCTAssertNil(value("PageSize", in: r))
@@ -168,14 +169,42 @@ final class TicketWriteResolverTests: XCTestCase {
overrides: TargetPrintOverrides(paperSize: "")) overrides: TargetPrintOverrides(paperSize: ""))
XCTAssertNil(value("PageSize", in: emptyPaper)) XCTAssertNil(value("PageSize", in: emptyPaper))
XCTAssertNil(emptyPaper.paperToken) 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))
} }
/// The write list equals the locked 1–10 order, exactly. /// #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() { func testLockedWriteOrder() {
let r = resolve( let r = resolve(
overrides: TargetPrintOverrides( overrides: TargetPrintOverrides(
paperSize: "A4", mediaType: "Photo", paperSize: "A4", mediaType: "Photo",
qualityKey: "EPIJ_Qual", quality: "305", qualityKey: "EPIJ_Qual", quality: "305",
trayKey: "EPIJ_FdSo", tray: "Rear",
orientation: "landscape"), orientation: "landscape"),
optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"]) optionKeys: ["MediaType", "EPIJ_Qual", "EPIJ_CMat"])
XCTAssertEqual(keys(r), [ XCTAssertEqual(keys(r), [
@@ -185,6 +214,7 @@ final class TicketWriteResolverTests: XCTestCase {
"PMCustomColorMatchingProfile", "PMCustomColorMatchingProfile",
"com.apple.print.PrintSettings.PMColorMatchingMode", "com.apple.print.PrintSettings.PMColorMatchingMode",
"PageSize", "PageSize",
"EPIJ_FdSo",
"MediaType", "MediaType",
"EPIJ_Qual", "EPIJ_Qual",
"EPIJ_CMat", "EPIJ_CMat",
+1 -1
View File
@@ -15,7 +15,7 @@ for arg in "$@"; do
done done
if [ "$list" = "1" ]; then if [ "$list" = "1" ]; then
printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' 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 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n'
printf 'EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307\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 'EPIJ_CMat/Color Adjust: *0 1 2 3\n'
@@ -124,8 +124,7 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(element("printerSelect").exists) XCTAssertTrue(element("printerSelect").exists)
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(element("btnPrinterProperties").exists) XCTAssertTrue(element("btnPrinterProperties").exists)
} }
+20 -31
View File
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
return recordedSpoolLog() return recordedSpoolLog()
} }
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button /// Scrolls `stage-2` with the synthesized scroll wheel so
/// moves up, clear of the Dock collision zone at the window's /// `identifier`'s button moves up, clear of the Dock collision
/// bottom edge (#132). /// zone at the window's bottom edge (#132).
/// ///
/// macOS overlay scrollbars are not in the AX tree — never use /// macOS overlay scrollbars are not in the AX tree — never use
/// `app.scrollBars` — and a synthesized scroll wheel is inert on /// `app.scrollBars` — and a click-drag does not scroll a macOS
/// this LazyVGrid, so the scroll is a real drag on the gallery /// ScrollView (content-drag scrolling is iOS-only); the scroll
/// cell's content. A stale/off-screen AX frame resolves to a screen /// wheel is the mechanism the platform supports (#215). A
/// point that can be a Dock icon — a coordinate click there once /// stale/off-screen AX frame resolves to a screen point that can
/// opened Calendar instead of Print. Callers must click only when /// be a Dock icon — a coordinate click there once opened Calendar
/// the returned element `isHittable`; never coordinate-click a /// instead of Print. Callers must click only when the returned
/// stale frame. /// element `isHittable`; never coordinate-click a stale frame.
@discardableResult @discardableResult
private func scrollStage2UntilHittable( private func scrollStage2UntilHittable(
_ identifier: String, _ identifier: String,
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
var button = app.buttons[identifier] var button = app.buttons[identifier]
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0") 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) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
button.frame.maxY < windowBottom - 80 { button.frame.maxY < windowBottom - 80 {
return button return button
} }
// Grab the upper half of the cell (the TIFF, not the Print // Wheel-down inside the stage-2 viewport: content moves
// button / Dock) and drag toward the top of the window. // UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves // toward the document bottom (#215).
// 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: scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.55)) CGVector(dx: 0.5, dy: 0.5))
.press(forDuration: 0.15, thenDragTo: .scroll(byDeltaX: 0, deltaY: -60)
scrollView.coordinate(withNormalizedOffset:
CGVector(dx: 0.5, dy: 0.15)))
}
RunLoop.current.run(until: Date().addingTimeInterval(0.4)) RunLoop.current.run(until: Date().addingTimeInterval(0.4))
button = app.buttons[identifier] button = app.buttons[identifier]
} }
@@ -183,8 +172,7 @@ final class Milestone3UITests: XCTestCase {
.waitForExistence(timeout: 10)) .waitForExistence(timeout: 10))
XCTAssertTrue(element("printerTraySelect").exists) XCTAssertTrue(element("printerTraySelect").exists)
XCTAssertTrue(element("printerMediaTypeSelect").exists) XCTAssertTrue(element("printerMediaTypeSelect").exists)
XCTAssertTrue(element("btnOrientPortrait").exists) XCTAssertTrue(element("printerOrientationSelect").exists)
XCTAssertTrue(element("btnOrientLandscape").exists)
XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled)
} }
@@ -213,7 +201,7 @@ final class Milestone3UITests: XCTestCase {
func testPanelSelectionsApplyBackToSpoolWrites() throws { func testPanelSelectionsApplyBackToSpoolWrites() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"InputSlot=Rear MediaType=PhotographicGlossy" "EPIJ_FdSo=Rear MediaType=PhotographicGlossy"
launchApp() launchApp()
reachPrintPanel() reachPrintPanel()
_ = waitFor("printerStatusBadge") _ = waitFor("printerStatusBadge")
@@ -231,6 +219,7 @@ final class Milestone3UITests: XCTestCase {
XCTAssertTrue(log.contains( XCTAssertTrue(log.contains(
"AP.ColorMatchingMode=AP_ApplicationColorMatching"), log) "AP.ColorMatchingMode=AP_ApplicationColorMatching"), log)
XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log) XCTAssertTrue(log.contains("MediaType=PhotographicGlossy"), log)
XCTAssertTrue(log.contains("EPIJ_FdSo=Rear"), log)
// Detected bypass for the mock queue (EPIJ_CMat present in // Detected bypass for the mock queue (EPIJ_CMat present in
// lpoptions -l) is always written. // lpoptions -l) is always written.
XCTAssertTrue(log.contains("EPIJ_CMat=3"), log) XCTAssertTrue(log.contains("EPIJ_CMat=3"), log)
+4 -3
View File
@@ -43,8 +43,9 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Meaning | | 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). | | `id` | `u16` | Windows: Win32 bin ID from `DC_BINS`. Unix/macOS: 1-based index of the detected tray listing. |
| `name` | `String` | Windows: 24-WCHAR `DC_BINNAMES` string, or `"Tray {id}"` if empty. Unix: PPD choice with leading `*` stripped (e.g. `Auto`, `Upper`). | | `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) ### `PrinterPaperSize` (mod.rs:22–25)
@@ -78,7 +79,7 @@ AGENTS.md warns: adding fields requires updating **every** platform constructor
| Field | Type | Windows | Linux | macOS | | 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 | | `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 | | `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` | | `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` |
+8 -5
View File
@@ -236,7 +236,8 @@ After OK:
4. Copy via `CStr`, `free(opts_ptr)`. 4. Copy via `CStr`, `free(opts_ptr)`.
5. `filter_cups_options_string` → `cups_options`. 5. `filter_cups_options_string` → `cups_options`.
6. `extract_media_type_from_options` → `media_type`. 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`. Failure of `PMPrintSettingsToOptions` is a hard `Err`.
@@ -264,7 +265,7 @@ Captured from the panel into the Stage 2 mirror (v1 forwarded them to `lp`):
``` ```
Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType Media: MediaType, CNIJMediaType, EPIJ_Medi, StpMediaType
Tray: InputSlot, AP_D_InputSlot Tray: InputSlot, MediaSource, AP_D_InputSlot, EPIJ_FdSo, EPIJ_PSrc, CNIJMediaSupply
Size: PageSize Size: PageSize
Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat, Color: CNIJIntent2, CNIJIntent, EPIJ_CMat, EPIJ_CCor, EPIJ_OSColMat,
ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel ColorCorrection, StpColorCorrection, EpsonColorMode, ColorModel
@@ -306,8 +307,9 @@ S14 PMRelease the printer on every path (defer)
`TicketWriteResolver` produces the resolved write list as a pure value, in a `TicketWriteResolver` produces the resolved write list as a pure value, in a
locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) → locked order: both `AP_*` keys (locked) → the three Quartz keys (⑤′) →
`PageSize` → the detected media key → the detected quality key → the driver `PageSize` → the detected tray key → the detected media key → the detected
colour bypass → `orientation-requested`. Stage 2 overrides **always win** 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); over the rehydrated ticket (D6 — the v1 captured-wins inversion is gone);
`raw` can never appear because there is no `lp`. Panels stay off `raw` can never appear because there is no `lp`. Panels stay off
(`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread` (`showsPrintPanel` / `showsProgressPanel` false, `canSpawnSeparateThread`
@@ -407,7 +409,8 @@ AppKit: `NSPrintInfo`, `NSPrintPanel`, `NSPrinter::printerWithName`, `NSPrintPan
### PPD keys (detection) ### PPD keys (detection)
``` ```
Media: CNIJMediaType, EPIJ_Medi, StpMediaType, MediaType, MediaSource, InputSlot, PageSize, MediaSize 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 Color: CNIJIntent2, CNIJIntent, EPIJ_CCor, EPIJ_CMat, StpColorCorrection, ColorCorrection, EpsonColorMode
``` ```
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:** **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`). - 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`. - Canon Super Fine is three coordinated keys (`CNIJPrintQuality=0`, `CNIJPrintMode2=5`, `CNIJPQualitySlider=5`), not `cupsPrintQuality=High`.
--- ---
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.2" MARKETING_VERSION: "2.0.4"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or # Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version. # `git describe` string); About shows it next to the marketing version.
+1 -1
View File
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
fi fi
echo "==> Building DMG" 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')" BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
# Tagged/described builds carry the tag in the DMG name so prerelease # 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 # artefacts are self-describing (ICCery-2.0.0-pre2-grok-236.dmg); a plain