Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a02a8a640 | ||
|
|
14f521a65e | ||
|
|
4281d07754 | ||
|
|
73db1c8c25 |
@@ -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,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<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.
|
||||
public static let alwaysDropped: Set<String> = [
|
||||
"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: " ")
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,16 @@ public enum CupsParsers {
|
||||
mediaTypeKeys.first { optionKeys.contains($0) }
|
||||
}
|
||||
|
||||
/// Media type from a captured `key=value key=value` options string.
|
||||
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
|
||||
public static func extractMediaType(fromOptionsString options: String) -> String? {
|
||||
let pairs = lpoptions(options)
|
||||
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
|
||||
return v
|
||||
}
|
||||
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
|
||||
}
|
||||
|
||||
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
|
||||
/// (docs/11 layer ④): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
|
||||
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
|
||||
|
||||
@@ -11,6 +11,7 @@ struct AppEnvironment: Sendable {
|
||||
let settingsStore: SettingsStore
|
||||
let presetStore: PresetStore
|
||||
let runner: ArgyllRunner
|
||||
let cupsService: CupsService
|
||||
|
||||
static func live(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment
|
||||
@@ -18,10 +19,14 @@ struct AppEnvironment: Sendable {
|
||||
let settingsStore = SettingsStore()
|
||||
var overrideDir = settingsStore.load().argyllBinaryDir
|
||||
.map { URL(fileURLWithPath: $0) }
|
||||
var cupsDir = URL(fileURLWithPath: "/usr/bin")
|
||||
#if DEBUG
|
||||
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
|
||||
overrideDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
|
||||
cupsDir = URL(fileURLWithPath: dir)
|
||||
}
|
||||
#endif
|
||||
return AppEnvironment(
|
||||
stateStore: WizardStateStore(),
|
||||
@@ -30,7 +35,10 @@ struct AppEnvironment: Sendable {
|
||||
runner: ArgyllRunner(
|
||||
processManager: .shared,
|
||||
binaryResolver: BinaryResolver(overrideDir: overrideDir)
|
||||
)
|
||||
),
|
||||
cupsService: CupsService(
|
||||
processManager: .shared,
|
||||
binaryDir: cupsDir)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +71,48 @@ enum UITestHooks {
|
||||
/// Preset export destination.
|
||||
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
|
||||
|
||||
// MARK: - Print panel / CUPS stubs (issue 13/17)
|
||||
|
||||
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts —
|
||||
/// `CupsService.binaryDir` under UI tests.
|
||||
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
|
||||
|
||||
/// Path the mock `lp` script appends its argv to, for assertions.
|
||||
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
|
||||
|
||||
/// Whether the `NSPrintPanel` should be stubbed under UI testing —
|
||||
/// separate from the stub's *result* so "cancel" (`nil`) does not
|
||||
/// fall through to the real modal.
|
||||
static var printPanelStubbed: Bool { isEnabled }
|
||||
|
||||
/// Canned `NSPrintPanel` outcome — XCUITest cannot drive the
|
||||
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
|
||||
/// - `cancel` (or unset while testing) → user cancelled → `nil`
|
||||
/// - `ok` → `PrintPropertiesResult` with
|
||||
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
|
||||
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
|
||||
/// queue the panel was opened for).
|
||||
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
|
||||
switch env["ICCERY_TEST_PRINT_PANEL"] {
|
||||
case "ok":
|
||||
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
}
|
||||
return PrintPropertiesResult(
|
||||
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? queue,
|
||||
options: PrintOptions(
|
||||
mediaType: options.flatMap {
|
||||
CupsParsers.extractMediaType(fromOptionsString: $0)
|
||||
},
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: options))
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func url(_ key: String) -> URL? {
|
||||
guard let raw = env[key], !raw.isEmpty else { return nil }
|
||||
return URL(fileURLWithPath: raw)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import ICCeryCore
|
||||
|
||||
/// Errors raised while preparing the bound print panel.
|
||||
enum PrintPanelError: LocalizedError {
|
||||
case sessionBindingFailed(OSStatus)
|
||||
case noPrinterFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sessionBindingFailed(let status):
|
||||
return "Could not bind the print session to the queue (OSStatus \(status))."
|
||||
case .noPrinterFound(let name):
|
||||
return "No printer found for '\(name)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
||||
/// queue (issue 13, docs/11).
|
||||
///
|
||||
/// This is a **settings-capture** dialog — the default button is
|
||||
/// "Use Settings", never "Print". It is never System Settings, the
|
||||
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
|
||||
/// and is not an error.
|
||||
///
|
||||
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)` →
|
||||
/// `PMSessionSetCurrentPMPrinter` → session default settings/page
|
||||
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
|
||||
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
|
||||
/// label) → `printInfo.printer`.
|
||||
@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(
|
||||
queue: String,
|
||||
displayName: String?,
|
||||
cupsService: CupsService
|
||||
) async throws -> PrintPropertiesResult? {
|
||||
#if DEBUG
|
||||
if UITestHooks.printPanelStubbed {
|
||||
return UITestHooks.printPanelResult(forQueue: queue)
|
||||
}
|
||||
#endif
|
||||
let display = displayName
|
||||
?? (try? await cupsService.displayName(for: queue))
|
||||
// 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?,
|
||||
optionKeys: Set<String>
|
||||
) throws -> PrintPropertiesResult? {
|
||||
let printInfo = NSPrintInfo()
|
||||
var pmPrinter: PMPrinter?
|
||||
var boundViaPM = false
|
||||
|
||||
// ① Bind the session to the selected CUPS queue (docs/11).
|
||||
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
||||
pmPrinter = printer
|
||||
let session = unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
||||
let settings = unsafeBitCast(
|
||||
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
||||
let pageFormat = unsafeBitCast(
|
||||
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
||||
|
||||
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
||||
if status != 0 {
|
||||
PMRelease(pmObject(printer))
|
||||
throw PrintPanelError.sessionBindingFailed(status)
|
||||
}
|
||||
// Warn-only: defaults keep the panel consistent with the
|
||||
// queue but are not fatal when they fail.
|
||||
_ = PMSessionDefaultPrintSettings(session, settings)
|
||||
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
||||
boundViaPM = true
|
||||
} else {
|
||||
// Fallback: NSPrinter by display name (docs/11 §binding).
|
||||
guard let displayName,
|
||||
let nsPrinter = NSPrinter(name: displayName)
|
||||
else {
|
||||
throw PrintPanelError.noPrinterFound(
|
||||
displayName ?? queue)
|
||||
}
|
||||
printInfo.printer = nsPrinter
|
||||
printInfo.setUpPrintOperationDefaultValues()
|
||||
}
|
||||
defer {
|
||||
if let printer = pmPrinter {
|
||||
PMRelease(pmObject(printer))
|
||||
}
|
||||
}
|
||||
|
||||
// ②–⑤ 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 = [
|
||||
.showsCopies, .showsPageRange, .showsPaperSize,
|
||||
.showsOrientation, .showsScaling, .showsPrintSelection,
|
||||
.showsPageSetupAccessory, .showsPreview,
|
||||
]
|
||||
panel.defaultButtonTitle = "Use Settings"
|
||||
|
||||
let response = panel.runModal(with: printInfo)
|
||||
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(
|
||||
session: unsafeBitCast(
|
||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
options: PrintOptions(
|
||||
mediaType: mediaType,
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
}
|
||||
|
||||
// MARK: - PM helpers
|
||||
|
||||
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
||||
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
||||
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
||||
unsafeBitCast(printer, to: PMObject.self)
|
||||
}
|
||||
|
||||
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
||||
private static func currentPrinterID(
|
||||
session: PMPrintSession,
|
||||
fallback: String
|
||||
) -> String {
|
||||
var current: PMPrinter?
|
||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||
let printer = current
|
||||
else { return fallback }
|
||||
defer { PMRelease(pmObject(printer)) }
|
||||
guard let id = PMPrinterGetID(printer)
|
||||
else { return fallback }
|
||||
return id.takeUnretainedValue() as String
|
||||
}
|
||||
}
|
||||
@@ -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,71 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ICCeryCore
|
||||
@testable import ICCery
|
||||
|
||||
/// Issue 13 — panel outcome mapping (cancel → nil, ok → result).
|
||||
/// The real `NSPrintPanel` is never run in tests; these exercise the
|
||||
/// `UITestHooks` seam the UI tests rely on.
|
||||
@Suite("PrintPanelStub")
|
||||
struct PrintPanelStubTests {
|
||||
|
||||
private func withEnv(
|
||||
_ vars: [String: String?],
|
||||
_ body: () throws -> Void
|
||||
) rethrows {
|
||||
var saved: [String: String?] = [:]
|
||||
for key in vars.keys {
|
||||
saved[key] = ProcessInfo.processInfo.environment[key]
|
||||
}
|
||||
for (key, value) in vars {
|
||||
if let value { setenv(key, value, 1) } else { unsetenv(key) }
|
||||
}
|
||||
defer {
|
||||
for (key, value) in saved {
|
||||
if let value { setenv(key, value, 1) } else { unsetenv(key) }
|
||||
}
|
||||
}
|
||||
try body()
|
||||
}
|
||||
|
||||
@Test("Cancel returns nil — not an error")
|
||||
func cancelIsNil() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "cancel",
|
||||
]) {
|
||||
#expect(UITestHooks.printPanelStubbed)
|
||||
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK returns captured options + selected printer")
|
||||
func okResult() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
|
||||
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "q")
|
||||
#expect(result?.selectedPrinter == "Other_Queue")
|
||||
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
|
||||
#expect(result?.options.mediaType == "Photo")
|
||||
#expect(result?.options.ppdUncorrectedPassthrough == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("OK defaults selected printer to the opened queue")
|
||||
func okDefaultsPrinter() throws {
|
||||
try withEnv([
|
||||
"ICCERY_UI_TESTING": "1",
|
||||
"ICCERY_TEST_PRINT_PANEL": "ok",
|
||||
"ICCERY_TEST_PANEL_OPTIONS": nil,
|
||||
"ICCERY_TEST_PANEL_PRINTER": nil,
|
||||
]) {
|
||||
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
|
||||
#expect(result?.selectedPrinter == "My_Queue")
|
||||
#expect(result?.options.cupsOptions == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user