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,135 @@
import Foundation
/// Output of one CUPS command-line tool run.
public struct ToolResult {
public let executable: String
public let arguments: [String]
public let status: Int32
public let standardOutput: Data
public let standardError: Data
public var commandLine: String { CupsTools.commandLine(executable, arguments) }
public var succeeded: Bool { status == 0 }
/// stderr then stdout as text, trimmed — what to show a user when a tool fails.
public var message: String {
(String(decoding: standardError, as: UTF8.self) + String(decoding: standardOutput, as: UTF8.self))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// Writes go through the CUPS tools (lpadmin handles _lpadmin auth and IPP Everywhere PPD generation).
/// Nothing here prints; callers decide what to show.
public enum CupsTools {
public static let lpadminPath = "/usr/sbin/lpadmin"
public static let cupsenablePath = "/usr/sbin/cupsenable"
public static let cupsdisablePath = "/usr/sbin/cupsdisable"
public static let cupsacceptPath = "/usr/sbin/cupsaccept"
public static let cupsrejectPath = "/usr/sbin/cupsreject"
public static let cancelPath = "/usr/bin/cancel"
public static let lpPath = "/usr/bin/lp"
public static let lpmovePath = "/usr/sbin/lpmove"
public static let lpinfoPath = "/usr/sbin/lpinfo"
/// The CUPS standard test page (what the web UI's Print Test Page sent).
public static let testPagePath = "/usr/share/cups/data/testprint"
public static func lpadmin(_ arguments: [String]) throws -> ToolResult {
try run(lpadminPath, arguments)
}
public static func enable(queue: String) throws -> ToolResult {
try run(cupsenablePath, [queue])
}
public static func disable(queue: String, reason: String? = nil) throws -> ToolResult {
try run(cupsdisablePath, (reason.map { ["-r", $0] } ?? []) + [queue])
}
public static func accept(queue: String) throws -> ToolResult {
try run(cupsacceptPath, [queue])
}
public static func reject(queue: String, reason: String? = nil) throws -> ToolResult {
try run(cupsrejectPath, (reason.map { ["-r", $0] } ?? []) + [queue])
}
/// `cancel -a queue`: every job on the queue.
public static func cancelAll(queue: String) throws -> ToolResult {
try run(cancelPath, ["-a", queue])
}
/// `lp -i id -H hold`.
public static func hold(jobID: Int) throws -> ToolResult {
try run(lpPath, ["-i", String(jobID), "-H", "hold"])
}
/// `lp -i id -H resume`.
public static func release(jobID: Int) throws -> ToolResult {
try run(lpPath, ["-i", String(jobID), "-H", "resume"])
}
/// `lpmove id destination`.
public static func move(jobID: Int, to destination: String) throws -> ToolResult {
try run(lpmovePath, [String(jobID), destination])
}
/// `lpadmin -d queue`: the server default printer.
public static func setDefault(queue: String) throws -> ToolResult {
try run(lpadminPath, ["-d", queue])
}
/// `lpadmin -x queue`.
public static func delete(queue: String) throws -> ToolResult {
try run(lpadminPath, ["-x", queue])
}
/// `lp -d queue -t "Test Page" file`; the job id is in stdout ("request id is Q-12 (1 file(s))").
public static func printTestPage(queue: String, file: String) throws -> ToolResult {
try run(lpPath, ["-d", queue, "-t", "Test Page", file])
}
/// `lpinfo -m`: every driver cupsd knows, as (model, name).
public static func models() throws -> [(model: String, name: String)] {
let result = try run(lpinfoPath, ["-m"])
guard result.succeeded else { throw CupsAdminError.failed("lpinfo -m: \(result.message)") }
return String(decoding: result.standardOutput, as: UTF8.self).split(whereSeparator: \.isNewline).compactMap { line in
let parts = line.split(separator: " ", maxSplits: 1)
guard parts.count == 2 else { return nil }
return (String(parts[0]), String(parts[1]))
}
}
/// Runs a tool and captures stdout/stderr. stdin is inherited; libcups password prompts use /dev/tty.
public static func run(_ executable: String, _ arguments: [String]) throws -> ToolResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
try process.run()
// Drain both pipes concurrently so a chatty tool can't block on a full pipe buffer.
final class Box: @unchecked Sendable { var data = Data() }
let out = Box(), err = Box()
let group = DispatchGroup()
for (pipe, box) in [(outPipe, out), (errPipe, err)] {
group.enter()
DispatchQueue.global().async {
box.data = pipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
}
process.waitUntilExit()
group.wait()
return ToolResult(executable: executable, arguments: arguments, status: process.terminationStatus,
standardOutput: out.data, standardError: err.data)
}
/// Shell-style display of a command, quoting arguments that contain spaces or quotes.
public static func commandLine(_ executable: String, _ arguments: [String]) -> String {
([executable] + arguments).map { $0.contains(where: { " '\"$".contains($0) }) ? "'\($0)'" : $0 }
.joined(separator: " ")
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
public enum Format {
public static func printerState(_ state: Int?) -> String {
switch state {
case 3: return "idle"
case 4: return "processing"
case 5: return "stopped"
case let s?: return "state-\(s)"
case nil: return "?"
}
}
public static func jobState(_ state: Int?) -> String {
switch state {
case 3: return "pending"
case 4: return "held"
case 5: return "processing"
case 6: return "stopped"
case 7: return "canceled"
case 8: return "aborted"
case 9: return "completed"
case let s?: return "state-\(s)"
case nil: return "?"
}
}
/// CUPS reports most *-time attributes as integer seconds since the epoch.
public static func epoch(_ seconds: Int?) -> String {
guard let seconds, seconds > 0 else { return "-" }
return timestamp(Date(timeIntervalSince1970: TimeInterval(seconds)))
}
public static func timestamp(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f.string(from: date)
}
public static func kilobytes(_ k: Int?) -> String {
guard let k else { return "-" }
if k < 1024 { return "\(k)K" }
return String(format: "%.1fM", Double(k) / 1024)
}
public static func yesNo(_ b: Bool?) -> String {
guard let b else { return "?" }
return b ? "yes" : "no"
}
/// state-reasons without the "none" placeholder.
public static func reasons(_ values: [String]) -> String {
let real = values.filter { $0 != "none" }
return real.isEmpty ? "none" : real.joined(separator: ",")
}
public static func value(_ value: IPPValue, attribute: String) -> String {
switch value {
case .integer(let i):
if attribute.hasSuffix("-time") || attribute.hasPrefix("time-at-"), i > 946_684_800 {
return "\(i) (\(epoch(Int(i))))"
}
return String(i)
case .enumeration(let e):
switch attribute {
case "printer-state": return printerState(Int(e))
case "job-state": return jobState(Int(e))
case "print-quality-default", "print-quality-supported":
return [3: "draft", 4: "normal", 5: "high"][e] ?? String(e)
default: return String(e)
}
case .boolean(let b): return b ? "true" : "false"
case .string(_, let s): return s
case .stringWithLanguage(_, let language, let s): return "\(s) [\(language)]"
case .octetString(let d):
if let s = String(data: d, encoding: .utf8), !s.contains(where: { $0.isASCII && $0.asciiValue! < 0x20 }) {
return s
}
return "<\(d.count) bytes>"
case .dateTime(let date): return timestamp(date)
case .resolution(let x, let y, let units):
let unit = units == 3 ? "dpi" : units == 4 ? "dpcm" : "units\(units)"
return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)"
case .range(let lower, let upper): return "\(lower)-\(upper)"
case .collection(let members):
let inner = members.map { m in
"\(m.name)=" + m.values.map { self.value($0, attribute: m.name) }.joined(separator: ",")
}
return "{" + inner.joined(separator: " ") + "}"
case .outOfBand(let tag):
switch tag {
case .unsupported: return "(unsupported)"
case .unknown: return "(unknown)"
case .noValue: return "(no-value)"
default: return "(out-of-band 0x\(String(tag.rawValue, radix: 16)))"
}
case .other(let tag, let d):
return "<tag 0x\(String(tag.rawValue, radix: 16)), \(d.count) bytes>"
}
}
}

View File

@@ -0,0 +1,173 @@
import Foundation
public enum CupsAdminError: Error, CustomStringConvertible {
case usage(String)
case transport(String)
case ipp(status: UInt16, message: String?)
case failed(String)
public var description: String {
switch self {
case .usage(let m): return m
case .transport(let m), .failed(let m): return m
case .ipp(let status, let message):
let name = IPPStatus.name(status)
if let message, !message.isEmpty { return "\(name): \(message)" }
return name
}
}
}
public enum IPPStatus {
public static let notFound: UInt16 = 0x0406
public static func name(_ code: UInt16) -> String {
let known: [UInt16: String] = [
0x0400: "client-error-bad-request",
0x0401: "client-error-forbidden",
0x0402: "client-error-not-authenticated",
0x0403: "client-error-not-authorized",
0x0404: "client-error-not-possible",
0x0405: "client-error-timeout",
0x0406: "client-error-not-found",
0x0407: "client-error-gone",
0x040B: "client-error-attributes-or-values-not-supported",
0x0500: "server-error-internal-error",
0x0501: "server-error-operation-not-supported",
0x0502: "server-error-service-unavailable",
0x0503: "server-error-version-not-supported",
]
return known[code] ?? String(format: "ipp-status-0x%04x", code)
}
}
/// Talks IPP to the local cupsd over its Unix domain socket (see UnixSocketHTTP for why not TCP 631).
public final class CupsClient {
public let socketPath = "/private/var/run/cupsd"
private var nextRequestID: Int32 = 1
public init() {}
// MARK: operations
/// CUPS-Get-Printers: one printer-attributes group per queue (printers and classes).
public func getPrinters(requested: [String]) async throws -> [IPPGroup] {
let request = makeRequest(IPPOperation.cupsGetPrinters, target: serverURI, requested: requested)
return try await send(request, resource: "/").groups(.printerAttributes)
}
public func getPrinterAttributes(queue: String, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getPrinterAttributes, target: printerURI(queue), requested: requested)
let response = try await send(request, resource: printerResource(queue))
guard let group = response.groups(.printerAttributes).first else {
throw CupsAdminError.transport("cupsd returned no printer attributes for \(queue)")
}
return group
}
public func queueExists(_ queue: String) async throws -> Bool {
do {
_ = try await getPrinterAttributes(queue: queue, requested: ["printer-name"])
return true
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return false
}
}
/// CUPS-Get-Default: the server default printer's name, or nil when there is none.
public func getDefaultPrinter() async throws -> String? {
let request = makeRequest(IPPOperation.cupsGetDefault, target: serverURI, requested: ["printer-name"])
do {
return try await send(request, resource: "/").groups(.printerAttributes).first?.string("printer-name")
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return nil
}
}
/// Get-Jobs. `queue` nil means all queues. `which` is "not-completed", "completed" or "all".
public func getJobs(queue: String?, which: String, requested: [String]) async throws -> [IPPGroup] {
let extra = [
IPPAttribute.string("which-jobs", .keyword, [which]),
IPPAttribute.boolean("my-jobs", false),
]
let request = makeRequest(IPPOperation.getJobs, target: queue.map(printerURI) ?? serverURI,
extra: extra, requested: requested)
return try await send(request, resource: queue.map(printerResource) ?? "/").groups(.jobAttributes)
}
public func getJobAttributes(jobID: Int, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getJobAttributes, target: jobURI(jobID), requested: requested)
guard let group = try await send(request, resource: "/jobs/").groups(.jobAttributes).first else {
throw CupsAdminError.transport("cupsd returned no attributes for job \(jobID)")
}
return group
}
public func cancelJob(jobID: Int) async throws {
let request = makeRequest(IPPOperation.cancelJob, target: jobURI(jobID), requested: [])
_ = try await send(request, resource: "/jobs/")
}
/// The queue's PPD text (GET /printers/<queue>.ppd), or nil if the queue has no PPD.
public func getPPD(queue: String) async throws -> String? {
let response = try UnixSocketHTTP.get(socketPath: socketPath, resource: printerResource(queue) + ".ppd")
switch response.status {
case 200: return String(decoding: response.body, as: UTF8.self)
case 404: return nil
default: throw CupsAdminError.transport("HTTP \(response.status) fetching PPD for \(queue)")
}
}
// MARK: plumbing
private typealias Target = (name: String, uri: String)
private var serverURI: Target { ("printer-uri", "ipp://localhost/") }
private func printerURI(_ queue: String) -> Target {
("printer-uri", "ipp://localhost\(printerResource(queue))")
}
private func jobURI(_ jobID: Int) -> Target {
("job-uri", "ipp://localhost/jobs/\(jobID)")
}
private func printerResource(_ queue: String) -> String {
let escaped = queue.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(["/"])) ?? queue
return "/printers/\(escaped)"
}
private func makeRequest(_ operation: UInt16, target: Target, extra: [IPPAttribute] = [],
requested: [String]) -> IPPMessage {
// Order matters: charset, natural-language, target, then the rest (RFC 8011 §4.1.4).
var attributes: [IPPAttribute] = [
.string("attributes-charset", .charset, ["utf-8"]),
.string("attributes-natural-language", .naturalLanguage, ["en"]),
.string(target.name, .uri, [target.uri]),
.string("requesting-user-name", .nameWithoutLanguage, [NSUserName()]),
]
attributes += extra
if !requested.isEmpty {
attributes.append(.string("requested-attributes", .keyword, requested))
}
return IPPMessage(code: operation, groups: [IPPGroup(tag: .operationAttributes, attributes: attributes)])
}
private func send(_ message: IPPMessage, resource: String) async throws -> IPPMessage {
var message = message
message.requestID = nextRequestID
nextRequestID += 1
let response = try UnixSocketHTTP.post(socketPath: socketPath, resource: resource,
contentType: "application/ipp", body: IPPEncoder.encode(message))
guard response.status == 200 else {
throw CupsAdminError.transport("HTTP \(response.status) from cupsd for POST \(resource)")
}
let reply = try IPPDecoder.decode(response.body)
guard reply.isSuccess else {
throw CupsAdminError.ipp(status: reply.code, message: reply.operationGroup?.string("status-message"))
}
return reply
}
}

