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:
kfattic
2026-09-16 13:22:19 -05:00
parent 3af6a4e88f
commit 8d39bafb6f
12 changed files with 496 additions and 159 deletions

View File

@@ -31,8 +31,12 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
a per-user override. a per-user override.
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are - **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates). written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
- **Quick actions** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input - **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason. from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
value; otherwise the action is unavailable with the reason. `cupsadmin ppdreport <queue>` shows a
driver's keywords and which actions its profile enables.
## Build and test ## Build and test
``` ```

View File

@@ -12,7 +12,8 @@ let package = Package(
.library(name: "CupsKit", targets: ["CupsKit"]), .library(name: "CupsKit", targets: ["CupsKit"]),
], ],
targets: [ targets: [
.target(name: "CupsKit", path: "Sources/CupsKit"), // driver-profiles.json is compiled into the code: the CLI ships as a single binary with no resource bundle.
.target(name: "CupsKit", path: "Sources/CupsKit", resources: [.embedInCode("Resources/driver-profiles.json")]),
.executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"), .executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"),
.executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"), .executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"),
.testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"), .testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"),

View File

@@ -28,6 +28,7 @@ cupsadmin options <queue> # PPD options and IPP *-default values, like lp
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...] cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
cupsadmin ppdreport <queue> # every PPD group and option: keyword, label, type, default, choices
``` ```
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly. Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
@@ -48,7 +49,25 @@ cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no
cupsadmin quick default <queue> # make this the server default printer cupsadmin quick default <queue> # make this the server default printer
``` ```
Actions whose option isn't in the queue's PPD are refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal. Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Resources/driver-profiles.json`), matched on the PPD's manufacturer and model. If no profile maps an action for a queue's driver, the action is refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal.
### Supported drivers
| Driver | Quick actions |
|---|---|
| Ricoh PostScript (IM C2000/C4500, MP C2004ex/C3004ex/C307/C3504) | ✓ all |
| Ricoh MP 5055 PS (mono) | ✓ all except color / black & white |
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
| Canon, HP, Xerox, Konica Minolta user/department codes | not yet — contribute a profile |
### Adding your printer's driver
1. `cupsadmin ppdreport <queue>` lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`.
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
## How it works ## How it works

View File

@@ -101,8 +101,8 @@ final class PrinterStore {
private(set) var revision = 0 private(set) var revision = 0
/// The server default printer (CUPS-Get-Default), if any. /// The server default printer (CUPS-Get-Default), if any.
private(set) var defaultPrinter: String? private(set) var defaultPrinter: String?
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil. /// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
private(set) var ppds: [String: PPD?] = [:] private(set) var drivers: [String: DriverContext] = [:]
var failure: Failure? var failure: Failure?
let client = CupsClient() let client = CupsClient()
@@ -190,31 +190,28 @@ final class PrinterStore {
} }
func noteChange(queue: String? = nil) { func noteChange(queue: String? = nil) {
if let queue { ppds[queue] = nil } if let queue { drivers[queue] = nil }
revision += 1 revision += 1
} }
// MARK: PPDs for Quick Actions // MARK: drivers for Quick Actions
/// Fetches PPDs not yet cached, in the background of a refresh. /// Loads driver info (PPD, IPP attributes) not yet cached, in the background of a refresh.
func loadPPDs() async { func loadPPDs() async {
for printer in printers where ppds[printer.name] == nil { for printer in printers where drivers[printer.name] == nil {
let text = try? await client.getPPD(queue: printer.name) if let context = try? await DriverContext.load(client: client, queue: printer.name) {
ppds[printer.name] = .some(text.map(PPD.init(text:))) drivers[printer.name] = context
}
} }
} }
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip). /// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
func unavailableReason(_ action: QuickAction, queue: String) -> String? { func unavailableReason(_ action: QuickAction, queue: String) -> String? {
if case .defaultPrinter = action.effect { if action.isDefaultPrinter {
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
} }
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" } guard let context = drivers[queue] else { return "Checking \(queue)’s driver…" }
if action.isAvailable(for: cached) { return nil } return action.unavailableReason(in: context)
if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) {
return unavailable.reason
}
return "Not available on \(queue)"
} }
// MARK: per-printer actions // MARK: per-printer actions
@@ -256,7 +253,7 @@ final class PrinterStore {
report("ERROR: delete \(queue): \(error)") report("ERROR: delete \(queue): \(error)")
fail("Couldn’t delete \(queue)", String(describing: error)) fail("Couldn’t delete \(queue)", String(describing: error))
} }
ppds[queue] = nil drivers[queue] = nil
noteChange() noteChange()
await refreshQuietly() await refreshQuietly()
return deleted return deleted

