cupsadmin: CLI, CupsKit, app in progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:13:07 -05:00
commit eba94aae4e
44 changed files with 7256 additions and 0 deletions

View File

@@ -0,0 +1,477 @@
import Foundation
// MARK: - Controls
/// A typed custom value (`*ParamCustomX`) edited as text, stored as `Custom.<value>`.
public struct CustomField: Hashable {
public enum Kind: String {
case string, passcode, password, int, real
}
public let kind: Kind
public let parameterName: String
public let minimum: Double?
public let maximum: Double?
/// The PPD's own choices (e.g. `None`, `0`). An empty field means the first of them.
public let choices: [String]
/// Passcodes and passwords: masked in the UI and in displayed commands.
public var isSecret: Bool { kind == .passcode || kind == .password }
public var isNumeric: Bool { kind == .int || kind == .real }
init?(parameter: PPD.CustomParameter, choices: [String]) {
guard let kind = Kind(rawValue: parameter.type) else { return nil }
self.kind = kind
parameterName = parameter.name
minimum = Double(parameter.minimum)
maximum = Double(parameter.maximum)
self.choices = choices
}
/// Stored option value -> field text. `Custom.1234` -> `1234`; a plain choice -> "" (or the choice for numbers).
public func text(fromValue value: String?) -> String {
guard let value else { return "" }
if value.hasPrefix("Custom.") { return String(value.dropFirst("Custom.".count)) }
if isNumeric, value != choices.first { return value }
return ""
}
/// Field text -> option value. "" -> the PPD's placeholder choice; text matching a choice -> that choice.
public func value(fromText text: String) -> String {
if text.isEmpty { return choices.first ?? "None" }
if choices.contains(text) { return text }
return "Custom." + text
}
/// nil when valid.
public func validationError(_ text: String) -> String? {
guard !text.isEmpty, !choices.contains(text) else { return nil }
switch kind {
case .string, .password, .passcode:
if kind == .passcode, !text.allSatisfy(\.isASCIIDigit) { return "Digits only" }
if let minimum, Double(text.count) < minimum { return "At least \(Int(minimum)) characters" }
if let maximum, Double(text.count) > maximum { return "At most \(Int(maximum)) characters" }
case .int:
guard let number = Int(text) else { return "Whole number" }
return rangeError(Double(number))
case .real:
guard let number = Double(text) else { return "Number" }
return rangeError(number)
}
return nil
}
private func rangeError(_ number: Double) -> String? {
if let minimum, number < minimum { return "\(format(minimum)) to \(format(maximum))" }
if let maximum, number > maximum { return "\(format(minimum)) to \(format(maximum))" }
return nil
}
private func format(_ n: Double?) -> String {
guard let n else { return "…" }
return n == n.rounded() ? String(Int(n)) : String(n)
}
/// "8 characters max", "4–8 digits", "0 to 23".
public var hint: String {
switch kind {
case .string, .password:
return maximum.map { "Up to \(Int($0)) characters" } ?? "Text"
case .passcode:
if let minimum, let maximum { return "\(Int(minimum))–\(Int(maximum)) digits" }
return "Digits"
case .int, .real:
return "\(format(minimum)) to \(format(maximum))"
}
}
}
public enum OptionControl: Hashable {
/// Menu of choices. `(keyword, label)` pairs.
case picker([PPD.Choice])
/// Boolean with exactly a true and a false choice; the keywords to write for each state.
case toggle(on: String, off: String)
/// Custom value typed as text; the PPD's choices are only a placeholder.
case field(CustomField)
/// Read-only, with the reason (e.g. "Set by PPD option PageSize").
case readOnly(String)
}
extension PPD.Option {
public var control: OptionControl {
// A true Boolean has exactly a True and a False choice; some PPDs mislabel PickOnes as Boolean.
if uiType == .boolean,
let on = choices.first(where: { $0.caseInsensitiveCompare("True") == .orderedSame }),
let off = choices.first(where: { $0.caseInsensitiveCompare("False") == .orderedSame }),
choices.count == 2 {
return .toggle(on: on, off: off)
}
// Ricoh codes/passwords: a placeholder choice plus one typed custom parameter.
if customParameters.count == 1, choices.count <= 1,
let field = CustomField(parameter: customParameters[0], choices: choices) {
return .field(field)
}
return .picker(choiceList)
}
}
// MARK: - Encoding
public enum OptionEncoding {
/// `key=value` for lpadmin/lpoptions `-o`, quoting values cupsParseOptions would otherwise split.
/// (Unquoted, `Custom.John Smith` is silently stored as `Custom.John`.)
public static func argument(key: String, value: String) -> String {
"\(key)=\(quoted(value))"
}
public static func quoted(_ value: String) -> String {
guard value.contains(where: { $0.isWhitespace || "\"'\\{}".contains($0) }) else { return value }
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Masks the value part of a custom secret (`Custom.1234` -> `Custom.••••`).
public static func masked(_ value: String) -> String {
if value.hasPrefix("Custom.") { return "Custom." + String(repeating: "•", count: max(4, value.count - 7)) }
return value
}
}
// MARK: - lpoptions
/// Per-user option defaults from `~/.cups/lpoptions` (read only, to show where a user overrides a queue default).
public enum LpOptionsFile {
public static var userFileURL: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cups/lpoptions")
}
/// Options on the `Dest`/`Default` line for `queue` (no instance). Empty if none or no file.
public static func options(for queue: String, fileURL: URL = userFileURL) -> [String: String] {
guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { return [:] }
return options(for: queue, in: text)
}
public static func options(for queue: String, in text: String) -> [String: String] {
for line in text.split(whereSeparator: \.isNewline) {
let fields = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard fields.count >= 2, fields[0] == "Dest" || fields[0] == "Default",
fields[1].caseInsensitiveCompare(queue) == .orderedSame else { continue }
return fields.count > 2 ? parse(String(fields[2])) : [:]
}
return [:]
}
/// cupsParseOptions-style: `a=b c="d e" f={g=h i=j}`.
public static func parse(_ string: String) -> [String: String] {
var result: [String: String] = [:]
var chars = Array(string)
var i = 0
func skipSpaces() { while i < chars.count, chars[i].isWhitespace { i += 1 } }
while true {
skipSpaces()
guard i < chars.count else { break }
var name = ""
while i < chars.count, !chars[i].isWhitespace, chars[i] != "=" { name.append(chars[i]); i += 1 }
guard i < chars.count, chars[i] == "=" else {
if !name.isEmpty { result[name] = "true" }
continue
}
i += 1
var value = ""
var quote: Character?
var braces = 0
while i < chars.count {
let c = chars[i]
if c == "\\", i + 1 < chars.count {
value.append(chars[i + 1]); i += 2; continue
}
if let q = quote {
if c == q { quote = nil } else { value.append(c) }
} else if c == "\"" || c == "'" {
quote = c
} else if c == "{" {
braces += 1; value.append(c)
} else if c == "}" {
braces -= 1; value.append(c)
} else if c.isWhitespace, braces == 0 {
break
} else {
value.append(c)
}
i += 1
}
result[name] = value
}
chars.removeAll()
return result
}
}
// MARK: - Catalog
public struct OptionRow: Identifiable, Hashable {
public enum Source: Hashable {
case ppd, ipp, policy
}
/// The key written with `lpadmin -p queue -o key=value` (PPD keyword, `sides-default`, `job-quota-period`).
public let key: String
public let label: String
public let group: String
public let control: OptionControl
public let source: Source
public var id: String { key }
public var isSecret: Bool {
if case .field(let field) = control { return field.isSecret }
return false
}
}
public struct OptionSection: Identifiable, Hashable {
public let title: String
public let rows: [OptionRow]
public var id: String { title }
}
/// Queue defaults for one queue: IPP attributes and PPD (what lpadmin changes), plus the user's
/// ~/.cups/lpoptions, read only to point out per-user overrides.
public struct OptionState {
public let queue: String
public let snapshot: QueueSnapshot
public let userOptions: [String: String]
public static func load(client: CupsClient, queue: String) async throws -> OptionState {
OptionState(queue: queue, snapshot: try await QueueSnapshot.load(client: client, queue: queue),
userOptions: LpOptionsFile.options(for: queue))
}
public var ppd: PPD? { snapshot.ppd }
/// IPP job-template defaults an admin can set with `lpadmin -o name-default=value`.
public static let ippDefaults = ["copies", "media", "sides", "print-color-mode", "print-quality", "output-bin",
"printer-resolution", "number-up", "orientation-requested", "job-hold-until",
"job-priority", "job-cancel-after"]
/// On PPD queues cupsd derives these IPP defaults from a PPD option, and setting both makes duplicates.
public static let ppdControlled = ["media": "PageSize", "sides": "Duplex", "output-bin": "OutputBin",
"printer-resolution": "Resolution", "print-color-mode": "ColorModel",
"print-quality": "cupsPrintQuality"]
public static let integerPolicies = ["job-quota-period", "job-page-limit", "job-k-limit"]
/// PPD groups shown first, in this order; "Installable Options" goes last (hardware, rarely changed).
public static let leadingGroups = [PPD.generalGroup, "Basic", "Paper"]
public static let trailingGroup = "Installable Options"
/// General, Basic, Paper, other PPD groups in file order, IPP, Policies, Installable Options.
public func sections() -> [OptionSection] {
var ppdSections: [OptionSection] = []
if let ppd {
let ordered = Self.leadingGroups.filter(ppd.groups.contains)
+ ppd.groups.filter { !Self.leadingGroups.contains($0) && $0 != Self.trailingGroup }
for group in ordered {
let rows = ppd.options.filter { $0.group == group }.map {
OptionRow(key: $0.keyword, label: $0.text, group: group, control: $0.control, source: .ppd)
}
ppdSections.append(OptionSection(title: group, rows: rows))
}
}
var sections = ppdSections
var ipp: [OptionRow] = []
for base in Self.ippDefaults {
let name = base + "-default"
guard snapshot.attributes[name] != nil || snapshot.attributes[base + "-supported"] != nil else { continue }
var control = ippControl(base: base)
if let keyword = Self.ppdControlled[base], ppd?.option(keyword) != nil {
control = .readOnly("Set by PPD option \(keyword)")
}
ipp.append(OptionRow(key: name, label: Self.label(base), group: "IPP", control: control, source: .ipp))
}
if !ipp.isEmpty { sections.append(OptionSection(title: "IPP", rows: ipp)) }
var policies: [OptionRow] = []
for name in ["printer-error-policy", "printer-op-policy"] where snapshot.attributes[name] != nil {
let choices = supportedChoices(name + "-supported")
policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies",
control: .picker(choices), source: .policy))
}
if snapshot.attributes["job-sheets-default"] != nil {
let sheets = supportedChoices("job-sheets-supported").map(\.keyword)
var pairs: [PPD.Choice] = []
for start in sheets {
for end in sheets { pairs.append(PPD.Choice(keyword: "\(start),\(end)", text: "\(start), \(end)")) }
}
policies.append(OptionRow(key: "job-sheets-default", label: "Banner pages (start, end)",
group: "Policies", control: .picker(pairs), source: .policy))
}
for name in Self.integerPolicies where snapshot.attributes[name] != nil {
let field = CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int",
minimum: "0", maximum: "2147483647"),
choices: ["0"])!
policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies", control: .field(field), source: .policy))
}
if !policies.isEmpty { sections.append(OptionSection(title: "Policies", rows: policies)) }
if let ppd, ppd.groups.contains(Self.trailingGroup) {
let rows = ppd.options.filter { $0.group == Self.trailingGroup }.map {
OptionRow(key: $0.keyword, label: $0.text, group: Self.trailingGroup, control: $0.control, source: .ppd)
}
sections.append(OptionSection(title: Self.trailingGroup, rows: rows))
}
return sections
}
/// "print-color-mode" -> "Print color mode".
static func label(_ name: String) -> String {
let words = name.replacingOccurrences(of: "-", with: " ")
return words.prefix(1).uppercased() + words.dropFirst()
}
private func ippControl(base: String) -> OptionControl {
let supported = snapshot.attributes[base + "-supported"]
if let supported, case .range(let lower, let upper)? = supported.values.first, supported.values.count == 1 {
return .field(integerField(base, lower: Int(lower), upper: Int(upper)))
}
if base == "job-priority" {
return .field(integerField(base, lower: 1, upper: 100))
}
let choices = supportedChoices(base + "-supported")
return choices.isEmpty ? .readOnly("No \(base)-supported values") : .picker(choices)
}
private func integerField(_ name: String, lower: Int, upper: Int) -> CustomField {
CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int",
minimum: String(lower), maximum: String(upper)), choices: [])!
}
/// `*-supported` values as choices: raw value to write, human text to show.
private func supportedChoices(_ name: String) -> [PPD.Choice] {
guard let attribute = snapshot.attributes[name] else { return [] }
let base = String(name.dropLast("-supported".count))
return attribute.values.compactMap { value in
guard let raw = Self.rawValue(value) else { return nil }
let shown = Format.value(value, attribute: base + "-default")
return PPD.Choice(keyword: raw, text: shown == raw ? raw : "\(shown) (\(raw))")
}
}
/// What lpadmin accepts for an IPP value: enums and integers as numbers, resolutions as 600dpi.
static func rawValue(_ value: IPPValue) -> String? {
switch value {
case .integer(let i), .enumeration(let i): return String(i)
case .string(_, let s), .stringWithLanguage(_, _, let s): return s
case .resolution(let x, let y, let units):
let unit = units == 4 ? "dpcm" : "dpi"
return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)"
case .range(let lower, let upper): return "\(lower)-\(upper)"
case .boolean(let b): return b ? "true" : "false"
default: return nil
}
}
// MARK: values
/// The queue default for a row key (PPD default, or the first IPP attribute of that name), in raw form.
public func queueValue(_ key: String) -> String? {
if let option = ppd?.option(key) { return option.defaultChoice }
guard let attribute = snapshot.attributes[key] else { return nil }
let raw = attribute.values.compactMap(Self.rawValue)
return raw.isEmpty ? nil : raw.joined(separator: ",")
}
/// This user's own lpoptions value for a row (`sides-default` is `sides` there), if they have one.
public func userOverride(_ key: String) -> String? {
let base = key.hasSuffix("-default") ? String(key.dropLast("-default".count)) : key
return userOptions[Self.ippDefaults.contains(base) ? base : key]
}
/// Human form of a raw value for a row, e.g. `Custom.1234` -> `1234`, `5` -> `high`, `DuplexNoTumble` -> `Long Edge`.
public func display(_ value: String?, row: OptionRow) -> String {
guard let value else { return "—" }
switch row.control {
case .picker(let choices):
return choices.first { $0.keyword == value }?.text ?? value
case .toggle(let on, _):
return value == on ? "On" : "Off"
case .field(let field):
let text = field.text(fromValue: value)
if text.isEmpty { return value }
return field.isSecret ? String(repeating: "•", count: text.count) : text
case .readOnly:
return value
}
}
}
// MARK: - Apply
public struct OptionChange: Hashable {
public let key: String
public let value: String
public let isSecret: Bool
public init(key: String, value: String, isSecret: Bool) {
self.key = key
self.value = value
self.isSecret = isSecret
}
}
public struct OptionReadBack: Hashable {
public let key: String
public let applied: Bool
/// Why it didn't apply.
public let reason: String?
}
/// Writes queue defaults with `lpadmin -p queue -o ...` (the PPD in /etc/cups/ppd and printers.conf),
/// the same thing the CUPS web UI's Set Default Options page did. Never lpoptions.
public enum OptionApplier {
public static func command(queue: String, changes: [OptionChange]) -> (executable: String, arguments: [String]) {
var args = ["-p", queue]
for change in changes {
args += ["-o", OptionEncoding.argument(key: change.key, value: change.value)]
}
return (CupsTools.lpadminPath, args)
}
/// The command as shown to the user, with secret values masked.
public static func displayCommand(queue: String, changes: [OptionChange]) -> String {
let masked = changes.map {
OptionChange(key: $0.key, value: $0.isSecret ? OptionEncoding.masked($0.value) : $0.value, isSecret: $0.isSecret)
}
let (executable, args) = command(queue: queue, changes: masked)
return CupsTools.commandLine(executable, args)
}
/// Runs one lpadmin call for all changes, then re-reads the queue and checks each change.
public static func apply(client: CupsClient, queue: String,
changes: [OptionChange]) async throws -> (result: ToolResult, readBack: [OptionReadBack], state: OptionState) {
let (executable, args) = command(queue: queue, changes: changes)
let result = try await Task.detached { try CupsTools.run(executable, args) }.value
let state = try await OptionState.load(client: client, queue: queue)
guard result.succeeded else {
let message = result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message
return (result, changes.map { OptionReadBack(key: $0.key, applied: false, reason: message) }, state)
}
return (result, changes.map { verify($0, state: state) }, state)
}
public static func verify(_ change: OptionChange, state: OptionState) -> OptionReadBack {
let actual = state.queueValue(change.key)
// PPD choices are written verbatim; IPP values read back in their own spelling (none,none / true).
let exact = state.ppd?.option(change.key) != nil
if let actual, exact ? actual == change.value : QueueSnapshot.matches(actual, change.value) {
return OptionReadBack(key: change.key, applied: true, reason: nil)
}
guard let actual else {
return OptionReadBack(key: change.key, applied: false, reason: "Not applied: \(state.queue) has no option \(change.key)")
}
return OptionReadBack(key: change.key, applied: false,
reason: "Not applied: still \(change.isSecret ? OptionEncoding.masked(actual) : actual)")
}
}
private extension Character {
var isASCIIDigit: Bool { ("0"..."9").contains(self) }
}