View File

@@ -0,0 +1,272 @@
import Foundation
/// RFC 8010 wire-format encoder.
enum IPPEncoder {
static func encode(_ message: IPPMessage) -> Data {
var out: [UInt8] = [message.versionMajor, message.versionMinor]
out.appendUInt16(message.code)
out.appendInt32(message.requestID)
for group in message.groups {
out.append(group.tag.rawValue)
for attribute in group.attributes {
for (index, value) in attribute.values.enumerated() {
// Additional values of a 1setOf carry an empty name.
write(value, name: index == 0 ? attribute.name : "", into: &out)
}
}
}
out.append(IPPTag.endOfAttributes.rawValue)
return Data(out) + message.data
}
private static func write(_ value: IPPValue, name: String, into out: inout [UInt8]) {
out.append(value.tag.rawValue)
out.appendLengthPrefixed(Array(name.utf8))
switch value {
case .integer(let i), .enumeration(let i):
out.appendUInt16(4)
out.appendInt32(i)
case .boolean(let b):
out.appendUInt16(1)
out.append(b ? 1 : 0)
case .string(_, let s):
out.appendLengthPrefixed(Array(s.utf8))
case .stringWithLanguage(_, let language, let s):
var payload: [UInt8] = []
payload.appendLengthPrefixed(Array(language.utf8))
payload.appendLengthPrefixed(Array(s.utf8))
out.appendLengthPrefixed(payload)
case .octetString(let d), .other(_, let d):
out.appendLengthPrefixed([UInt8](d))
case .dateTime(let date):
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: 0)!
let c = cal.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: date)
var payload: [UInt8] = []
payload.appendUInt16(UInt16(c.year ?? 1970))
payload += [c.month, c.day, c.hour, c.minute, c.second].map { UInt8($0 ?? 0) }
payload += [UInt8((c.nanosecond ?? 0) / 100_000_000), UInt8(ascii: "+"), 0, 0]
out.appendLengthPrefixed(payload)
case .resolution(let x, let y, let units):
out.appendUInt16(9)
out.appendInt32(x)
out.appendInt32(y)
out.append(units)
case .range(let lower, let upper):
out.appendUInt16(8)
out.appendInt32(lower)
out.appendInt32(upper)
case .outOfBand:
out.appendUInt16(0)
case .collection(let members):
out.appendUInt16(0)
for member in members {
out.append(IPPTag.memberAttrName.rawValue)
out.appendUInt16(0)
out.appendLengthPrefixed(Array(member.name.utf8))
for memberValue in member.values {
write(memberValue, name: "", into: &out)
}
}
out.append(IPPTag.endCollection.rawValue)
out.appendUInt16(0)
out.appendUInt16(0)
}
}
}
/// RFC 8010 wire-format decoder.
struct IPPDecoder {
enum DecodeError: Error, CustomStringConvertible {
case truncated(offset: Int)
case malformed(String, offset: Int)
var description: String {
switch self {
case .truncated(let offset): return "IPP response truncated at byte \(offset)"
case .malformed(let what, let offset): return "malformed IPP response at byte \(offset): \(what)"
}
}
}
private let bytes: [UInt8]
private var offset = 0
static func decode(_ data: Data) throws -> IPPMessage {
var decoder = IPPDecoder(bytes: [UInt8](data))
return try decoder.decodeMessage()
}
private init(bytes: [UInt8]) {
self.bytes = bytes
}
private mutating func decodeMessage() throws -> IPPMessage {
let major = try readUInt8()
let minor = try readUInt8()
var message = IPPMessage(code: try readUInt16())
message.versionMajor = major
message.versionMinor = minor
message.requestID = try readInt32()
var current: IPPGroup?
while true {
let tag = IPPTag(rawValue: try readUInt8())
if tag.isDelimiter {
if let group = current { message.groups.append(group) }
current = nil
if tag == .endOfAttributes { break }
current = IPPGroup(tag: tag, attributes: [])
continue
}
guard current != nil else {
throw DecodeError.malformed("value tag 0x\(String(tag.rawValue, radix: 16)) outside a group", offset: offset)
}
let name = try readString(length: Int(try readUInt16()))
let value = try readValue(tag: tag)
if name.isEmpty {
guard !current!.attributes.isEmpty else {
throw DecodeError.malformed("additional value with no preceding attribute", offset: offset)
}
current!.attributes[current!.attributes.count - 1].values.append(value)
} else {
current!.attributes.append(IPPAttribute(name: name, values: [value]))
}
}
message.data = Data(bytes[offset...])
return message
}
/// Reads value-length + value for `tag` (the name has already been consumed).
private mutating func readValue(tag: IPPTag) throws -> IPPValue {
let length = Int(try readUInt16())
if tag == .begCollection {
_ = try readBytes(length)
return .collection(try readCollectionMembers())
}
// Rebase to 0 so v[0], v[8] etc. index the value, not the whole response.
let v = Array(try readBytes(length))[...]
switch tag {
case _ where tag.isOutOfBand:
return .outOfBand(tag)
case .integer where length == 4:
return .integer(Self.int32(v, 0))
case .enumeration where length == 4:
return .enumeration(Self.int32(v, 0))
case .boolean where length == 1:
return .boolean(v[0] != 0)
case .octetString:
return .octetString(Data(v))
case .dateTime where length == 11:
return Self.date(v).map { .dateTime($0) } ?? .other(tag, Data(v))
case .resolution where length == 9:
return .resolution(x: Self.int32(v, 0), y: Self.int32(v, 4), units: v[8])
case .rangeOfInteger where length == 8:
return .range(Self.int32(v, 0), Self.int32(v, 4))
case .textWithLanguage, .nameWithLanguage:
guard length >= 4 else { return .other(tag, Data(v)) }
let langLength = Int(UInt16(v[0]) << 8 | UInt16(v[1]))
guard 2 + langLength + 2 <= length else { return .other(tag, Data(v)) }
let language = String(decoding: v[2 ..< 2 + langLength], as: UTF8.self)
let textStart = 2 + langLength
let textLength = Int(UInt16(v[textStart]) << 8 | UInt16(v[textStart + 1]))
guard textStart + 2 + textLength <= length else { return .other(tag, Data(v)) }
let text = String(decoding: v[textStart + 2 ..< textStart + 2 + textLength], as: UTF8.self)
return .stringWithLanguage(tag, language: language, text)
case _ where tag.isCharacterString:
return .string(tag, String(decoding: v, as: UTF8.self))
default:
return .other(tag, Data(v))
}
}
/// Called just after a begCollection value; consumes through the matching endCollection.
private mutating func readCollectionMembers() throws -> [IPPAttribute] {
var members: [IPPAttribute] = []
while true {
let tag = IPPTag(rawValue: try readUInt8())
guard !tag.isDelimiter else {
throw DecodeError.malformed("delimiter inside collection", offset: offset)
}
_ = try readBytes(Int(try readUInt16())) // name, always empty inside a collection
if tag == .endCollection {
_ = try readBytes(Int(try readUInt16()))
return members
}
if tag == .memberAttrName {
let memberName = try readString(length: Int(try readUInt16()))
members.append(IPPAttribute(name: memberName, values: []))
continue
}
guard !members.isEmpty else {
throw DecodeError.malformed("collection value before memberAttrName", offset: offset)
}
let value = try readValue(tag: tag)
members[members.count - 1].values.append(value)
}
}
// MARK: primitives
private mutating func readBytes(_ count: Int) throws -> ArraySlice<UInt8> {
guard offset + count <= bytes.count else { throw DecodeError.truncated(offset: offset) }
defer { offset += count }
return bytes[offset ..< offset + count]
}
private mutating func readUInt8() throws -> UInt8 { try readBytes(1).first! }
private mutating func readUInt16() throws -> UInt16 {
let b = try readBytes(2)
return UInt16(b[b.startIndex]) << 8 | UInt16(b[b.startIndex + 1])
}
private mutating func readInt32() throws -> Int32 { Self.int32(try readBytes(4), 0) }
private mutating func readString(length: Int) throws -> String {
String(decoding: try readBytes(length), as: UTF8.self)
}
private static func int32(_ b: ArraySlice<UInt8>, _ at: Int) -> Int32 {
let i = b.startIndex + at
return Int32(bitPattern: UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]))
}
/// RFC 2579 DateAndTime (11 octets).
private static func date(_ b: ArraySlice<UInt8>) -> Date? {
let s = b.startIndex
var c = DateComponents()
c.year = Int(UInt16(b[s]) << 8 | UInt16(b[s + 1]))
c.month = Int(b[s + 2])
c.day = Int(b[s + 3])
c.hour = Int(b[s + 4])
c.minute = Int(b[s + 5])
c.second = Int(b[s + 6])
c.nanosecond = Int(b[s + 7]) * 100_000_000
let sign = b[s + 8] == UInt8(ascii: "-") ? -1 : 1
let utcOffset = sign * (Int(b[s + 9]) * 3600 + Int(b[s + 10]) * 60)
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: utcOffset) ?? TimeZone(secondsFromGMT: 0)!
return cal.date(from: c)
}
}
private extension Array where Element == UInt8 {
mutating func appendUInt16(_ v: UInt16) {
append(UInt8(v >> 8))
append(UInt8(v & 0xFF))
}
mutating func appendInt32(_ v: Int32) {
let u = UInt32(bitPattern: v)
append(contentsOf: [UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)])
}
mutating func appendLengthPrefixed(_ payload: [UInt8]) {
appendUInt16(UInt16(payload.count))
append(contentsOf: payload)
}
}

