diff --git a/CLAUDE.md b/CLAUDE.md index 9ab05c3..1fd2e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,8 +31,12 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored). a per-user override. - **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are written as `Custom.` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates). -- **Quick actions** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input - type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason. +- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes + 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 ` shows a + driver's keywords and which actions its profile enables. ## Build and test ``` diff --git a/Package.swift b/Package.swift index 41e34c2..0e074d1 100644 --- a/Package.swift +++ b/Package.swift @@ -12,7 +12,8 @@ let package = Package( .library(name: "CupsKit", targets: ["CupsKit"]), ], 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: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"), .testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"), diff --git a/README.md b/README.md index 2d97fee..05447e0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ cupsadmin options # PPD options and IPP *-default values, like lp cupsadmin add -v [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...] cupsadmin set -o k=v ... # change queue defaults, then read them back and report cupsadmin quick [value] # task-named shortcuts, see below +cupsadmin ppdreport # 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. @@ -48,7 +49,25 @@ cupsadmin quick letter # Letter paper + fit to nearest size (no cupsadmin quick default # 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 -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 -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 ` 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 ` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request. ## How it works diff --git a/Sources/CUPSAdminApp/PrinterStore.swift b/Sources/CUPSAdminApp/PrinterStore.swift index d6aec6a..5d93455 100644 --- a/Sources/CUPSAdminApp/PrinterStore.swift +++ b/Sources/CUPSAdminApp/PrinterStore.swift @@ -101,8 +101,8 @@ final class PrinterStore { private(set) var revision = 0 /// The server default printer (CUPS-Get-Default), if any. private(set) var defaultPrinter: String? - /// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil. - private(set) var ppds: [String: PPD?] = [:] + /// Driver PPD + IPP attributes per queue, for enabling Quick Actions. + private(set) var drivers: [String: DriverContext] = [:] var failure: Failure? let client = CupsClient() @@ -190,31 +190,28 @@ final class PrinterStore { } func noteChange(queue: String? = nil) { - if let queue { ppds[queue] = nil } + if let queue { drivers[queue] = nil } 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 { - for printer in printers where ppds[printer.name] == nil { - let text = try? await client.getPPD(queue: printer.name) - ppds[printer.name] = .some(text.map(PPD.init(text:))) + for printer in printers where drivers[printer.name] == nil { + if let context = try? await DriverContext.load(client: client, queue: printer.name) { + drivers[printer.name] = context + } } } /// 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? { - if case .defaultPrinter = action.effect { + if action.isDefaultPrinter { return defaultPrinter == queue ? "\(queue) is already the default printer" : nil } - guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" } - if action.isAvailable(for: cached) { return nil } - if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) { - return unavailable.reason - } - return "Not available on \(queue)" + guard let context = drivers[queue] else { return "Checking \(queue)’s driver…" } + return action.unavailableReason(in: context) } // MARK: per-printer actions @@ -256,7 +253,7 @@ final class PrinterStore { report("ERROR: delete \(queue): \(error)") fail("Couldn’t delete \(queue)", String(describing: error)) } - ppds[queue] = nil + drivers[queue] = nil noteChange() await refreshQuietly() return deleted diff --git a/Sources/CUPSAdminApp/QuickActionSheet.swift b/Sources/CUPSAdminApp/QuickActionSheet.swift index ed4c01a..469a171 100644 --- a/Sources/CUPSAdminApp/QuickActionSheet.swift +++ b/Sources/CUPSAdminApp/QuickActionSheet.swift @@ -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. 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 { - 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 "" } return OptionApplier.displayCommand(queue: queue, changes: changes) } /// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default. var userDefaultNote: String? { - guard case .defaultPrinter = action.effect, + guard action.isDefaultPrinter, let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8), let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil } let name = line.split(separator: " ")[1].split(separator: "/")[0] @@ -70,8 +80,8 @@ final class QuickActionModel: Identifiable { phase = .running let started = Date() do { - let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil), - client: store.client) + let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : 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) let seconds = elapsedText(since: started) @@ -108,6 +118,11 @@ struct QuickActionSheet: View { .font(.title3.weight(.semibold)) Text("\(model.queue) · saved as the queue default for every user on this Mac") .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 { @@ -184,7 +199,7 @@ struct QuickActionSheet: View { .frame(height: 130) } - if case .defaultPrinter = model.action.effect { + if model.action.isDefaultPrinter { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { GridRow { Text("Server default").foregroundStyle(.secondary) diff --git a/Sources/CupsKit/DriverProfiles.swift b/Sources/CupsKit/DriverProfiles.swift new file mode 100644 index 0000000..64c0443 --- /dev/null +++ b/Sources/CupsKit/DriverProfiles.swift @@ -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) } + } +} diff --git a/Sources/CupsKit/QuickActions.swift b/Sources/CupsKit/QuickActions.swift index e5e08c1..7d151aa 100644 --- a/Sources/CupsKit/QuickActions.swift +++ b/Sources/CupsKit/QuickActions.swift @@ -1,108 +1,112 @@ import Foundation /// Task-named shortcuts that set queue defaults without knowing option keywords. -/// Each one is data: which PPD keyword/value pairs to write (first variant the PPD supports wins). -/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed. +/// What each one writes comes from the driver profiles in Resources/driver-profiles.json. public struct QuickAction: Identifiable, Hashable { public enum Input: Hashable { case none - /// Digits, up to the PPD's own limit. An empty value clears the code. + /// Digits, up to the PPD's own limit. case userCode } - public enum Effect: Hashable { - /// `lpadmin -p queue -o ...` with the first variant whose keywords and values this PPD has. - /// `{value}` in a value is replaced with the input. - case options(variants: [[String: String]], clearVariants: [[String: String]]) - /// `lpadmin -d queue`. - case defaultPrinter - } - - /// CLI name: `cupsadmin quick `. + /// CLI name (`cupsadmin quick `) and the key in driver-profiles.json. public let id: String public let title: String public let systemImage: String public let input: Input - public let effect: Effect /// One line for the CLI list and tooltips. public let summary: String + public var isDefaultPrinter: Bool { id == "default" } + public static let all: [QuickAction] = [ QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none, - effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []), - summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"), + summary: "Color by default"), QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none, - effect: .options(variants: [["ColorModel": "Gray"], ["HQColorMode": "BW"]], clearVariants: []), - summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"), + summary: "Black & white by default"), QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode, - effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]], - clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]), - summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.)"), + summary: "User/department code sent with every job (--clear to remove)"), QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none, - effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []), - summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"), + summary: "Two-sided, long edge"), QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, - effect: .options(variants: [["Duplex": "None"]], clearVariants: []), - summary: "One-sided (Duplex=None)"), + summary: "One-sided"), QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right", - input: .none, - effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []), - summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"), + input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"), QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, - effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"), + summary: "Server default printer (lpadmin -d)"), ] public static func named(_ id: String) -> QuickAction? { all.first { $0.id == id } } - /// The option changes for this PPD, or why the action isn't available on it. - public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> { - guard case .options(let variants, let clearVariants) = effect else { return .success([]) } - let clearing = input == .userCode && (value ?? "").isEmpty - let candidates = clearing ? clearVariants : variants - guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) } - - for variant in candidates { - var changes: [OptionChange] = [] - for key in variant.keys.sorted() { - guard let option = ppd.option(key) else { changes = []; break } - let raw = variant[key]! - if raw.contains("{value}") { - guard !option.customParameters.isEmpty else { changes = []; break } - changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false)) - } else { - guard option.choices.contains(raw) else { changes = []; break } - changes.append(OptionChange(key: key, value: raw, isSecret: false)) + /// The profile and option changes this action would write on a queue, or why it can't. + public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false, + profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result { + let key = clearing ? id + "-clear" : id + var firstMissing: String? + for profile in DriverProfiles.matching(context, in: profiles) { + guard let alternatives = profile.actions[key] else { continue } + for pairs in alternatives { + let parsed = pairs.compactMap(Self.parse) + let filled = parsed.map { ($0.key, $0.value.replacingOccurrences(of: "{value}", with: value ?? "")) } + if let missing = filled.first(where: { !context.supports(key: $0.0, value: $0.1) }) { + if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" } + continue } + let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) } + return .success(ResolvedQuickAction(profile: profile, changes: changes)) } - if !changes.isEmpty { return .success(changes) } } - let needed = Set(candidates.flatMap { $0.keys }).sorted().joined(separator: " / ") - return .failure(QuickActionUnavailable(reason: "This printer’s PPD has no \(needed) with the needed choice")) + let detail = firstMissing.map { " (no \($0))" } ?? " (no driver profile maps \(key))" + return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName)\(detail)")) } - public func isAvailable(for ppd: PPD?) -> Bool { - if case .defaultPrinter = effect { return true } - if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true } - return false + /// nil when the action can run on this queue; otherwise the reason (for tooltips and CLI errors). + public func unavailableReason(in context: DriverContext) -> String? { + if isDefaultPrinter { return nil } + switch resolve(context, value: input == .userCode ? "0" : nil) { + case .success: return nil + case .failure(let unavailable): return unavailable.reason + } } - /// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid. - public func validationError(_ value: String, ppd: PPD?) -> String? { + public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil } + + /// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid. + public func validationError(_ value: String, context: DriverContext) -> String? { guard input == .userCode, !value.isEmpty else { return nil } guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } - if let max = ppd?.option("RIUserCode")?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { + if let keyword = codeKeyword(context), + let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { return "At most \(max) digits" } return nil } - /// The current user code on the queue, without the Custom. prefix (nil when none is set). - public static func currentUserCode(_ ppd: PPD?) -> String? { - guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil } + /// The code currently set on the queue, without the Custom. prefix (nil when none). + public func currentCode(_ context: DriverContext) -> String? { + guard let keyword = codeKeyword(context), + let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil } return String(value.dropFirst("Custom.".count)) } + + /// The option that receives `{value}` for this queue's profile (e.g. RIUserCode). + private func codeKeyword(_ context: DriverContext) -> String? { + guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil } + let profilePairs = resolved.profile.actions[id]?.first ?? [] + return profilePairs.compactMap(Self.parse).first { $0.value.contains("{value}") }?.key + } + + static func parse(_ pair: String) -> (key: String, value: String)? { + guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else { return nil } + return (String(pair[.. QuickActionOutcome { - switch effect { - case .defaultPrinter: + /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits. + public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome { + if isDefaultPrinter { let outcome = try await QueueActions.setDefault(queue: queue, client: client) - return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, readBack: [], - changes: [], message: outcome.message) - case .options: - let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:)) - let changes = try self.changes(for: ppd, value: value).get() - let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes) - let failed = readBack.filter { !$0.applied } - return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: changes), - succeeded: result.succeeded && failed.isEmpty, readBack: readBack, changes: changes, - message: failed.first?.reason) + return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil, + readBack: [], changes: [], message: outcome.message) } + let context = try await DriverContext.load(client: client, queue: queue) + let resolved = try resolve(context, value: value, clearing: clearing).get() + let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes) + let failed = readBack.filter { !$0.applied } + return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes), + succeeded: result.succeeded && failed.isEmpty, profile: resolved.profile, + readBack: readBack, changes: resolved.changes, message: failed.first?.reason) } } diff --git a/Sources/CupsKit/Resources/driver-profiles.json b/Sources/CupsKit/Resources/driver-profiles.json new file mode 100644 index 0000000..3d1b5da --- /dev/null +++ b/Sources/CupsKit/Resources/driver-profiles.json @@ -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 ", + "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"]] + } + } + ] +} diff --git a/Sources/cupsadmin/Commands/PPDReportCommand.swift b/Sources/cupsadmin/Commands/PPDReportCommand.swift new file mode 100644 index 0000000..4b28ec9 --- /dev/null +++ b/Sources/cupsadmin/Commands/PPDReportCommand.swift @@ -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 ") + } + + 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.)") + } + } + } + return "OK: \(source) \(ppd.options.count) options in \(ppd.groups.count) groups" + } +} diff --git a/Sources/cupsadmin/Commands/QuickCommand.swift b/Sources/cupsadmin/Commands/QuickCommand.swift index b1ecb30..33b1cd2 100644 --- a/Sources/cupsadmin/Commands/QuickCommand.swift +++ b/Sources/cupsadmin/Commands/QuickCommand.swift @@ -7,7 +7,10 @@ enum QuickCommand { printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map { [$0.id, $0.input == .userCode ? " | --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 ") + return "OK: \(QuickAction.all.count) quick actions, \(DriverProfiles.builtIn.count) driver profiles" } guard let action = QuickAction.named(name) else { throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))") @@ -22,7 +25,7 @@ enum QuickCommand { case .userCode: if clear { guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") } - value = "" + value = nil } else { guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } value = args[2] @@ -30,7 +33,7 @@ enum QuickCommand { } 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 status = try runTool(CupsTools.lpadminPath, ["-d", queue]) 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) - 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)") } - let changes: [OptionChange] - switch action.changes(for: before.ppd, value: value) { - case .success(let resolved): changes = resolved - case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)") + let resolved: ResolvedQuickAction + switch action.resolve(context, value: value, clearing: clear) { + case .success(let r): resolved = r + 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) guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") } 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() - 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) ?? "-", 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: " + 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" } } diff --git a/Sources/cupsadmin/main.swift b/Sources/cupsadmin/main.swift index de24090..70ff1b0 100644 --- a/Sources/cupsadmin/main.swift +++ b/Sources/cupsadmin/main.swift @@ -41,6 +41,8 @@ let usage = """ quick list quick actions (task-named queue defaults) quick [code | --clear] apply one via lpadmin, then verify + ppdreport | --file + every PPD group and option: keyword, label, type, default, choices help show this help """ @@ -117,6 +119,19 @@ func runCommand(_ args: [String]) async throws -> String { let (queue, spec) = try QueueSpec.parse(rest, command: command) 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 ") + } + return try await PPDReportCommand.run(client: client, queue: rest.first, file: file) + case "quick": let clear = takeFlag("--clear") try rejectUnknownFlags() diff --git a/Tests/CupsKitTests/QuickActionTests.swift b/Tests/CupsKitTests/QuickActionTests.swift index 8de6617..ffb52f1 100644 --- a/Tests/CupsKitTests/QuickActionTests.swift +++ b/Tests/CupsKitTests/QuickActionTests.swift @@ -1,61 +1,127 @@ -import CupsKit +@testable import CupsKit import Foundation import Testing -private func available(_ id: String, _ ppd: PPD?) -> Bool { - QuickAction.named(id)!.isAvailable(for: ppd) +private func context(_ ppdFile: String) -> DriverContext? { + vendorPPD(ppdFile).map { DriverContext(ppd: $0, attributes: nil) } } -@Suite struct QuickActionAvailability { - @Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) - func ricohPSHasEverything() throws { - let ppd = try #require(vendorPPD("RICOH IM C4500")) - for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] { - #expect(available(id, ppd), "\(id)") +private func available(_ id: String, _ context: DriverContext) -> Bool { + QuickAction.named(id)!.isAvailable(in: context) +} + +private func profileID(_ id: String, _ context: DriverContext) -> String? { + 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), 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"]) - 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"]) } - @Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd"))) - func otherDriversOnlyGetWhatTheirPPDHas() throws { - // SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex. - let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd")) - #expect(!available("color", sp) && !available("bw", sp)) - #expect(!available("letter", sp) && !available("usercode", sp)) - #expect(available("duplex", sp) && available("simplex", sp)) + @Test(.enabled(if: hasVendorPPD("RICOH MP 5055"))) + func ricohMonoPostScript() throws { + let c = try #require(context("RICOH MP 5055")) + #expect(!available("color", c) && !available("bw", c)) // mono: no ColorModel + #expect(QuickAction.named("color")!.unavailableReason(in: c)?.hasPrefix("Not available for RICOH MP 5055 PS") == true) + for id in ["usercode", "duplex", "simplex", "letter"] { #expect(profileID(id, c) == "ricoh", "\(id)") } + } - // M C251FW: color via HQColorMode. - let fw = try #require(vendorPPD("RICOH M C251FW.ppd")) - #expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)]) + @Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd"))) + func ricohMC251FWUsesHQColorMode() throws { + 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. - let airprint = PPD(text: """ + @Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd"))) + 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 *DefaultColorModel: Gray *ColorModel Gray/Grayscale: "" *CloseUI: *ColorModel - *OpenUI *PageSize/Media Size: PickOne - *DefaultPageSize: Letter - *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") + """), attributes: nil) + #expect(available("bw", gray) && !available("color", gray)) } } @@ -68,21 +134,24 @@ struct LiveQuickActions { @Test func everyActionOnRicohQueue() async throws { try #require(queues.count == 2) let client = CupsClient() - for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"), - ("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] { - let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client) + for (id, value, clearing) in [("letter", nil, false), ("bw", nil, false), ("color", nil, false), ("usercode", "2468", false), + ("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, clearing: clearing, client: client) #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) let client = CupsClient() let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client) #expect(page.succeeded && jobID != nil, "\(page)") - let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client) - #expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing + let letter = try await QuickAction.named("letter")!.run(queue: queues[1], client: client) + #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) #expect(made.succeeded, "\(made.message ?? "")")