View File

@@ -39,24 +39,34 @@ final class QuickActionModel: Identifiable {
} }
} }
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) } var context: DriverContext {
DriverContext(ppd: state?.ppd, attributes: state?.snapshot.attributes)
}
var validationError: String? { action.validationError(code, ppd: state?.ppd) } var currentCode: String? { action.currentCode(context) }
var validationError: String? { action.validationError(code, context: context) }
/// What will be written, or why the action can't run here. /// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> { func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.changes(for: state?.ppd, value: clearing ? "" : code) action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
}
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
var profileName: String? {
guard !action.isDefaultPrinter, case .success(let resolved) = action.resolve(context, value: code.isEmpty ? "0" : code) else { return nil }
return resolved.profile.name
} }
var command: String { var command: String {
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) } if action.isDefaultPrinter { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
guard case .success(let changes) = plannedChanges() else { return "" } guard case .success(let changes) = plannedChanges() else { return "" }
return OptionApplier.displayCommand(queue: queue, changes: changes) return OptionApplier.displayCommand(queue: queue, changes: changes)
} }
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default. /// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
var userDefaultNote: String? { var userDefaultNote: String? {
guard case .defaultPrinter = action.effect, guard action.isDefaultPrinter,
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8), let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil } let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
let name = line.split(separator: " ")[1].split(separator: "/")[0] let name = line.split(separator: " ")[1].split(separator: "/")[0]
@@ -70,8 +80,8 @@ final class QuickActionModel: Identifiable {
phase = .running phase = .running
let started = Date() let started = Date()
do { do {
let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil), let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
client: store.client) clearing: clearing, client: store.client)
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) }) readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
state = try? await OptionState.load(client: store.client, queue: queue) state = try? await OptionState.load(client: store.client, queue: queue)
let seconds = elapsedText(since: started) let seconds = elapsedText(since: started)
@@ -108,6 +118,11 @@ struct QuickActionSheet: View {
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("\(model.queue) · saved as the queue default for every user on this Mac") Text("\(model.queue) · saved as the queue default for every user on this Mac")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if let driver = model.state.map({ _ in model.context.driverName }), let profile = model.profileName {
Text("\(driver) · driver profile: \(profile)")
.font(.callout)
.foregroundStyle(.secondary)
}
} }
switch model.phase { switch model.phase {
@@ -184,7 +199,7 @@ struct QuickActionSheet: View {
.frame(height: 130) .frame(height: 130)
} }
if case .defaultPrinter = model.action.effect { if model.action.isDefaultPrinter {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
GridRow { GridRow {
Text("Server default").foregroundStyle(.secondary) Text("Server default").foregroundStyle(.secondary)

View 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) }
}
}

View File

@@ -1,108 +1,112 @@
import Foundation import Foundation
/// Task-named shortcuts that set queue defaults without knowing option keywords. /// 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). /// What each one writes comes from the driver profiles in Resources/driver-profiles.json.
/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed.
public struct QuickAction: Identifiable, Hashable { public struct QuickAction: Identifiable, Hashable {
public enum Input: Hashable { public enum Input: Hashable {
case none 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 case userCode
} }
public enum Effect: Hashable { /// CLI name (`cupsadmin quick <id> <queue>`) and the key in driver-profiles.json.
/// `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>`.
public let id: String public let id: String
public let title: String public let title: String
public let systemImage: String public let systemImage: String
public let input: Input public let input: Input
public let effect: Effect
/// One line for the CLI list and tooltips. /// One line for the CLI list and tooltips.
public let summary: String public let summary: String
public var isDefaultPrinter: Bool { id == "default" }
public static let all: [QuickAction] = [ public static let all: [QuickAction] = [
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none, QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []), summary: "Color by default"),
summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"),
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none, 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"),
summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"),
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode, QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]], summary: "User/department code sent with every job (--clear to remove)"),
clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]),
summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.<code>)"),
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none, QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []), summary: "Two-sided, long edge"),
summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
effect: .options(variants: [["Duplex": "None"]], clearVariants: []), summary: "One-sided"),
summary: "One-sided (Duplex=None)"),
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right", QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
input: .none, input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []),
summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"),
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, 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? { public static func named(_ id: String) -> QuickAction? {
all.first { $0.id == id } all.first { $0.id == id }
} }
/// The option changes for this PPD, or why the action isn't available on it. /// The profile and option changes this action would write on a queue, or why it can't.
public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> { public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
guard case .options(let variants, let clearVariants) = effect else { return .success([]) } profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
let clearing = input == .userCode && (value ?? "").isEmpty let key = clearing ? id + "-clear" : id
let candidates = clearing ? clearVariants : variants var firstMissing: String?
guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) } for profile in DriverProfiles.matching(context, in: profiles) {
guard let alternatives = profile.actions[key] else { continue }
for variant in candidates { for pairs in alternatives {
var changes: [OptionChange] = [] let parsed = pairs.compactMap(Self.parse)
for key in variant.keys.sorted() { let filled = parsed.map { ($0.key, $0.value.replacingOccurrences(of: "{value}", with: value ?? "")) }
guard let option = ppd.option(key) else { changes = []; break } if let missing = filled.first(where: { !context.supports(key: $0.0, value: $0.1) }) {
let raw = variant[key]! if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
if raw.contains("{value}") { continue
guard !option.customParameters.isEmpty else { changes = []; break } }
changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false)) let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
} else { return .success(ResolvedQuickAction(profile: profile, changes: changes))
guard option.choices.contains(raw) else { changes = []; break }
changes.append(OptionChange(key: key, value: raw, isSecret: false))
} }
} }
if !changes.isEmpty { return .success(changes) } let detail = firstMissing.map { " (no \($0))" } ?? " (no driver profile maps \(key))"
} return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName)\(detail)"))
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"))
} }
public func isAvailable(for ppd: PPD?) -> Bool { /// nil when the action can run on this queue; otherwise the reason (for tooltips and CLI errors).
if case .defaultPrinter = effect { return true } public func unavailableReason(in context: DriverContext) -> String? {
if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true } if isDefaultPrinter { return nil }
return false 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 isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
public func validationError(_ value: String, ppd: PPD?) -> String? {
/// 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 input == .userCode, !value.isEmpty else { return nil }
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } 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 "At most \(max) digits"
} }
return nil return nil
} }
/// The current user code on the queue, without the Custom. prefix (nil when none is set). /// The code currently set on the queue, without the Custom. prefix (nil when none).
public static func currentUserCode(_ ppd: PPD?) -> String? { public func currentCode(_ context: DriverContext) -> String? {
guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil } guard let keyword = codeKeyword(context),
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count)) 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 { public struct QuickActionUnavailable: Error, Hashable {
@@ -112,6 +116,8 @@ public struct QuickActionUnavailable: Error, Hashable {
public struct QuickActionOutcome { public struct QuickActionOutcome {
public let command: String public let command: String
public let succeeded: Bool 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. /// Per-option read-back; empty for Make Default Printer.
public let readBack: [OptionReadBack] public let readBack: [OptionReadBack]
public let changes: [OptionChange] public let changes: [OptionChange]
@@ -119,21 +125,19 @@ public struct QuickActionOutcome {
} }
extension QuickAction { extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options. /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome { public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
switch effect { if isDefaultPrinter {
case .defaultPrinter:
let outcome = try await QueueActions.setDefault(queue: queue, client: client) let outcome = try await QueueActions.setDefault(queue: queue, client: client)
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, readBack: [], return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
changes: [], message: outcome.message) 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)
} }
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)
} }
} }