View File

@@ -0,0 +1,164 @@
import Foundation
/// IPP tag byte (RFC 8010 §3.5). A struct rather than an enum so unknown tags survive decoding.
public struct IPPTag: RawRepresentable, Hashable {
public let rawValue: UInt8
public init(rawValue: UInt8) { self.rawValue = rawValue }
// Delimiter tags
public static let operationAttributes = IPPTag(rawValue: 0x01)
public static let jobAttributes = IPPTag(rawValue: 0x02)
public static let endOfAttributes = IPPTag(rawValue: 0x03)
public static let printerAttributes = IPPTag(rawValue: 0x04)
public static let unsupportedAttributes = IPPTag(rawValue: 0x05)
// Out-of-band values
public static let unsupported = IPPTag(rawValue: 0x10)
public static let unknown = IPPTag(rawValue: 0x12)
public static let noValue = IPPTag(rawValue: 0x13)
// Integer values
public static let integer = IPPTag(rawValue: 0x21)
public static let boolean = IPPTag(rawValue: 0x22)
public static let enumeration = IPPTag(rawValue: 0x23)
// Octet-string values
public static let octetString = IPPTag(rawValue: 0x30)
public static let dateTime = IPPTag(rawValue: 0x31)
public static let resolution = IPPTag(rawValue: 0x32)
public static let rangeOfInteger = IPPTag(rawValue: 0x33)
public static let begCollection = IPPTag(rawValue: 0x34)
public static let textWithLanguage = IPPTag(rawValue: 0x35)
public static let nameWithLanguage = IPPTag(rawValue: 0x36)
public static let endCollection = IPPTag(rawValue: 0x37)
// Character-string values
public static let textWithoutLanguage = IPPTag(rawValue: 0x41)
public static let nameWithoutLanguage = IPPTag(rawValue: 0x42)
public static let keyword = IPPTag(rawValue: 0x44)
public static let uri = IPPTag(rawValue: 0x45)
public static let uriScheme = IPPTag(rawValue: 0x46)
public static let charset = IPPTag(rawValue: 0x47)
public static let naturalLanguage = IPPTag(rawValue: 0x48)
public static let mimeMediaType = IPPTag(rawValue: 0x49)
public static let memberAttrName = IPPTag(rawValue: 0x4A)
public static let extensionTag = IPPTag(rawValue: 0x7F)
public var isDelimiter: Bool { rawValue < 0x10 }
public var isOutOfBand: Bool { (0x10...0x1F).contains(rawValue) }
public var isCharacterString: Bool { (0x40...0x5F).contains(rawValue) }
}
public enum IPPValue {
case integer(Int32)
case boolean(Bool)
case enumeration(Int32)
/// textWithoutLanguage, nameWithoutLanguage, keyword, uri, charset, mimeMediaType, ...
case string(IPPTag, String)
/// textWithLanguage / nameWithLanguage
case stringWithLanguage(IPPTag, language: String, String)
case octetString(Data)
case dateTime(Date)
case resolution(x: Int32, y: Int32, units: UInt8)
case range(Int32, Int32)
case collection([IPPAttribute])
case outOfBand(IPPTag)
case other(IPPTag, Data)
public var tag: IPPTag {
switch self {
case .integer: return .integer
case .boolean: return .boolean
case .enumeration: return .enumeration
case .string(let t, _): return t
case .stringWithLanguage(let t, _, _): return t
case .octetString: return .octetString
case .dateTime: return .dateTime
case .resolution: return .resolution
case .range: return .rangeOfInteger
case .collection: return .begCollection
case .outOfBand(let t): return t
case .other(let t, _): return t
}
}
public var stringValue: String? {
switch self {
case .string(_, let s), .stringWithLanguage(_, _, let s): return s
case .octetString(let d): return String(data: d, encoding: .utf8)
default: return nil
}
}
public var intValue: Int? {
switch self {
case .integer(let i), .enumeration(let i): return Int(i)
default: return nil
}
}
public var boolValue: Bool? {
if case .boolean(let b) = self { return b }
return nil
}
}
public struct IPPAttribute {
public var name: String
public var values: [IPPValue]
public init(name: String, values: [IPPValue]) {
self.name = name
self.values = values
}
public static func string(_ name: String, _ tag: IPPTag, _ values: [String]) -> IPPAttribute {
IPPAttribute(name: name, values: values.map { .string(tag, $0) })
}
public static func boolean(_ name: String, _ value: Bool) -> IPPAttribute {
IPPAttribute(name: name, values: [.boolean(value)])
}
}
/// One attribute group (operation, printer, job, ...) in a message.
public struct IPPGroup {
public var tag: IPPTag
public var attributes: [IPPAttribute]
public subscript(name: String) -> IPPAttribute? {
attributes.first { $0.name == name }
}
public func string(_ name: String) -> String? { self[name]?.values.first?.stringValue }
public func strings(_ name: String) -> [String] { self[name]?.values.compactMap(\.stringValue) ?? [] }
public func int(_ name: String) -> Int? { self[name]?.values.first?.intValue }
public func ints(_ name: String) -> [Int] { self[name]?.values.compactMap(\.intValue) ?? [] }
public func bool(_ name: String) -> Bool? { self[name]?.values.first?.boolValue }
}
public struct IPPMessage {
public var versionMajor: UInt8 = 2
public var versionMinor: UInt8 = 0
/// operation-id in a request, status-code in a response
public var code: UInt16
public var requestID: Int32 = 1
public var groups: [IPPGroup] = []
public var data = Data()
/// Status codes 0x0000-0x00FF are successful-ok-*.
public var isSuccess: Bool { code < 0x0100 }
public func groups(_ tag: IPPTag) -> [IPPGroup] { groups.filter { $0.tag == tag } }
public var operationGroup: IPPGroup? { groups.first { $0.tag == .operationAttributes } }
}
public enum IPPOperation {
public static let cancelJob: UInt16 = 0x0008
public static let getJobAttributes: UInt16 = 0x0009
public static let getJobs: UInt16 = 0x000A
public static let getPrinterAttributes: UInt16 = 0x000B
public static let cupsGetDefault: UInt16 = 0x4001
public static let cupsGetPrinters: UInt16 = 0x4002
}

