Xerox and Konica Minolta driver profiles; optional second quick action value

Xerox: XROutputColor with XRColorCorrection fallback for color/black & white; user code
sets XRAccountingSystem=XSA + AcctUserID, and an optional account ID (AcctAccountID)
via a profile-declared second input, in the sheet and as
'cupsadmin quick usercode <queue> <user> [account]'. Konica Minolta: KMDuplex,
PageSize=8.5x11, color from the generic ColorModel; user code unavailable with the
reason. Offline tests sweep every Xerox and bizhub PPD found in CUPSKIT_PPD_DIRS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 21:15:14 -05:00
parent 795ab7a981
commit 37a64af25a
10 changed files with 269 additions and 28 deletions

View File

@@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable {
let action: QuickAction
let queue: String
var code = ""
/// Optional second value (Xerox account ID), when the driver profile has one.
var secondValue = ""
var showCurrentCode = false
private(set) var phase: Phase = .loading
private(set) var state: OptionState?
@@ -50,9 +52,15 @@ final class QuickActionModel: Identifiable {
var validationError: String? { action.validationError(code, context: context) }
var inputLabel: String { action.inputLabel(context) }
var secondInputLabel: String? { action.secondInputLabel(context) }
var currentSecondValue: String? { action.currentSecondValue(context) }
var secondValidationError: String? { action.secondValidationError(secondValue, context: context) }
/// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
clearing: clearing).map(\.changes)
}
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
@@ -83,7 +91,9 @@ final class QuickActionModel: Identifiable {
phase = .running
let started = Date()
do {
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
let typed = action.input == .userCode && !clearing
let outcome = try await action.run(queue: queue, value: typed ? code : nil,
secondValue: typed && !secondValue.isEmpty ? secondValue : nil,
clearing: clearing, client: store.client)
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
state = try? await OptionState.load(client: store.client, queue: queue)
@@ -161,7 +171,7 @@ struct QuickActionSheet: View {
private var canApply: Bool {
guard model.phase == .ready else { return false }
if model.action.input == .userCode {
return !model.code.isEmpty && model.validationError == nil
return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil
}
if case .failure = model.plannedChanges() { return false }
return true
@@ -170,7 +180,7 @@ struct QuickActionSheet: View {
@ViewBuilder private var content: some View {
if model.action.input == .userCode {
Form {
LabeledContent("Current code") {
LabeledContent("Current \(model.inputLabel.lowercased())") {
HStack(spacing: 8) {
if let current = model.currentCode {
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
@@ -184,22 +194,39 @@ struct QuickActionSheet: View {
}
}
LabeledContent {
TextField("User code", text: $model.code, prompt: Text("Digits"))
TextField(model.inputLabel, text: $model.code,
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospacedDigit()
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text("New code")
Text("New \(model.inputLabel.lowercased())")
if let error = model.validationError {
Text(error).foregroundStyle(.red)
}
}
if let secondLabel = model.secondInputLabel {
LabeledContent {
TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) })
.labelsHidden()
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text(secondLabel)
if let error = model.secondValidationError {
Text(error).foregroundStyle(.red)
} else if model.currentSecondValue != nil {
Text("Current value shown in the field").foregroundStyle(.secondary)
}
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(height: 130)
.frame(height: model.secondInputLabel == nil ? 130 : 190)
}
if model.action.isDefaultPrinter {

View File

@@ -54,8 +54,25 @@ public struct DriverProfile: Decodable, Identifiable {
public let actions: [String: [[String]]]
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
public let unavailable: [String: String]
/// Action id -> how its typed value is labeled and checked, plus an optional second value.
public let inputs: [String: Input]
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable }
public struct Input: Decodable {
/// "User ID"; nil keeps the default "User code".
public let label: String?
/// Default true (Ricoh user codes). Xerox user IDs are strings.
public let digitsOnly: Bool?
public let second: SecondInput?
}
/// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}`
/// and are added only when the user fills it in.
public struct SecondInput: Decodable {
public let label: String
public let pairs: [String]
}
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -68,6 +85,7 @@ public struct DriverProfile: Decodable, Identifiable {
for (action, pairs) in raw { actions[action] = pairs.alternatives }
self.actions = actions
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:]
}
private struct FlexiblePairs: Decodable {

View File

@@ -25,7 +25,7 @@ public struct QuickAction: Identifiable, Hashable {
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
summary: "Black & white by default"),
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
summary: "User/department code sent with every job (--clear to remove)"),
summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"),
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
summary: "Two-sided, long edge"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
@@ -51,7 +51,7 @@ public struct QuickAction: Identifiable, Hashable {
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
/// 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,
public func resolve(_ context: DriverContext, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
let key = clearing ? id + "-clear" : id
var firstMissing: String?
@@ -67,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable {
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) }
var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
if !clearing, let secondValue, !secondValue.isEmpty {
guard let second = profile.inputs[id]?.second else {
return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two"))
}
for pair in second.pairs.compactMap(Self.parse) {
let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue)
guard context.supports(key: pair.key, value: value) else {
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))"))
}
changes.append(OptionChange(key: pair.key, value: value, isSecret: false))
}
}
return .success(ResolvedQuickAction(profile: profile, changes: changes))
}
}
@@ -86,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable {
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.
/// The profile's input settings for this action on this queue (label, digits, second value).
public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
return resolved.profile.inputs[id]
}
/// "User code" (Ricoh) or the profile's label ("User ID" for Xerox).
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" }
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
public func digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true }
/// For Set User Code: digits only unless the profile allows text, within the PPD's limit. 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 keyword = codeKeyword(context),
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) digits"
return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context)
}
/// For the optional second value (text, within the PPD's limit). nil when valid.
public func secondValidationError(_ value: String, context: DriverContext) -> String? {
guard input == .userCode, !value.isEmpty else { return nil }
return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context)
}
private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? {
if digitsOnly {
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
} else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) {
return "No quotes, backslashes or line breaks"
}
if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) \(digitsOnly ? "digits" : "characters")"
}
return nil
}
@@ -104,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable {
return String(value.dropFirst("Custom.".count))
}
/// The second value currently set on the queue (e.g. the Xerox account ID), without Custom.
public func currentSecondValue(_ context: DriverContext) -> String? {
guard let keyword = secondKeyword(context),
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count))
}
private func secondKeyword(_ context: DriverContext) -> String? {
inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key
}
/// 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 }
@@ -139,14 +189,15 @@ public struct QuickActionOutcome {
extension QuickAction {
/// 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 {
public func run(queue: String, value: String? = nil, secondValue: 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, 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 resolved = try resolve(context, value: value, secondValue: secondValue, 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),

View File

@@ -1,5 +1,5 @@
{
"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. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
"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. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
"profiles": [
{
"id": "ricoh-m-c251fw",
@@ -54,6 +54,37 @@
"usercode": "HP drivers have no accounting code option"
}
},
{
"id": "xerox",
"name": "Xerox",
"match": { "manufacturer": "^Xerox$" },
"actions": {
"color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]],
"bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]],
"usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"],
"usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]
},
"inputs": {
"usercode": {
"label": "User ID",
"digitsOnly": false,
"second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] }
}
}
},
{
"id": "konica-minolta",
"name": "Konica Minolta",
"match": { "manufacturer": "^KONICA MINOLTA$" },
"actions": {
"duplex": ["KMDuplex=Double"],
"simplex": ["KMDuplex=Single"],
"letter": ["PageSize=8.5x11"]
},
"unavailable": {
"usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD"
}
},
{
"id": "generic",
"name": "Generic (standard PPD keywords, IPP Everywhere)",

View File

@@ -5,7 +5,7 @@ enum QuickCommand {
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
guard let name = args.first else {
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> [account] | --clear" : "", $0.summary]
})
print("\nDriver profiles (first match wins, generic last): "
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
@@ -18,6 +18,7 @@ enum QuickCommand {
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
let queue = args[1]
let value: String?
var secondValue: String?
switch action.input {
case .none:
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
@@ -27,8 +28,11 @@ enum QuickCommand {
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
value = nil
} else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
guard args.count == 3 || args.count == 4 else {
throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear")
}
value = args[2]
secondValue = args.count == 4 ? args[3] : nil
}
}
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
@@ -48,10 +52,13 @@ enum QuickCommand {
let before = try await OptionState.load(client: client, queue: queue)
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("\(action.inputLabel(context).lowercased()) \(value): \(error)")
}
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
throw CupsAdminError.usage("second value \(secondValue): \(error)")
}
let resolved: ResolvedQuickAction
switch action.resolve(context, value: value, clearing: clear) {
switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) {
case .success(let r): resolved = r
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
}

View File

@@ -40,7 +40,7 @@ let usage = """
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
change a queue via lpadmin, then show before/after
quick list quick actions (task-named queue defaults)
quick <action> <queue> [code | --clear]
quick <action> <queue> [code [account] | --clear]
apply one via lpadmin, then verify
ppdreport <queue | path.ppd[.gz]>
every PPD group and option: keyword, label, type, default, choices