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:
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