4 Commits

Author SHA1 Message Date
kfattic
3665c3f5c4 Delete Printer: a queue that's already gone is a successful delete
Judge the delete by the read-back (is the queue gone?) instead of lpadmin's exit
status. Deleting a queue that was removed elsewhere before the sidebar refreshed
made lpadmin fail with 'The printer or class does not exist', and the app showed a
failure alert although the queue was gone. Now the status bar shows 'Deleted <queue>'
and os_log notes it was already deleted. Unit tests for all four outcomes, plus an
opt-in live test (CUPSKIT_LIVE_DELETE=1) on a name that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:24:46 -05:00
kfattic
08ae1d3043 Quick action input labels shown as the profile writes them
"Current User ID" / "New User ID" on Xerox, "Current User Code" on Ricoh; the CLI
validation error uses the same label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:22:10 -05:00
kfattic
d1d6c8045c README: Xerox and Konica Minolta profiles are untested on printers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:18:54 -05:00
kfattic
37a64af25a 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>
2026-09-16 21:15:14 -05:00
13 changed files with 330 additions and 36 deletions

View File

@@ -40,7 +40,8 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
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. A profile's `unavailable` map gives
the reason an action can't be done with that driver (e.g. Canon department IDs). Use Letter Paper is
the reason an action can't be done with that driver (e.g. Canon department IDs); its `inputs` map relabels
the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). Use Letter Paper is
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.
@@ -67,7 +68,7 @@ swift build # debug build of everything
verifies both BOM ownership entries. The app is notarized and stapled before packaging; the PKG after.
- The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it.
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`, `CUPSKIT_LIVE_DELETE=1` (deletes a queue name that never existed; writes nothing). Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
`CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not
installed) and `/Library/Printers/PPDs`, and skip when they aren't there.

View File

@@ -48,6 +48,7 @@ The things admins actually do, without knowing the driver's option keywords:
cupsadmin quick color <queue> # always print in color
cupsadmin quick bw <queue> # always print black & white
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
cupsadmin quick usercode <queue> jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID
cupsadmin quick duplex <queue> # default to two-sided
cupsadmin quick simplex <queue>
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
@@ -67,13 +68,17 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
| Xerox, Konica Minolta and others | generic only — add a profile (below) |
| Xerox (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) |
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
| Others | generic only — add a profile (below) |
Xerox and Konica Minolta profiles are tested against the vendor PPDs, not yet against a printer — reports welcome.
### Adding your printer's driver
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) 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, or explain why under `unavailable`. Keep your profile above `generic`.
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, or explain why under `unavailable`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). 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

View File

@@ -251,7 +251,7 @@ final class PrinterStore {
deleted = outcome.succeeded
if deleted {
report("Deleted \(queue) in \(elapsedText(since: started))")
log.info("deleted \(queue, privacy: .public)")
log.info("deleted \(queue, privacy: .public)\(outcome.message.map { " (\($0))" } ?? "", privacy: .public)")
} else {
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")

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)") {
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)")
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

