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:
135
Sources/CupsKit/CupsTools.swift
Normal file
135
Sources/CupsKit/CupsTools.swift
Normal 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: " ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user