View 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"]]
}
}
]
}

View File

@@ -0,0 +1,56 @@
import Foundation
import CupsKit
/// Every OpenGroup/OpenUI in a queue's PPD, so contributors can find their driver's keywords.
enum PPDReportCommand {
static func run(client: CupsClient, queue: String?, file: String?) async throws -> String {
let text: String
let source: String
var attributes: IPPGroup?
if let file {
guard let data = FileManager.default.contents(atPath: file) else { throw CupsAdminError.failed("can’t read \(file)") }
text = String(decoding: data, as: UTF8.self)
source = file
} else if let queue {
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
guard let ppdText = try await client.getPPD(queue: queue) else {
throw CupsAdminError.failed("\(queue) has no PPD (driverless or raw queue)")
}
text = ppdText
source = queue
attributes = try? await client.getPrinterAttributes(queue: queue, requested: [
"printer-make-and-model", "print-color-mode-supported", "sides-supported", "media-supported"])
} else {
throw CupsAdminError.usage("ppdreport needs a queue name or --file <path.ppd>")
}
let ppd = PPD(text: text)
let a = ppd.attributes
print("PPD: \(a["NickName"] ?? "?")")
print("Manufacturer: \(a["Manufacturer"] ?? "?")")
print("PCFileName: \(a["PCFileName"] ?? "?") version \(a["FileVersion"] ?? "?")")
let context = DriverContext(ppd: ppd, attributes: attributes)
let profiles = DriverProfiles.matching(context)
print("Profiles: \(profiles.map(\.id).joined(separator: ", "))")
let quick = QuickAction.all.filter { !$0.isDefaultPrinter }.map { action -> String in
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
}
print("Quick: \(quick.joined(separator: ", "))")
for group in ppd.groups {
print("\n\(group)")
for option in ppd.options where option.group == group {
let type = option.uiType == .unknown ? "?" : option.uiType.rawValue
print(" \(option.keyword) — \(option.text) (\(type)\(option.isJCL ? ", JCL" : ""), default \(option.defaultChoice ?? "none"))")
let choices = option.choiceList.map { $0.text == $0.keyword ? $0.keyword : "\($0.keyword) \"\($0.text)\"" }
if !choices.isEmpty { print(" choices: \(choices.joined(separator: ", "))") }
if !option.customParameters.isEmpty {
let params = option.customParameters.map { "\($0.name) \($0.type) \($0.minimum)–\($0.maximum)" }
print(" custom: \(params.joined(separator: "; ")) (write as \(option.keyword)=Custom.<value>)")
}
}
}
return "OK: \(source) \(ppd.options.count) options in \(ppd.groups.count) groups"
}
}