@@ -222,12 +222,21 @@ extension QueueActions {
/// `lpadmin -x queue`, verified by the queue being gone.
public static func delete(queue: String, client: CupsClient) async throws -> ActionOutcome {
let result = try await Task.detached { try CupsTools.delete(queue: queue) }.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message)
return deleteOutcome(queue: queue, result: result, stillExists: try await client.queueExists(queue))
}
/// Judged by the read-back, not lpadmin's exit status: the goal is that the queue is gone. A queue that
/// was already deleted (by another app, lpadmin, or a row the sidebar hadn't refreshed yet) makes
/// lpadmin fail with "The printer or class does not exist" — that's still a successful delete.
/// `message` is set on success only in that case ("already deleted").
static func deleteOutcome(queue: String, result: ToolResult, stillExists: Bool) -> ActionOutcome {
if stillExists {
let message = result.succeeded ? "Not applied: \(queue) still exists"
: result.message.isEmpty ? "exited \(result.status)" : result.message
return ActionOutcome(succeeded: false, command: result.commandLine, message: message)
}
let stillThere = try await client.queueExists(queue)
return ActionOutcome(succeeded: !stillThere, command: result.commandLine,
message: stillThere ? "Not applied: \(queue) still exists" : nil)
return ActionOutcome(succeeded: true, command: result.commandLine,
message: result.succeeded ? nil : "\(queue) was already deleted")
}
/// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified.

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). Shown as written, never re-cased.
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)) \(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

View File

@@ -1,4 +1,4 @@
import CupsKit
@testable import CupsKit
import Foundation
import Testing
@@ -57,3 +57,45 @@ struct LiveQueueActions {
}
}
}
/// Delete is judged by whether the queue is gone afterwards, not by lpadmin's exit status.
@Suite struct DeleteReadBack {
private func result(_ status: Int32, _ stderr: String = "") -> ToolResult {
ToolResult(executable: CupsTools.lpadminPath, arguments: ["-x", "Old_Queue"], status: status,
standardOutput: Data(), standardError: Data(stderr.utf8))
}
@Test func deletedNormally() {
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(0), stillExists: false)
#expect(outcome == ActionOutcome(succeeded: true, command: "/usr/sbin/lpadmin -x Old_Queue", message: nil))
}
/// The reported bug: the queue was removed elsewhere before Delete Printer ran, lpadmin said
/// "The printer or class does not exist", and the app showed a failure alert.
@Test func alreadyGoneIsASuccess() {
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue",
result: result(1, "lpadmin: The printer or class does not exist."),
stillExists: false)
#expect(outcome.succeeded)
#expect(outcome.message == "Old_Queue was already deleted")
}
@Test func failureWhileQueueRemainsShowsToolOutput() {
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(1, "lpadmin: Forbidden"), stillExists: true)
#expect(!outcome.succeeded && outcome.message == "lpadmin: Forbidden")
}
@Test func exitZeroButStillThereIsNotApplied() {
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(0), stillExists: true)
#expect(!outcome.succeeded && outcome.message == "Not applied: Old_Queue still exists")
}
/// Opt-in (CUPSKIT_LIVE_DELETE=1): the real lpadmin and cupsd on a name that never existed. Writes nothing.
@Test(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_DELETE"] != nil))
func liveDeleteOfMissingQueue() async throws {
let name = "cupsadmin_missing_\(UInt32.random(in: 0 ... .max))"
let outcome = try await QueueActions.delete(queue: name, client: CupsClient())
#expect(outcome.succeeded, "\(outcome)")
#expect(outcome.message == "\(name) was already deleted")
}
}

View File

@@ -18,13 +18,17 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
@Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"])
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "xerox", "konica-minolta", "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())")
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
#expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id))))
for input in profile.inputs.values {
#expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true)
}
for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
}
@@ -51,6 +55,10 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
#expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
#expect(clear.map(\.value) == ["False", "None"])
// Ricoh takes one value: digits, no second input.
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User Code")
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
}
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))

View File

@@ -146,3 +146,98 @@ private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String)
#expect(hpColor > 0)
}
}
@Suite struct XeroxProfile {
static let altaLink = "Xerox AltaLink C8170.gz"
static let monoVersaLink = "Xerox VersaLink B620 Printer.gz"
@Test(.enabled(if: ExtraPPDs.path(altaLink) != nil))
func altaLinkColorAndStandardAccounting() throws {
let c = try #require(ExtraPPDs.context(Self.altaLink))
#expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"])
#expect(pairs("color", c) == ["XROutputColor=PrintAsColor"])
#expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"])
#expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
let usercode = QuickAction.named("usercode")!
#expect(usercode.inputLabel(c) == "User ID")
#expect(usercode.secondInputLabel(c) == "Account ID (optional)")
#expect(!usercode.digitsOnly(c))
#expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"])
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2)
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"])
#expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"])
#expect(usercode.validationError("jdoe", context: c) == nil)
#expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters")
#expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks")
#expect(usercode.secondValidationError("ART101", context: c) == nil)
#expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters")
}
@Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil))
func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws {
let c = try #require(ExtraPPDs.context(Self.monoVersaLink))
#expect(pairs("bw", c) == ["XRColorCorrection=Gray"])
#expect(resolved("bw", c)?.profile.id == "xerox")
#expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice
#expect(QuickAction.named("usercode")!.isAvailable(in: c))
}
/// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs.
@Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100))
func everyXeroxPPD() throws {
var accounting = 0
for file in ExtraPPDs.files(prefix: "Xerox ") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)")
#expect(QuickAction.named("color")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)")
let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false
#expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)")
if xsa { accounting += 1 }
}
#expect(accounting > 0)
}
}
@Suite struct KonicaMinoltaProfile {
static let bizhub = "KONICAMINOLTAC651i.gz"
static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD"
@Test(.enabled(if: ExtraPPDs.path(bizhub) != nil))
func bizhubC651i() throws {
let c = try #require(ExtraPPDs.context(Self.bizhub))
#expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"])
#expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic")
#expect(pairs("bw", c) == ["ColorModel=Gray"])
#expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta")
#expect(pairs("simplex", c) == ["KMDuplex=Single"])
#expect(pairs("letter", c) == ["PageSize=8.5x11"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
/// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants).
@Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty))
func everyKonicaMinoltaPPD() throws {
for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)")
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)")
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)")
#expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
}
}
}