From 0a02a8a640a8b2bd060595a5b5e3da402dc7ddff Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:19:09 +0100 Subject: [PATCH] =?UTF-8?q?ColorSync=20suppression=20engine=20=E2=80=94=20?= =?UTF-8?q?six=20layers=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ColorMatchingAttempts (Core): dlsym order Lock -> Mode -> NoLock and modes AP_ApplicationColorMatching -> ApplicationColorMatching as data; both AP_ print-settings keys; 2-arg (PMPrintSession, CFStringRef) signature — never integer 1 (#188). - ColorSyncSuppressor (app): layer 2 SPI via injectable dlsym resolver, first 0 wins; layer 3 PMPrintSettingsSetValue both keys locked; layer 4 driver bypass from lpoptions -l keys, unlocked; layer 5 NSPrintInfo.printSettings mirror; layer 6 PMPrintSettingsToOptions (dlsym'd) -> filter -> captured cupsOptions + mediaType. - CupsOptionsFilter (Core): drops com.apple.*, collate/copies/ job-sheets/pserrorhandler-requested, empty values, both AP_* keys; keeps relevant + unknown non-com.* keys; extractMediaType prefers MediaType then EPIJ_Medi. - PrintPanelService: suppression runs between session binding and runModal on the PM path; capture feeds PrintPropertiesResult. Tests: filter fixtures + injected-resolver call-order/first-win/ mode-fallback/missing-symbol suites. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Print/ColorMatchingAttempts.swift | 49 +++++ .../ICCeryCore/Print/CupsOptionsFilter.swift | 59 ++++++ .../ICCery/Print/ColorSyncSuppressor.swift | 171 ++++++++++++++++++ Sources/ICCery/Print/PrintPanelService.swift | 49 ++++- .../CupsOptionsFilterTests.swift | 151 ++++++++++++++++ 5 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift create mode 100644 Sources/ICCery/Print/ColorSyncSuppressor.swift create mode 100644 Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift new file mode 100644 index 0000000..5e535a9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift @@ -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", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift new file mode 100644 index 0000000..fcf3bf1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -0,0 +1,59 @@ +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 = [ + // 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. + public static let alwaysDropped: Set = [ + "collate", "copies", "pserrorhandler-requested", "job-sheets", + "AP_ColorMatchingMode", "AP.ColorMatchingMode", + ] + + /// 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: " ") + } +} diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift new file mode 100644 index 0000000..28862b6 --- /dev/null +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -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?>) -> 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 + ) -> (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? + 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) + } +} diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index ee9af07..79435ee 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -33,6 +33,9 @@ enum PrintPanelError: LocalizedError { @MainActor struct PrintPanelService { + /// The suppression engine — injectable for tests. + var suppressor = ColorSyncSuppressor() + /// Resolves the display name (off-panel `lpoptions` fetch) and runs /// the modal panel. Returns `nil` when the user cancels. func showProperties( @@ -47,14 +50,20 @@ struct PrintPanelService { #endif let display = displayName ?? (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 private func runNativePanel( queue: String, - displayName: String? + displayName: String?, + optionKeys: Set ) throws -> PrintPropertiesResult? { let printInfo = NSPrintInfo() var pmPrinter: PMPrinter? @@ -97,8 +106,21 @@ struct PrintPanelService { } } - // Colour-suppression layers ②–⑤ land in issue 14 here, between - // binding and runModal. + // ②–⑤ ColourSync suppression — only on the PM path: the SPI + // and PMPrintSettingsSetValue need a session with a current + // printer to attach to. + var settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + var driverBypass: (key: String, value: String)? + if boundViaPM { + let session = unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self) + suppressor.applySPIMode(to: session) // ② + suppressor.applyLockedKeys(to: settings) // ③ + driverBypass = suppressor.applyDriverBypass( // ④ + to: settings, optionKeys: optionKeys) + suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ + } let panel = NSPrintPanel() panel.options = [ @@ -109,10 +131,22 @@ struct PrintPanelService { panel.defaultButtonTitle = "Use Settings" let response = panel.runModal(with: printInfo) - // Layer ⑥ capture (PMPrintSettingsToOptions) lands in issue 14. guard response == NSApplication.ModalResponse.OK.rawValue else { 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( selectedPrinter: boundViaPM ? Self.currentPrinterID( @@ -120,7 +154,10 @@ struct PrintPanelService { printInfo.pmPrintSession(), to: PMPrintSession.self), fallback: queue) : nil, - options: PrintOptions(ppdUncorrectedPassthrough: true)) + options: PrintOptions( + mediaType: mediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: cupsOptions)) } // MARK: - PM helpers diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift new file mode 100644 index 0000000..c6951f3 --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -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) + } +} -- 2.39.5