View File

@@ -7,7 +7,10 @@ enum QuickCommand {
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map { printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary] [$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
}) })
return "OK: \(QuickAction.all.count) quick actions" print("\nDriver profiles (first match wins, generic last): "
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
print("See what a queue's driver offers: cupsadmin ppdreport <queue>")
return "OK: \(QuickAction.all.count) quick actions, \(DriverProfiles.builtIn.count) driver profiles"
} }
guard let action = QuickAction.named(name) else { guard let action = QuickAction.named(name) else {
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))") throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
@@ -22,7 +25,7 @@ enum QuickCommand {
case .userCode: case .userCode:
if clear { if clear {
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") } guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
value = "" value = nil
} else { } else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
value = args[2] value = args[2]
@@ -30,7 +33,7 @@ enum QuickCommand {
} }
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") } guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
if case .defaultPrinter = action.effect { if action.isDefaultPrinter {
let before = try await client.getDefaultPrinter() let before = try await client.getDefaultPrinter()
let status = try runTool(CupsTools.lpadminPath, ["-d", queue]) let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") } guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
@@ -43,23 +46,25 @@ enum QuickCommand {
} }
let before = try await OptionState.load(client: client, queue: queue) let before = try await OptionState.load(client: client, queue: queue)
if let value, let error = action.validationError(value, ppd: before.ppd) { let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
if let value, let error = action.validationError(value, context: context) {
throw CupsAdminError.usage("user code \(value): \(error)") throw CupsAdminError.usage("user code \(value): \(error)")
} }
let changes: [OptionChange] let resolved: ResolvedQuickAction
switch action.changes(for: before.ppd, value: value) { switch action.resolve(context, value: value, clearing: clear) {
case .success(let resolved): changes = resolved case .success(let r): resolved = r
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)") case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
} }
print("driver: \(context.driverName) · profile: \(resolved.profile.id)")
let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes) let (executable, arguments) = OptionApplier.command(queue: queue, changes: resolved.changes)
let status = try runTool(executable, arguments) let status = try runTool(executable, arguments)
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") } guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
let after = try await OptionState.load(client: client, queue: queue) let after = try await OptionState.load(client: client, queue: queue)
let checks = changes.map { OptionApplier.verify($0, state: after) } let checks = resolved.changes.map { OptionApplier.verify($0, state: after) }
print() print()
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(resolved.changes, checks).map { change, check in
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-", [change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"] check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
}) })
@@ -68,6 +73,6 @@ enum QuickCommand {
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: " throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
+ failed.map(\.key).joined(separator: ", ")) + failed.map(\.key).joined(separator: ", "))
} }
return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified" return "OK: \(queue) \(action.id) applied (\(resolved.profile.id)), \(resolved.changes.count) setting(s) verified"
} }
} }