View File

@@ -0,0 +1,175 @@
import Foundation
/// Minimal HTTP/1.1 client (POST for IPP, GET for PPDs) over a Unix domain socket.
///
/// Why not URLSession to localhost:631: on macOS 27 launchd only socket-activates cupsd on
/// /private/var/run/cupsd. TCP 631 is bound by cupsd itself and disappears when cupsd idle-exits,
/// so connecting over TCP fails whenever the scheduler is asleep. URLSession can't do AF_UNIX.
enum UnixSocketHTTP {
struct Response {
let status: Int
let body: Data
}
static func post(socketPath: String, resource: String, contentType: String, body: Data) throws -> Response {
try request(method: "POST", socketPath: socketPath, resource: resource, contentType: contentType, body: body)
}
static func get(socketPath: String, resource: String) throws -> Response {
try request(method: "GET", socketPath: socketPath, resource: resource, contentType: nil, body: Data())
}
private static func request(method: String, socketPath: String, resource: String, contentType: String?,
body: Data, timeoutSeconds: Int = 15) throws -> Response {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw failure("socket()") }
defer { close(fd) }
var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0)
let timevalSize = socklen_t(MemoryLayout<timeval>.size)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timevalSize)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timevalSize)
var noSigPipe: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let path = Array(socketPath.utf8) + [0]
let capacity = MemoryLayout.size(ofValue: address.sun_path)
guard path.count <= capacity else { throw CupsAdminError.transport("socket path too long: \(socketPath)") }
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) }
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
let connected = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { throw failure("connect to \(socketPath)") }
var head = "\(method) \(resource) HTTP/1.1\r\nHost: localhost\r\n"
if let contentType {
head += "Content-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
}
head += "Connection: close\r\n\r\n"
try writeAll(fd, [UInt8](Data(head.utf8) + body))
var reader = Reader(fd: fd)
let statusLine = try reader.readLine()
let statusParts = statusLine.split(separator: " ", maxSplits: 2)
guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else {
throw CupsAdminError.transport("bad HTTP status line from cupsd: \(statusLine)")
}
var headers: [String: String] = [:]
while true {
let line = try reader.readLine()
if line.isEmpty { break }
guard let colon = line.firstIndex(of: ":") else { continue }
let name = line[..<colon].lowercased()
headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
let payload: [UInt8]
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
var chunks: [UInt8] = []
while true {
let sizeLine = try reader.readLine()
let hex = sizeLine.split(separator: ";").first.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? ""
guard let size = Int(hex, radix: 16) else {
throw CupsAdminError.transport("bad chunk size from cupsd: \(sizeLine)")
}
if size == 0 {
while try !reader.readLine().isEmpty {} // trailers
break
}
chunks += try reader.readBytes(size)
_ = try reader.readLine()
}
payload = chunks
} else if let length = headers["content-length"].flatMap(Int.init) {
payload = try reader.readBytes(length)
} else {
payload = try reader.readToEnd()
}
return Response(status: status, body: Data(payload))
}
private static func writeAll(_ fd: Int32, _ bytes: [UInt8]) throws {
var sent = 0
while sent < bytes.count {
let n = bytes[sent...].withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw failure("write to cupsd")
}
sent += n
}
}
fileprivate static func failure(_ what: String) -> CupsAdminError {
let code = errno
let reason: String
switch code {
case ENOENT: reason = "socket not found (is CUPS installed?)"
case ECONNREFUSED: reason = "connection refused (cupsd not running?)"
case EAGAIN: reason = "timed out"
default: reason = String(cString: strerror(code))
}
return CupsAdminError.transport("\(what): \(reason)")
}
private struct Reader {
let fd: Int32
private var buffer: [UInt8] = []
private var position = 0
init(fd: Int32) { self.fd = fd }
/// Returns false at EOF.
private mutating func fill() throws -> Bool {
if position > 0 {
buffer.removeFirst(position)
position = 0
}
var chunk = [UInt8](repeating: 0, count: 65_536)
while true {
let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw UnixSocketHTTP.failure("read from cupsd")
}
if n == 0 { return false }
buffer += chunk[0 ..< n]
return true
}
}
mutating func readLine() throws -> String {
while true {
if let lf = buffer[position...].firstIndex(of: UInt8(ascii: "\n")) {
var end = lf
if end > position, buffer[end - 1] == UInt8(ascii: "\r") { end -= 1 }
let line = String(decoding: buffer[position ..< end], as: UTF8.self)
position = lf + 1
return line
}
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
}
mutating func readBytes(_ count: Int) throws -> [UInt8] {
while buffer.count - position < count {
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
defer { position += count }
return Array(buffer[position ..< position + count])
}
mutating func readToEnd() throws -> [UInt8] {
while try fill() {}
defer { position = buffer.count }
return Array(buffer[position...])
}
}
}

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

