Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7f6a8bd87 | ||
|
|
ed91ebf807 | ||
|
|
77c065f433 | ||
|
|
f7099a9808 |
@@ -93,6 +93,53 @@ codesign -dvv <sidecar>
|
||||
```
|
||||
Universal (`ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO`) is still required for release verification / packaging.
|
||||
|
||||
## Remote test builds (runner host)
|
||||
Test builds done as part of change implementations run on the CI runner
|
||||
host, not the dev machine — the runner is a macOS 12.7.6 **x86_64** VM
|
||||
with Xcode 14.2, and CI failures there are often environmental and do not
|
||||
reproduce locally.
|
||||
|
||||
- Host: `localadmin@192.168.0.172` (SSH, LAN). Runner daemon:
|
||||
`gitea-runner daemon -c /opt/gitea-runner/config.yaml`.
|
||||
- Credentials: `~/Projects/build-host.env` defines `BUILD_HOST_USER` /
|
||||
`BUILD_HOST_PASSWORD`. **Never read or print this file** — load it with
|
||||
`set -a; source ~/Projects/build-host.env; set +a` so values stay in the
|
||||
environment.
|
||||
- `sshpass` is installed (`/usr/local/bin/sshpass`). Feed the password via
|
||||
the `SSHPASS` env var — never on the command line:
|
||||
```sh
|
||||
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e ssh localadmin@192.168.0.172 '<cmd>'
|
||||
SSHPASS="$BUILD_HOST_PASSWORD" sshpass -e scp <local> localadmin@192.168.0.172:<remote>
|
||||
```
|
||||
First connection needs `-o StrictHostKeyChecking=accept-new`.
|
||||
- Remote checkout: `~/Projects/iccery-v2-mac` (anonymous HTTPS clone works;
|
||||
full history needed by `scripts/version.sh`). Before testing, sync it to
|
||||
the same base commit as the local work:
|
||||
`git fetch origin && git checkout <local base sha>`, then `scp` each
|
||||
locally-changed file over its remote counterpart (uncommitted work
|
||||
transfers this way — there is no push).
|
||||
- `xcodegen` is **not** on PATH: prepend
|
||||
`$HOME/.local/xcodegen/2.38.0/xcodegen/bin` (prebuilt 2.38.0 installed
|
||||
by `scripts/ensure-host-tools.sh`; brew's formula needs Xcode 15.3,
|
||||
impossible on macOS 12 — #109).
|
||||
- Mirror `.gitea/workflows/macos.yml` exactly, from the repo root with
|
||||
`DERIVED=build/DerivedData-test`:
|
||||
`scripts/ensure-host-tools.sh` → `xcodegen generate --spec project.yml`
|
||||
→ `eval "$(scripts/version.sh)"` → `xcodebuild build-for-testing
|
||||
-scheme ICCery -destination 'platform=macOS' -derivedDataPath "$DERIVED"
|
||||
-configuration Debug ARCHS="$(uname -m)" ONLY_ACTIVE_ARCH=NO
|
||||
CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY='-' MARKETING_VERSION=…
|
||||
CURRENT_PROJECT_VERSION=… ICCERY_RELEASE_TAG=…` → codesign every
|
||||
`*_PackageProduct.framework` under `$DERIVED/Build/Products/Debug`
|
||||
(#119) → `xcodebuild test-without-building -xctestrun <ICCery*.xctestrun>
|
||||
-only-testing:ICCeryCoreTests -destination 'platform=macOS'
|
||||
-derivedDataPath "$DERIVED"`.
|
||||
- Run **unit tests only** (`ICCeryCoreTests`). UI tests need a logged-in
|
||||
GUI session and fail environmentally on the VM (runs 29700, 29804,
|
||||
42183) — do not use them for verification unless asked.
|
||||
- xcodebuild takes minutes — run the ssh command in a background shell
|
||||
and poll rather than blocking.
|
||||
|
||||
## Private ColorSync SPI
|
||||
2-arg `(PMPrintSession, CFStringRef) -> OSStatus`. Never pass integer `1`.
|
||||
Modes: `AP_ApplicationColorMatching` then `ApplicationColorMatching`.
|
||||
|
||||
@@ -115,9 +115,16 @@ public struct CupsService: Sendable {
|
||||
|
||||
/// Trays / paper sizes / media types for a queue, with PPD
|
||||
/// `*Key id/Human:` enrichment when the queue's PPD is readable.
|
||||
/// Also resolves the per-media quality map from vendor driver
|
||||
/// data (`MediaQualityConstraints`, #214) — empty when the driver
|
||||
/// exposes none.
|
||||
public func capabilities(for queue: String) async throws -> PrinterCapabilities {
|
||||
let listings = try await optionListings(for: queue)
|
||||
return capabilities(from: listings, ppd: loadPPD(for: queue))
|
||||
let ppd = loadPPD(for: queue)
|
||||
var caps = capabilities(from: listings, ppd: ppd)
|
||||
caps.qualityIDsByMediaType = MediaQualityConstraints.resolve(
|
||||
listings: listings, ppd: ppd)
|
||||
return caps
|
||||
}
|
||||
|
||||
/// Pure mapping — extracted so fixture tests need no process.
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import Foundation
|
||||
|
||||
/// Per-media quality validity (#214): which of the queue's quality
|
||||
/// tokens the driver actually accepts for a given media type. The
|
||||
/// answer is not in `lpoptions -l`, IPP, or standard PPD
|
||||
/// `*UIConstraints` for the two driver families handled here —
|
||||
///
|
||||
/// - Epson InkjetPrinter2: `*EPIJUIConstraint: <cond>|<forbidden>`
|
||||
/// lines in the machine bundle's
|
||||
/// `…/Contents/Resources/PDEData.dat`. A rule forbids its RHS
|
||||
/// choice while every LHS `*<key> <value>` term holds. LHS keys
|
||||
/// other than the media key (`EPIJ_PSrc`, `EPIJ_FdSo`,
|
||||
/// `EPIJ_Ink_`, …) are evaluated against the queue's `lpoptions`
|
||||
/// defaults — reproducing the driver's PDE state when it opens.
|
||||
/// - Canon BJPrinter: `*CNIJNameTblPath` + `*CNIJTableID` PPD keys
|
||||
/// locate `cnb_<TableID>0.tbl`, a binary record DB whose 20-byte
|
||||
/// `{u16 0x30, u16 1, u32 0, u16 family, u16 flag, u32 mediaID,
|
||||
/// u16 0, u16 quality}` records enumerate the allowed
|
||||
/// `CNIJPrintQuality` values per `CNIJMediaType` (`mediaID`
|
||||
/// `| 0x10000` marks the borderless variant of the same media).
|
||||
/// - Generic: standard PPD `*UIConstraints:`/`*Constraints:` pairs
|
||||
/// (Gutenprint etc.) — first non-empty source wins.
|
||||
///
|
||||
/// Every parser is pure; file access is injected so tests need no
|
||||
/// installed driver. Failure or absence yields an empty map — callers
|
||||
/// treat that as "unconstrained" and keep the full quality list.
|
||||
public enum MediaQualityConstraints {
|
||||
|
||||
/// media-type id → allowed quality ids. Sources tried in order
|
||||
/// (PPD constraints → Epson `PDEData.dat` → Canon `cnb` table);
|
||||
/// the first source producing any entries wins.
|
||||
public static func resolve(
|
||||
listings: [CupsOptionListing],
|
||||
ppd: String?,
|
||||
readFile: (URL) -> Data? = { try? Data(contentsOf: $0) }
|
||||
) -> [String: Set<String>] {
|
||||
let optionKeys = Set(listings.map(\.key))
|
||||
guard let mediaKey = CupsParsers.detectMediaTypeKey(
|
||||
optionKeys: optionKeys),
|
||||
let qualityKey = CupsParsers.detectQualityKey(
|
||||
optionKeys: optionKeys),
|
||||
let mediaListing = listings.first(where: {
|
||||
$0.key == mediaKey }),
|
||||
let qualityListing = listings.first(where: {
|
||||
$0.key == qualityKey })
|
||||
else { return [:] }
|
||||
let mediaIDs = Set(mediaListing.choices)
|
||||
let qualityIDs = Set(qualityListing.choices)
|
||||
let defaults = Dictionary(
|
||||
listings.compactMap { l in l.defaultChoice.map { (l.key, $0) } },
|
||||
uniquingKeysWith: { first, _ in first })
|
||||
guard let ppd else { return [:] }
|
||||
|
||||
let ppdMap = allowedMap(
|
||||
forbidden: ppdUIConstraints(
|
||||
ppd, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||
mediaIDs: mediaIDs, qualityIDs: qualityIDs),
|
||||
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||
if !ppdMap.isEmpty { return ppdMap }
|
||||
|
||||
if let url = epijPDEDataPath(ppd: ppd),
|
||||
let data = readFile(url),
|
||||
let dat = String(data: data, encoding: .utf8)
|
||||
?? String(data: data, encoding: .isoLatin1) {
|
||||
let map = allowedMap(
|
||||
forbidden: epijUIConstraints(
|
||||
dat, mediaKey: mediaKey, qualityKey: qualityKey,
|
||||
mediaIDs: mediaIDs, qualityIDs: qualityIDs,
|
||||
defaults: defaults),
|
||||
mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||
if !map.isEmpty { return map }
|
||||
}
|
||||
|
||||
if let url = cnijTablePath(ppd: ppd),
|
||||
let data = readFile(url) {
|
||||
let map = cnijMediaQualityTable(
|
||||
data, mediaIDs: mediaIDs, qualityIDs: qualityIDs)
|
||||
if !map.isEmpty { return map }
|
||||
}
|
||||
return [:]
|
||||
}
|
||||
|
||||
// MARK: - PPD keyword lookup
|
||||
|
||||
/// `*<key>: <value>` or `*<key>: "<value>"` → the value.
|
||||
/// Exact key match — `*CNIJTableIDFoo:` must not satisfy a lookup
|
||||
/// for `CNIJTableID`.
|
||||
public static func ppdKeyword(_ ppd: String, _ key: String) -> String? {
|
||||
for raw in ppd.split(separator: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*\(key)") else { continue }
|
||||
let rest = line.dropFirst(key.count + 1)
|
||||
guard rest.first == ":" else { continue }
|
||||
var value = rest.dropFirst()
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
if value.count >= 2,
|
||||
value.hasPrefix("\""), value.hasSuffix("\"") {
|
||||
value = String(value.dropFirst().dropLast())
|
||||
}
|
||||
if !value.isEmpty { return value }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Generic PPD constraints
|
||||
|
||||
/// `*UIConstraints:`/`*Constraints:` lines pair two conflicting
|
||||
/// option choices; the ones naming both the media key and the
|
||||
/// quality key produce forbidden (media, quality) pairs.
|
||||
/// `*cupsUIConstraints` resolver triples do not match the
|
||||
/// `*…Constraints:` prefixes, so they are skipped naturally.
|
||||
public static func ppdUIConstraints(
|
||||
_ ppd: String,
|
||||
mediaKey: String,
|
||||
qualityKey: String,
|
||||
mediaIDs: Set<String>,
|
||||
qualityIDs: Set<String>
|
||||
) -> [String: Set<String>] {
|
||||
var forbidden: [String: Set<String>] = [:]
|
||||
for raw in ppd.split(separator: "\n") {
|
||||
var line = raw.trimmingCharacters(in: .whitespaces)
|
||||
if line.hasPrefix("*UIConstraints:") {
|
||||
line = String(line.dropFirst("*UIConstraints:".count))
|
||||
} else if line.hasPrefix("*Constraints:") {
|
||||
line = String(line.dropFirst("*Constraints:".count))
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
let terms = constraintTerms(line)
|
||||
guard let media = terms[mediaKey], mediaIDs.contains(media),
|
||||
let quality = terms[qualityKey],
|
||||
qualityIDs.contains(quality)
|
||||
else { continue }
|
||||
forbidden[media, default: []].insert(quality)
|
||||
}
|
||||
return forbidden
|
||||
}
|
||||
|
||||
// MARK: - Epson PDEData.dat
|
||||
|
||||
/// `EPIJDriverBasePath` + `EPIJMachineBundleName` → the machine
|
||||
/// bundle's `Contents/Resources/PDEData.dat` (uniform across the
|
||||
/// InkjetPrinter2 family).
|
||||
public static func epijPDEDataPath(ppd: String) -> URL? {
|
||||
guard let base = ppdKeyword(ppd, "EPIJDriverBasePath"),
|
||||
let bundle = ppdKeyword(ppd, "EPIJMachineBundleName")
|
||||
else { return nil }
|
||||
return URL(fileURLWithPath: base)
|
||||
.appendingPathComponent("Machine")
|
||||
.appendingPathComponent(bundle)
|
||||
.appendingPathComponent("Contents/Resources/PDEData.dat")
|
||||
}
|
||||
|
||||
/// `*EPIJUIConstraint:` forbidden-pair rules → (media, quality)
|
||||
/// pairs the driver greys out. A rule *fires* when its LHS
|
||||
/// `*<mediaKey>` term equals the media (absent media term →
|
||||
/// applies to every media) and every other LHS term's value
|
||||
/// equals that key's `lpoptions` default. A term whose key the
|
||||
/// queue does not advertise counts as satisfied — hiding a usable
|
||||
/// quality is a soft restriction, while showing an invalid one
|
||||
/// re-creates the reported print failure.
|
||||
public static func epijUIConstraints(
|
||||
_ dat: String,
|
||||
mediaKey: String,
|
||||
qualityKey: String,
|
||||
mediaIDs: Set<String>,
|
||||
qualityIDs: Set<String>,
|
||||
defaults: [String: String]
|
||||
) -> [String: Set<String>] {
|
||||
var forbidden: [String: Set<String>] = [:]
|
||||
for raw in dat.split(separator: "\n") {
|
||||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||
guard line.hasPrefix("*EPIJUIConstraint:"),
|
||||
let bar = line.firstIndex(of: "|")
|
||||
else { continue }
|
||||
let rhs = constraintTerms(
|
||||
String(line[line.index(after: bar)...]))
|
||||
guard let quality = rhs[qualityKey],
|
||||
qualityIDs.contains(quality)
|
||||
else { continue }
|
||||
let lhs = constraintTerms(
|
||||
String(line[
|
||||
line.index(
|
||||
line.startIndex,
|
||||
offsetBy: "*EPIJUIConstraint:".count)..<bar]))
|
||||
// Non-media terms must hold at the queue's defaults;
|
||||
// a key absent from `defaults` is satisfied (see above).
|
||||
let fires = lhs.allSatisfy { key, value in
|
||||
key == mediaKey || value.isEmpty
|
||||
|| (defaults[key].map { $0 == value } ?? true)
|
||||
}
|
||||
guard fires else { continue }
|
||||
if let media = lhs[mediaKey] {
|
||||
guard mediaIDs.contains(media) else { continue }
|
||||
forbidden[media, default: []].insert(quality)
|
||||
} else {
|
||||
for media in mediaIDs {
|
||||
forbidden[media, default: []].insert(quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
return forbidden
|
||||
}
|
||||
|
||||
// MARK: - Canon cnb table
|
||||
|
||||
/// `CNIJNameTblPath` + `CNIJTableID` → `cnb_<TableID>0.tbl`.
|
||||
public static func cnijTablePath(ppd: String) -> URL? {
|
||||
guard let dir = ppdKeyword(ppd, "CNIJNameTblPath"),
|
||||
let tableID = ppdKeyword(ppd, "CNIJTableID")
|
||||
else { return nil }
|
||||
return URL(fileURLWithPath: dir)
|
||||
.appendingPathComponent("cnb_\(tableID)0.tbl")
|
||||
}
|
||||
|
||||
/// Scans the Canon table for 20-byte LE records anchored on
|
||||
/// `30 00 01 00 00 00 00 00`: `{u16 family, u16 flag,
|
||||
/// u32 mediaID, u16 pad, u16 quality}` follows the anchor.
|
||||
/// A row counts only when `pad == 0`, `mediaID & 0xFFFF` is a
|
||||
/// listed media id, and `quality` is a listed quality id — so
|
||||
/// unrelated tables cannot inject false entries. Records carry a
|
||||
/// `family` field (0x03 on the Pro9500 II); the modal family is
|
||||
/// used so sibling record layouts in the same file are ignored.
|
||||
/// Returns allowed (not forbidden) sets directly.
|
||||
public static func cnijMediaQualityTable(
|
||||
_ data: Data,
|
||||
mediaIDs: Set<String>,
|
||||
qualityIDs: Set<String>
|
||||
) -> [String: Set<String>] {
|
||||
let mediaNums = Set(mediaIDs.compactMap { UInt32($0) })
|
||||
let qualityNums = Set(qualityIDs.compactMap { UInt16($0) })
|
||||
guard !mediaNums.isEmpty, !qualityNums.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
let anchor: [UInt8] = [0x30, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00]
|
||||
var rows: [UInt16: [(media: UInt32, quality: UInt16)]] = [:]
|
||||
data.withUnsafeBytes { buffer in
|
||||
guard let base = buffer.baseAddress else { return }
|
||||
var i = 0
|
||||
while i + 20 <= buffer.count {
|
||||
if memcmp(base + i, anchor, anchor.count) == 0 {
|
||||
let family = readU16(base, i + 8)
|
||||
let media = readU32(base, i + 12)
|
||||
let pad = readU16(base, i + 16)
|
||||
let quality = readU16(base, i + 18)
|
||||
let baseMedia = media & 0xFFFF
|
||||
if pad == 0,
|
||||
mediaNums.contains(baseMedia),
|
||||
qualityNums.contains(quality) {
|
||||
rows[family, default: []].append(
|
||||
(baseMedia, quality))
|
||||
}
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
guard let dominant = rows.max(by: { $0.value.count < $1.value.count })
|
||||
else { return [:] }
|
||||
var map: [String: Set<String>] = [:]
|
||||
for row in dominant.value {
|
||||
map[String(row.media), default: []].insert(String(row.quality))
|
||||
}
|
||||
// Keep strict subsets only — a media with the full set (or an
|
||||
// empty one) is unconstrained as far as the UI is concerned.
|
||||
map = map.filter { _, ids in
|
||||
!ids.isEmpty && ids != qualityIDs
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// MARK: - Shared
|
||||
|
||||
/// `*<key> <value>` term extraction for both PPD and Epson
|
||||
/// constraint syntax. A `*<key>` not followed by a value is a
|
||||
/// wildcard term (empty-string value → always satisfied).
|
||||
private static func constraintTerms(_ text: String) -> [String: String] {
|
||||
var terms: [String: String] = [:]
|
||||
let tokens = text.split(separator: " ").map(String.init)
|
||||
var index = 0
|
||||
while index < tokens.count {
|
||||
guard tokens[index].hasPrefix("*") else {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let key = String(tokens[index].dropFirst())
|
||||
if index + 1 < tokens.count,
|
||||
!tokens[index + 1].hasPrefix("*") {
|
||||
terms[key] = tokens[index + 1]
|
||||
index += 2
|
||||
} else {
|
||||
terms[key] = ""
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
/// forbidden (media → quality ids) → allowed map entries,
|
||||
/// emitting only strict non-empty subsets: a media whose allowed
|
||||
/// set is empty or equals the full roster is unconstrained as far
|
||||
/// as the picker is concerned.
|
||||
private static func allowedMap(
|
||||
forbidden: [String: Set<String>],
|
||||
mediaIDs: Set<String>,
|
||||
qualityIDs: Set<String>
|
||||
) -> [String: Set<String>] {
|
||||
var map: [String: Set<String>] = [:]
|
||||
for media in mediaIDs {
|
||||
let allowed = qualityIDs.subtracting(forbidden[media] ?? [])
|
||||
if !allowed.isEmpty, allowed != qualityIDs {
|
||||
map[media] = allowed
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private static func readU16(_ base: UnsafeRawPointer, _ offset: Int) -> UInt16 {
|
||||
UInt16(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||
| UInt16(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||
}
|
||||
|
||||
private static func readU32(_ base: UnsafeRawPointer, _ offset: Int) -> UInt32 {
|
||||
UInt32(base.load(fromByteOffset: offset, as: UInt8.self))
|
||||
| UInt32(base.load(fromByteOffset: offset + 1, as: UInt8.self)) << 8
|
||||
| UInt32(base.load(fromByteOffset: offset + 2, as: UInt8.self)) << 16
|
||||
| UInt32(base.load(fromByteOffset: offset + 3, as: UInt8.self)) << 24
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
public var qualities: [PrinterQuality]
|
||||
/// The `*`-marked default choice from `lpoptions -l`, if any.
|
||||
public var qualityDefault: String?
|
||||
/// Media-type id → the quality ids the driver accepts for it
|
||||
/// (#214 — `MediaQualityConstraints`). Empty when the driver
|
||||
/// exposes no per-media quality matrix; a missing entry means
|
||||
/// "unconstrained" — show everything.
|
||||
public var qualityIDsByMediaType: [String: Set<String>]
|
||||
|
||||
public init(
|
||||
trays: [PrinterTray] = [],
|
||||
@@ -115,7 +120,8 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
supportsOrientation: Bool = true,
|
||||
qualityKey: String? = nil,
|
||||
qualities: [PrinterQuality] = [],
|
||||
qualityDefault: String? = nil
|
||||
qualityDefault: String? = nil,
|
||||
qualityIDsByMediaType: [String: Set<String>] = [:]
|
||||
) {
|
||||
self.trays = trays
|
||||
self.paperSizes = paperSizes
|
||||
@@ -124,6 +130,35 @@ public struct PrinterCapabilities: Codable, Equatable, Sendable {
|
||||
self.qualityKey = qualityKey
|
||||
self.qualities = qualities
|
||||
self.qualityDefault = qualityDefault
|
||||
self.qualityIDsByMediaType = qualityIDsByMediaType
|
||||
}
|
||||
|
||||
/// Qualities valid for `mediaID`, preserving the driver's listing
|
||||
/// order (#180). 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?) -> [PrinterQuality] {
|
||||
guard let mediaID,
|
||||
let allowed = qualityIDsByMediaType[mediaID],
|
||||
!allowed.isEmpty
|
||||
else { return qualities }
|
||||
let filtered = qualities.filter { allowed.contains($0.id) }
|
||||
return filtered.isEmpty ? qualities : filtered
|
||||
}
|
||||
|
||||
/// Whether `qualityID` is usable with `mediaID` — `true` whenever
|
||||
/// the driver exposes no constraint for that media (#214). A
|
||||
/// quality absent from `qualities` entirely is still allowed: a
|
||||
/// driver-captured token can be legitimate even when `lpoptions`
|
||||
/// never listed it.
|
||||
public func allowsQuality(
|
||||
_ qualityID: String, forMediaType mediaID: String?
|
||||
) -> Bool {
|
||||
guard let mediaID,
|
||||
let allowed = qualityIDsByMediaType[mediaID],
|
||||
!allowed.isEmpty
|
||||
else { return true }
|
||||
return allowed.contains(qualityID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
|
||||
|---|---|
|
||||
| Product | ICCery v2 for macOS |
|
||||
| Bundle | `com.gronod.iccery2` |
|
||||
| Version | 2.0.0 |
|
||||
| Version | 2.0.3 |
|
||||
| Floor | macOS 12.0 Monterey, universal `arm64` + `x86_64` |
|
||||
| Toolchain | Xcode 14.2 / Swift 5.7 (project `SWIFT_VERSION` is 5.0) |
|
||||
| CI | Gitea Actions `macos-12` runner |
|
||||
|
||||
@@ -24,6 +24,7 @@ struct AppEnvironment: Sendable {
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
var bundledRoot = AppPaths.bundledArgyllDir
|
||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||
var ppdDir = URL(fileURLWithPath: "/etc/cups/ppd")
|
||||
#if DEBUG
|
||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||
overrideDir = URL(fileURLWithPath: dir)
|
||||
@@ -34,6 +35,11 @@ struct AppEnvironment: Sendable {
|
||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||
cupsDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
// #214 — fixture PPD dir lets UI tests exercise the media→
|
||||
// quality constraint resolver without installed drivers.
|
||||
if let dir = environment["ICCERY_CUPS_PPD_DIR"], !dir.isEmpty {
|
||||
ppdDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
#endif
|
||||
return AppEnvironment(
|
||||
stateStore: WizardStateStore(),
|
||||
@@ -46,7 +52,7 @@ struct AppEnvironment: Sendable {
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: .shared,
|
||||
binaryDir: cupsDir),
|
||||
binaryDir: cupsDir, ppdDir: ppdDir),
|
||||
historyStore: VerificationHistoryStore(),
|
||||
mediaStore: MediaLibraryStore(),
|
||||
recentProjectsStore: RecentProjectsStore()
|
||||
|
||||
@@ -25,7 +25,15 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
}
|
||||
@Published var printerCaps = PrinterCapabilities()
|
||||
@Published var selectedTray: Int?
|
||||
@Published var selectedMediaType: String?
|
||||
@Published var selectedMediaType: String? {
|
||||
didSet {
|
||||
// A media switch can invalidate the current quality pick —
|
||||
// re-clamp into the driver's allowed set (#214).
|
||||
if selectedMediaType != oldValue {
|
||||
clampQualityToMedia()
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PrinterPaperSize.id` — `0` is the synthetic custom entry (#183).
|
||||
@Published var selectedPaperSize: Int?
|
||||
/// Print-quality option token, e.g. `"303"` (#183).
|
||||
@@ -123,9 +131,16 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
if selectedTray == nil {
|
||||
selectedTray = printerCaps.trays.first?.id
|
||||
}
|
||||
// Quality seeds inside the selected media's allowed set
|
||||
// (#214): driver default when valid there, else the first
|
||||
// allowed choice.
|
||||
if selectedQuality == nil {
|
||||
selectedQuality = printerCaps.qualityDefault
|
||||
?? printerCaps.qualities.first?.id
|
||||
let allowed = availableQualities
|
||||
selectedQuality = printerCaps.qualityDefault.flatMap { d in
|
||||
allowed.contains(where: { $0.id == d }) ? d : nil
|
||||
} ?? allowed.first?.id
|
||||
} else {
|
||||
clampQualityToMedia()
|
||||
}
|
||||
// Caps reload is a re-mirror trigger for the paper picker
|
||||
// (#183 E4) — pageSize + printer changes route here too.
|
||||
@@ -137,6 +152,32 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
|
||||
// MARK: - Paper / quality selection (#183)
|
||||
|
||||
/// The quality picker's source (#214): `printerCaps.qualities`
|
||||
/// filtered to what the driver accepts for `selectedMediaType`.
|
||||
/// Equals the full list whenever the queue exposes no per-media
|
||||
/// quality matrix — and never empty while `qualities` is not.
|
||||
var availableQualities: [PrinterQuality] {
|
||||
printerCaps.qualities(forMediaType: selectedMediaType)
|
||||
}
|
||||
|
||||
/// Keep `selectedQuality` inside the allowed set for the current
|
||||
/// media: keep the pick when still valid, else the driver default
|
||||
/// when valid, else the first allowed choice (#214).
|
||||
private func clampQualityToMedia() {
|
||||
let allowed = availableQualities
|
||||
guard !allowed.isEmpty else { return }
|
||||
if let quality = selectedQuality,
|
||||
allowed.contains(where: { $0.id == quality }) {
|
||||
return
|
||||
}
|
||||
if let fallback = printerCaps.qualityDefault,
|
||||
allowed.contains(where: { $0.id == fallback }) {
|
||||
selectedQuality = fallback
|
||||
} else {
|
||||
selectedQuality = allowed.first?.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Seed `selectedPaperSize` from Stage 1's `workflow.pageSize`:
|
||||
/// a capability whose name matches `pageSize.rawValue` → its id;
|
||||
/// `.custom` → the synthetic `Custom.<pt>x<pt>` entry (`id: 0`);
|
||||
@@ -230,7 +271,12 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
.first(where: { $0.name == paper }) {
|
||||
selectedPaperSize = match.id
|
||||
}
|
||||
if let quality = result.properties.options.quality {
|
||||
// A captured quality the driver rejects for the
|
||||
// (possibly just-captured) media is dropped — keeping
|
||||
// it would only re-create a failed print (#214).
|
||||
if let quality = result.properties.options.quality,
|
||||
printerCaps.allowsQuality(
|
||||
quality, forMediaType: selectedMediaType) {
|
||||
selectedQuality = quality
|
||||
}
|
||||
if let orientation = result.properties.options.orientation {
|
||||
@@ -358,6 +404,15 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
let optionKeys = (try? await environment.cupsService
|
||||
.optionKeys(for: queue)) ?? []
|
||||
attachDiagnostics()
|
||||
// Defence-in-depth (#214): an invalid media+quality pair can
|
||||
// never reach the ticket — substitute the first allowed
|
||||
// quality when the pick is stale (UI already clamps; this is
|
||||
// the last gate before the driver).
|
||||
var quality = selectedQuality
|
||||
if let q = quality,
|
||||
!printerCaps.allowsQuality(q, forMediaType: selectedMediaType) {
|
||||
quality = availableQualities.first?.id ?? q
|
||||
}
|
||||
return TargetPrintRequest(
|
||||
queue: queue,
|
||||
displayName: printers.first { $0.name == queue }?.displayName,
|
||||
@@ -373,7 +428,7 @@ final class PrintSessionViewModel: ObservableObject {
|
||||
paperSize: selectedPaperSizeToken,
|
||||
mediaType: selectedMediaType,
|
||||
qualityKey: printerCaps.qualityKey,
|
||||
quality: selectedQuality,
|
||||
quality: quality,
|
||||
orientation: printOrientation),
|
||||
optionKeys: optionKeys)
|
||||
}
|
||||
|
||||
@@ -410,11 +410,13 @@ struct Stage2View: View {
|
||||
.accessibilityIdentifier("paperSizeGroup")
|
||||
}
|
||||
|
||||
/// Quality picker — driver tokens with PPD-enriched labels (#183).
|
||||
/// Quality picker — driver tokens with PPD-enriched labels (#183),
|
||||
/// filtered to what the driver accepts for the selected media
|
||||
/// (#214).
|
||||
private var qualityGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Picker("Quality", selection: $workflow.print.selectedQuality) {
|
||||
ForEach(workflow.print.printerCaps.qualities, id: \.id) {
|
||||
ForEach(workflow.print.availableQualities, id: \.id) {
|
||||
Text($0.name).tag(Optional($0.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
|
||||
/// #214 — media → allowed-quality resolution: generic PPD
|
||||
/// `*UIConstraints`, Epson `PDEData.dat` `*EPIJUIConstraint` rules,
|
||||
/// and the Canon `cnb_*.tbl` record scan. All fixtures inline /
|
||||
/// injected — no installed drivers touched.
|
||||
final class MediaQualityConstraintsTests: XCTestCase {
|
||||
|
||||
/// Epson-flavoured `lpoptions -l` listings: the XP-55 advertises
|
||||
/// the conditional LHS keys (`EPIJ_PSrc`/`EPIJ_FdSo`/`EPIJ_Ink_`)
|
||||
/// with defaults 2/2/1.
|
||||
private var epsonListings: [CupsOptionListing] {
|
||||
CupsParsers.lpoptionsList("""
|
||||
PageSize/Media Size: 4x6 5x7 *A4 Letter
|
||||
EPIJ_Medi/Media Type: *0 92 13 15 145 12 2 75 26 76 93
|
||||
EPIJ_PSrc/Page Setup: *2 3 25
|
||||
EPIJ_FdSo/Paper Source: *2 3 12
|
||||
EPIJ_Ink_/Grayscale: *1 0
|
||||
EPIJ_Qual/Print Quality: 301 302 *303 308 304 305 307
|
||||
""")
|
||||
}
|
||||
|
||||
private var epsonQualityIDs: Set<String> {
|
||||
["301", "302", "303", "304", "305", "307", "308"]
|
||||
}
|
||||
|
||||
// MARK: - ppdKeyword
|
||||
|
||||
func testPPDKeywordQuotedAndBare() {
|
||||
let ppd = """
|
||||
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
|
||||
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||
*CNIJTableID: 354
|
||||
*CNIJTableIDFoo: 999
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
MediaQualityConstraints.ppdKeyword(ppd, "EPIJDriverBasePath"),
|
||||
"/Library/Printers/EPSON/InkjetPrinter2")
|
||||
XCTAssertEqual(
|
||||
MediaQualityConstraints.ppdKeyword(ppd, "EPIJMachineBundleName"),
|
||||
"EP14C0605W.data")
|
||||
XCTAssertEqual(
|
||||
MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableID"), "354")
|
||||
// Prefix-safety: CNIJTableIDFoo must not satisfy the lookup.
|
||||
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "CNIJTableIDX"))
|
||||
XCTAssertNil(MediaQualityConstraints.ppdKeyword(ppd, "Missing"))
|
||||
}
|
||||
|
||||
// MARK: - Path derivation
|
||||
|
||||
func testEPIJPDEDataPath() {
|
||||
let ppd = """
|
||||
*EPIJDriverBasePath: "/Library/Printers/EPSON/InkjetPrinter2"
|
||||
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
MediaQualityConstraints.epijPDEDataPath(ppd: ppd)?.path,
|
||||
"/Library/Printers/EPSON/InkjetPrinter2/Machine/"
|
||||
+ "EP14C0605W.data/Contents/Resources/PDEData.dat")
|
||||
XCTAssertNil(MediaQualityConstraints.epijPDEDataPath(
|
||||
ppd: "*CNIJTableID: 354\n"))
|
||||
}
|
||||
|
||||
func testCNIJTablePath() {
|
||||
let ppd = """
|
||||
*CNIJNameTblPath: "/Library/Printers/Canon/BJPrinter/Resources/Database/CIJPro9500IIseries.db/Contents/Resources"
|
||||
*CNIJTableID: 354
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
MediaQualityConstraints.cnijTablePath(ppd: ppd)?.path,
|
||||
"/Library/Printers/Canon/BJPrinter/Resources/Database/"
|
||||
+ "CIJPro9500IIseries.db/Contents/Resources/cnb_3540.tbl")
|
||||
XCTAssertNil(MediaQualityConstraints.cnijTablePath(
|
||||
ppd: "*EPIJMachineBundleName: \"x\"\n"))
|
||||
}
|
||||
|
||||
// MARK: - Generic PPD UIConstraints
|
||||
|
||||
func testPPDUIConstraintsBothOrders() {
|
||||
let ppd = """
|
||||
*UIConstraints: *MediaType Glossy *PrintQuality Draft
|
||||
*UIConstraints: *PrintQuality Draft *MediaType Matte
|
||||
*Constraints: *MediaType Glossy *PrintQuality Low
|
||||
*UIConstraints: *InputSlot Rear *PrintQuality Draft
|
||||
"""
|
||||
let forbidden = MediaQualityConstraints.ppdUIConstraints(
|
||||
ppd, mediaKey: "MediaType", qualityKey: "PrintQuality",
|
||||
mediaIDs: ["Glossy", "Matte"],
|
||||
qualityIDs: ["Draft", "Low", "High"])
|
||||
XCTAssertEqual(forbidden["Glossy"], ["Draft", "Low"])
|
||||
XCTAssertEqual(forbidden["Matte"], ["Draft"])
|
||||
}
|
||||
|
||||
// MARK: - Epson EPIJUIConstraint
|
||||
|
||||
/// Realistic XP-55 fragment: pure-media rules plus conditional
|
||||
/// rules on `EPIJ_PSrc`/`EPIJ_Ink_` evaluated against defaults.
|
||||
func testEPIJConstraintsAgainstDefaults() {
|
||||
let dat = """
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
|
||||
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 0|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0 *EPIJ_Ink_ 0|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *EPIJ_PSrc 3 *EPIJ_Medi 92|*EPIJ_Qual 999
|
||||
"""
|
||||
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||
mediaIDs: ["0", "15", "92"], qualityIDs: epsonQualityIDs,
|
||||
defaults: ["EPIJ_PSrc": "2", "EPIJ_Ink_": "1"])
|
||||
// Media 0: pure rules forbid 305/307/308; the PSrc=3 rule is
|
||||
// inert at default PSrc=2 (301 survives); the PSrc=2 rule
|
||||
// additionally requires Ink_=0 — default is 1, so inert too
|
||||
// (302 survives).
|
||||
XCTAssertEqual(forbidden["0"], ["305", "307", "308"])
|
||||
XCTAssertEqual(forbidden["15"], ["301", "302", "303", "304"])
|
||||
// 999 is not a listed quality id — ignored outright.
|
||||
XCTAssertEqual(forbidden["92"], ["301"])
|
||||
}
|
||||
|
||||
/// A conditional rule whose extra terms all match the defaults
|
||||
/// fires like a pure-media rule.
|
||||
func testEPIJConditionalRuleFiresAtDefaults() {
|
||||
let dat = """
|
||||
*EPIJUIConstraint: *EPIJ_PSrc 2 *EPIJ_Medi 0|*EPIJ_Qual 304
|
||||
"""
|
||||
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||
mediaIDs: ["0"], qualityIDs: epsonQualityIDs,
|
||||
defaults: ["EPIJ_PSrc": "2"])
|
||||
XCTAssertEqual(forbidden["0"], ["304"])
|
||||
}
|
||||
|
||||
/// A rule with no media term applies to every listed media; a term
|
||||
/// on a key the queue does not advertise counts as satisfied
|
||||
/// (conservative-forbid — #214 semantics).
|
||||
func testEPIJGlobalAndUnknownKeyRules() {
|
||||
let dat = """
|
||||
*EPIJUIConstraint: *EPIJ_Ink_ 1|*EPIJ_Qual 305
|
||||
*EPIJUIConstraint: *EPIJ_Mode 9 *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||
"""
|
||||
let forbidden = MediaQualityConstraints.epijUIConstraints(
|
||||
dat, mediaKey: "EPIJ_Medi", qualityKey: "EPIJ_Qual",
|
||||
mediaIDs: ["0", "15"], qualityIDs: epsonQualityIDs,
|
||||
defaults: ["EPIJ_Ink_": "1"])
|
||||
// Global rule: Ink_=1 matches default → 305 forbidden on both.
|
||||
XCTAssertTrue(forbidden["0"]!.contains("305"))
|
||||
XCTAssertTrue(forbidden["15"]!.contains("305"))
|
||||
// EPIJ_Mode is unlisted → satisfied → (0, 307) forbidden too.
|
||||
XCTAssertTrue(forbidden["0"]!.contains("307"))
|
||||
}
|
||||
|
||||
/// The end-to-end XP-55 expectation through `resolve` — the exact
|
||||
/// sets the user reported from the driver PDE.
|
||||
func testResolveEpsonProducesDriverSets() {
|
||||
let dat = """
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 305
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 307
|
||||
*EPIJUIConstraint: *EPIJ_Medi 0|*EPIJ_Qual 308
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 303
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 304
|
||||
*EPIJUIConstraint: *EPIJ_Medi 92|*EPIJ_Qual 308
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 303
|
||||
*EPIJUIConstraint: *EPIJ_Medi 15|*EPIJ_Qual 304
|
||||
"""
|
||||
let ppd = """
|
||||
*EPIJDriverBasePath: "/drivers/epson"
|
||||
*EPIJMachineBundleName: "EP14C0605W.data"
|
||||
"""
|
||||
let datURL = URL(fileURLWithPath:
|
||||
"/drivers/epson/Machine/EP14C0605W.data/"
|
||||
+ "Contents/Resources/PDEData.dat")
|
||||
let map = MediaQualityConstraints.resolve(
|
||||
listings: epsonListings, ppd: ppd,
|
||||
readFile: { $0 == datURL ? dat.data(using: .utf8) : nil })
|
||||
// Plain paper: Fast Economy / Economy / Normal / Fine.
|
||||
XCTAssertEqual(map["0"], ["301", "302", "303", "304"])
|
||||
// Premium Semigloss: Quality / Best Quality / Draft.
|
||||
XCTAssertEqual(map["15"], ["305", "307", "308"])
|
||||
// Ultra Glossy drops Fast Economy…Fine and Draft.
|
||||
XCTAssertEqual(map["92"], ["305", "307"])
|
||||
// Unconstrained media (no rules) produce no entry.
|
||||
XCTAssertNil(map["13"])
|
||||
}
|
||||
|
||||
// MARK: - Canon cnb table
|
||||
|
||||
/// Builds one 20-byte Canon record: `30 00 01 00 00 00 00 00`,
|
||||
/// `family:u16`, `flag:u16`, `media:u32`, `pad:u16`, `quality:u16`.
|
||||
private func canonRecord(
|
||||
family: UInt16 = 3, flag: UInt16 = 0,
|
||||
media: UInt32, pad: UInt16 = 0, quality: UInt16
|
||||
) -> Data {
|
||||
var d = Data([0x30, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
d.append(contentsOf: [UInt8(family & 0xFF), UInt8(family >> 8)])
|
||||
d.append(contentsOf: [UInt8(flag & 0xFF), UInt8(flag >> 8)])
|
||||
d.append(contentsOf: [
|
||||
UInt8(media & 0xFF), UInt8((media >> 8) & 0xFF),
|
||||
UInt8((media >> 16) & 0xFF), UInt8((media >> 24) & 0xFF)])
|
||||
d.append(contentsOf: [UInt8(pad & 0xFF), UInt8(pad >> 8)])
|
||||
d.append(contentsOf: [UInt8(quality & 0xFF), UInt8(quality >> 8)])
|
||||
return d
|
||||
}
|
||||
|
||||
func testCNBTableScan() {
|
||||
var data = Data()
|
||||
// Junk prefix — even length keeps the records 2-byte aligned.
|
||||
data.append(contentsOf: [0xFF, 0x00])
|
||||
// media 0 → {0, 10, 15, 20} across flag 0/1 variants.
|
||||
for q: UInt16 in [10, 15, 20, 0] {
|
||||
data.append(canonRecord(media: 0, quality: q))
|
||||
}
|
||||
data.append(canonRecord(flag: 1, media: 0, quality: 10))
|
||||
// media 50 → {0, 5, 10}; plus its borderless variant 0x10032.
|
||||
for q: UInt16 in [0, 5, 10] {
|
||||
data.append(canonRecord(media: 50, quality: q))
|
||||
}
|
||||
data.append(canonRecord(media: 0x10032, quality: 0))
|
||||
// Noise: unknown media, unknown quality, nonzero pad, and a
|
||||
// different record family that must lose the modal vote.
|
||||
data.append(canonRecord(media: 999, quality: 5))
|
||||
data.append(canonRecord(media: 50, quality: 7))
|
||||
data.append(canonRecord(media: 50, pad: 1, quality: 15))
|
||||
data.append(canonRecord(family: 9, media: 50, quality: 15))
|
||||
data.append(Data([0x11, 0x22, 0x33]))
|
||||
|
||||
let map = MediaQualityConstraints.cnijMediaQualityTable(
|
||||
data,
|
||||
mediaIDs: ["0", "50", "42"],
|
||||
qualityIDs: ["0", "5", "10", "15", "20"])
|
||||
XCTAssertEqual(map["0"], ["0", "10", "15", "20"])
|
||||
XCTAssertEqual(map["50"], ["0", "5", "10"])
|
||||
XCTAssertNil(map["42"])
|
||||
}
|
||||
|
||||
func testCNBGarbageFailsOpen() {
|
||||
let map = MediaQualityConstraints.cnijMediaQualityTable(
|
||||
Data((0..<4096).map { _ in UInt8.random(in: 0...255) }),
|
||||
mediaIDs: ["0", "50"], qualityIDs: ["0", "5", "10"])
|
||||
XCTAssertTrue(map.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - resolve ordering / fallback
|
||||
|
||||
func testResolvePrefersPPDConstraints() {
|
||||
let ppd = """
|
||||
*UIConstraints: *EPIJ_Medi 0 *EPIJ_Qual 305
|
||||
*EPIJDriverBasePath: "/should/not/be/read"
|
||||
*EPIJMachineBundleName: "x.data"
|
||||
"""
|
||||
var reads = 0
|
||||
let map = MediaQualityConstraints.resolve(
|
||||
listings: epsonListings, ppd: ppd,
|
||||
readFile: { _ in reads += 1; return nil })
|
||||
XCTAssertEqual(map["0"], epsonQualityIDs.subtracting(["305"]))
|
||||
// The Epson path is never touched once PPD constraints hit.
|
||||
XCTAssertEqual(reads, 0)
|
||||
}
|
||||
|
||||
func testResolveNoPPDOrKeysFailsOpen() {
|
||||
XCTAssertTrue(MediaQualityConstraints.resolve(
|
||||
listings: epsonListings, ppd: nil).isEmpty)
|
||||
// No media key in the roster → no map.
|
||||
XCTAssertTrue(MediaQualityConstraints.resolve(
|
||||
listings: CupsParsers.lpoptionsList(
|
||||
"PageSize/Media Size: *A4 Letter\n"),
|
||||
ppd: "*CNIJTableID: 354\n").isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - PrinterCapabilities accessors
|
||||
|
||||
func testCapabilitiesAccessorFallbacks() {
|
||||
var caps = PrinterCapabilities(
|
||||
mediaTypes: [
|
||||
PrinterMediaType(id: "0", name: "Plain"),
|
||||
PrinterMediaType(id: "15", name: "Semigloss"),
|
||||
],
|
||||
qualities: ["301", "302", "303", "308", "304", "305", "307"]
|
||||
.map { PrinterQuality(id: $0, name: "Q\($0)") })
|
||||
// No map → everything, in driver order (#180).
|
||||
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
XCTAssertTrue(caps.allowsQuality("305", forMediaType: "0"))
|
||||
|
||||
caps.qualityIDsByMediaType = [
|
||||
"0": ["301", "302", "303", "304"],
|
||||
"15": ["305", "307", "308"],
|
||||
]
|
||||
XCTAssertEqual(caps.qualities(forMediaType: "0").map(\.id),
|
||||
["301", "302", "303", "304"])
|
||||
// Driver order preserved — 308 sits before 304 in lpoptions.
|
||||
XCTAssertEqual(caps.qualities(forMediaType: "15").map(\.id),
|
||||
["308", "305", "307"])
|
||||
XCTAssertTrue(caps.allowsQuality("308", forMediaType: "15"))
|
||||
XCTAssertFalse(caps.allowsQuality("308", forMediaType: "0"))
|
||||
// Unknown media → unconstrained.
|
||||
XCTAssertEqual(caps.qualities(forMediaType: "99").count, 7)
|
||||
XCTAssertTrue(caps.allowsQuality("305", forMediaType: nil))
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,169 @@ final class PrintSessionViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Media-aware quality filtering (#214)
|
||||
|
||||
/// Constrains the loaded fixture caps: `Stationery` allows
|
||||
/// {301,302,303,304}, `Glossy` allows {305,307}, `Matte` is
|
||||
/// unconstrained. Mirrors the Epson `EPIJUIConstraint` matrix.
|
||||
private func constrainCaps(_ vm: PrintSessionViewModel) {
|
||||
var caps = vm.printerCaps
|
||||
caps.qualityIDsByMediaType = [
|
||||
"Stationery": ["301", "302", "303", "304"],
|
||||
"Glossy": ["305", "307"],
|
||||
]
|
||||
vm.printerCaps = caps
|
||||
}
|
||||
|
||||
/// Media switch → an invalid quality pick re-seeds to the first
|
||||
/// allowed choice; the picker source shrinks to the media's set.
|
||||
func testMediaChangeClampsInvalidQuality() async {
|
||||
let workflow = makeWorkflow()
|
||||
await loadCaps(workflow.print)
|
||||
constrainCaps(workflow.print)
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
|
||||
workflow.print.selectedMediaType = "Glossy"
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||
["305", "307"])
|
||||
}
|
||||
|
||||
/// A still-valid pick survives a media switch; an unconstrained
|
||||
/// media keeps the pick too.
|
||||
func testMediaChangeKeepsValidQuality() async {
|
||||
let workflow = makeWorkflow()
|
||||
await loadCaps(workflow.print)
|
||||
constrainCaps(workflow.print)
|
||||
workflow.print.selectedQuality = "302"
|
||||
|
||||
workflow.print.selectedMediaType = "Matte" // unconstrained
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "302")
|
||||
|
||||
workflow.print.selectedMediaType = "Glossy" // 302 invalid
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
workflow.print.selectedQuality = "307"
|
||||
workflow.print.selectedMediaType = "Stationery" // 307 invalid
|
||||
// Driver default 303 is allowed there → preferred over first.
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// Seeding lands inside the allowed set even when the driver
|
||||
/// default quality is invalid for the first media — end to end
|
||||
/// through `CupsService.capabilities(for:)` + a fixture
|
||||
/// `PDEData.dat` (#214).
|
||||
func testQualitySeedRespectsMediaMap() async throws {
|
||||
let ppdDir = env.root.appendingPathComponent("ppd")
|
||||
let epsonRoot = env.root.appendingPathComponent("epson-driver")
|
||||
let datDir = epsonRoot.appendingPathComponent(
|
||||
"Machine/M.data/Contents/Resources")
|
||||
try FileManager.default.createDirectory(
|
||||
at: ppdDir, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: datDir, withIntermediateDirectories: true)
|
||||
try """
|
||||
*EPIJDriverBasePath: "\(epsonRoot.path)"
|
||||
*EPIJMachineBundleName: "M.data"
|
||||
""".write(
|
||||
to: ppdDir.appendingPathComponent("Mock_Q.ppd"),
|
||||
atomically: true, encoding: .utf8)
|
||||
// Stationery forbids everything except 305/307 — including the
|
||||
// `lpoptions` default 303.
|
||||
try """
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 303
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 304
|
||||
""".write(
|
||||
to: datDir.appendingPathComponent("PDEData.dat"),
|
||||
atomically: true, encoding: .utf8)
|
||||
|
||||
var environment = env.environment
|
||||
environment = AppEnvironment(
|
||||
stateStore: environment.stateStore,
|
||||
settingsStore: environment.settingsStore,
|
||||
presetStore: environment.presetStore,
|
||||
runner: environment.runner,
|
||||
cupsService: CupsService(
|
||||
processManager: ProcessManager(),
|
||||
binaryDir: binDir, ppdDir: ppdDir),
|
||||
historyStore: environment.historyStore,
|
||||
mediaStore: environment.mediaStore,
|
||||
recentProjectsStore: environment.recentProjectsStore)
|
||||
let workflow = TargetWorkflowViewModel(environment: environment)
|
||||
workflow.print.selectedPrinter = "Mock_Q"
|
||||
await workflow.print.reloadSelectedCapabilities()
|
||||
|
||||
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
|
||||
XCTAssertEqual(workflow.print.printerCaps
|
||||
.qualityIDsByMediaType["Stationery"], ["305", "307"])
|
||||
// 303 is the driver default but invalid on Stationery → 305.
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "305")
|
||||
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||
["305", "307"])
|
||||
}
|
||||
|
||||
/// No constraint map → every quality stays selectable on every
|
||||
/// media (the pre-#214 behaviour, by design for unknown drivers).
|
||||
func testUnconstrainedDriverKeepsAllQualities() async {
|
||||
let workflow = makeWorkflow()
|
||||
await loadCaps(workflow.print)
|
||||
workflow.print.selectedMediaType = "Glossy"
|
||||
XCTAssertEqual(workflow.print.availableQualities.map(\.id),
|
||||
["301", "302", "303", "308", "304", "305", "307"])
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// A captured quality invalid for the captured media is dropped —
|
||||
/// the clamped selection stands.
|
||||
func testPanelResultInvalidQualityDropped() async throws {
|
||||
setenv("ICCERY_UI_TESTING", "1", 1)
|
||||
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
|
||||
setenv("ICCERY_TEST_PANEL_OPTIONS", "EPIJ_Qual=305", 1)
|
||||
defer {
|
||||
unsetenv("ICCERY_UI_TESTING")
|
||||
unsetenv("ICCERY_TEST_PRINT_PANEL")
|
||||
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
|
||||
}
|
||||
|
||||
let workflow = makeWorkflow()
|
||||
await loadCaps(workflow.print)
|
||||
constrainCaps(workflow.print) // Stationery forbids 305
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
|
||||
workflow.print.openPrinterPreferences()
|
||||
await waitForNotice(workflow.print, containing: "Settings captured")
|
||||
XCTAssertEqual(workflow.print.selectedQuality, "303")
|
||||
}
|
||||
|
||||
/// `makeRequest` substitutes a stale-invalid quality before the
|
||||
/// ticket is written — the last gate before the driver (#214).
|
||||
func testSpoolSubstitutesStaleQuality() async throws {
|
||||
let workflow = makeWorkflow()
|
||||
await loadCaps(workflow.print)
|
||||
constrainCaps(workflow.print)
|
||||
workflow.print.selectedMediaType = "Glossy"
|
||||
workflow.print.selectedQuality = "308" // stale, invalid
|
||||
workflow.print.spooler = RecordingTargetSpooler(
|
||||
logURL: spoolLogURL)
|
||||
|
||||
let tiff = env.root.appendingPathComponent("page1.tif")
|
||||
try Data([0x49, 0x49]).write(to: tiff)
|
||||
let page = GalleryPage(
|
||||
index: 0,
|
||||
page: PrinttargPage(
|
||||
filename: "page1.tif", patches: 10,
|
||||
widthMm: 210, heightMm: 297),
|
||||
fileURL: tiff, previewPNG: nil, previewError: nil)
|
||||
workflow.print.printPage(page)
|
||||
|
||||
let log = await waitForFile(spoolLogURL)
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||
XCTAssertFalse(log.contains("EPIJ_Qual=308"), log)
|
||||
}
|
||||
|
||||
/// Poll until the panel task posts a notice whose text contains
|
||||
/// `fragment` (the Task-completion signal for `nil` results too).
|
||||
private func waitForNotice(
|
||||
|
||||
@@ -251,6 +251,87 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
|
||||
XCTAssertTrue(log.contains("EPIJ_Qual=305"), log)
|
||||
}
|
||||
|
||||
/// #214 — a fixture PPD pointing at a fixture `PDEData.dat` (via
|
||||
/// `ICCERY_CUPS_PPD_DIR`) constrains the quality picker to the
|
||||
/// media's allowed set; switching media re-filters and clamps the
|
||||
/// selection.
|
||||
func testQualityPickerFiltersByMediaConstraints() throws {
|
||||
let ppdDir = testRoot.appendingPathComponent("ppd")
|
||||
let epsonRoot = testRoot.appendingPathComponent("epson-driver")
|
||||
let datDir = epsonRoot.appendingPathComponent(
|
||||
"Machine/M.data/Contents/Resources")
|
||||
try FileManager.default.createDirectory(
|
||||
at: ppdDir, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: datDir, withIntermediateDirectories: true)
|
||||
try """
|
||||
*EPIJDriverBasePath: "\(epsonRoot.path)"
|
||||
*EPIJMachineBundleName: "M.data"
|
||||
""".write(
|
||||
to: ppdDir.appendingPathComponent("Mock_Epson_7450.ppd"),
|
||||
atomically: true, encoding: .utf8)
|
||||
try """
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 305
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 307
|
||||
*EPIJUIConstraint: *MediaType Stationery|*EPIJ_Qual 308
|
||||
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 301
|
||||
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 302
|
||||
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 303
|
||||
*EPIJUIConstraint: *MediaType PhotographicGlossy|*EPIJ_Qual 304
|
||||
""".write(
|
||||
to: datDir.appendingPathComponent("PDEData.dat"),
|
||||
atomically: true, encoding: .utf8)
|
||||
app.launchEnvironment["ICCERY_CUPS_PPD_DIR"] = ppdDir.path
|
||||
launchAppWithDefaults()
|
||||
reachPrintPanel()
|
||||
_ = waitFor("printerStatusBadge")
|
||||
|
||||
let qualityPopup = app.popUpButtons["printerQualitySelect"]
|
||||
XCTAssertTrue(qualityPopup.waitForExistence(timeout: 10))
|
||||
|
||||
// Default media Stationery → {301,302,303,304} only.
|
||||
qualityPopup.click()
|
||||
let stationeryExpected = ["301", "302", "303", "304"]
|
||||
for token in stationeryExpected {
|
||||
XCTAssertTrue(
|
||||
app.menuItems[token].waitForExistence(timeout: 5),
|
||||
"Missing quality menu item \(token)")
|
||||
}
|
||||
XCTAssertFalse(app.menuItems["307"].exists)
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
|
||||
// Switch to PhotographicGlossy → {305,307,308} in driver
|
||||
// order (308 first — lpoptions order), selection clamped.
|
||||
let mediaPopup = element("printerMediaTypeSelect")
|
||||
.descendants(matching: .popUpButton).firstMatch
|
||||
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
|
||||
mediaPopup.click()
|
||||
let glossyItem = app.menuItems["PhotographicGlossy"]
|
||||
XCTAssertTrue(glossyItem.waitForExistence(timeout: 5))
|
||||
glossyItem.click()
|
||||
|
||||
qualityPopup.click()
|
||||
let glossyExpected = ["308", "305", "307"]
|
||||
for token in glossyExpected {
|
||||
XCTAssertTrue(
|
||||
app.menuItems[token].waitForExistence(timeout: 5),
|
||||
"Missing quality menu item \(token)")
|
||||
}
|
||||
let titles = app.menuItems.allElementsBoundByIndex
|
||||
.map(\.title)
|
||||
.filter { glossyExpected.contains($0) }
|
||||
XCTAssertEqual(titles, glossyExpected)
|
||||
app.typeKey(XCUIKeyboardKey.escape, modifierFlags: [])
|
||||
|
||||
// The stale pick (303) clamped to the first allowed token.
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline,
|
||||
selection(of: "printerQualitySelect") != "308" {
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
XCTAssertEqual(selection(of: "printerQualitySelect"), "308")
|
||||
}
|
||||
|
||||
/// #201 D5 — per-page mode records one line per page; the
|
||||
/// `chkSingleSpoolJob` toggle collapses the job into a single
|
||||
/// request logged once with `pages=N`.
|
||||
|
||||
@@ -114,18 +114,18 @@ final class Milestone3UITests: XCTestCase {
|
||||
return recordedSpoolLog()
|
||||
}
|
||||
|
||||
/// Drags `#galleryPage-0`'s TIFF upward so `identifier`'s button
|
||||
/// moves up, clear of the Dock collision zone at the window's
|
||||
/// bottom edge (#132).
|
||||
/// Scrolls `stage-2` with the synthesized scroll wheel so
|
||||
/// `identifier`'s button moves up, clear of the Dock collision
|
||||
/// zone at the window's bottom edge (#132).
|
||||
///
|
||||
/// macOS overlay scrollbars are not in the AX tree — never use
|
||||
/// `app.scrollBars` — and a synthesized scroll wheel is inert on
|
||||
/// this LazyVGrid, so the scroll is a real drag on the gallery
|
||||
/// cell's content. A stale/off-screen AX frame resolves to a screen
|
||||
/// point that can be a Dock icon — a coordinate click there once
|
||||
/// opened Calendar instead of Print. Callers must click only when
|
||||
/// the returned element `isHittable`; never coordinate-click a
|
||||
/// stale frame.
|
||||
/// `app.scrollBars` — and a click-drag does not scroll a macOS
|
||||
/// ScrollView (content-drag scrolling is iOS-only); the scroll
|
||||
/// wheel is the mechanism the platform supports (#215). A
|
||||
/// stale/off-screen AX frame resolves to a screen point that can
|
||||
/// be a Dock icon — a coordinate click there once opened Calendar
|
||||
/// instead of Print. Callers must click only when the returned
|
||||
/// element `isHittable`; never coordinate-click a stale frame.
|
||||
@discardableResult
|
||||
private func scrollStage2UntilHittable(
|
||||
_ identifier: String,
|
||||
@@ -134,6 +134,8 @@ final class Milestone3UITests: XCTestCase {
|
||||
var button = app.buttons[identifier]
|
||||
let cell = app.descendants(matching: .any)["galleryPage-0"].firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "galleryPage-0")
|
||||
let scrollView = app.scrollViews["stage-2"]
|
||||
XCTAssertTrue(scrollView.waitForExistence(timeout: 10), "stage-2")
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
@@ -142,25 +144,12 @@ final class Milestone3UITests: XCTestCase {
|
||||
button.frame.maxY < windowBottom - 80 {
|
||||
return button
|
||||
}
|
||||
// Grab the upper half of the cell (the TIFF, not the Print
|
||||
// button / Dock) and drag toward the top of the window.
|
||||
// Mouse moves UP ⇒ gallery content moves UP ⇒ Print leaves
|
||||
// the Dock zone.
|
||||
if cell.isHittable {
|
||||
let start = cell.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.25))
|
||||
let end = start.withOffset(CGVector(dx: 0, dy: -280))
|
||||
start.press(forDuration: 0.15, thenDragTo: end)
|
||||
} else {
|
||||
// Cell not hit-testable: drag the stage-2 content
|
||||
// directly — still content, still never scrollBars.
|
||||
let scrollView = app.scrollViews["stage-2"]
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.55))
|
||||
.press(forDuration: 0.15, thenDragTo:
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.15)))
|
||||
}
|
||||
// Wheel-down inside the stage-2 viewport: content moves
|
||||
// UP ⇒ Print leaves the Dock zone. Negative deltaY scrolls
|
||||
// toward the document bottom (#215).
|
||||
scrollView.coordinate(withNormalizedOffset:
|
||||
CGVector(dx: 0.5, dy: 0.5))
|
||||
.scroll(byDeltaX: 0, deltaY: -60)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||
button = app.buttons[identifier]
|
||||
}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ targets:
|
||||
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
|
||||
INFOPLIST_KEY_NSPrincipalClass: NSApplication
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
|
||||
MARKETING_VERSION: "2.0.2"
|
||||
MARKETING_VERSION: "2.0.3"
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
# Stamped by scripts/version.sh at packaging/CI time (release tag or
|
||||
# `git describe` string); About shows it next to the marketing version.
|
||||
|
||||
@@ -150,7 +150,7 @@ if [ ! -f "$DMG_BACKGROUND" ]; then
|
||||
fi
|
||||
|
||||
echo "==> Building DMG"
|
||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.0')"
|
||||
VERSION="$(plutil -extract CFBundleShortVersionString raw "$APP/Contents/Info.plist" 2>/dev/null || printf '2.0.3')"
|
||||
BUILD_NUM="$(plutil -extract CFBundleVersion raw "$APP/Contents/Info.plist" 2>/dev/null || printf '1')"
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user