mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
cupsadmin: CLI, CupsKit, app in progress
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
173
Sources/CupsKit/IPP/CupsClient.swift
Normal file
173
Sources/CupsKit/IPP/CupsClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
272
Sources/CupsKit/IPP/IPPCoding.swift
Normal file
272
Sources/CupsKit/IPP/IPPCoding.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
164
Sources/CupsKit/IPP/IPPTypes.swift
Normal file
164
Sources/CupsKit/IPP/IPPTypes.swift
Normal 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
|
||||
}
|
||||
175
Sources/CupsKit/IPP/UnixSocketHTTP.swift
Normal file
175
Sources/CupsKit/IPP/UnixSocketHTTP.swift
Normal 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...])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user