173
Sources/CupsKit/PPD.swift Normal file
View File

@@ -0,0 +1,173 @@
import Foundation
/// PPD parsing for what `lpoptions -l` and the CUPS web UI's "Set Default Options" page showed:
/// UI options with their groups, UI types, choices, defaults and custom-value parameters.
public struct PPD {
public enum UIType: String {
case pickOne = "PickOne"
case pickMany = "PickMany"
case boolean = "Boolean"
case unknown
}
public struct Choice: Hashable {
public let keyword: String
/// Translation text after the slash, or the keyword when there is none.
public let text: String
public init(keyword: String, text: String) {
self.keyword = keyword
self.text = text
}
}
/// One `*ParamCustomX Name/Text: order type min max` line.
public struct CustomParameter: Hashable {
public let name: String
public let text: String
public let order: Int
/// PPD type as written: string, passcode, password, int, real, points, curve, invcurve.
public let type: String
public let minimum: String
public let maximum: String
}
public struct Option {
public let keyword: String
public let text: String
public var choices: [String] = []
public var defaultChoice: String?
/// "Custom.INTEGER", "Custom.WIDTHxHEIGHT", ... when the PPD allows a custom value.
public var customChoice: String?
/// `*OpenGroup` label (with " / subgroup" when nested); "General" for ungrouped options.
public var group: String = PPD.generalGroup
public var uiType: UIType = .unknown
public var choiceList: [Choice] = []
/// Parameters from `*ParamCustomX`, in order; empty unless the PPD has `*CustomX True`.
public var customParameters: [CustomParameter] = []
public var isJCL = false
}
/// Where libcups and the CUPS web UI put options that aren't inside an `*OpenGroup`.
public static let generalGroup = "General"
public private(set) var options: [Option] = []
/// Group names in first-appearance order ("General" where the first ungrouped option appears).
public private(set) var groups: [String] = []
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
public private(set) var attributes: [String: String] = [:]
public init(text: String) {
var defaults: [String: String] = [:]
var customKeywords: Set<String> = []
var customParams: [String: [CustomParameter]] = [:]
var groupStack: [String] = []
var current: Option?
var inQuotedValue = false
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
let line = String(rawLine)
// Multi-line quoted values (invocation code, etc.) — skip until the closing quote.
if inQuotedValue {
if line.contains("\"") { inQuotedValue = false }
continue
}
guard line.hasPrefix("*"), !line.hasPrefix("*%"), let colon = line.firstIndex(of: ":") else { continue }
let key = line[line.index(after: line.startIndex) ..< colon] // "PageSize Letter/US Letter"
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
if value.hasPrefix("\""), value.filter({ $0 == "\"" }).count == 1 { inQuotedValue = true }
let parts = key.split(separator: " ", maxSplits: 1)
let mainKey = String(parts[0])
switch mainKey {
case "OpenGroup", "OpenSubGroup":
// *OpenGroup: InstallableOptions/Installable Options
let (name, label) = Self.splitText(value)
groupStack.append(label ?? name)
case "CloseGroup", "CloseSubGroup":
_ = groupStack.popLast()
case "OpenUI", "JCLOpenUI":
// *OpenUI *PageSize/Media Size: PickOne
guard parts.count == 2 else { continue }
let spec = parts[1].trimmingCharacters(in: .whitespaces).drop { $0 == "*" }
let nameAndText = spec.split(separator: "/", maxSplits: 1)
let keyword = String(nameAndText[0])
let text = nameAndText.count > 1 ? String(nameAndText[1]) : Self.standardText[keyword] ?? keyword
current = Option(keyword: keyword, text: text)
current!.group = groupStack.isEmpty ? Self.generalGroup : groupStack.joined(separator: " / ")
current!.uiType = UIType(rawValue: value) ?? .unknown
current!.isJCL = mainKey == "JCLOpenUI"
case "CloseUI", "JCLCloseUI":
if let option = current { options.append(option) }
current = nil
default:
if mainKey.hasPrefix("ParamCustom"), parts.count == 2 {
// *ParamCustomRIAngel Angel/Text: 1 int 0 359
let fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
let (name, label) = Self.splitText(String(parts[1]))
if fields.count >= 2, let order = Int(fields[0]) {
customParams[String(mainKey.dropFirst("ParamCustom".count)), default: []].append(
CustomParameter(name: name, text: label ?? name, order: order, type: fields[1],
minimum: fields.count > 2 ? fields[2] : "",
maximum: fields.count > 3 ? fields[3] : ""))
}
} else if mainKey.hasPrefix("Custom"), parts.count == 2, Self.splitText(String(parts[1])).name == "True" {
customKeywords.insert(String(mainKey.dropFirst("Custom".count)))
} else if mainKey.hasPrefix("Default"), parts.count == 1 {
defaults[String(mainKey.dropFirst("Default".count))] = value
} else if parts.count == 2, let option = current, mainKey == option.keyword {
let (choice, label) = Self.splitText(String(parts[1]))
current!.choices.append(choice)
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
} else if parts.count == 1, attributes[mainKey] == nil {
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
}
if let option = current { options.append(option) }
// lpoptions hides PageRegion (it mirrors PageSize).
options.removeAll { $0.keyword == "PageRegion" }
for i in options.indices {
let keyword = options[i].keyword
options[i].defaultChoice = defaults[keyword]
if !groups.contains(options[i].group) { groups.append(options[i].group) }
guard customKeywords.contains(keyword) else { continue }
let params = (customParams[keyword] ?? []).sorted { $0.order < $1.order }
options[i].customParameters = params
if keyword == "PageSize" {
options[i].customChoice = "Custom.WIDTHxHEIGHT"
} else if params.count == 1 {
options[i].customChoice = "Custom.\(Self.lpoptionsTypeName(params[0].type))"
} else if params.count > 1 {
options[i].customChoice = "{" + params.map { "\($0.name)=\(Self.lpoptionsTypeName($0.type))" }
.joined(separator: " ") + "}"
}
}
}
/// Labels libcups gives options whose *OpenUI line has no translation text.
private static let standardText = ["PageSize": "Media Size", "MediaType": "Media Type",
"InputSlot": "Media Source", "ColorModel": "Output Mode",
"Resolution": "Resolution"]
/// How `lpoptions -l` spells a custom parameter type.
private static func lpoptionsTypeName(_ type: String) -> String {
type == "int" ? "INTEGER" : type.uppercased()
}
/// "Letter/US Letter" -> ("Letter", "US Letter"); "Letter" -> ("Letter", nil).
private static func splitText(_ s: String) -> (name: String, text: String?) {
let pieces = s.split(separator: "/", maxSplits: 1)
let name = pieces.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? ""
let text = pieces.count > 1 ? pieces[1].trimmingCharacters(in: .whitespaces) : nil
return (name, text)
}
public func option(_ keyword: String) -> Option? {
options.first { $0.keyword == keyword }
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// CUPS printer-type bits (cups/cups.h cups_ptype_e) we surface.
public enum PrinterType {
public static let isClass = 0x0000_0001
public static let remote = 0x0000_0002
public static let bw = 0x0000_0004
public static let color = 0x0000_0008
public static let duplex = 0x0000_0010
public static let staple = 0x0000_0020
public static let fax = 0x0004_0000
public static let discovered = 0x0100_0000
public static let scanner = 0x0200_0000
public static let mfp = 0x0400_0000
public static func kind(_ type: Int?) -> String {
guard let type else { return "?" }
var parts = [type & isClass != 0 ? "class" : "printer"]
if type & remote != 0 { parts.append("remote") }
if type & discovered != 0 { parts.append("discovered") }
if type & fax != 0 { parts.append("fax") }
return parts.joined(separator: ",")
}
public static func capabilities(_ type: Int?) -> String {
guard let type else { return "?" }
var caps: [String] = []
if type & color != 0 { caps.append("color") } else if type & bw != 0 { caps.append("b/w") }
if type & duplex != 0 { caps.append("duplex") }
if type & staple != 0 { caps.append("staple") }
if type & scanner != 0 { caps.append("scanner") }
if type & mfp != 0 { caps.append("mfp") }
return caps.isEmpty ? "-" : caps.joined(separator: ", ")
}
}

View File

@@ -0,0 +1,268 @@
import CoreGraphics
import Foundation
/// One job as the Jobs table shows it.
public struct JobSummary: Identifiable, Hashable {
public let id: Int
public let name: String
public let user: String
public let queue: String
/// Size in kilobytes (job-k-octets).
public let sizeK: Int
/// Pages printed or counted so far, when cupsd knows.
public let pages: Int?
public let state: Int
public let reasons: [String]
public let submitted: Date?
public static let requestedAttributes = [
"job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-k-octets",
"job-media-sheets-completed", "job-impressions-completed", "job-impressions",
"job-state", "job-state-reasons", "time-at-creation",
]
public init(_ job: IPPGroup) {
id = job.int("job-id") ?? 0
name = job.string("job-name") ?? ""
user = job.string("job-originating-user-name") ?? ""
queue = Self.queueName(fromPrinterURI: job.string("job-printer-uri")) ?? ""
sizeK = job.int("job-k-octets") ?? 0
pages = [job.int("job-impressions-completed"), job.int("job-media-sheets-completed"), job.int("job-impressions")]
.compactMap { $0 }.first { $0 > 0 }
state = job.int("job-state") ?? 0
reasons = job.strings("job-state-reasons").filter { $0 != "none" }
submitted = job.int("time-at-creation").flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
}
/// pending, held, processing, stopped, canceled, aborted, completed.
public var stateName: String { Format.jobState(state) }
public var isHeld: Bool { state == 4 }
/// pending / held / processing / stopped: cancel, hold, release and move still make sense.
public var isActive: Bool { state >= 3 && state <= 6 }
/// ipp://host/printers/NAME -> NAME.
public static func queueName(fromPrinterURI uri: String?) -> String? {
guard let uri, let last = uri.split(separator: "/").last else { return nil }
return last.removingPercentEncoding ?? String(last)
}
}
public enum PrinterAction: CaseIterable {
case pause, resume, accept, reject, cancelAllJobs
public var title: String {
switch self {
case .pause: return "Pause"
case .resume: return "Resume"
case .accept: return "Accept Jobs"
case .reject: return "Reject Jobs"
case .cancelAllJobs: return "Cancel All Jobs"
}
}
public var systemImage: String {
switch self {
case .pause: return "pause.circle"
case .resume: return "play.circle"
case .accept: return "checkmark.circle"
case .reject: return "nosign"
case .cancelAllJobs: return "xmark.circle"
}
}
/// "Paused", "Resumed", ... for status lines.
public var pastTense: String {
switch self {
case .pause: return "Paused"
case .resume: return "Resumed"
case .accept: return "Accepting jobs on"
case .reject: return "Rejecting jobs on"
case .cancelAllJobs: return "Canceled all jobs on"
}
}
}
public enum JobAction: Hashable {
case cancel, hold, release
case move(to: String)
public var title: String {
switch self {
case .cancel: return "Cancel"
case .hold: return "Hold"
case .release: return "Release"
case .move(let destination): return "Move to \(destination)"
}
}
public var pastTense: String {
switch self {
case .cancel: return "Canceled"
case .hold: return "Held"
case .release: return "Released"
case .move(let destination): return "Moved to \(destination):"
}
}
}
/// The outcome of an action after reading cupsd back.
public struct ActionOutcome: Hashable {
public let succeeded: Bool
/// The command that ran (shell-style), or the IPP operation.
public let command: String
/// Why it failed: tool output, IPP status, or what the read-back found.
public let message: String?
}
/// Pause/resume/accept/reject/cancel-all and job cancel/hold/release/move, each verified against cupsd.
public enum QueueActions {
public static func perform(_ action: PrinterAction, queue: String, client: CupsClient) async throws -> ActionOutcome {
let result: ToolResult = try await Task.detached {
switch action {
case .pause: return try CupsTools.disable(queue: queue)
case .resume: return try CupsTools.enable(queue: queue)
case .accept: return try CupsTools.accept(queue: queue)
case .reject: return try CupsTools.reject(queue: queue)
case .cancelAllJobs: return try CupsTools.cancelAll(queue: queue)
}
}.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine,
message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
let problem: String?
switch action {
case .pause, .resume, .accept, .reject:
let printer = try await client.getPrinterAttributes(queue: queue, requested: ["printer-state", "printer-is-accepting-jobs"])
let stopped = printer.int("printer-state") == 5
let accepting = printer.bool("printer-is-accepting-jobs") ?? true
switch action {
case .pause: problem = stopped ? nil : "still \(Format.printerState(printer.int("printer-state")))"
case .resume: problem = stopped ? "still stopped" : nil
case .accept: problem = accepting ? nil : "still rejecting jobs"
default: problem = accepting ? "still accepting jobs" : nil
}
case .cancelAllJobs:
let left = try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).count
problem = left == 0 ? nil : "\(left) job\(left == 1 ? "" : "s") still active"
}
return ActionOutcome(succeeded: problem == nil, command: result.commandLine, message: problem.map { "Not applied: \($0)" })
}
/// Runs the action on each job, then reads each job back. Returns one outcome per job id, in order.
public static func perform(_ action: JobAction, jobIDs: [Int], client: CupsClient) async -> [Int: ActionOutcome] {
var outcomes: [Int: ActionOutcome] = [:]
for id in jobIDs {
outcomes[id] = await perform(action, jobID: id, client: client)
}
return outcomes
}
static func perform(_ action: JobAction, jobID: Int, client: CupsClient) async -> ActionOutcome {
var command = ""
do {
switch action {
case .cancel:
// Same IPP Cancel-Job the CLI's `cupsadmin cancel` uses.
command = "IPP Cancel-Job \(jobID)"
try await client.cancelJob(jobID: jobID)
case .hold, .release, .move:
let result: ToolResult = try await Task.detached {
switch action {
case .hold: return try CupsTools.hold(jobID: jobID)
case .release: return try CupsTools.release(jobID: jobID)
case .move(let destination): return try CupsTools.move(jobID: jobID, to: destination)
case .cancel: fatalError("handled above")
}
}.value
command = result.commandLine
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: command,
message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
}
} catch {
return ActionOutcome(succeeded: false, command: command, message: String(describing: error))
}
do {
let job = JobSummary(try await client.getJobAttributes(jobID: jobID, requested: JobSummary.requestedAttributes))
let problem: String?
switch action {
case .cancel: problem = job.state == 7 ? nil : "still \(job.stateName)"
case .hold: problem = job.isHeld ? nil : "still \(job.stateName)"
case .release: problem = job.isHeld ? "still held" : nil
case .move(let destination): problem = job.queue == destination ? nil : "still on \(job.queue)"
}
return ActionOutcome(succeeded: problem == nil, command: command, message: problem.map { "Not applied: \($0)" })
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound && action == .cancel {
// Purged from history right away: canceled.
return ActionOutcome(succeeded: true, command: command, message: nil)
} catch {
return ActionOutcome(succeeded: false, command: command, message: "Couldn’t read job \(jobID) back: \(error)")
}
}
}
// MARK: - Default, delete, test page
extension QueueActions {
/// `lpadmin -d queue`, verified with CUPS-Get-Default.
public static func setDefault(queue: String, client: CupsClient) async throws -> ActionOutcome {
let result = try await Task.detached { try CupsTools.setDefault(queue: queue) }.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
let current = try await client.getDefaultPrinter()
return ActionOutcome(succeeded: current == queue, command: result.commandLine,
message: current == queue ? nil : "Not applied: server default is \(current ?? "none")")
}
/// `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)
}
let stillThere = try await client.queueExists(queue)
return ActionOutcome(succeeded: !stillThere, command: result.commandLine,
message: stillThere ? "Not applied: \(queue) still exists" : nil)
}
/// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified.
public static func printTestPage(queue: String, client: CupsClient) async throws -> (outcome: ActionOutcome, jobID: Int?) {
let file = FileManager.default.fileExists(atPath: CupsTools.testPagePath)
? CupsTools.testPagePath : try generatedTestPage(queue: queue)
let result = try await Task.detached { try CupsTools.printTestPage(queue: queue, file: file) }.value
guard result.succeeded else {
return (ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message), nil)
}
// "request id is Q-12 (1 file(s))"
let text = String(decoding: result.standardOutput, as: UTF8.self)
guard let token = text.split(separator: " ").first(where: { $0.hasPrefix(queue + "-") }),
let id = Int(token.dropFirst(queue.count + 1)) else {
return (ActionOutcome(succeeded: false, command: result.commandLine, message: "lp didn’t report a job id: \(text)"), nil)
}
let job = JobSummary(try await client.getJobAttributes(jobID: id, requested: JobSummary.requestedAttributes))
let ok = job.queue == queue
return (ActionOutcome(succeeded: ok, command: result.commandLine, message: ok ? nil : "Job \(id) isn’t on \(queue)"), id)
}
private static func generatedTestPage(queue: String) throws -> String {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-testpage.pdf")
var box = CGRect(x: 0, y: 0, width: 612, height: 792)
guard let context = CGContext(url as CFURL, mediaBox: &box, nil) else {
throw CupsAdminError.failed("couldn’t create a test page PDF")
}
context.beginPDFPage(nil)
context.setLineWidth(4)
context.stroke(box.insetBy(dx: 36, dy: 36))
context.move(to: CGPoint(x: 36, y: 36)); context.addLine(to: CGPoint(x: 576, y: 756))
context.move(to: CGPoint(x: 36, y: 756)); context.addLine(to: CGPoint(x: 576, y: 36))
context.strokePath()
context.endPDFPage()
context.closePDF()
return url.path
}
}

