438 lines
17 KiB
Swift
438 lines
17 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import Darwin
|
|
|
|
/// SPEC §10 — libcups queue inspection, AirPrint detection, vendor PPD injection.
|
|
|
|
struct PPDChoice: Equatable, Hashable {
|
|
var name: String // Computer-readable identifier (e.g. "13", "2", "Plain")
|
|
var title: String // Human-readable display label (e.g. "Epson Premium Glossy", "Cassette 1")
|
|
}
|
|
|
|
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 mediaTypeChoices: [PPDChoice]
|
|
var mediaTypeKeyword: String?
|
|
var trays: [String]
|
|
var trayChoices: [PPDChoice]
|
|
var trayKeyword: String?
|
|
var qualityChoices: [PPDChoice]
|
|
var qualityKeyword: String?
|
|
var resolutions: [String]
|
|
var resolutionChoices: [PPDChoice]
|
|
var ppdText: String
|
|
|
|
init(
|
|
name: String,
|
|
instance: String? = nil,
|
|
displayName: String,
|
|
uri: String,
|
|
make: String,
|
|
model: String,
|
|
isAirPrint: Bool,
|
|
mediaSizes: [String],
|
|
mediaTypes: [String],
|
|
mediaTypeChoices: [PPDChoice] = [],
|
|
mediaTypeKeyword: String? = nil,
|
|
trays: [String],
|
|
trayChoices: [PPDChoice] = [],
|
|
trayKeyword: String? = nil,
|
|
qualityChoices: [PPDChoice] = [],
|
|
qualityKeyword: String? = nil,
|
|
resolutions: [String],
|
|
resolutionChoices: [PPDChoice] = [],
|
|
ppdText: String
|
|
) {
|
|
self.name = name
|
|
self.instance = instance
|
|
self.displayName = displayName
|
|
self.uri = uri
|
|
self.make = make
|
|
self.model = model
|
|
self.isAirPrint = isAirPrint
|
|
self.mediaSizes = mediaSizes
|
|
self.mediaTypes = mediaTypes
|
|
self.mediaTypeChoices = mediaTypeChoices
|
|
self.mediaTypeKeyword = mediaTypeKeyword
|
|
self.trays = trays
|
|
self.trayChoices = trayChoices
|
|
self.trayKeyword = trayKeyword
|
|
self.qualityChoices = qualityChoices
|
|
self.qualityKeyword = qualityKeyword
|
|
self.resolutions = resolutions
|
|
self.resolutionChoices = resolutionChoices
|
|
self.ppdText = ppdText
|
|
}
|
|
}
|
|
|
|
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,
|
|
mediaTypeChoices: options.mediaTypeChoices,
|
|
mediaTypeKeyword: options.mediaTypeKeyword,
|
|
trays: options.trays,
|
|
trayChoices: options.trayChoices,
|
|
trayKeyword: options.trayKeyword,
|
|
qualityChoices: options.qualityChoices,
|
|
qualityKeyword: options.qualityKeyword,
|
|
resolutions: options.resolutions,
|
|
resolutionChoices: options.resolutionChoices,
|
|
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 mediaTypeChoices: [PPDChoice] = []
|
|
var mediaTypeKeyword: String? = nil
|
|
var trays: [String] = []
|
|
var trayChoices: [PPDChoice] = []
|
|
var trayKeyword: String? = nil
|
|
var qualityChoices: [PPDChoice] = []
|
|
var qualityKeyword: String? = nil
|
|
var resolutions: [String] = []
|
|
var resolutionChoices: [PPDChoice] = []
|
|
var colorChoices: [(keyword: String, choice: String)] = []
|
|
}
|
|
|
|
static func discoverPPDOptions(_ ppdText: String) -> PPDOptions {
|
|
var out = PPDOptions()
|
|
out.pageSizes = optionChoices(in: ppdText, keyword: "PageSize")
|
|
|
|
let media = discoverOptionChoices(
|
|
in: ppdText,
|
|
candidateKeywords: ["MediaType", "CNIJMediaType"],
|
|
openUITargets: ["media type", "mediatype"]
|
|
)
|
|
out.mediaTypeKeyword = media.keyword
|
|
out.mediaTypeChoices = media.choices
|
|
out.mediaTypes = media.choices.map { $0.title }
|
|
|
|
let trays = discoverOptionChoices(
|
|
in: ppdText,
|
|
candidateKeywords: ["InputSlot", "EPIJ_FdSo", "CNIJMediaSupply"],
|
|
openUITargets: ["paper source", "media source", "input slot", "paper feed", "feed source"]
|
|
)
|
|
out.trayKeyword = trays.keyword
|
|
out.trayChoices = trays.choices
|
|
out.trays = trays.choices.map { $0.title }
|
|
|
|
let quality = discoverOptionChoices(
|
|
in: ppdText,
|
|
candidateKeywords: ["CNIJPrintQuality", "EPIJ_Qual", "cupsPrintQuality", "PrintQuality", "CNIJPrintMode2", "Quality", "StpQuality"],
|
|
openUITargets: ["print quality", "quality"]
|
|
)
|
|
out.qualityKeyword = quality.keyword
|
|
out.qualityChoices = quality.choices
|
|
|
|
out.resolutionChoices = optionDetails(in: ppdText, keyword: "Resolution")
|
|
out.resolutions = out.resolutionChoices.map { $0.name }
|
|
// 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)
|
|
}
|
|
|
|
/// Decodes Adobe PPD hex escape sequences (e.g. `<2F>` -> `/`, `<2E>` -> `.`, `<3A>` -> `:`).
|
|
static func decodePPDString(_ s: String) -> String {
|
|
var result = ""
|
|
var idx = s.startIndex
|
|
while idx < s.endIndex {
|
|
if s[idx] == "<" {
|
|
if let closeIdx = s[idx...].firstIndex(of: ">") {
|
|
let hexContent = s[s.index(after: idx)..<closeIdx]
|
|
let cleanHex = hexContent.filter { $0.isHexDigit }
|
|
if !cleanHex.isEmpty && cleanHex.count % 2 == 0 {
|
|
var bytes: [UInt8] = []
|
|
var hexIdx = cleanHex.startIndex
|
|
var valid = true
|
|
while hexIdx < cleanHex.endIndex {
|
|
let nextHexIdx = cleanHex.index(hexIdx, offsetBy: 2)
|
|
let byteStr = cleanHex[hexIdx..<nextHexIdx]
|
|
if let b = UInt8(byteStr, radix: 16) {
|
|
bytes.append(b)
|
|
} else {
|
|
valid = false
|
|
break
|
|
}
|
|
hexIdx = nextHexIdx
|
|
}
|
|
if valid, let decoded = String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) {
|
|
result.append(decoded)
|
|
idx = s.index(after: closeIdx)
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result.append(s[idx])
|
|
idx = s.index(after: idx)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// Parses option lines for a keyword, extracting both internal choice code (`name`) and human-readable label (`title`).
|
|
static func optionDetails(in ppdText: String, keyword: String) -> [PPDChoice] {
|
|
var choices: [PPDChoice] = []
|
|
let prefix = "*\(keyword) "
|
|
for raw in ppdText.split(whereSeparator: \.isNewline) {
|
|
let line = String(raw).trimmingCharacters(in: .whitespaces)
|
|
guard line.hasPrefix(prefix) else { continue }
|
|
let rest = String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
|
|
guard let colonIdx = rest.firstIndex(of: ":") else { continue }
|
|
let choicePart = rest[..<colonIdx].trimmingCharacters(in: .whitespaces)
|
|
let name: String
|
|
let title: String
|
|
if let slashIdx = choicePart.firstIndex(of: "/") {
|
|
name = String(choicePart[..<slashIdx]).trimmingCharacters(in: .whitespaces)
|
|
var rawTitle = String(choicePart[choicePart.index(after: slashIdx)...]).trimmingCharacters(in: .whitespaces)
|
|
if rawTitle.hasPrefix("\"") && rawTitle.hasSuffix("\"") && rawTitle.count >= 2 {
|
|
rawTitle = String(rawTitle.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
let decoded = decodePPDString(rawTitle).trimmingCharacters(in: .whitespaces)
|
|
title = decoded.isEmpty ? name : decoded
|
|
} else {
|
|
name = choicePart
|
|
title = choicePart
|
|
}
|
|
guard !name.isEmpty else { continue }
|
|
if !choices.contains(where: { $0.name == name }) {
|
|
choices.append(PPDChoice(name: name, title: title))
|
|
}
|
|
}
|
|
return choices
|
|
}
|
|
|
|
static func optionChoices(in ppdText: String, keyword: String) -> [String] {
|
|
optionDetails(in: ppdText, keyword: keyword).map { $0.name }
|
|
}
|
|
|
|
static func discoverOptionChoices(
|
|
in ppdText: String,
|
|
candidateKeywords: [String],
|
|
openUITargets: [String] = []
|
|
) -> (keyword: String?, choices: [PPDChoice]) {
|
|
for kw in candidateKeywords {
|
|
let choices = optionDetails(in: ppdText, keyword: kw)
|
|
if !choices.isEmpty {
|
|
return (kw, choices)
|
|
}
|
|
}
|
|
// If not found in candidate keywords, scan *OpenUI for matching translations
|
|
let lines = ppdText.split(whereSeparator: \.isNewline)
|
|
for raw in lines {
|
|
let s = String(raw).trimmingCharacters(in: .whitespaces)
|
|
guard s.hasPrefix("*OpenUI") else { continue }
|
|
guard let (kw, trans) = openUIKeywordAndTranslation(s) else { continue }
|
|
let lowerTrans = trans.lowercased()
|
|
let lowerKw = kw.lowercased()
|
|
if openUITargets.contains(where: { lowerTrans.contains($0) || lowerKw.contains($0) }) {
|
|
let choices = optionDetails(in: ppdText, keyword: kw)
|
|
if !choices.isEmpty {
|
|
return (kw, choices)
|
|
}
|
|
}
|
|
}
|
|
return (nil, [])
|
|
}
|
|
|
|
private static func openUIKeywordAndTranslation(_ line: String) -> (keyword: String, translation: String)? {
|
|
// e.g. *OpenUI *PageSize/Media Size: PickOne
|
|
guard let star = line.firstIndex(of: "*") else { return nil }
|
|
let afterFirstStar = line[star...].dropFirst()
|
|
guard let secondStar = afterFirstStar.firstIndex(of: "*") else { return nil }
|
|
let rest = afterFirstStar[secondStar...].dropFirst()
|
|
guard let colonIdx = rest.firstIndex(of: ":") else { return nil }
|
|
let target = rest[..<colonIdx].trimmingCharacters(in: .whitespaces)
|
|
if let slashIdx = target.firstIndex(of: "/") {
|
|
let kw = String(target[..<slashIdx]).trimmingCharacters(in: .whitespaces)
|
|
let trans = decodePPDString(String(target[target.index(after: slashIdx)...]).trimmingCharacters(in: .whitespaces))
|
|
return (kw, trans)
|
|
} else {
|
|
let kw = String(target)
|
|
return (kw, kw)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|