Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbdfd978e | ||
|
|
597fd897ed | ||
|
|
0a02a8a640 | ||
|
|
14f521a65e |
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Ordered private-SPI attempt table for the ColorSync suppression
|
||||||
|
/// engine (issue 14 layer ②, docs/11).
|
||||||
|
///
|
||||||
|
/// The ordering is data so the exact dlsym/mode sequence is unit-
|
||||||
|
/// testable without resolving any private symbols. The app layer walks
|
||||||
|
/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,…)`, and
|
||||||
|
/// calls the first `(symbol, mode)` that returns `0` — verified on
|
||||||
|
/// macOS 14+ that all three symbols exist.
|
||||||
|
///
|
||||||
|
/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||||
|
/// The second argument is the **mode string**, never integer `1`
|
||||||
|
/// (#188 — a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and
|
||||||
|
/// `AP_VendorColorMatching` are forbidden modes — they re-enable
|
||||||
|
/// ColorSync/driver colour management.
|
||||||
|
public enum ColorMatchingAttempts {
|
||||||
|
|
||||||
|
/// dlsym order: `…Lock` first (holds the print-session lock while
|
||||||
|
/// setting), then the plain setter, then `…NoLock`.
|
||||||
|
public static let symbols: [String] = [
|
||||||
|
"PMSessionSetColorMatchingModeLock",
|
||||||
|
"PMSessionSetColorMatchingMode",
|
||||||
|
"PMSessionSetColorMatchingModeNoLock",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Mode strings tried per symbol, in order. `AP_…` is the
|
||||||
|
/// documented mode; the unprefixed variant is the older alias.
|
||||||
|
public static let modes: [String] = [
|
||||||
|
"AP_ApplicationColorMatching",
|
||||||
|
"ApplicationColorMatching",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Symbol-outer, mode-inner — the full attempt sequence; the app
|
||||||
|
/// stops at the first call that returns `0`.
|
||||||
|
public static var attempts: [(symbol: String, mode: String)] {
|
||||||
|
symbols.flatMap { symbol in
|
||||||
|
modes.map { (symbol: symbol, mode: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layer ③: both spellings of the print-settings key are written
|
||||||
|
/// with `locked = true`. Written as `CFString` values.
|
||||||
|
public static let applicationMatchingValue = "AP_ApplicationColorMatching"
|
||||||
|
public static let printSettingsKeys: [String] = [
|
||||||
|
"AP_ColorMatchingMode",
|
||||||
|
"AP.ColorMatchingMode",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CUPS option filtering for `PMPrintSettingsToOptions` capture
|
||||||
|
/// (issue 14 layer ⑥, docs/11 §filter).
|
||||||
|
///
|
||||||
|
/// The captured `key=value` string is reduced to the options that
|
||||||
|
/// should be replayed on `lp`: `com.apple.*` ticket keys, job
|
||||||
|
/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`,
|
||||||
|
/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode`
|
||||||
|
/// keys are dropped — `build_lp_args` always re-adds those itself
|
||||||
|
/// (issue 15). Unknown non-`com.*` keys are kept (permissive — vendor
|
||||||
|
/// driver keys survive).
|
||||||
|
public enum CupsOptionsFilter {
|
||||||
|
|
||||||
|
/// Option keys forwarded from the panel to `lp` (docs/11 roster).
|
||||||
|
public static let relevantKeys: Set<String> = [
|
||||||
|
// Media
|
||||||
|
"MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType",
|
||||||
|
// Tray
|
||||||
|
"InputSlot", "AP_D_InputSlot",
|
||||||
|
// Size
|
||||||
|
"PageSize",
|
||||||
|
// Colour bypass
|
||||||
|
"CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor",
|
||||||
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
|
"EpsonColorMode", "ColorModel",
|
||||||
|
// Quality
|
||||||
|
"Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality",
|
||||||
|
"CNIJQuality", "StpQuality", "OutputMode",
|
||||||
|
// Duplex
|
||||||
|
"Duplex", "sides",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Keys we always drop regardless of the relevant list. `raw` is
|
||||||
|
/// included — a captured `raw=…` would re-enable CUPS raw mode and
|
||||||
|
/// bypass the raster filter that honours `AP_ApplicationColorMatching`
|
||||||
|
/// (#92).
|
||||||
|
public static let alwaysDropped: Set<String> = [
|
||||||
|
"collate", "copies", "pserrorhandler-requested", "job-sheets",
|
||||||
|
"AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// A `key=value` pair survives when the key is non-empty, the value
|
||||||
|
/// is non-empty, the key is not `com.apple.*`, not always-dropped,
|
||||||
|
/// and either relevant or an unknown non-`com.*` driver key.
|
||||||
|
public static func isRelevant(key: String, value: String) -> Bool {
|
||||||
|
guard !key.isEmpty, !value.isEmpty else { return false }
|
||||||
|
if key.hasPrefix("com.apple.") { return false }
|
||||||
|
if alwaysDropped.contains(key) { return false }
|
||||||
|
if relevantKeys.contains(key) { return true }
|
||||||
|
// Permissive: unknown vendor keys survive (non-com.*).
|
||||||
|
return !key.hasPrefix("com.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `key=value key=value …` → filtered string, order preserved.
|
||||||
|
public static func filter(_ options: String) -> String {
|
||||||
|
CupsParsers.lpoptions(options)
|
||||||
|
.filter { isRelevant(key: $0.key, value: $0.value) }
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,27 @@ public struct CupsService: Sendable {
|
|||||||
Set(try await optionListings(for: queue).map(\.key))
|
Set(try await optionListings(for: queue).map(\.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Spool (issue 15)
|
||||||
|
|
||||||
|
/// `lp -d <queue> … <tiff>` — spool one target page unmanaged.
|
||||||
|
/// Never uses `-o raw` (#92). `page` disambiguates the process id
|
||||||
|
/// when several pages are spooled in sequence.
|
||||||
|
public func printTarget(
|
||||||
|
queue: String,
|
||||||
|
tiffPath: String,
|
||||||
|
options: PrintOptions,
|
||||||
|
page: Int = 0
|
||||||
|
) async throws {
|
||||||
|
guard FileManager.default.fileExists(atPath: tiffPath) else {
|
||||||
|
throw CupsError.tiffMissing(tiffPath)
|
||||||
|
}
|
||||||
|
let optionKeys = (try? await self.optionKeys(for: queue)) ?? []
|
||||||
|
let argv = try LpArgs.build(
|
||||||
|
queue: queue, tiffPath: tiffPath,
|
||||||
|
options: options, optionKeys: optionKeys)
|
||||||
|
try await run("lp", argv, id: ProcessID.lp(queue, page: page))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - PPD
|
// MARK: - PPD
|
||||||
|
|
||||||
private func loadPPD(for queue: String) -> String? {
|
private func loadPPD(for queue: String) -> String? {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Errors from `buildLpArgs`.
|
||||||
|
public enum LpArgsError: LocalizedError, Equatable {
|
||||||
|
case unsanitisedOption(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unsanitisedOption(let option):
|
||||||
|
return "Captured CUPS option contains unsafe characters: \(option)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `lp` argv builder — issue 15, docs/11 `build_lp_args`.
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// lp -d <queue> -t "ICCery Target - <file>"
|
||||||
|
/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching
|
||||||
|
/// <captured cups_options>
|
||||||
|
/// <media_type, if no media key already captured>
|
||||||
|
/// <driver bypass, if no bypass key captured>
|
||||||
|
/// <orientation-requested=3|4, unless captured>
|
||||||
|
/// <PageSize, unless captured>
|
||||||
|
/// <tiff>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// - **Never `-o raw`** — `raw` skips the raster filter that honours
|
||||||
|
/// `AP_ApplicationColorMatching` (#92).
|
||||||
|
/// - Captured options **win** over explicit fields: any key already
|
||||||
|
/// present (case-insensitive) suppresses the derived `-o`.
|
||||||
|
/// - Captured keys/values are sanitised — `;`, newlines, or shell
|
||||||
|
/// metacharacters throw `unsanitisedOption`; args are passed as a
|
||||||
|
/// `Process` argv array, never through a shell.
|
||||||
|
/// - The TIFF path is always the **last** argument.
|
||||||
|
public enum LpArgs {
|
||||||
|
|
||||||
|
/// `options` = the captured `PrintOptions`; `optionKeys` = the
|
||||||
|
/// queue's `lpoptions -l` key set (for media-key/bypass detection).
|
||||||
|
public static func build(
|
||||||
|
queue: String,
|
||||||
|
tiffPath: String,
|
||||||
|
options: PrintOptions,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) throws -> [String] {
|
||||||
|
var argv: [String] = [
|
||||||
|
"-d", queue,
|
||||||
|
"-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)",
|
||||||
|
"-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching",
|
||||||
|
"-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching",
|
||||||
|
]
|
||||||
|
var addedKeys: Set<String> = [
|
||||||
|
"ap_colormatchingmode", "ap.colormatchingmode",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Captured CUPS options — sanitised, lowercased-key dedup.
|
||||||
|
if let captured = options.cupsOptions, !captured.isEmpty {
|
||||||
|
// Newlines can't survive the tokeniser — check the raw
|
||||||
|
// string so embedded line breaks are still rejected.
|
||||||
|
if captured.contains("\n") || captured.contains("\r") {
|
||||||
|
throw LpArgsError.unsanitisedOption(captured)
|
||||||
|
}
|
||||||
|
for pair in CupsParsers.lpoptions(captured) {
|
||||||
|
try sanitize(pair.key, pair.value)
|
||||||
|
let lowered = pair.key.lowercased()
|
||||||
|
// Defence in depth: never let a captured `raw` reach
|
||||||
|
// argv — `-o raw` skips the raster filter that honours
|
||||||
|
// AP_ApplicationColorMatching (#92).
|
||||||
|
if lowered == "raw" { continue }
|
||||||
|
guard !addedKeys.contains(lowered) else { continue }
|
||||||
|
addedKeys.insert(lowered)
|
||||||
|
argv += ["-o", "\(pair.key)=\(pair.value)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media type — only when the captured options didn't carry one.
|
||||||
|
if let mediaType = options.mediaType,
|
||||||
|
let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(mediaKey.lowercased()) {
|
||||||
|
addedKeys.insert(mediaKey.lowercased())
|
||||||
|
argv += ["-o", "\(mediaKey)=\(mediaType)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Driver colour bypass — when no bypass key was captured. NOT
|
||||||
|
// gated on ppdUncorrectedPassthrough (macOS always bypasses).
|
||||||
|
let capturedKeys = Set(
|
||||||
|
CupsParsers.lpoptions(options.cupsOptions ?? "")
|
||||||
|
.map { $0.key })
|
||||||
|
if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys),
|
||||||
|
let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys),
|
||||||
|
!addedKeys.contains(bypass.key.lowercased()) {
|
||||||
|
addedKeys.insert(bypass.key.lowercased())
|
||||||
|
argv += ["-o", "\(bypass.key)=\(bypass.value)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orientation — portrait=3, landscape=4.
|
||||||
|
if let orientation = options.orientation,
|
||||||
|
!addedKeys.contains("orientation-requested") {
|
||||||
|
let value = orientation == "landscape" ? "4" : "3"
|
||||||
|
addedKeys.insert("orientation-requested")
|
||||||
|
argv += ["-o", "orientation-requested=\(value)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageSize — the printtarg layout page size.
|
||||||
|
if let paperSize = options.paperSize, !paperSize.isEmpty,
|
||||||
|
!addedKeys.contains("pagesize") {
|
||||||
|
argv += ["-o", "PageSize=\(paperSize)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
argv.append(tiffPath)
|
||||||
|
return argv
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject shell/metachar injection — args go to `Process` as an
|
||||||
|
/// argv array, but a hostile captured string must not smuggle a
|
||||||
|
/// second option or command.
|
||||||
|
static func sanitize(_ key: String, _ value: String) throws {
|
||||||
|
let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'")
|
||||||
|
if key.rangeOfCharacter(from: forbidden) != nil
|
||||||
|
|| value.rangeOfCharacter(from: forbidden) != nil
|
||||||
|
|| key.isEmpty {
|
||||||
|
throw LpArgsError.unsanitisedOption("\(key)=\(value)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Captured output from `runCaptured` (used by printcal/applycal —
|
/// Captured output from `runCaptured` — one-shot tools whose results
|
||||||
/// the only tools whose results arrive as one-shot output).
|
/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools).
|
||||||
public struct CapturedResult: Sendable, Equatable {
|
public struct CapturedResult: Sendable, Equatable {
|
||||||
public let stdout: String
|
public let stdout: String
|
||||||
public let stderr: String
|
public let stderr: String
|
||||||
@@ -173,7 +173,8 @@ public actor ProcessManager {
|
|||||||
|
|
||||||
/// Runs a child to completion and returns all output. Reads stdout
|
/// Runs a child to completion and returns all output. Reads stdout
|
||||||
/// and stderr concurrently so a full pipe buffer can never deadlock
|
/// and stderr concurrently so a full pipe buffer can never deadlock
|
||||||
/// the child. Used by `printcal` / `applycal` (docs/03).
|
/// the child. Used by `printcal` / `applycal` (docs/03) and by
|
||||||
|
/// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15).
|
||||||
public func runCaptured(
|
public func runCaptured(
|
||||||
id: String,
|
id: String,
|
||||||
binary: URL,
|
binary: URL,
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
import ICCeryCore
|
||||||
|
|
||||||
|
/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`.
|
||||||
|
/// The second argument is the mode string — never integer `1` (#188).
|
||||||
|
typealias ColorMatchingModeFunction =
|
||||||
|
@convention(c) (PMPrintSession, CFString) -> OSStatus
|
||||||
|
|
||||||
|
/// `PMPrintSettingsToOptions` — public symbol, resolved via dlsym so a
|
||||||
|
/// missing SDK declaration can't break the build.
|
||||||
|
typealias PrintSettingsToOptionsFunction =
|
||||||
|
@convention(c) (PMPrintSettings, UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>) -> OSStatus
|
||||||
|
|
||||||
|
/// The six-layer unmanaged-printing engine (issue 14, docs/11):
|
||||||
|
///
|
||||||
|
/// ① session binding — done by `PrintPanelService` before calling us.
|
||||||
|
/// ② private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}` —
|
||||||
|
/// resolved by `dlsym(RTLD_DEFAULT,…)`; first `(symbol, mode)`
|
||||||
|
/// returning `0` wins.
|
||||||
|
/// ③ `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and
|
||||||
|
/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked.
|
||||||
|
/// ④ driver "no colour adjustment" pre-select from `lpoptions -l`
|
||||||
|
/// keys, unlocked (`detectDriverColorBypass`).
|
||||||
|
/// ⑤ mirror ③+④ into `NSPrintInfo.printSettings` so the PDE sees them.
|
||||||
|
/// ⑥ after "Use Settings": `PMPrintSettingsToOptions` →
|
||||||
|
/// `CupsOptionsFilter` → captured `cupsOptions` + `mediaType`.
|
||||||
|
///
|
||||||
|
/// All layers degrade gracefully — a missing symbol or non-zero status
|
||||||
|
/// is logged and the next layer still runs.
|
||||||
|
@MainActor
|
||||||
|
struct ColorSyncSuppressor {
|
||||||
|
|
||||||
|
/// Injected for tests: symbol → function. Default resolves via
|
||||||
|
/// `dlsym(RTLD_DEFAULT, …)`.
|
||||||
|
typealias ModeResolver = (String) -> ColorMatchingModeFunction?
|
||||||
|
typealias OptionsResolver = () -> PrintSettingsToOptionsFunction?
|
||||||
|
|
||||||
|
var modeResolver: ModeResolver = Self.dlsymMode
|
||||||
|
var optionsResolver: OptionsResolver = Self.dlsymOptions
|
||||||
|
var log: (String) -> Void = { AppLogger.shared.log(.info, $0) }
|
||||||
|
|
||||||
|
// MARK: - Layer ② SPI
|
||||||
|
|
||||||
|
/// Walk `ColorMatchingAttempts.attempts` (Lock → plain → NoLock ×
|
||||||
|
/// `AP_ApplicationColorMatching` → `ApplicationColorMatching`); the
|
||||||
|
/// first call returning `0` wins. `false` when nothing worked.
|
||||||
|
@discardableResult
|
||||||
|
func applySPIMode(to session: PMPrintSession) -> Bool {
|
||||||
|
for attempt in ColorMatchingAttempts.attempts {
|
||||||
|
guard let function = modeResolver(attempt.symbol) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let status = function(session, attempt.mode as CFString)
|
||||||
|
if status == 0 {
|
||||||
|
log("ColorSync: \(attempt.symbol) accepted "
|
||||||
|
+ "\(attempt.mode)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("ColorSync: no PMSessionSetColorMatchingMode* accepted a "
|
||||||
|
+ "mode — falling back to PMPrintSettingsSetValue")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ③ locked AP_* keys
|
||||||
|
|
||||||
|
/// `PMPrintSettingsSetValue` both key spellings, locked.
|
||||||
|
@discardableResult
|
||||||
|
func applyLockedKeys(to settings: PMPrintSettings) -> Int {
|
||||||
|
var applied = 0
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings,
|
||||||
|
key as CFString,
|
||||||
|
ColorMatchingAttempts.applicationMatchingValue as CFString,
|
||||||
|
true)
|
||||||
|
if status == 0 { applied += 1 }
|
||||||
|
}
|
||||||
|
if applied == 0 {
|
||||||
|
log("ColorSync: PMPrintSettingsSetValue could not lock "
|
||||||
|
+ "AP_ColorMatchingMode")
|
||||||
|
}
|
||||||
|
return applied
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ④ driver bypass
|
||||||
|
|
||||||
|
/// Pre-select the driver "no colour adjustment" option, unlocked —
|
||||||
|
/// the PDE may override it. Returns the `(key, value)` applied.
|
||||||
|
@discardableResult
|
||||||
|
func applyDriverBypass(
|
||||||
|
to settings: PMPrintSettings,
|
||||||
|
optionKeys: Set<String>
|
||||||
|
) -> (key: String, value: String)? {
|
||||||
|
guard let bypass = CupsParsers.detectDriverColorBypass(
|
||||||
|
optionKeys: optionKeys)
|
||||||
|
else { return nil }
|
||||||
|
let status = PMPrintSettingsSetValue(
|
||||||
|
settings,
|
||||||
|
bypass.key as CFString,
|
||||||
|
bypass.value as CFString,
|
||||||
|
false)
|
||||||
|
if status != 0 {
|
||||||
|
log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) "
|
||||||
|
+ "rejected (\(status))")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return bypass
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ⑤ NSPrintInfo mirror
|
||||||
|
|
||||||
|
/// Mirror the applied keys into `printSettings` so the PDE pick
|
||||||
|
/// sees them.
|
||||||
|
func mirror(
|
||||||
|
into printInfo: NSPrintInfo,
|
||||||
|
driverBypass: (key: String, value: String)?
|
||||||
|
) {
|
||||||
|
let settings = printInfo.printSettings
|
||||||
|
for key in ColorMatchingAttempts.printSettingsKeys {
|
||||||
|
settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString
|
||||||
|
}
|
||||||
|
if let driverBypass {
|
||||||
|
settings[driverBypass.key as NSString] = driverBypass.value as NSString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layer ⑥ capture
|
||||||
|
|
||||||
|
/// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`.
|
||||||
|
/// The malloc'd C string is freed after copying.
|
||||||
|
func captureOptions(
|
||||||
|
from settings: PMPrintSettings
|
||||||
|
) -> (cupsOptions: String?, mediaType: String?) {
|
||||||
|
guard let toOptions = optionsResolver() else {
|
||||||
|
log("ColorSync: PMPrintSettingsToOptions unavailable — "
|
||||||
|
+ "panel options not captured")
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
var raw: UnsafeMutablePointer<CChar>?
|
||||||
|
guard toOptions(settings, &raw) == 0, let raw else {
|
||||||
|
return (nil, nil)
|
||||||
|
}
|
||||||
|
defer { free(raw) }
|
||||||
|
let unfiltered = String(cString: raw)
|
||||||
|
let filtered = CupsOptionsFilter.filter(unfiltered)
|
||||||
|
return (
|
||||||
|
filtered.isEmpty ? nil : filtered,
|
||||||
|
CupsParsers.extractMediaType(fromOptionsString: unfiltered)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - dlsym
|
||||||
|
|
||||||
|
private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? {
|
||||||
|
guard let symbol = dlsym(Self.rtldDefault, name) else { return nil }
|
||||||
|
return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func dlsymOptions() -> PrintSettingsToOptionsFunction? {
|
||||||
|
guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions")
|
||||||
|
else { return nil }
|
||||||
|
return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RTLD_DEFAULT` — `UnsafeMutableRawPointer(bitPattern: -2)`.
|
||||||
|
private static var rtldDefault: UnsafeMutableRawPointer? {
|
||||||
|
UnsafeMutableRawPointer(bitPattern: -2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ enum PrintPanelError: LocalizedError {
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct PrintPanelService {
|
struct PrintPanelService {
|
||||||
|
|
||||||
|
/// The suppression engine — injectable for tests.
|
||||||
|
var suppressor = ColorSyncSuppressor()
|
||||||
|
|
||||||
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
|
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
|
||||||
/// the modal panel. Returns `nil` when the user cancels.
|
/// the modal panel. Returns `nil` when the user cancels.
|
||||||
func showProperties(
|
func showProperties(
|
||||||
@@ -47,14 +50,20 @@ struct PrintPanelService {
|
|||||||
#endif
|
#endif
|
||||||
let display = displayName
|
let display = displayName
|
||||||
?? (try? await cupsService.displayName(for: queue))
|
?? (try? await cupsService.displayName(for: queue))
|
||||||
return try runNativePanel(queue: queue, displayName: display)
|
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
||||||
|
// the driver colour-bypass before the panel opens.
|
||||||
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
||||||
|
?? []
|
||||||
|
return try runNativePanel(
|
||||||
|
queue: queue, displayName: display, optionKeys: optionKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Panel
|
// MARK: - Panel
|
||||||
|
|
||||||
private func runNativePanel(
|
private func runNativePanel(
|
||||||
queue: String,
|
queue: String,
|
||||||
displayName: String?
|
displayName: String?,
|
||||||
|
optionKeys: Set<String>
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PrintPropertiesResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
@@ -97,8 +106,21 @@ struct PrintPanelService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Colour-suppression layers ②–⑤ land in issue 14 here, between
|
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
||||||
// binding and runModal.
|
// and PMPrintSettingsSetValue need a session with a current
|
||||||
|
// printer to attach to.
|
||||||
|
var settings = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
var driverBypass: (key: String, value: String)?
|
||||||
|
if boundViaPM {
|
||||||
|
let session = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||||
|
suppressor.applySPIMode(to: session) // ②
|
||||||
|
suppressor.applyLockedKeys(to: settings) // ③
|
||||||
|
driverBypass = suppressor.applyDriverBypass( // ④
|
||||||
|
to: settings, optionKeys: optionKeys)
|
||||||
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
||||||
|
}
|
||||||
|
|
||||||
let panel = NSPrintPanel()
|
let panel = NSPrintPanel()
|
||||||
panel.options = [
|
panel.options = [
|
||||||
@@ -109,10 +131,22 @@ struct PrintPanelService {
|
|||||||
panel.defaultButtonTitle = "Use Settings"
|
panel.defaultButtonTitle = "Use Settings"
|
||||||
|
|
||||||
let response = panel.runModal(with: printInfo)
|
let response = panel.runModal(with: printInfo)
|
||||||
// Layer ⑥ capture (PMPrintSettingsToOptions) lands in issue 14.
|
|
||||||
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⑥ Capture the user's choices — filtered replay options plus
|
||||||
|
// the media type they picked. Re-fetch the settings handle so
|
||||||
|
// we read back what the modal wrote.
|
||||||
|
var cupsOptions: String?
|
||||||
|
var mediaType: String?
|
||||||
|
if boundViaPM {
|
||||||
|
settings = unsafeBitCast(
|
||||||
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||||
|
let captured = suppressor.captureOptions(from: settings)
|
||||||
|
cupsOptions = captured.cupsOptions
|
||||||
|
mediaType = captured.mediaType
|
||||||
|
}
|
||||||
return PrintPropertiesResult(
|
return PrintPropertiesResult(
|
||||||
selectedPrinter: boundViaPM
|
selectedPrinter: boundViaPM
|
||||||
? Self.currentPrinterID(
|
? Self.currentPrinterID(
|
||||||
@@ -120,7 +154,10 @@ struct PrintPanelService {
|
|||||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||||
fallback: queue)
|
fallback: queue)
|
||||||
: nil,
|
: nil,
|
||||||
options: PrintOptions(ppdUncorrectedPassthrough: true))
|
options: PrintOptions(
|
||||||
|
mediaType: mediaType,
|
||||||
|
ppdUncorrectedPassthrough: true,
|
||||||
|
cupsOptions: cupsOptions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PM helpers
|
// MARK: - PM helpers
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
@testable import ICCery
|
||||||
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
|
||||||
|
/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥).
|
||||||
|
@Suite("CupsOptionsFilter")
|
||||||
|
struct CupsOptionsFilterTests {
|
||||||
|
|
||||||
|
@Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys")
|
||||||
|
func dropsReserved() {
|
||||||
|
let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
|
+ "AP.ColorMatchingMode=AP_ApplicationColorMatching "
|
||||||
|
+ "com.apple.print.JobTicket.PMTotalSidesImaged=0 "
|
||||||
|
+ "collate=true copies=1 job-sheets=none,none "
|
||||||
|
+ "pserrorhandler-requested=standard "
|
||||||
|
+ "MediaType=PhotographicGlossy"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Keeps relevant driver keys, order preserved")
|
||||||
|
func keepsRelevant() {
|
||||||
|
let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 "
|
||||||
|
+ "Resolution=600x600dpi Duplex=None"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Permissive: unknown non-com.* keys survive")
|
||||||
|
func keepsUnknown() {
|
||||||
|
let raw = "VendorFooBar=baz MediaType=Plain"
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Drops empty keys and values")
|
||||||
|
func dropsEmpty() {
|
||||||
|
let raw = "=noval MediaType= InputSlot=Rear"
|
||||||
|
// "MediaType=" has an empty value → dropped; "=noval" empty key.
|
||||||
|
#expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("extractMediaType prefers MediaType then EPIJ_Medi")
|
||||||
|
func extractMedia() {
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo")
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "EPIJ_Medi=7") == "7")
|
||||||
|
#expect(CupsParsers.extractMediaType(
|
||||||
|
fromOptionsString: "PageSize=A4") == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue 14 — the dlsym attempt order and first-success semantics.
|
||||||
|
/// A fake resolver records every call; no private symbols are touched.
|
||||||
|
@Suite("ColorSyncSuppressor")
|
||||||
|
@MainActor
|
||||||
|
struct ColorSyncSuppressorTests {
|
||||||
|
|
||||||
|
/// Fake PMPrintSession — the injected resolver never dereferences it.
|
||||||
|
private var fakeSession: PMPrintSession {
|
||||||
|
unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suppressor(
|
||||||
|
succeeding symbol: String? = nil,
|
||||||
|
mode: String = "AP_ApplicationColorMatching",
|
||||||
|
calls: UnsafeMutablePointer<[(String, String)]>
|
||||||
|
) -> ColorSyncSuppressor {
|
||||||
|
var s = ColorSyncSuppressor()
|
||||||
|
s.log = { _ in }
|
||||||
|
s.modeResolver = { name in
|
||||||
|
// Missing symbol → nil (older macOS path).
|
||||||
|
if name == "PMSessionSetColorMatchingModeLock" && symbol == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return { _, modeArg in
|
||||||
|
calls.pointee.append((name, modeArg as String))
|
||||||
|
return (name == symbol && (modeArg as String) == mode) ? 0 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first")
|
||||||
|
func attemptOrder() {
|
||||||
|
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
||||||
|
calls.initialize(to: [])
|
||||||
|
defer { calls.deallocate() }
|
||||||
|
|
||||||
|
let s = suppressor(succeeding: nil, calls: calls)
|
||||||
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
|
#expect(calls.pointee == ColorMatchingAttempts.attempts
|
||||||
|
.map { ($0.symbol, $0.mode) }
|
||||||
|
.filter { $0.0 != "PMSessionSetColorMatchingModeLock" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("First zero wins — later symbols not called")
|
||||||
|
func firstZeroWins() {
|
||||||
|
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
||||||
|
calls.initialize(to: [])
|
||||||
|
defer { calls.deallocate() }
|
||||||
|
|
||||||
|
let s = suppressor(
|
||||||
|
succeeding: "PMSessionSetColorMatchingMode", calls: calls)
|
||||||
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
|
// Lock symbol missing → skipped; Mode tried AP_ then plain? No —
|
||||||
|
// Mode succeeds on the first mode → 2 calls total.
|
||||||
|
#expect(calls.pointee == [
|
||||||
|
("PMSessionSetColorMatchingMode", "AP_ApplicationColorMatching"),
|
||||||
|
])
|
||||||
|
// NoLock never attempted.
|
||||||
|
#expect(!calls.pointee.contains { $0.0 == "PMSessionSetColorMatchingModeNoLock" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried")
|
||||||
|
func modeFallback() {
|
||||||
|
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
||||||
|
calls.initialize(to: [])
|
||||||
|
defer { calls.deallocate() }
|
||||||
|
|
||||||
|
var s = suppressor(
|
||||||
|
succeeding: "PMSessionSetColorMatchingModeLock",
|
||||||
|
mode: "ApplicationColorMatching",
|
||||||
|
calls: calls)
|
||||||
|
// Make the Lock symbol resolvable this time.
|
||||||
|
let record: (String) -> ColorMatchingModeFunction? = { name in
|
||||||
|
{ _, modeArg in
|
||||||
|
calls.pointee.append((name, modeArg as String))
|
||||||
|
return (modeArg as String) == "ApplicationColorMatching" ? 0 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.modeResolver = record
|
||||||
|
#expect(s.applySPIMode(to: fakeSession))
|
||||||
|
#expect(calls.pointee.first
|
||||||
|
== ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching"))
|
||||||
|
#expect(calls.pointee.last
|
||||||
|
== ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("All symbols missing → false, no calls")
|
||||||
|
func allMissing() {
|
||||||
|
let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1)
|
||||||
|
calls.initialize(to: [])
|
||||||
|
defer { calls.deallocate() }
|
||||||
|
var s = suppressor(succeeding: nil, calls: calls)
|
||||||
|
s.modeResolver = { _ in nil }
|
||||||
|
#expect(s.applySPIMode(to: fakeSession) == false)
|
||||||
|
#expect(calls.pointee.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ICCeryCore
|
||||||
|
|
||||||
|
/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`).
|
||||||
|
/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover
|
||||||
|
/// flag order, captured-option precedence, and sanitisation.
|
||||||
|
@Suite("LpArgs")
|
||||||
|
struct LpArgsTests {
|
||||||
|
|
||||||
|
private let tiff = "/tmp/work/target_001.tif"
|
||||||
|
private let queue = "EPSON_XP_55_Series"
|
||||||
|
|
||||||
|
private func build(
|
||||||
|
options: PrintOptions = PrintOptions(),
|
||||||
|
optionKeys: Set<String> = []
|
||||||
|
) throws -> [String] {
|
||||||
|
try LpArgs.build(
|
||||||
|
queue: queue, tiffPath: tiff,
|
||||||
|
options: options, optionKeys: optionKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Header: -d queue -t title, both AP_* first, TIFF last")
|
||||||
|
func header() throws {
|
||||||
|
let argv = try build()
|
||||||
|
#expect(Array(argv[0...1]) == ["-d", queue])
|
||||||
|
#expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"])
|
||||||
|
#expect(Array(argv[4...5])
|
||||||
|
== ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
|
#expect(Array(argv[6...7])
|
||||||
|
== ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"])
|
||||||
|
#expect(argv.last == tiff)
|
||||||
|
#expect(!argv.contains { $0 == "raw" || $0 == "-o raw" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Never emits -o raw; captured raw= is dropped")
|
||||||
|
func neverRaw() throws {
|
||||||
|
let argv = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "raw=true MediaType=Photo"))
|
||||||
|
for (i, arg) in argv.enumerated() where arg == "-o" {
|
||||||
|
#expect(argv[i + 1] != "raw")
|
||||||
|
#expect(argv[i + 1] != "raw=true")
|
||||||
|
}
|
||||||
|
#expect(!argv.contains { $0.hasPrefix("raw=") })
|
||||||
|
#expect(argv.contains("MediaType=Photo"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Captured options replayed after AP_* headers")
|
||||||
|
func capturedReplay() throws {
|
||||||
|
let argv = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "InputSlot=Rear MediaType=Photo"))
|
||||||
|
let rear = argv.firstIndex(of: "InputSlot=Rear")!
|
||||||
|
let apFirst = argv.firstIndex(of:
|
||||||
|
"AP_ColorMatchingMode=AP_ApplicationColorMatching")!
|
||||||
|
#expect(rear > apFirst)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Captured wins: media key present → derived media skipped")
|
||||||
|
func capturedWinsMedia() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(
|
||||||
|
mediaType: "Plain",
|
||||||
|
cupsOptions: "MediaType=Glossy"),
|
||||||
|
optionKeys: ["MediaType"])
|
||||||
|
#expect(argv.contains("MediaType=Glossy"))
|
||||||
|
#expect(!argv.contains("MediaType=Plain"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Media emitted via detected key when not captured")
|
||||||
|
func mediaDerived() throws {
|
||||||
|
let argv = try build(
|
||||||
|
options: PrintOptions(mediaType: "SemiGloss"),
|
||||||
|
optionKeys: ["CNIJMediaType", "MediaType"])
|
||||||
|
// CNIJMediaType wins over MediaType in detection order.
|
||||||
|
#expect(argv.contains("CNIJMediaType=SemiGloss"))
|
||||||
|
#expect(!argv.contains("MediaType=SemiGloss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Driver bypass emitted when absent, skipped when captured")
|
||||||
|
func bypassRules() throws {
|
||||||
|
let withBypass = try build(
|
||||||
|
optionKeys: ["EPIJ_CMat"])
|
||||||
|
#expect(withBypass.contains("EPIJ_CMat=3"))
|
||||||
|
|
||||||
|
let captured = try build(
|
||||||
|
options: PrintOptions(cupsOptions: "EPIJ_CMat=1"),
|
||||||
|
optionKeys: ["EPIJ_CMat"])
|
||||||
|
// Captured value kept, detection not re-applied.
|
||||||
|
#expect(captured.filter { $0.hasPrefix("EPIJ_CMat") }
|
||||||
|
== ["EPIJ_CMat=1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Orientation: portrait=3 landscape=4; captured wins")
|
||||||
|
func orientation() throws {
|
||||||
|
#expect(try build(options: PrintOptions(orientation: "portrait"))
|
||||||
|
.contains("orientation-requested=3"))
|
||||||
|
#expect(try build(options: PrintOptions(orientation: "landscape"))
|
||||||
|
.contains("orientation-requested=4"))
|
||||||
|
#expect(!try build(options: PrintOptions(
|
||||||
|
orientation: "landscape",
|
||||||
|
cupsOptions: "orientation-requested=5"))
|
||||||
|
.contains("orientation-requested=4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PageSize emitted unless captured")
|
||||||
|
func pageSize() throws {
|
||||||
|
#expect(try build(options: PrintOptions(paperSize: "A4"))
|
||||||
|
.contains("PageSize=A4"))
|
||||||
|
#expect(!try build(options: PrintOptions(
|
||||||
|
paperSize: "A4", cupsOptions: "PageSize=Letter"))
|
||||||
|
.contains("PageSize=A4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Sanitise rejects `;`, newline, and shell metachars")
|
||||||
|
func sanitise() throws {
|
||||||
|
#expect(throws: LpArgsError.self) {
|
||||||
|
_ = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "InputSlot=Rear;rm -rf /"))
|
||||||
|
}
|
||||||
|
#expect(throws: LpArgsError.self) {
|
||||||
|
_ = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "InputSlot=Rear\nMediaType=Photo"))
|
||||||
|
}
|
||||||
|
#expect(throws: LpArgsError.self) {
|
||||||
|
_ = try build(options: PrintOptions(
|
||||||
|
cupsOptions: "InputSlot=$(whoami)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user