View File

@@ -0,0 +1,152 @@
import Foundation
/// What `add` / `set` were asked to change. Maps 1:1 onto lpadmin flags.
public struct QueueSpec {
public var deviceURI: String?
public var model: String?
public var ppdFile: String?
public var description: String?
public var location: String?
public var options: [(key: String, value: String)] = []
public init() {}
public var isEmpty: Bool {
deviceURI == nil && model == nil && ppdFile == nil && description == nil && location == nil && options.isEmpty
}
public func lpadminArguments(queue: String) -> [String] {
var args = ["-p", queue]
if let deviceURI { args += ["-v", deviceURI] }
if let model { args += ["-m", model] }
if let ppdFile { args += ["-P", ppdFile] }
if let description { args += ["-D", description] }
if let location { args += ["-L", location] }
for option in options { args += ["-o", "\(option.key)=\(option.value)"] }
return args
}
}
/// One requested change and where it landed.
public struct SettingCheck {
public let name: String
public let source: String
public let value: String?
public let wanted: String
public var applied: Bool { QueueSnapshot.matches(value, wanted) }
}
/// Queue state used to verify that lpadmin actually applied each change.
public struct QueueSnapshot {
public let attributes: IPPGroup
public let ppd: PPD?
public static func load(client: CupsClient, queue: String) async throws -> QueueSnapshot {
QueueSnapshot(attributes: try await client.getPrinterAttributes(queue: queue, requested: ["all"]),
ppd: try await client.getPPD(queue: queue).map(PPD.init(text:)))
}
/// Where an lpadmin `-o key=value` lands: an IPP attribute of that name, a PPD option, or key-default.
public func lookup(option key: String) -> (value: String, source: String)? {
if let attribute = attributes[key] { return (Self.join(attribute), "ipp") }
if let option = ppd?.option(key) { return (option.defaultChoice ?? "?", "ppd") }
if let attribute = attributes[key + "-default"] { return (Self.join(attribute), "ipp \(key)-default") }
return nil
}
public func settings(for spec: QueueSpec) -> [SettingCheck] {
var result: [SettingCheck] = []
if let uri = spec.deviceURI {
result.append(SettingCheck(name: "device-uri (-v)", source: "ipp", value: attributes.string("device-uri"), wanted: uri))
}
if let d = spec.description {
result.append(SettingCheck(name: "printer-info (-D)", source: "ipp", value: attributes.string("printer-info"), wanted: d))
}
if let l = spec.location {
result.append(SettingCheck(name: "printer-location (-L)", source: "ipp", value: attributes.string("printer-location"), wanted: l))
}
for option in spec.options {
let found = lookup(option: option.key)
result.append(SettingCheck(name: option.key, source: found?.source ?? "not found", value: found?.value, wanted: option.value))
}
return result
}
private static func join(_ attribute: IPPAttribute) -> String {
attribute.values.map { Format.value($0, attribute: attribute.name) }.joined(separator: ",")
}
/// Loose equality: case-insensitive, yes/on/true style booleans, and "none" matching "none,none".
public static func matches(_ actual: String?, _ wanted: String) -> Bool {
guard let actual else { return false }
func normalize(_ s: String) -> String {
let lower = s.lowercased()
return ["yes": "true", "on": "true", "no": "false", "off": "false"][lower] ?? lower
}
let a = normalize(actual), w = normalize(wanted)
if a == w { return true }
let parts = a.split(separator: ",").map(String.init)
return !w.contains(",") && parts.count > 1 && parts.allSatisfy { $0 == w }
}
}
/// Add/modify flows, split around the lpadmin run so a caller can show the command and its output in between.
public enum QueueAdmin {
/// Validates an add request and applies the sharing default. Returns the spec to hand to lpadmin.
public static func prepareAdd(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> QueueSpec {
try validateQueueName(queue)
guard spec.deviceURI != nil else { throw CupsAdminError.usage("add needs -v <device-uri>") }
// lpadmin creates queues shared by default; ours aren't unless asked (--shared or an explicit -o).
var spec = spec
if let explicit = spec.options.first(where: { $0.key == "printer-is-shared" }) {
if shared, !QueueSnapshot.matches(explicit.value, "true") {
throw CupsAdminError.usage("--shared conflicts with -o printer-is-shared=\(explicit.value)")
}
} else {
spec.options.append(("printer-is-shared", shared ? "true" : "false"))
}
if try await client.queueExists(queue) {
throw CupsAdminError.failed("queue \(queue) already exists (use set to change it)")
}
return spec
}
/// lpadmin arguments for add. -E after -p: enable and accept jobs, like the web UI's Add Printer did.
public static func addArguments(queue: String, spec: QueueSpec) -> [String] {
spec.lpadminArguments(queue: queue) + ["-E"]
}
/// After lpadmin ran for add: checks it worked and returns the new queue's state.
public static func confirmAdded(client: CupsClient, queue: String, lpadminStatus: Int32) async throws -> QueueSnapshot {
guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); queue not added") }
guard try await client.queueExists(queue) else {
throw CupsAdminError.failed("lpadmin exited 0 but \(queue) does not exist")
}
return try await QueueSnapshot.load(client: client, queue: queue)
}
/// Validates a set request and returns the current values of everything it will change.
public static func prepareSet(client: CupsClient, queue: String, spec: QueueSpec) async throws -> [SettingCheck] {
guard !spec.isEmpty else { throw CupsAdminError.usage("set needs at least one of -o, -v, -D, -L, -m, -P") }
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec)
}
/// After lpadmin ran for set: checks the exit status and returns the new values.
public static func confirmSet(client: CupsClient, queue: String, spec: QueueSpec, lpadminStatus: Int32) async throws -> [SettingCheck] {
guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); nothing verified") }
return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec)
}
/// cupsd's rules: printable, no space / slash / backslash / quote / ? / #, at most 127 bytes.
public static func validateQueueName(_ queue: String) throws {
let forbidden = Set(" \t/\\?'\"#")
guard !queue.isEmpty, queue.utf8.count <= 127,
queue.unicodeScalars.allSatisfy({ $0.value > 0x20 && $0.value != 0x7F }),
!queue.contains(where: forbidden.contains) else {
throw CupsAdminError.usage("invalid queue name \(queue): no spaces, / \\ ? ' \" #, max 127 bytes")
}
}
}

View File

@@ -0,0 +1,139 @@
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.
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.
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 <id> <queue>`.
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 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)"),
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)"),
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.<code>)"),
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)"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
effect: .options(variants: [["Duplex": "None"]], clearVariants: []),
summary: "One-sided (Duplex=None)"),
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)"),
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
effect: .defaultPrinter, 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))
}
}
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"))
}
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
}
/// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid.
public func validationError(_ value: String, ppd: PPD?) -> 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 {
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 }
return String(value.dropFirst("Custom.".count))
}
}
public struct QuickActionUnavailable: Error, Hashable {
public let reason: String
}
public struct QuickActionOutcome {
public let command: String
public let succeeded: Bool
/// Per-option read-back; empty for Make Default Printer.
public let readBack: [OptionReadBack]
public let changes: [OptionChange]
public let message: String?
}
extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options.
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome {
switch effect {
case .defaultPrinter:
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)
}
}
}