239 lines
8.9 KiB
Swift
239 lines
8.9 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import Darwin
|
|
|
|
/// SPEC §10 — libcups queue inspection, AirPrint detection, vendor PPD injection.
|
|
|
|
struct PrinterQueue: Equatable {
|
|
var name: String
|
|
var instance: String?
|
|
var displayName: String
|
|
var uri: String
|
|
var make: String
|
|
var model: String
|
|
var isAirPrint: Bool
|
|
var mediaSizes: [String]
|
|
var mediaTypes: [String]
|
|
var trays: [String]
|
|
var resolutions: [String]
|
|
var ppdText: String
|
|
}
|
|
|
|
enum CUPSManager {
|
|
|
|
static let airPrintWarning =
|
|
"Selected printer uses an AirPrint / driverless queue. Hardware colour management cannot be reliably disabled. Install the official OEM driver (Epson, Canon, etc.) for accurate profiling."
|
|
|
|
// MARK: - Queue listing
|
|
|
|
static func listQueues() throws -> [PrinterQueue] {
|
|
var dests: UnsafeMutablePointer<cups_dest_t>? = nil
|
|
let count = cupsGetDests(&dests)
|
|
defer { if let dests = dests { cupsFreeDests(count, dests) } }
|
|
guard count > 0, let dests = dests else {
|
|
return []
|
|
}
|
|
var result: [PrinterQueue] = []
|
|
for i in 0..<Int(count) {
|
|
let dest = dests.advanced(by: i).pointee
|
|
guard let cName = dest.name else { continue }
|
|
let name = String(cString: cName)
|
|
let instance = dest.instance.map { String(cString: $0) }
|
|
let uri = destValue(dest, key: "printer-uri-supported")
|
|
?? destValue(dest, key: "device-uri")
|
|
?? ""
|
|
let makeModel = destValue(dest, key: "printer-make-and-model") ?? ""
|
|
let (make, model) = splitMakeModel(makeModel)
|
|
let ppdText = ppdContents(forQueue: name) ?? ""
|
|
let options = discoverPPDOptions(ppdText)
|
|
let air = AirPrintDetector.isAirPrint(
|
|
uri: uri,
|
|
ppdText: ppdText,
|
|
make: make,
|
|
model: model
|
|
)
|
|
result.append(PrinterQueue(
|
|
name: name,
|
|
instance: instance,
|
|
displayName: instance.map { "\(name)/\($0)" } ?? name,
|
|
uri: uri,
|
|
make: make,
|
|
model: model,
|
|
isAirPrint: air,
|
|
mediaSizes: options.pageSizes,
|
|
mediaTypes: options.mediaTypes,
|
|
trays: options.trays,
|
|
resolutions: options.resolutions,
|
|
ppdText: ppdText
|
|
))
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func namedQueue(_ name: String) throws -> PrinterQueue? {
|
|
try listQueues().first { $0.name == name }
|
|
}
|
|
|
|
// MARK: - PPD
|
|
|
|
static func ppdPath(forQueue name: String) -> String? {
|
|
name.withCString { cName in
|
|
guard let cPath = TPCupsGetPPD(cName) else { return nil }
|
|
return String(cString: cPath)
|
|
}
|
|
}
|
|
|
|
static func ppdContents(forQueue name: String) -> String? {
|
|
guard let path = ppdPath(forQueue: name) else { return nil }
|
|
defer { unlink(path) }
|
|
return try? String(contentsOfFile: path, encoding: .isoLatin1)
|
|
}
|
|
|
|
struct PPDOptions {
|
|
var pageSizes: [String] = []
|
|
var mediaTypes: [String] = []
|
|
var trays: [String] = []
|
|
var resolutions: [String] = []
|
|
var colorChoices: [(keyword: String, choice: String)] = []
|
|
}
|
|
|
|
static func discoverPPDOptions(_ ppdText: String) -> PPDOptions {
|
|
var out = PPDOptions()
|
|
out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize")
|
|
out.mediaTypes = optionChoices(in: ppdText, keyword: "MediaType")
|
|
out.trays = optionChoices(in: ppdText, keyword: "InputSlot")
|
|
out.resolutions = optionChoices(in: ppdText, keyword: "Resolution")
|
|
// Walk every *OpenUI for Colour/Color keys (SPEC §10.3 generic).
|
|
let lines = ppdText.split(whereSeparator: \.isNewline)
|
|
var currentKeyword: String?
|
|
for line in lines {
|
|
let s = String(line)
|
|
if s.hasPrefix("*OpenUI") {
|
|
if let kw = openUIKeyword(s) { currentKeyword = kw }
|
|
} else if s.hasPrefix("*CloseUI") {
|
|
currentKeyword = nil
|
|
} else if let kw = currentKeyword, looksLikeColorKeyword(kw) {
|
|
if let choice = choiceName(s, keyword: kw) {
|
|
out.colorChoices.append((kw, choice))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// SPEC §10.3 vendor keys for “off”.
|
|
static func vendorColorBypass(make: String, model: String, ppdText: String) -> [String: String] {
|
|
let vendor = make.lowercased()
|
|
if vendor.contains("epson") {
|
|
return ["ColorModel": "RGB", "EPSONColorControls": "Off"]
|
|
}
|
|
if vendor.contains("canon") {
|
|
return ["CNColorMatching": "None"]
|
|
}
|
|
if vendor.contains("hp") || vendor.contains("hewlett") {
|
|
return ["ColorModel": "RGB", "HPColorControl": "Off"]
|
|
}
|
|
// Generic: prefer any Color/Colour option whose choice is None / Off / No.
|
|
let options = discoverPPDOptions(ppdText)
|
|
var found: [String: String] = [:]
|
|
for pair in options.colorChoices {
|
|
if ["none", "off", "no", "nocoloradjustment"].contains(pair.choice.lowercased()) {
|
|
found[pair.keyword] = pair.choice
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
static func applyVendorBypass(_ map: [String: String], to printInfo: NSPrintInfo) {
|
|
let dict = printInfo.dictionary()
|
|
let settingsKey = NSPrintInfo.AttributeKey(rawValue: "com.apple.print.printSettings")
|
|
let nested: NSMutableDictionary
|
|
if let existing = dict[settingsKey] as? NSMutableDictionary {
|
|
nested = existing
|
|
} else {
|
|
nested = NSMutableDictionary()
|
|
dict[settingsKey] = nested
|
|
}
|
|
for (k, v) in map {
|
|
nested[k] = v
|
|
dict[NSPrintInfo.AttributeKey(rawValue: k)] = v
|
|
TPLog.info("PPD bypass \(k)=\(v)")
|
|
}
|
|
}
|
|
|
|
// MARK: - helpers
|
|
|
|
private static func destValue(_ dest: cups_dest_t, key: String) -> String? {
|
|
guard let cKey = key.cString(using: .utf8) else { return nil }
|
|
guard let raw = cupsGetOption(cKey, dest.num_options, dest.options) else { return nil }
|
|
return String(cString: raw)
|
|
}
|
|
|
|
private static func splitMakeModel(_ makeModel: String) -> (String, String) {
|
|
let parts = makeModel.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
|
if parts.count == 2 { return (String(parts[0]), String(parts[1])) }
|
|
if parts.count == 1 { return (String(parts[0]), "") }
|
|
return ("", makeModel)
|
|
}
|
|
|
|
static func optionChoices(in ppdText: String, keyword: String) -> [String] {
|
|
var choices: [String] = []
|
|
let prefix = "*\(keyword) "
|
|
for raw in ppdText.split(whereSeparator: \.isNewline) {
|
|
let line = String(raw)
|
|
guard line.hasPrefix(prefix) else { continue }
|
|
let rest = String(line.dropFirst(prefix.count))
|
|
let token = rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first
|
|
if let token = token {
|
|
let name = String(token)
|
|
if !choices.contains(name) { choices.append(name) }
|
|
}
|
|
}
|
|
return choices
|
|
}
|
|
|
|
private static func openUIKeyword(_ line: String) -> String? {
|
|
// *OpenUI *PageSize/Media Size: PickOne
|
|
guard let star = line.firstIndex(of: "*") else { return nil }
|
|
let after = line[star...].dropFirst()
|
|
guard let second = after.firstIndex(of: "*") else { return nil }
|
|
let rest = after[second...].dropFirst()
|
|
var kw = ""
|
|
for ch in rest {
|
|
if ch == "/" || ch == ":" || ch == " " { break }
|
|
kw.append(ch)
|
|
}
|
|
return kw.isEmpty ? nil : kw
|
|
}
|
|
|
|
private static func choiceName(_ line: String, keyword: String) -> String? {
|
|
let prefix = "*\(keyword) "
|
|
guard line.hasPrefix(prefix) else { return nil }
|
|
let rest = String(line.dropFirst(prefix.count))
|
|
return rest.split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == " " }).first.map(String.init)
|
|
}
|
|
|
|
private static func looksLikeColorKeyword(_ kw: String) -> Bool {
|
|
let l = kw.lowercased()
|
|
return l.contains("color") || l.contains("colour")
|
|
}
|
|
}
|
|
|
|
/// SPEC §10.2 — pure function so unit tests do not need libcups.
|
|
enum AirPrintDetector {
|
|
static func isAirPrint(uri: String, ppdText: String, make: String, model: String) -> Bool {
|
|
let u = uri.lowercased()
|
|
if u.hasPrefix("apple-airprint://") { return true }
|
|
if ppdText.range(of: "*APAirPrint:\\s*True", options: [.regularExpression, .caseInsensitive]) != nil {
|
|
return true
|
|
}
|
|
if make.caseInsensitiveCompare("Apple") == .orderedSame && model.lowercased().contains("airprint") {
|
|
return true
|
|
}
|
|
if u.hasPrefix("ipps://") && ppdText.lowercased().contains("airprint") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|