View File

@@ -41,6 +41,8 @@ let usage = """
quick list quick actions (task-named queue defaults) quick list quick actions (task-named queue defaults)
quick <action> <queue> [code | --clear] quick <action> <queue> [code | --clear]
apply one via lpadmin, then verify apply one via lpadmin, then verify
ppdreport <queue> | --file <path.ppd>
every PPD group and option: keyword, label, type, default, choices
help show this help help show this help
""" """
@@ -117,6 +119,19 @@ func runCommand(_ args: [String]) async throws -> String {
let (queue, spec) = try QueueSpec.parse(rest, command: command) let (queue, spec) = try QueueSpec.parse(rest, command: command)
return try await SetCommand.run(client: client, queue: queue, spec: spec) return try await SetCommand.run(client: client, queue: queue, spec: spec)
case "ppdreport":
var file: String?
if let i = rest.firstIndex(of: "--file") {
guard i + 1 < rest.count else { throw CupsAdminError.usage("--file needs a path") }
file = rest[i + 1]
rest.removeSubrange(i ... i + 1)
}
try rejectUnknownFlags()
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
throw CupsAdminError.usage("ppdreport needs one queue name or --file <path.ppd>")
}
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)
case "quick": case "quick":
let clear = takeFlag("--clear") let clear = takeFlag("--clear")
try rejectUnknownFlags() try rejectUnknownFlags()

View File

@@ -1,61 +1,127 @@
import CupsKit @testable import CupsKit
import Foundation import Foundation
import Testing import Testing
private func available(_ id: String, _ ppd: PPD?) -> Bool { private func context(_ ppdFile: String) -> DriverContext? {
QuickAction.named(id)!.isAvailable(for: ppd) vendorPPD(ppdFile).map { DriverContext(ppd: $0, attributes: nil) }
} }
@Suite struct QuickActionAvailability { private func available(_ id: String, _ context: DriverContext) -> Bool {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) QuickAction.named(id)!.isAvailable(in: context)
func ricohPSHasEverything() throws { }
let ppd = try #require(vendorPPD("RICOH IM C4500"))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] { private func profileID(_ id: String, _ context: DriverContext) -> String? {
#expect(available(id, ppd), "\(id)") guard case .success(let resolved) = QuickAction.named(id)!.resolve(context, value: "0") else { return nil }
return resolved.profile.id
}
@Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"])
#expect(profiles.last?.isGeneric == true)
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
for profile in profiles {
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
} }
let letter = try QuickAction.named("letter")!.changes(for: ppd).get() }
}
}
/// One test per Ricoh PPD documented in docs/ricoh-ppd-options.md (vendor PPDs; skipped when not installed).
@Suite struct RicohProfileMatching {
static let postScript = ["RICOH IM C2000", "RICOH IM C4500", "RICOH MP C2004ex", "RICOH MP C3004ex",
"RICOH MP C307", "RICOH MP C3504"]
@Test(.enabled(if: postScript.allSatisfy(hasVendorPPD)), arguments: postScript)
func ricohColorPostScript(_ file: String) throws {
let c = try #require(context(file))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter"] {
#expect(profileID(id, c) == "ricoh", "\(file) \(id)")
}
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false), #expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)]) OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get() let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
#expect(code.map(\.value) == ["True", "Custom.4321"]) #expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.changes(for: ppd, value: "").get() let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
#expect(clear.map(\.value) == ["False", "None"]) #expect(clear.map(\.value) == ["False", "None"])
} }
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd"))) @Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
func otherDriversOnlyGetWhatTheirPPDHas() throws { func ricohMonoPostScript() throws {
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex. let c = try #require(context("RICOH MP 5055"))
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd")) #expect(!available("color", c) && !available("bw", c)) // mono: no ColorModel
#expect(!available("color", sp) && !available("bw", sp)) #expect(QuickAction.named("color")!.unavailableReason(in: c)?.hasPrefix("Not available for RICOH MP 5055 PS") == true)
#expect(!available("letter", sp) && !available("usercode", sp)) for id in ["usercode", "duplex", "simplex", "letter"] { #expect(profileID(id, c) == "ricoh", "\(id)") }
#expect(available("duplex", sp) && available("simplex", sp)) }
// M C251FW: color via HQColorMode. @Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
let fw = try #require(vendorPPD("RICOH M C251FW.ppd")) func ricohMC251FWUsesHQColorMode() throws {
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)]) let c = try #require(context("RICOH M C251FW.ppd"))
#expect(profileID("color", c) == "ricoh-m-c251fw")
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
#expect(!available("usercode", c))
#expect(profileID("letter", c) == "ricoh-m-c251fw")
}
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no. @Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd")))
let airprint = PPD(text: """ func ricohPCLFallsBackToGenericLetter() throws {
let c = try #require(context("RICOH SP 3710DN.ppd"))
#expect(!available("color", c) && !available("usercode", c))
#expect(profileID("duplex", c) == "ricoh")
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
#expect(profileID("letter", c) == "generic")
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func userCodeValidationUsesThePPDLimit() throws {
let c = try #require(context("RICOH IM C4500"))
let action = QuickAction.named("usercode")!
#expect(action.validationError("12345", context: c) == nil)
#expect(action.validationError("12a", context: c) == "Digits only")
#expect(action.validationError("123456789", context: c) == "At most 8 digits")
}
}
@Suite struct GenericProfile {
static let genericPPD = "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Resources/Generic.ppd"
@Test(.enabled(if: FileManager.default.fileExists(atPath: GenericProfile.genericPPD)))
func genericPostScriptPPD() throws {
let text = try String(contentsOfFile: Self.genericPPD, encoding: .isoLatin1)
let c = DriverContext(ppd: PPD(text: text), attributes: nil)
#expect(DriverProfiles.matching(c).map(\.id) == ["generic"])
for id in ["duplex", "simplex", "letter"] { #expect(profileID(id, c) == "generic", "\(id)") }
#expect(!available("color", c) && !available("bw", c) && !available("usercode", c))
#expect(QuickAction.named("default")!.isAvailable(in: c))
}
@Test func ippEverywhereUsesIPPAttributes() throws {
let attributes = IPPGroup(tag: .printerAttributes, attributes: [
.string("printer-make-and-model", .textWithoutLanguage, ["Example Laser 5000"]),
.string("print-color-mode-supported", .keyword, ["auto", "color", "monochrome"]),
.string("sides-supported", .keyword, ["one-sided", "two-sided-long-edge"]),
.string("media-supported", .keyword, ["na_letter_8.5x11in", "iso_a4_210x297mm"]),
])
let c = DriverContext(ppd: nil, attributes: attributes)
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "print-color-mode-default", value: "color", isSecret: false)])
#expect(try QuickAction.named("duplex")!.resolve(c).get().changes == [OptionChange(key: "sides-default", value: "two-sided-long-edge", isSecret: false)])
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "media-default", value: "na_letter_8.5x11in", isSecret: false)])
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Not available for Example Laser 5000 (no driver profile maps usercode)")
// Grayscale-only AirPrint PPD: black & white yes, color no.
let gray = DriverContext(ppd: PPD(text: """
*OpenUI *ColorModel/Color Mode: PickOne *OpenUI *ColorModel/Color Mode: PickOne
*DefaultColorModel: Gray *DefaultColorModel: Gray
*ColorModel Gray/Grayscale: "" *ColorModel Gray/Grayscale: ""
*CloseUI: *ColorModel *CloseUI: *ColorModel
*OpenUI *PageSize/Media Size: PickOne """), attributes: nil)
*DefaultPageSize: Letter #expect(available("bw", gray) && !available("color", gray))
*PageSize Letter/US Letter: ""
*CloseUI: *PageSize
""")
#expect(!available("color", airprint))
#expect(available("bw", airprint))
#expect(!available("duplex", airprint))
}
@Test func userCodeValidation() {
let action = QuickAction.named("usercode")!
let ppd = vendorPPD("RICOH IM C4500")
#expect(action.validationError("12345", ppd: ppd) == nil)
#expect(action.validationError("12a", ppd: ppd) == "Digits only")
#expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits")
} }
} }
@@ -68,21 +134,24 @@ struct LiveQuickActions {
@Test func everyActionOnRicohQueue() async throws { @Test func everyActionOnRicohQueue() async throws {
try #require(queues.count == 2) try #require(queues.count == 2)
let client = CupsClient() let client = CupsClient()
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"), for (id, value, clearing) in [("letter", nil, false), ("bw", nil, false), ("color", nil, false), ("usercode", "2468", false),
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] { ("usercode", nil, true), ("simplex", nil, false), ("duplex", nil, false)] as [(String, String?, Bool)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client) let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, clearing: clearing, client: client)
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")") #expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
#expect(outcome.profile?.id == "ricoh", "\(id)")
} }
} }
@Test func testPageDefaultAndDelete() async throws { @Test func genericQueueTestPageDefaultAndDelete() async throws {
try #require(queues.count == 2) try #require(queues.count == 2)
let client = CupsClient() let client = CupsClient()
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client) let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
#expect(page.succeeded && jobID != nil, "\(page)") #expect(page.succeeded && jobID != nil, "\(page)")
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client) let letter = try await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing #expect(letter.succeeded && letter.profile?.id == "generic", "\(letter.message ?? "")")
let color = try? await QuickAction.named("usercode")!.run(queue: queues[1], value: "1", client: client)
#expect(color == nil) // generic PPD: no user code mapping, throws, writes nothing
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client) let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
#expect(made.succeeded, "\(made.message ?? "")") #expect(made.succeeded, "\(made.message ?? "")")