Files
cupsadmin/Sources/CupsKit/CupsTools.swift
kfattic eba94aae4e cupsadmin: CLI, CupsKit, app in progress
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:13:07 -05:00

136 lines
5.6 KiB
Swift

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