mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Vendor-neutral Quick Actions: driver profiles, generic fallback, ppdreport
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
109
Sources/CupsKit/DriverProfiles.swift
Normal file
109
Sources/CupsKit/DriverProfiles.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
import Foundation
|
||||
|
||||
/// What Quick Actions need to know about a queue's driver: its PPD (if any) and IPP attributes.
|
||||
public struct DriverContext {
|
||||
public let ppd: PPD?
|
||||
/// Printer attributes, including `*-supported` values for IPP Everywhere pairs.
|
||||
public let attributes: IPPGroup?
|
||||
|
||||
public init(ppd: PPD?, attributes: IPPGroup?) {
|
||||
self.ppd = ppd
|
||||
self.attributes = attributes
|
||||
}
|
||||
|
||||
static let requestedAttributes = [
|
||||
"printer-make-and-model", "print-color-mode-supported", "print-color-mode-default",
|
||||
"sides-supported", "sides-default", "media-supported", "media-default",
|
||||
]
|
||||
|
||||
public static func load(client: CupsClient, queue: String) async throws -> DriverContext {
|
||||
let attributes = try await client.getPrinterAttributes(queue: queue, requested: requestedAttributes)
|
||||
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
|
||||
return DriverContext(ppd: ppd, attributes: attributes)
|
||||
}
|
||||
|
||||
public var manufacturer: String? { ppd?.attributes["Manufacturer"] }
|
||||
public var nickName: String? { ppd?.attributes["NickName"] ?? attributes?.string("printer-make-and-model") }
|
||||
/// For messages: "RICOH SP 3710DN".
|
||||
public var driverName: String { nickName ?? manufacturer ?? "this driver" }
|
||||
|
||||
/// Whether this queue can take `key=value`: a PPD option with that choice (or a custom value for
|
||||
/// `Custom.` values), or an IPP `name-default` whose `name-supported` lists the value.
|
||||
public func supports(key: String, value: String) -> Bool {
|
||||
if let option = ppd?.option(key) {
|
||||
if value.hasPrefix("Custom.") { return !option.customParameters.isEmpty }
|
||||
return option.choices.contains(value)
|
||||
}
|
||||
guard key.hasSuffix("-default"), let attributes else { return false }
|
||||
let supported = String(key.dropLast("-default".count)) + "-supported"
|
||||
return attributes.strings(supported).contains(value)
|
||||
}
|
||||
}
|
||||
|
||||
public struct DriverProfile: Decodable, Identifiable {
|
||||
public struct Match: Decodable {
|
||||
/// Regular expressions (case-insensitive) on the PPD's `*Manufacturer` / `*NickName`; all given must match.
|
||||
public let manufacturer: String?
|
||||
public let nickname: String?
|
||||
}
|
||||
|
||||
public let id: String
|
||||
public let name: String
|
||||
public let match: Match
|
||||
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
||||
public let actions: [String: [[String]]]
|
||||
|
||||
enum CodingKeys: String, CodingKey { case id, name, match, actions }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try c.decode(String.self, forKey: .id)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
match = try c.decode(Match.self, forKey: .match)
|
||||
// Accept one list of pairs or a list of alternative lists.
|
||||
var actions: [String: [[String]]] = [:]
|
||||
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
||||
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
||||
self.actions = actions
|
||||
}
|
||||
|
||||
private struct FlexiblePairs: Decodable {
|
||||
let alternatives: [[String]]
|
||||
init(from decoder: Decoder) throws {
|
||||
if let nested = try? [[String]](from: decoder) {
|
||||
alternatives = nested
|
||||
} else {
|
||||
alternatives = [try [String](from: decoder)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var isGeneric: Bool { match.manufacturer == nil && match.nickname == nil }
|
||||
|
||||
public func matches(_ context: DriverContext) -> Bool {
|
||||
func test(_ pattern: String?, _ value: String?) -> Bool {
|
||||
guard let pattern else { return true }
|
||||
guard let value else { return false }
|
||||
return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
return test(match.manufacturer, context.manufacturer) && test(match.nickname, context.nickName)
|
||||
}
|
||||
}
|
||||
|
||||
public enum DriverProfiles {
|
||||
private struct File: Decodable { let profiles: [DriverProfile] }
|
||||
|
||||
/// The profiles compiled in from Resources/driver-profiles.json, in match order.
|
||||
public static let builtIn: [DriverProfile] = {
|
||||
do {
|
||||
return try JSONDecoder().decode(File.self, from: Data(PackageResources.driver_profiles_json)).profiles
|
||||
} catch {
|
||||
fatalError("driver-profiles.json is invalid: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
/// Matching profiles in order: vendor profiles first, generic last.
|
||||
public static func matching(_ context: DriverContext, in profiles: [DriverProfile] = builtIn) -> [DriverProfile] {
|
||||
profiles.filter { $0.matches(context) }
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
/// Task-named shortcuts that set queue defaults without knowing option keywords.
|
||||
/// Each one is data: which PPD keyword/value pairs to write (first variant the PPD supports wins).
|
||||
/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed.
|
||||
/// What each one writes comes from the driver profiles in Resources/driver-profiles.json.
|
||||
public struct QuickAction: Identifiable, Hashable {
|
||||
public enum Input: Hashable {
|
||||
case none
|
||||
/// Digits, up to the PPD's own limit. An empty value clears the code.
|
||||
/// Digits, up to the PPD's own limit.
|
||||
case userCode
|
||||
}
|
||||
|
||||
public enum Effect: Hashable {
|
||||
/// `lpadmin -p queue -o ...` with the first variant whose keywords and values this PPD has.
|
||||
/// `{value}` in a value is replaced with the input.
|
||||
case options(variants: [[String: String]], clearVariants: [[String: String]])
|
||||
/// `lpadmin -d queue`.
|
||||
case defaultPrinter
|
||||
}
|
||||
|
||||
/// CLI name: `cupsadmin quick <id> <queue>`.
|
||||
/// CLI name (`cupsadmin quick <id> <queue>`) and the key in driver-profiles.json.
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let systemImage: String
|
||||
public let input: Input
|
||||
public let effect: Effect
|
||||
/// One line for the CLI list and tooltips.
|
||||
public let summary: String
|
||||
|
||||
public var isDefaultPrinter: Bool { id == "default" }
|
||||
|
||||
public static let all: [QuickAction] = [
|
||||
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
|
||||
effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []),
|
||||
summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"),
|
||||
summary: "Color by default"),
|
||||
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
||||
effect: .options(variants: [["ColorModel": "Gray"], ["HQColorMode": "BW"]], clearVariants: []),
|
||||
summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"),
|
||||
summary: "Black & white by default"),
|
||||
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
||||
effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]],
|
||||
clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]),
|
||||
summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.<code>)"),
|
||||
summary: "User/department code sent with every job (--clear to remove)"),
|
||||
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
||||
effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []),
|
||||
summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"),
|
||||
summary: "Two-sided, long edge"),
|
||||
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
||||
effect: .options(variants: [["Duplex": "None"]], clearVariants: []),
|
||||
summary: "One-sided (Duplex=None)"),
|
||||
summary: "One-sided"),
|
||||
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
|
||||
input: .none,
|
||||
effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []),
|
||||
summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"),
|
||||
input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
|
||||
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
||||
effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"),
|
||||
summary: "Server default printer (lpadmin -d)"),
|
||||
]
|
||||
|
||||
public static func named(_ id: String) -> QuickAction? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// The option changes for this PPD, or why the action isn't available on it.
|
||||
public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||
guard case .options(let variants, let clearVariants) = effect else { return .success([]) }
|
||||
let clearing = input == .userCode && (value ?? "").isEmpty
|
||||
let candidates = clearing ? clearVariants : variants
|
||||
guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) }
|
||||
|
||||
for variant in candidates {
|
||||
var changes: [OptionChange] = []
|
||||
for key in variant.keys.sorted() {
|
||||
guard let option = ppd.option(key) else { changes = []; break }
|
||||
let raw = variant[key]!
|
||||
if raw.contains("{value}") {
|
||||
guard !option.customParameters.isEmpty else { changes = []; break }
|
||||
changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false))
|
||||
} else {
|
||||
guard option.choices.contains(raw) else { changes = []; break }
|
||||
changes.append(OptionChange(key: key, value: raw, isSecret: false))
|
||||
/// The profile and option changes this action would write on a queue, or why it can't.
|
||||
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
||||
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
||||
let key = clearing ? id + "-clear" : id
|
||||
var firstMissing: String?
|
||||
for profile in DriverProfiles.matching(context, in: profiles) {
|
||||
guard let alternatives = profile.actions[key] else { continue }
|
||||
for pairs in alternatives {
|
||||
let parsed = pairs.compactMap(Self.parse)
|
||||
let filled = parsed.map { ($0.key, $0.value.replacingOccurrences(of: "{value}", with: value ?? "")) }
|
||||
if let missing = filled.first(where: { !context.supports(key: $0.0, value: $0.1) }) {
|
||||
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
||||
continue
|
||||
}
|
||||
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
||||
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
||||
}
|
||||
if !changes.isEmpty { return .success(changes) }
|
||||
}
|
||||
let needed = Set(candidates.flatMap { $0.keys }).sorted().joined(separator: " / ")
|
||||
return .failure(QuickActionUnavailable(reason: "This printer’s PPD has no \(needed) with the needed choice"))
|
||||
let detail = firstMissing.map { " (no \($0))" } ?? " (no driver profile maps \(key))"
|
||||
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName)\(detail)"))
|
||||
}
|
||||
|
||||
public func isAvailable(for ppd: PPD?) -> Bool {
|
||||
if case .defaultPrinter = effect { return true }
|
||||
if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true }
|
||||
return false
|
||||
/// nil when the action can run on this queue; otherwise the reason (for tooltips and CLI errors).
|
||||
public func unavailableReason(in context: DriverContext) -> String? {
|
||||
if isDefaultPrinter { return nil }
|
||||
switch resolve(context, value: input == .userCode ? "0" : nil) {
|
||||
case .success: return nil
|
||||
case .failure(let unavailable): return unavailable.reason
|
||||
}
|
||||
}
|
||||
|
||||
/// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid.
|
||||
public func validationError(_ value: String, ppd: PPD?) -> String? {
|
||||
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
||||
|
||||
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
|
||||
public func validationError(_ value: String, context: DriverContext) -> String? {
|
||||
guard input == .userCode, !value.isEmpty else { return nil }
|
||||
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
||||
if let max = ppd?.option("RIUserCode")?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
||||
if let keyword = codeKeyword(context),
|
||||
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
||||
return "At most \(max) digits"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// The current user code on the queue, without the Custom. prefix (nil when none is set).
|
||||
public static func currentUserCode(_ ppd: PPD?) -> String? {
|
||||
guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
||||
/// The code currently set on the queue, without the Custom. prefix (nil when none).
|
||||
public func currentCode(_ context: DriverContext) -> String? {
|
||||
guard let keyword = codeKeyword(context),
|
||||
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
||||
return String(value.dropFirst("Custom.".count))
|
||||
}
|
||||
|
||||
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
||||
private func codeKeyword(_ context: DriverContext) -> String? {
|
||||
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||
let profilePairs = resolved.profile.actions[id]?.first ?? []
|
||||
return profilePairs.compactMap(Self.parse).first { $0.value.contains("{value}") }?.key
|
||||
}
|
||||
|
||||
static func parse(_ pair: String) -> (key: String, value: String)? {
|
||||
guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else { return nil }
|
||||
return (String(pair[..<eq]), String(pair[pair.index(after: eq)...]))
|
||||
}
|
||||
}
|
||||
|
||||
public struct ResolvedQuickAction {
|
||||
public let profile: DriverProfile
|
||||
public let changes: [OptionChange]
|
||||
}
|
||||
|
||||
public struct QuickActionUnavailable: Error, Hashable {
|
||||
@@ -112,6 +116,8 @@ public struct QuickActionUnavailable: Error, Hashable {
|
||||
public struct QuickActionOutcome {
|
||||
public let command: String
|
||||
public let succeeded: Bool
|
||||
/// The driver profile used; nil for Make Default Printer.
|
||||
public let profile: DriverProfile?
|
||||
/// Per-option read-back; empty for Make Default Printer.
|
||||
public let readBack: [OptionReadBack]
|
||||
public let changes: [OptionChange]
|
||||
@@ -119,21 +125,19 @@ public struct QuickActionOutcome {
|
||||
}
|
||||
|
||||
extension QuickAction {
|
||||
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options.
|
||||
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome {
|
||||
switch effect {
|
||||
case .defaultPrinter:
|
||||
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
||||
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
|
||||
if isDefaultPrinter {
|
||||
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
||||
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, readBack: [],
|
||||
changes: [], message: outcome.message)
|
||||
case .options:
|
||||
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
|
||||
let changes = try self.changes(for: ppd, value: value).get()
|
||||
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
|
||||
let failed = readBack.filter { !$0.applied }
|
||||
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: changes),
|
||||
succeeded: result.succeeded && failed.isEmpty, readBack: readBack, changes: changes,
|
||||
message: failed.first?.reason)
|
||||
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
||||
readBack: [], changes: [], message: outcome.message)
|
||||
}
|
||||
let context = try await DriverContext.load(client: client, queue: queue)
|
||||
let resolved = try resolve(context, value: value, clearing: clearing).get()
|
||||
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
||||
let failed = readBack.filter { !$0.applied }
|
||||
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
||||
succeeded: result.succeeded && failed.isEmpty, profile: resolved.profile,
|
||||
readBack: readBack, changes: resolved.changes, message: failed.first?.reason)
|
||||
}
|
||||
}
|
||||
|
||||
43
Sources/CupsKit/Resources/driver-profiles.json
Normal file
43
Sources/CupsKit/Resources/driver-profiles.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport <queue>",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "ricoh-m-c251fw",
|
||||
"name": "Ricoh M C251FW",
|
||||
"match": { "manufacturer": "^RICOH$", "nickname": "^RICOH M C251FW" },
|
||||
"actions": {
|
||||
"color": ["HQColorMode=COLOR"],
|
||||
"bw": ["HQColorMode=BW"],
|
||||
"duplex": ["Duplex=DuplexNoTumble"],
|
||||
"simplex": ["Duplex=None"],
|
||||
"letter": ["PageSize=Letter"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ricoh",
|
||||
"name": "Ricoh",
|
||||
"match": { "manufacturer": "^RICOH$" },
|
||||
"actions": {
|
||||
"color": ["ColorModel=CMYK"],
|
||||
"bw": ["ColorModel=Gray"],
|
||||
"usercode": ["RIEnableUserCode=True", "RIUserCode=Custom.{value}"],
|
||||
"usercode-clear": ["RIEnableUserCode=False", "RIUserCode=None"],
|
||||
"duplex": ["Duplex=DuplexNoTumble"],
|
||||
"simplex": ["Duplex=None"],
|
||||
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "generic",
|
||||
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
||||
"match": {},
|
||||
"actions": {
|
||||
"color": [["ColorModel=CMYK"], ["ColorModel=RGB"], ["ColorModel=Color"], ["print-color-mode-default=color"]],
|
||||
"bw": [["ColorModel=Gray"], ["ColorModel=Grayscale"], ["print-color-mode-default=monochrome"]],
|
||||
"duplex": [["Duplex=DuplexNoTumble"], ["sides-default=two-sided-long-edge"]],
|
||||
"simplex": [["Duplex=None"], ["sides-default=one-sided"]],
|
||||
"letter": [["PageSize=Letter"], ["media-default=na_letter_8.5x11in"]]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user