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:
331
Sources/CUPSAdminApp/AddPrinterSheet.swift
Normal file
331
Sources/CUPSAdminApp/AddPrinterSheet.swift
Normal file
@@ -0,0 +1,331 @@
|
||||
import AppKit
|
||||
import CupsKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// Add Printer (manual) and Modify Printer: name, device URI, driver, description, location, shared.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AddPrinterModel: Identifiable {
|
||||
enum Driver: String, CaseIterable, Identifiable {
|
||||
case keep, everywhere, list, file
|
||||
var id: String { rawValue }
|
||||
var title: String {
|
||||
switch self {
|
||||
case .keep: return "Keep Current"
|
||||
case .everywhere: return "IPP Everywhere"
|
||||
case .list: return "Driver List"
|
||||
case .file: return "PPD File"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
let model: String
|
||||
let name: String
|
||||
var id: String { model }
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
/// nil when adding.
|
||||
let original: PrinterOriginal?
|
||||
var name = ""
|
||||
var deviceURI = ""
|
||||
var driver: Driver
|
||||
var modelSearch = ""
|
||||
var selectedModel: String?
|
||||
var ppdFile: String?
|
||||
var descriptionText = ""
|
||||
var location = ""
|
||||
var shared = false
|
||||
private(set) var models: [Model] = []
|
||||
private(set) var modelsError: String?
|
||||
private(set) var isRunning = false
|
||||
private(set) var errorText: String?
|
||||
private(set) var checks: [SettingCheck] = []
|
||||
private let store: PrinterStore
|
||||
|
||||
struct PrinterOriginal {
|
||||
let name: String
|
||||
let deviceURI: String
|
||||
let description: String
|
||||
let location: String
|
||||
let shared: Bool
|
||||
}
|
||||
|
||||
var isModify: Bool { original != nil }
|
||||
|
||||
init(store: PrinterStore, modifying printer: IPPGroup? = nil) {
|
||||
self.store = store
|
||||
if let printer {
|
||||
let original = PrinterOriginal(name: printer.string("printer-name") ?? "",
|
||||
deviceURI: printer.string("device-uri") ?? "",
|
||||
description: printer.string("printer-info") ?? "",
|
||||
location: printer.string("printer-location") ?? "",
|
||||
shared: printer.bool("printer-is-shared") ?? false)
|
||||
self.original = original
|
||||
name = original.name
|
||||
deviceURI = original.deviceURI
|
||||
descriptionText = original.description
|
||||
location = original.location
|
||||
shared = original.shared
|
||||
driver = .keep
|
||||
} else {
|
||||
original = nil
|
||||
driver = .everywhere
|
||||
}
|
||||
}
|
||||
|
||||
var drivers: [Driver] { isModify ? Driver.allCases : Driver.allCases.filter { $0 != .keep } }
|
||||
|
||||
var filteredModels: [Model] {
|
||||
let terms = modelSearch.split(separator: " ")
|
||||
guard !terms.isEmpty else { return models }
|
||||
return models.filter { m in terms.allSatisfy { m.name.localizedCaseInsensitiveContains($0) || m.model.localizedCaseInsensitiveContains($0) } }
|
||||
}
|
||||
|
||||
var nameError: String? {
|
||||
guard !isModify, !name.isEmpty else { return nil }
|
||||
do { try QueueAdmin.validateQueueName(name) } catch { return "No spaces, / \\ ? ' \" #; up to 127 bytes" }
|
||||
if store.printers.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) {
|
||||
return "A queue named \(name) already exists"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Only what changed, for Modify (so the PPD isn't regenerated needlessly); everything for Add.
|
||||
var spec: QueueSpec {
|
||||
var spec = QueueSpec()
|
||||
let uri = deviceURI.trimmingCharacters(in: .whitespaces)
|
||||
if original?.deviceURI != uri { spec.deviceURI = uri.isEmpty ? "<device-uri>" : uri }
|
||||
switch driver {
|
||||
case .keep: break
|
||||
case .everywhere: spec.model = "everywhere"
|
||||
case .list: spec.model = selectedModel
|
||||
case .file: spec.ppdFile = ppdFile
|
||||
}
|
||||
if original?.description != descriptionText, !(original == nil && descriptionText.isEmpty) { spec.description = descriptionText }
|
||||
if original?.location != location, !(original == nil && location.isEmpty) { spec.location = location }
|
||||
if let original, original.shared != shared {
|
||||
spec.options.append((key: "printer-is-shared", value: shared ? "true" : "false"))
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
var canSubmit: Bool {
|
||||
guard !isRunning, !deviceURI.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
|
||||
if !isModify, name.isEmpty || nameError != nil { return false }
|
||||
if driver == .list, selectedModel == nil { return false }
|
||||
if driver == .file, ppdFile == nil { return false }
|
||||
if isModify, spec.isEmpty { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
var command: String {
|
||||
let args: [String]
|
||||
if isModify {
|
||||
args = spec.lpadminArguments(queue: name)
|
||||
} else {
|
||||
var addSpec = spec
|
||||
addSpec.options.append((key: "printer-is-shared", value: shared ? "true" : "false"))
|
||||
args = QueueAdmin.addArguments(queue: name.isEmpty ? "<name>" : name, spec: addSpec)
|
||||
}
|
||||
return CupsTools.commandLine(CupsTools.lpadminPath, args)
|
||||
}
|
||||
|
||||
func loadModels() async {
|
||||
guard models.isEmpty else { return }
|
||||
do {
|
||||
let list = try await Task.detached { try CupsTools.models() }.value
|
||||
models = list.map { Model(model: $0.model, name: $0.name) }
|
||||
.filter { $0.model != "everywhere" && $0.model != "raw" }
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
} catch {
|
||||
modelsError = String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
func choosePPDFile() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Choose a PPD"
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = URL(fileURLWithPath: "/Library/Printers/PPDs/Contents/Resources")
|
||||
if panel.runModal() == .OK, let url = panel.url { ppdFile = url.path }
|
||||
}
|
||||
|
||||
/// Returns true when every setting read back correctly.
|
||||
func submit() async -> Bool {
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
errorText = nil
|
||||
checks = []
|
||||
let started = Date()
|
||||
let client = store.client
|
||||
do {
|
||||
if isModify {
|
||||
let spec = self.spec
|
||||
_ = try await QueueAdmin.prepareSet(client: client, queue: name, spec: spec)
|
||||
let result = try await Task.detached { [name] in try CupsTools.lpadmin(spec.lpadminArguments(queue: name)) }.value
|
||||
checks = try await QueueAdmin.confirmSet(client: client, queue: name, spec: spec, lpadminStatus: result.status)
|
||||
if !result.succeeded { errorText = result.message }
|
||||
} else {
|
||||
let prepared = try await QueueAdmin.prepareAdd(client: client, queue: name, spec: spec, shared: shared)
|
||||
let result = try await Task.detached { [name] in try CupsTools.lpadmin(QueueAdmin.addArguments(queue: name, spec: prepared)) }.value
|
||||
if !result.succeeded {
|
||||
errorText = PrinterStore.explain(result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message)
|
||||
} else {
|
||||
let snapshot = try await QueueAdmin.confirmAdded(client: client, queue: name, lpadminStatus: result.status)
|
||||
checks = snapshot.settings(for: prepared)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorText = PrinterStore.explain(String(describing: error))
|
||||
}
|
||||
|
||||
let failed = checks.filter { !$0.applied }
|
||||
let verb = isModify ? "Modified" : "Added"
|
||||
let ok = errorText == nil && failed.isEmpty
|
||||
if ok {
|
||||
store.report("\(verb) \(name) in \(elapsedText(since: started)), \(checks.count) setting\(checks.count == 1 ? "" : "s") verified")
|
||||
} else {
|
||||
if errorText == nil {
|
||||
errorText = "Not applied: " + failed.map { "\($0.name) (wanted \($0.wanted), is \($0.value ?? "nothing"))" }.joined(separator: "; ")
|
||||
}
|
||||
store.report("ERROR: \(isModify ? "modify" : "add") \(name): \(errorText!)")
|
||||
}
|
||||
store.noteChange(queue: name)
|
||||
await store.refreshQuietly()
|
||||
return ok
|
||||
}
|
||||
}
|
||||
|
||||
struct AddPrinterSheet: View {
|
||||
@Bindable var model: AddPrinterModel
|
||||
let onFinished: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(model.isModify ? "Modify \(model.name)" : "Add Printer")
|
||||
.font(.title3.weight(.semibold))
|
||||
.padding([.horizontal, .top], 20)
|
||||
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent {
|
||||
if model.isModify {
|
||||
Text(model.name).monospaced()
|
||||
} else {
|
||||
TextField("Name", text: $model.name, prompt: Text("Queue name"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
} label: {
|
||||
Text("Name")
|
||||
if let error = model.nameError { Text(error).foregroundStyle(.red) }
|
||||
}
|
||||
LabeledContent("Device URI") {
|
||||
TextField("Device URI", text: $model.deviceURI, prompt: Text("ipp://printer.example.edu/ipp/print"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospaced()
|
||||
}
|
||||
}
|
||||
|
||||
Section("Driver") {
|
||||
Picker("Driver", selection: $model.driver) {
|
||||
ForEach(model.drivers) { Text($0.title).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
|
||||
switch model.driver {
|
||||
case .keep:
|
||||
Text("The queue keeps its PPD and all option defaults.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .everywhere:
|
||||
Text("cupsd builds the PPD from the printer (lpadmin -m everywhere). The printer must be reachable.")
|
||||
.foregroundStyle(.secondary)
|
||||
case .list:
|
||||
TextField("Search drivers", text: $model.modelSearch, prompt: Text("Search \(model.models.count) drivers"))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
List(model.filteredModels, selection: $model.selectedModel) { item in
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name)
|
||||
Text(item.model).font(.caption).monospaced().foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(item.model)
|
||||
}
|
||||
.frame(height: 170)
|
||||
.overlay {
|
||||
if let error = model.modelsError {
|
||||
ContentUnavailableView("Can’t List Drivers", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
} else if model.models.isEmpty {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.task { await model.loadModels() }
|
||||
case .file:
|
||||
LabeledContent {
|
||||
Button("Choose PPD File…") { model.choosePPDFile() }
|
||||
} label: {
|
||||
Text(model.ppdFile ?? "No file chosen")
|
||||
.monospaced(model.ppdFile != nil)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Description") {
|
||||
TextField("Description", text: $model.descriptionText, prompt: Text("Optional"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Location") {
|
||||
TextField("Location", text: $model.location, prompt: Text("Optional"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
Toggle("Share this printer on the network", isOn: $model.shared)
|
||||
}
|
||||
|
||||
Section("Command") {
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let error = model.errorText {
|
||||
Label(error, systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
HStack {
|
||||
if model.isRunning { ProgressView().controlSize(.small) }
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button(model.isModify ? "Save" : "Add Printer") {
|
||||
Task {
|
||||
if await model.submit() {
|
||||
onFinished(model.name)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!model.canSubmit)
|
||||
}
|
||||
.padding([.horizontal, .bottom], 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(width: 600, height: model.driver == .list ? 760 : 600)
|
||||
}
|
||||
}
|
||||
12
Sources/CUPSAdminApp/CUPSAdminApp.swift
Normal file
12
Sources/CUPSAdminApp/CUPSAdminApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CUPSAdminApp: App {
|
||||
var body: some Scene {
|
||||
// Single-window utility; SwiftUI saves and restores the window frame.
|
||||
Window("CUPS Admin", id: "main") {
|
||||
ContentView()
|
||||
}
|
||||
.defaultSize(width: 980, height: 640)
|
||||
}
|
||||
}
|
||||
378
Sources/CUPSAdminApp/ContentView.swift
Normal file
378
Sources/CUPSAdminApp/ContentView.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var store = PrinterStore()
|
||||
// Remembered across launches.
|
||||
@AppStorage("selectedQueue") private var selection: String?
|
||||
@AppStorage("detailTab") private var tab: DetailTab = .jobs
|
||||
@State private var search = ""
|
||||
@State private var detail: PrinterDetailModel?
|
||||
@State private var options: OptionsModel?
|
||||
@State private var proposedSelection: String?
|
||||
@State private var confirmingDiscard = false
|
||||
@State private var cancelAllTarget: PrinterSummary?
|
||||
@State private var deleteTarget: PrinterSummary?
|
||||
@State private var quickAction: QuickActionModel?
|
||||
@State private var addPrinter: AddPrinterModel?
|
||||
|
||||
private var selectedPrinter: PrinterSummary? {
|
||||
store.printers.first { $0.name == selection }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(store: store, selection: guardedSelection, search: $search, actions: sidebarActions)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 270, max: 400)
|
||||
} detail: {
|
||||
if let detail, let options, selectedPrinter != nil {
|
||||
PrinterDetailView(model: detail, options: options, store: store, tab: tab)
|
||||
.id(detail.queue)
|
||||
} else {
|
||||
ContentUnavailableView("Select a Printer", systemImage: "printer",
|
||||
description: Text("Choose a queue in the sidebar."))
|
||||
.padding(.bottom, StatusBar.height)
|
||||
}
|
||||
}
|
||||
// Whole-window status bar, under both columns.
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
StatusBar(action: store.statusLine, summary: store.summaryLine)
|
||||
}
|
||||
.navigationTitle(selection ?? "CUPS Admin")
|
||||
.toolbar { toolbar }
|
||||
.task {
|
||||
await store.refresh()
|
||||
await store.loadPPDs()
|
||||
}
|
||||
.onChange(of: store.revision) { Task { await store.loadPPDs() } }
|
||||
.onChange(of: selection, initial: true) { _, queue in
|
||||
detail = queue.map { PrinterDetailModel(queue: $0, store: store) }
|
||||
options = queue.map { queue in
|
||||
OptionsModel(queue: queue) { [store] line in store.report(line) }
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Discard unsaved option changes for \(selection ?? "")?",
|
||||
isPresented: $confirmingDiscard) {
|
||||
Button("Discard Changes", role: .destructive) { selection = proposedSelection }
|
||||
Button("Keep Editing", role: .cancel) {}
|
||||
} message: {
|
||||
Text("\(options?.changeCount ?? 0) change(s) haven’t been applied.")
|
||||
}
|
||||
.confirmationDialog("Cancel all jobs on \(cancelAllTarget?.name ?? "")?",
|
||||
isPresented: Binding(get: { cancelAllTarget != nil }, set: { if !$0 { cancelAllTarget = nil } }),
|
||||
presenting: cancelAllTarget) { printer in
|
||||
Button("Cancel \(printer.jobCount) Job\(printer.jobCount == 1 ? "" : "s")", role: .destructive) {
|
||||
Task { await store.perform(.cancelAllJobs, on: printer.name) }
|
||||
}
|
||||
Button("Keep Jobs", role: .cancel) {}
|
||||
} message: { printer in
|
||||
Text("Runs cancel -a \(printer.name). This can’t be undone.")
|
||||
}
|
||||
.confirmationDialog("Delete printer \(deleteTarget?.name ?? "")?",
|
||||
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }),
|
||||
presenting: deleteTarget) { printer in
|
||||
Button("Delete \(printer.name)", role: .destructive) {
|
||||
Task {
|
||||
if await store.delete(printer.name), selection == printer.name { selection = nil }
|
||||
}
|
||||
}
|
||||
Button("Keep Printer", role: .cancel) {}
|
||||
} message: { printer in
|
||||
Text(printer.jobCount > 0
|
||||
? "Runs lpadmin -x \(printer.name). Its \(printer.jobCount) active job\(printer.jobCount == 1 ? "" : "s") will be canceled. This can’t be undone."
|
||||
: "Runs lpadmin -x \(printer.name). This can’t be undone.")
|
||||
}
|
||||
.sheet(item: $quickAction) { model in
|
||||
QuickActionSheet(model: model)
|
||||
}
|
||||
.sheet(item: $addPrinter) { model in
|
||||
AddPrinterSheet(model: model) { name in
|
||||
if !model.isModify { selection = name }
|
||||
}
|
||||
}
|
||||
.alert(item: $store.failure) { failure in
|
||||
Alert(title: Text(failure.title), message: Text(failure.message))
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder private var toolbar: some ToolbarContent {
|
||||
if selectedPrinter != nil {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Picker("View", selection: $tab) {
|
||||
ForEach(DetailTab.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Menu {
|
||||
if let printer = selectedPrinter {
|
||||
QuickActionItems(store: store, queue: printer.name) { action in
|
||||
quickAction = QuickActionModel(action: action, queue: printer.name, store: store)
|
||||
}
|
||||
} else {
|
||||
Text("Select a printer first")
|
||||
}
|
||||
} label: {
|
||||
Label("Quick Actions", systemImage: "bolt")
|
||||
}
|
||||
.help("Quick Actions for the selected printer")
|
||||
|
||||
if let printer = selectedPrinter {
|
||||
let pause: PrinterAction = printer.isPaused ? .resume : .pause
|
||||
Button { Task { await store.perform(pause, on: printer.name) } } label: {
|
||||
Label(pause.title, systemImage: pause.systemImage)
|
||||
}
|
||||
.help(printer.isPaused ? "Resume printing (cupsenable)" : "Pause printing (cupsdisable)")
|
||||
|
||||
let accept: PrinterAction = printer.accepting ? .reject : .accept
|
||||
Button { Task { await store.perform(accept, on: printer.name) } } label: {
|
||||
Label(accept.title, systemImage: accept.systemImage)
|
||||
}
|
||||
.help(printer.accepting ? "Stop accepting new jobs (cupsreject)" : "Accept new jobs (cupsaccept)")
|
||||
}
|
||||
Button {
|
||||
addPrinter = AddPrinterModel(store: store)
|
||||
} label: {
|
||||
Label("Add Printer", systemImage: "plus")
|
||||
}
|
||||
.help("Add a printer (lpadmin -p … -E)")
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await store.refresh()
|
||||
store.noteChange()
|
||||
}
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh printers and jobs")
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
.disabled(store.isRefreshing)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarActions: SidebarActions {
|
||||
SidebarActions(
|
||||
cancelAll: { cancelAllTarget = $0 },
|
||||
delete: { deleteTarget = $0 },
|
||||
modify: { printer in
|
||||
Task {
|
||||
if let attributes = try? await store.client.getPrinterAttributes(queue: printer.name,
|
||||
requested: PrinterDetailModel.headerAttributes) {
|
||||
addPrinter = AddPrinterModel(store: store, modifying: attributes)
|
||||
}
|
||||
}
|
||||
},
|
||||
quick: { action, printer in
|
||||
quickAction = QuickActionModel(action: action, queue: printer.name, store: store)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Selection that asks before leaving a printer with unapplied option edits.
|
||||
private var guardedSelection: Binding<String?> {
|
||||
Binding(
|
||||
get: { selection },
|
||||
set: { newValue in
|
||||
guard newValue != selection else { return }
|
||||
if options?.hasChanges == true {
|
||||
proposedSelection = newValue
|
||||
confirmingDiscard = true
|
||||
} else {
|
||||
selection = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SidebarActions {
|
||||
let cancelAll: (PrinterSummary) -> Void
|
||||
let delete: (PrinterSummary) -> Void
|
||||
let modify: (PrinterSummary) -> Void
|
||||
let quick: (QuickAction, PrinterSummary) -> Void
|
||||
}
|
||||
|
||||
struct SidebarView: View {
|
||||
let store: PrinterStore
|
||||
@Binding var selection: String?
|
||||
@Binding var search: String
|
||||
let actions: SidebarActions
|
||||
|
||||
private var filtered: [PrinterSummary] {
|
||||
store.printers.filter { $0.matches(search: search) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
Section("Printers") {
|
||||
ForEach(filtered) { printer in
|
||||
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name)
|
||||
.tag(printer.name)
|
||||
.contextMenu { menu(for: printer) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.searchable(text: $search, placement: .sidebar, prompt: "Search printers")
|
||||
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
|
||||
.overlay {
|
||||
if store.printers.isEmpty {
|
||||
if let error = store.errorMessage {
|
||||
ContentUnavailableView("Can’t Reach CUPS", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
} else if store.hasLoaded {
|
||||
ContentUnavailableView("No Printers", systemImage: "printer",
|
||||
description: Text("No queues are set up on this Mac."))
|
||||
} else {
|
||||
ProgressView()
|
||||
}
|
||||
} else if filtered.isEmpty {
|
||||
ContentUnavailableView.search(text: search)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func menu(for printer: PrinterSummary) -> some View {
|
||||
Group {
|
||||
let pause: PrinterAction = printer.isPaused ? .resume : .pause
|
||||
Button { Task { await store.perform(pause, on: printer.name) } } label: {
|
||||
Label(pause.title, systemImage: pause.systemImage)
|
||||
}
|
||||
let accept: PrinterAction = printer.accepting ? .reject : .accept
|
||||
Button { Task { await store.perform(accept, on: printer.name) } } label: {
|
||||
Label(accept.title, systemImage: accept.systemImage)
|
||||
}
|
||||
Button { Task { await store.printTestPage(printer.name) } } label: {
|
||||
Label("Print Test Page", systemImage: "doc.text")
|
||||
}
|
||||
Button(role: .destructive) { actions.cancelAll(printer) } label: {
|
||||
Label("Cancel All Jobs…", systemImage: "xmark.circle")
|
||||
}
|
||||
.disabled(printer.jobCount == 0)
|
||||
|
||||
Divider()
|
||||
Button {
|
||||
if let action = QuickAction.named("default") { actions.quick(action, printer) }
|
||||
} label: {
|
||||
Label("Set as Default Printer", systemImage: "star")
|
||||
}
|
||||
.disabled(store.defaultPrinter == printer.name)
|
||||
Button { actions.modify(printer) } label: {
|
||||
Label("Modify Printer…", systemImage: "slider.horizontal.3")
|
||||
}
|
||||
Button(role: .destructive) { actions.delete(printer) } label: {
|
||||
Label("Delete Printer…", systemImage: "trash")
|
||||
}
|
||||
|
||||
Divider()
|
||||
Menu {
|
||||
QuickActionItems(store: store, queue: printer.name) { actions.quick($0, printer) }
|
||||
} label: {
|
||||
Label("Quick Actions", systemImage: "bolt")
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button { store.copyDeviceURI(printer) } label: {
|
||||
Label("Copy Device URI", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(printer.deviceURI == nil)
|
||||
Button { store.showPPDInFinder(printer.name) } label: {
|
||||
Label("Show PPD in Finder", systemImage: "folder")
|
||||
}
|
||||
.disabled(!store.hasPPDFile(printer.name))
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick Action menu items for one queue; unavailable ones are disabled with the reason as tooltip.
|
||||
struct QuickActionItems: View {
|
||||
let store: PrinterStore
|
||||
let queue: String
|
||||
let choose: (QuickAction) -> Void
|
||||
|
||||
var body: some View {
|
||||
ForEach(QuickAction.all) { action in
|
||||
let reason = store.unavailableReason(action, queue: queue)
|
||||
Button { choose(action) } label: {
|
||||
Label(action.title, systemImage: action.systemImage)
|
||||
}
|
||||
.disabled(reason != nil)
|
||||
.help(reason ?? action.summary)
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrinterRow: View {
|
||||
let printer: PrinterSummary
|
||||
var isDefault = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(printer.status.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Text(printer.name)
|
||||
.lineLimit(1)
|
||||
if isDefault {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.help("Server default printer")
|
||||
.accessibilityLabel("Default printer")
|
||||
}
|
||||
}
|
||||
if let secondary = printer.secondaryLine {
|
||||
Text(secondary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.badge(printer.jobCount)
|
||||
.help(printer.stateSummary)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityValue(printer.stateSummary)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBar: View {
|
||||
/// Content above the bar adds this much bottom margin so its last row isn't hidden.
|
||||
static let height: CGFloat = 30
|
||||
|
||||
/// Last action with its timing, e.g. "Refreshed 21 printers in 42 ms" or "ERROR: ...".
|
||||
let action: String
|
||||
/// Totals, e.g. "21 printers · 0 active jobs".
|
||||
let summary: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
HStack(spacing: 12) {
|
||||
Text(action)
|
||||
.foregroundStyle(action.hasPrefix("ERROR") ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 0)
|
||||
Text(summary)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.callout)
|
||||
.monospacedDigit()
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
}
|
||||
257
Sources/CUPSAdminApp/OptionsModel.swift
Normal file
257
Sources/CUPSAdminApp/OptionsModel.swift
Normal file
@@ -0,0 +1,257 @@
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Options tab state for one queue: queue defaults, pending edits, and per-row apply results.
|
||||
/// Apply always writes queue defaults with `lpadmin -p queue -o` (never lpoptions).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class OptionsModel {
|
||||
enum RowStatus: Equatable {
|
||||
case applying
|
||||
case applied(note: String?)
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
struct CustomPair: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let key: String
|
||||
let value: String
|
||||
var status: RowStatus?
|
||||
}
|
||||
|
||||
struct Failure: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
let queue: String
|
||||
|
||||
private(set) var state: OptionState?
|
||||
private(set) var sections: [OptionSection] = []
|
||||
private(set) var loadError: String?
|
||||
private(set) var isApplying = false
|
||||
/// Row key -> new raw value.
|
||||
private(set) var pending: [String: String] = [:]
|
||||
/// Field text as typed, so invalid input stays visible with its error.
|
||||
private(set) var fieldText: [String: String] = [:]
|
||||
private(set) var rowStatus: [String: RowStatus] = [:]
|
||||
private(set) var customPairs: [CustomPair] = []
|
||||
var customEntry = ""
|
||||
private(set) var customEntryError: String?
|
||||
var failure: Failure?
|
||||
|
||||
private let report: (String) -> Void
|
||||
private let client = CupsClient()
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "options")
|
||||
|
||||
init(queue: String, report: @escaping (String) -> Void) {
|
||||
self.queue = queue
|
||||
self.report = report
|
||||
}
|
||||
|
||||
// MARK: loading
|
||||
|
||||
func load() async {
|
||||
let started = Date()
|
||||
do {
|
||||
state = try await OptionState.load(client: client, queue: queue)
|
||||
loadError = nil
|
||||
rebuild()
|
||||
let count = sections.reduce(0) { $0 + $1.rows.count }
|
||||
report("Loaded \(count) options for \(queue) in \(elapsedText(since: started))")
|
||||
log.info("loaded \(count) options for \(self.queue, privacy: .public) in \(Date().timeIntervalSince(started), format: .fixed(precision: 3)) s")
|
||||
} catch {
|
||||
loadError = String(describing: error)
|
||||
report("ERROR: \(error)")
|
||||
log.error("load failed for \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
sections = state?.sections() ?? []
|
||||
}
|
||||
|
||||
// MARK: values
|
||||
|
||||
var hasChanges: Bool { !pending.isEmpty || customPairs.contains { $0.status == nil } }
|
||||
var changeCount: Int { pending.count + customPairs.filter { $0.status == nil }.count }
|
||||
var hasErrors: Bool { sections.flatMap(\.rows).contains { validationError($0) != nil } }
|
||||
|
||||
/// The queue default before pending edits.
|
||||
func currentValue(_ row: OptionRow) -> String? {
|
||||
state?.queueValue(row.key)
|
||||
}
|
||||
|
||||
/// The value shown in the control: pending edit, else current.
|
||||
func value(_ row: OptionRow) -> String? {
|
||||
pending[row.key] ?? currentValue(row)
|
||||
}
|
||||
|
||||
func set(_ row: OptionRow, to newValue: String) {
|
||||
rowStatus[row.key] = nil
|
||||
pending[row.key] = newValue == currentValue(row) ? nil : newValue
|
||||
}
|
||||
|
||||
func fieldText(_ row: OptionRow, field: CustomField) -> String {
|
||||
fieldText[row.key] ?? field.text(fromValue: value(row))
|
||||
}
|
||||
|
||||
func setFieldText(_ text: String, row: OptionRow, field: CustomField) {
|
||||
fieldText[row.key] = text
|
||||
set(row, to: field.value(fromText: text))
|
||||
if pending[row.key] == nil { fieldText[row.key] = nil }
|
||||
}
|
||||
|
||||
func validationError(_ row: OptionRow) -> String? {
|
||||
guard case .field(let field) = row.control, let text = fieldText[row.key] else { return nil }
|
||||
return field.validationError(text)
|
||||
}
|
||||
|
||||
func isPending(_ row: OptionRow) -> Bool { pending[row.key] != nil }
|
||||
|
||||
/// Your own ~/.cups/lpoptions value wins over the queue default for your jobs; say so.
|
||||
func overrideNote(_ row: OptionRow) -> String? {
|
||||
guard let state, let mine = state.userOverride(row.key) else { return nil }
|
||||
return "Your ~/.cups/lpoptions overrides this for your jobs: \(state.display(mine, row: row))"
|
||||
}
|
||||
|
||||
// MARK: custom option row
|
||||
|
||||
func addCustomEntry() {
|
||||
let entry = customEntry.trimmingCharacters(in: .whitespaces)
|
||||
guard let eq = entry.firstIndex(of: "="), eq != entry.startIndex else {
|
||||
customEntryError = "Use keyword=value"
|
||||
return
|
||||
}
|
||||
let key = String(entry[..<eq])
|
||||
guard !key.contains(where: \.isWhitespace) else {
|
||||
customEntryError = "Keyword can't contain spaces"
|
||||
return
|
||||
}
|
||||
customPairs.append(CustomPair(key: key, value: String(entry[entry.index(after: eq)...])))
|
||||
customEntry = ""
|
||||
customEntryError = nil
|
||||
}
|
||||
|
||||
func removeCustomPair(_ pair: CustomPair) {
|
||||
customPairs.removeAll { $0.id == pair.id }
|
||||
}
|
||||
|
||||
// MARK: apply
|
||||
|
||||
struct ChangeSummary: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let from: String
|
||||
let to: String
|
||||
}
|
||||
|
||||
var changes: [OptionChange] {
|
||||
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
|
||||
let edits = pending.keys.sorted().map { key in
|
||||
OptionChange(key: key, value: pending[key]!, isSecret: rows[key]?.isSecret ?? false)
|
||||
}
|
||||
let raw = customPairs.filter { $0.status == nil }.map {
|
||||
OptionChange(key: $0.key, value: $0.value, isSecret: rows[$0.key]?.isSecret ?? false)
|
||||
}
|
||||
return edits + raw
|
||||
}
|
||||
|
||||
var changeSummaries: [ChangeSummary] {
|
||||
guard let state else { return [] }
|
||||
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
|
||||
return changes.map { change in
|
||||
guard let row = rows[change.key] else {
|
||||
return ChangeSummary(id: change.key, label: change.key, from: state.queueValue(change.key) ?? "—",
|
||||
to: change.value)
|
||||
}
|
||||
return ChangeSummary(id: change.key, label: "\(row.label) (\(row.key))",
|
||||
from: state.display(currentValue(row), row: row), to: state.display(change.value, row: row))
|
||||
}
|
||||
}
|
||||
|
||||
var command: String {
|
||||
OptionApplier.displayCommand(queue: queue, changes: changes)
|
||||
}
|
||||
|
||||
/// Shown in the apply sheet when passwords would land in a world-readable file.
|
||||
var secretWarning: String? {
|
||||
let secrets = changes.filter(\.isSecret)
|
||||
guard !secrets.isEmpty else { return nil }
|
||||
let names = secrets.map(\.key).joined(separator: ", ")
|
||||
return "\(names) will be saved in plain text in /etc/cups/ppd/\(queue).ppd as the queue default. "
|
||||
+ "Every user on this Mac can read that file, and every job on this queue will send it."
|
||||
}
|
||||
|
||||
let scopeDescription = "Every user on this Mac · lpadmin"
|
||||
|
||||
func revert() {
|
||||
pending = [:]
|
||||
fieldText = [:]
|
||||
customPairs.removeAll { $0.status == nil }
|
||||
rowStatus = [:]
|
||||
}
|
||||
|
||||
func apply() async {
|
||||
let changes = self.changes
|
||||
guard !changes.isEmpty, !isApplying else { return }
|
||||
isApplying = true
|
||||
defer { isApplying = false }
|
||||
let started = Date()
|
||||
for change in changes { rowStatus[change.key] = .applying }
|
||||
for i in customPairs.indices where customPairs[i].status == nil { customPairs[i].status = .applying }
|
||||
log.info("apply \(changes.count) options on \(self.queue, privacy: .public) with lpadmin")
|
||||
|
||||
do {
|
||||
let (result, readBack, newState) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
|
||||
state = newState
|
||||
rebuild()
|
||||
pending = [:]
|
||||
fieldText = [:]
|
||||
for check in readBack {
|
||||
let status: RowStatus = check.applied ? .applied(note: check.reason) : .failed(reason: check.reason ?? "Not applied")
|
||||
rowStatus[check.key] = status
|
||||
if let i = customPairs.firstIndex(where: { $0.key == check.key && $0.status == .applying }) {
|
||||
customPairs[i].status = status
|
||||
}
|
||||
}
|
||||
|
||||
let failed = readBack.filter { !$0.applied }
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
if failed.isEmpty {
|
||||
report("Set \(changes.count) option\(changes.count == 1 ? "" : "s") on \(queue) in \(elapsedText(seconds))")
|
||||
} else {
|
||||
report("ERROR: \(failed.count) of \(changes.count) not applied on \(queue): "
|
||||
+ failed.map(\.key).joined(separator: ", ") + " — \(failed[0].reason ?? "")")
|
||||
}
|
||||
log.info("apply finished on \(self.queue, privacy: .public): \(changes.count - failed.count) applied, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
|
||||
|
||||
if !result.succeeded {
|
||||
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: Self.explain(result))
|
||||
}
|
||||
} catch {
|
||||
for change in changes { rowStatus[change.key] = .failed(reason: String(describing: error)) }
|
||||
for i in customPairs.indices where customPairs[i].status == .applying {
|
||||
customPairs[i].status = .failed(reason: String(describing: error))
|
||||
}
|
||||
report("ERROR: \(error)")
|
||||
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: String(describing: error))
|
||||
log.error("apply failed on \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool output plus the fix when it's a permissions problem.
|
||||
private static func explain(_ result: ToolResult) -> String {
|
||||
let message = result.message.isEmpty ? "\(result.commandLine) exited \(result.status)" : result.message
|
||||
let lower = message.lowercased()
|
||||
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|
||||
|| lower.contains("authentication") {
|
||||
return message + "\n\nChanging queue defaults needs an admin account or membership in _lpadmin:\n"
|
||||
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
@@ -0,0 +1,335 @@
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
/// The web UI's "Set Default Options" page: every PPD option by group, IPP defaults, and queue policies.
|
||||
struct OptionsView: View {
|
||||
@Bindable var model: OptionsModel
|
||||
@State private var showingApplySheet = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.state == nil, let error = model.loadError {
|
||||
ContentUnavailableView("Can’t Load Options", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
} else if model.state == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
form
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) { actionBar }
|
||||
.sheet(isPresented: $showingApplySheet) {
|
||||
ApplyOptionsSheet(model: model) {
|
||||
showingApplySheet = false
|
||||
Task { await model.apply() }
|
||||
}
|
||||
}
|
||||
.alert(item: $model.failure) { failure in
|
||||
Alert(title: Text(failure.title), message: Text(failure.message))
|
||||
}
|
||||
.task(id: model.queue) { await model.load() }
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Text(model.scopeDescription)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.isApplying {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if model.hasChanges {
|
||||
Text("\(model.changeCount) unsaved change\(model.changeCount == 1 ? "" : "s")")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.accessibilityIdentifier("options.changeCount")
|
||||
}
|
||||
Button("Revert") { model.revert() }
|
||||
.disabled(!model.hasChanges || model.isApplying)
|
||||
.accessibilityIdentifier("options.revert")
|
||||
Button("Apply…") { showingApplySheet = true }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!model.hasChanges || model.hasErrors || model.isApplying)
|
||||
.accessibilityIdentifier("options.apply")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
Divider()
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(model.sections) { section in
|
||||
Section {
|
||||
ForEach(section.rows) { row in
|
||||
OptionRowView(model: model, row: row)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
} footer: {
|
||||
if section.title == "IPP" {
|
||||
Text("Queue-wide IPP defaults. On PPD queues, media, sides, output bin, resolution and color follow their PPD option.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(model.customPairs) { pair in
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
StatusIndicator(status: pair.status, pending: pair.status == nil)
|
||||
Button {
|
||||
model.removeCustomPair(pair)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove")
|
||||
.disabled(model.isApplying)
|
||||
}
|
||||
} label: {
|
||||
Text("\(pair.key)=\(pair.value)")
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
.accessibilityIdentifier("options.custom.\(pair.key)")
|
||||
if case .failed(let reason) = pair.status {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = pair.status {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
HStack {
|
||||
TextField("Add option manually", text: $model.customEntry, prompt: Text("keyword=value"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospaced()
|
||||
.frame(minWidth: 260)
|
||||
.onSubmit { model.addCustomEntry() }
|
||||
.accessibilityIdentifier("options.customEntry")
|
||||
Button("Add") { model.addCustomEntry() }
|
||||
.disabled(model.customEntry.isEmpty || model.isApplying)
|
||||
.accessibilityIdentifier("options.customAdd")
|
||||
}
|
||||
} label: {
|
||||
Text("Add option manually")
|
||||
Text(model.customEntryError ?? "Any lpadmin -o pair, e.g. RIUserCode=Custom.1234")
|
||||
.foregroundStyle(model.customEntryError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red))
|
||||
}
|
||||
} header: {
|
||||
Text("Advanced")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Keep the last row clear of the window's status bar.
|
||||
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionRowView: View {
|
||||
let model: OptionsModel
|
||||
let row: OptionRow
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
control
|
||||
.accessibilityIdentifier("option.\(row.key)")
|
||||
StatusIndicator(status: model.rowStatus[row.key], pending: model.isPending(row))
|
||||
}
|
||||
.disabled(model.isApplying)
|
||||
} label: {
|
||||
Text(row.label)
|
||||
Text(keywordLine)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
if let error = model.validationError(row) {
|
||||
Text(error).foregroundStyle(.red)
|
||||
} else if case .failed(let reason) = model.rowStatus[row.key] {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = model.rowStatus[row.key] {
|
||||
Text(note)
|
||||
} else if case .readOnly(let reason) = row.control {
|
||||
Text(reason)
|
||||
} else if let note = model.overrideNote(row) {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var keywordLine: String {
|
||||
if case .field(let field) = row.control { return "\(row.key) · \(field.hint)" }
|
||||
return row.key
|
||||
}
|
||||
|
||||
@ViewBuilder private var control: some View {
|
||||
switch row.control {
|
||||
case .picker(let choices):
|
||||
Picker(row.label, selection: Binding(
|
||||
get: { model.value(row) ?? "" },
|
||||
set: { model.set(row, to: $0) }
|
||||
)) {
|
||||
let current = model.value(row) ?? ""
|
||||
if !choices.contains(where: { $0.keyword == current }) {
|
||||
Text(current.isEmpty ? "Not set" : current).tag(current)
|
||||
}
|
||||
ForEach(choices, id: \.keyword) { choice in
|
||||
Text(choice.text).tag(choice.keyword)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
case .toggle(let on, let off):
|
||||
Toggle(row.label, isOn: Binding(
|
||||
get: { model.value(row) == on },
|
||||
set: { model.set(row, to: $0 ? on : off) }
|
||||
))
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
|
||||
case .field(let field):
|
||||
let text = Binding(
|
||||
get: { model.fieldText(row, field: field) },
|
||||
set: { model.setFieldText($0, row: row, field: field) }
|
||||
)
|
||||
let prompt = Text(field.choices.first ?? "")
|
||||
Group {
|
||||
if field.isSecret {
|
||||
SecureField(row.label, text: text, prompt: prompt)
|
||||
} else {
|
||||
TextField(row.label, text: text, prompt: prompt)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospacedDigit()
|
||||
.frame(width: field.isNumeric ? 110 : 200)
|
||||
|
||||
case .readOnly:
|
||||
Text(model.state?.display(model.value(row), row: row) ?? "—")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsaved dot, spinner while applying, then a green check or red X after read-back.
|
||||
struct StatusIndicator: View {
|
||||
let status: OptionsModel.RowStatus?
|
||||
let pending: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch status {
|
||||
case .applying:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .applied:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.help("Applied and verified")
|
||||
case .failed(let reason):
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.help(reason)
|
||||
case nil:
|
||||
if pending {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(.tint)
|
||||
.help("Unsaved change")
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
switch status {
|
||||
case .applying: return "Applying"
|
||||
case .applied: return "Applied"
|
||||
case .failed(let reason): return "Not applied: \(reason)"
|
||||
case nil: return pending ? "Unsaved change" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyOptionsSheet: View {
|
||||
let model: OptionsModel
|
||||
let apply: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Apply \(model.changeCount) Change\(model.changeCount == 1 ? "" : "s") to \(model.queue)?")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Changes the queue default for every user on this Mac.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let warning = model.secretWarning {
|
||||
Label {
|
||||
Text(warning)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Setting")
|
||||
Text("Current")
|
||||
Text("New")
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ForEach(model.changeSummaries) { change in
|
||||
GridRow {
|
||||
Text(change.label)
|
||||
Text(change.from).foregroundStyle(.secondary)
|
||||
Text(change.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Command")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("applySheet.cancel")
|
||||
Button("Apply") { apply() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.accessibilityIdentifier("applySheet.apply")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 560)
|
||||
}
|
||||
}
|
||||
149
Sources/CUPSAdminApp/PrinterDetailModel.swift
Normal file
149
Sources/CUPSAdminApp/PrinterDetailModel.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
enum DetailTab: String, CaseIterable, Identifiable {
|
||||
case jobs, options
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .jobs ? "Jobs" : "Options" }
|
||||
}
|
||||
|
||||
enum JobFilter: String, CaseIterable, Identifiable {
|
||||
case active, completed, all
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .active: return "Active"
|
||||
case .completed: return "Completed"
|
||||
case .all: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get-Jobs which-jobs.
|
||||
var whichJobs: String {
|
||||
switch self {
|
||||
case .active: return "not-completed"
|
||||
case .completed: return "completed"
|
||||
case .all: return "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header attributes and the Jobs table for one printer.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrinterDetailModel {
|
||||
let queue: String
|
||||
private(set) var printer: IPPGroup?
|
||||
private(set) var jobs: [JobSummary] = []
|
||||
private(set) var loadError: String?
|
||||
private(set) var isWorking = false
|
||||
var selection = Set<Int>()
|
||||
var sortOrder = [KeyPathComparator(\JobSummary.id, order: .reverse)]
|
||||
var jobFilter: JobFilter = .active {
|
||||
didSet { if jobFilter != oldValue { Task { await loadJobs() } } }
|
||||
}
|
||||
|
||||
private let store: PrinterStore
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "jobs")
|
||||
|
||||
init(queue: String, store: PrinterStore) {
|
||||
self.queue = queue
|
||||
self.store = store
|
||||
}
|
||||
|
||||
static let headerAttributes = [
|
||||
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
|
||||
"printer-is-accepting-jobs", "printer-is-shared", "device-uri", "printer-make-and-model",
|
||||
"printer-info", "printer-location", "queued-job-count",
|
||||
]
|
||||
|
||||
var sortedJobs: [JobSummary] { jobs.sorted(using: sortOrder) }
|
||||
var selectedJobs: [JobSummary] { jobs.filter { selection.contains($0.id) } }
|
||||
|
||||
/// The queue's PPD, if it has one (driverless and raw queues don't).
|
||||
var ppdPath: String? {
|
||||
let path = "/private/etc/cups/ppd/\(queue).ppd"
|
||||
return FileManager.default.fileExists(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
||||
loadError = nil
|
||||
} catch {
|
||||
loadError = String(describing: error)
|
||||
store.report("ERROR: \(queue): \(error)")
|
||||
}
|
||||
await loadJobs()
|
||||
}
|
||||
|
||||
func loadJobs() async {
|
||||
do {
|
||||
let groups = try await store.client.getJobs(queue: queue, which: jobFilter.whichJobs,
|
||||
requested: JobSummary.requestedAttributes)
|
||||
jobs = groups.map(JobSummary.init)
|
||||
selection = selection.intersection(jobs.map(\.id))
|
||||
} catch {
|
||||
store.report("ERROR: jobs on \(queue): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: job actions
|
||||
|
||||
func canPerform(_ action: JobAction, on ids: Set<Int>) -> Bool {
|
||||
let targets = jobs.filter { ids.contains($0.id) }
|
||||
guard !targets.isEmpty, !isWorking else { return false }
|
||||
switch action {
|
||||
case .cancel, .move: return targets.allSatisfy(\.isActive)
|
||||
case .hold: return targets.allSatisfy { $0.isActive && !$0.isHeld }
|
||||
case .release: return targets.allSatisfy(\.isHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func perform(_ action: JobAction, on ids: Set<Int>) async {
|
||||
let ordered = ids.sorted()
|
||||
guard !ordered.isEmpty else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
let started = Date()
|
||||
log.info("\(action.title, privacy: .public) jobs \(ordered.map(String.init).joined(separator: ","), privacy: .public) on \(self.queue, privacy: .public)")
|
||||
|
||||
let outcomes = await QueueActions.perform(action, jobIDs: ordered, client: store.client)
|
||||
let failed = ordered.filter { outcomes[$0]?.succeeded != true }
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
let noun = ordered.count == 1 ? "job \(ordered[0])" : "\(ordered.count) jobs"
|
||||
if failed.isEmpty {
|
||||
store.report("\(action.pastTense) \(noun) in \(elapsedText(seconds))")
|
||||
} else {
|
||||
let details = failed.map { "job \($0): \(outcomes[$0]?.message ?? "failed")" }
|
||||
store.report("ERROR: \(failed.count) of \(ordered.count) not applied — \(details[0])")
|
||||
store.fail("\(action.title) didn’t apply to \(failed.count) job\(failed.count == 1 ? "" : "s")",
|
||||
(details + failed.compactMap { outcomes[$0]?.command }.prefix(1)).joined(separator: "\n"))
|
||||
}
|
||||
log.info("\(action.title, privacy: .public): \(ordered.count - failed.count) ok, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
|
||||
|
||||
store.noteChange()
|
||||
await loadJobs()
|
||||
await store.refreshQuietly()
|
||||
}
|
||||
|
||||
/// Tab-separated rows for the clipboard, for pasting into tickets.
|
||||
func copyText(for ids: Set<Int>) -> String {
|
||||
let header = ["ID", "Name", "User", "Size", "Pages", "State", "Reasons", "Submitted"].joined(separator: "\t")
|
||||
let rows = sortedJobs.filter { ids.contains($0.id) }.map { job in
|
||||
[String(job.id), job.name, job.user, Format.kilobytes(job.sizeK), job.pages.map(String.init) ?? "",
|
||||
job.stateName, job.reasons.joined(separator: ","), job.submitted.map(Format.timestamp) ?? ""]
|
||||
.joined(separator: "\t")
|
||||
}
|
||||
return ([header] + rows).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
extension JobSummary {
|
||||
var pagesSortKey: Int { pages ?? -1 }
|
||||
var reasonsText: String { reasons.joined(separator: ", ") }
|
||||
var submittedSortKey: Date { submitted ?? .distantPast }
|
||||
}
|
||||
236
Sources/CUPSAdminApp/PrinterDetailView.swift
Normal file
236
Sources/CUPSAdminApp/PrinterDetailView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import AppKit
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
/// Printer header (grouped Form) above the Jobs or Options tab.
|
||||
struct PrinterDetailView: View {
|
||||
let model: PrinterDetailModel
|
||||
let options: OptionsModel
|
||||
let store: PrinterStore
|
||||
let tab: DetailTab
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let printer = model.printer {
|
||||
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath)
|
||||
Divider()
|
||||
} else if let error = model.loadError {
|
||||
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
.frame(maxHeight: 220)
|
||||
Divider()
|
||||
}
|
||||
switch tab {
|
||||
case .jobs:
|
||||
JobsView(model: model, store: store)
|
||||
case .options:
|
||||
OptionsView(model: options)
|
||||
}
|
||||
}
|
||||
.task(id: model.queue) { await model.load() }
|
||||
.onChange(of: store.revision) { Task { await model.load() } }
|
||||
}
|
||||
}
|
||||
|
||||
struct PrinterHeaderView: View {
|
||||
let printer: IPPGroup
|
||||
let ppdPath: String?
|
||||
|
||||
private var state: Int? { printer.int("printer-state") }
|
||||
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
||||
private var reasons: [String] { printer.strings("printer-state-reasons").filter { $0 != "none" } }
|
||||
private var stateColor: Color {
|
||||
if state == 5 || !accepting { return .red }
|
||||
return state == 4 ? .blue : .green
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Form {
|
||||
Section("Status") {
|
||||
LabeledContent("State") {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(stateColor).frame(width: 8, height: 8)
|
||||
Text(Format.printerState(state).capitalized)
|
||||
}
|
||||
}
|
||||
LabeledContent("Reasons") {
|
||||
Text(reasons.isEmpty ? "None" : reasons.joined(separator: ", "))
|
||||
.monospaced(!reasons.isEmpty)
|
||||
}
|
||||
LabeledContent("Message") {
|
||||
Text(printer.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
.lineLimit(2)
|
||||
}
|
||||
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
||||
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
Form {
|
||||
Section("Queue") {
|
||||
LabeledContent("Device URI") {
|
||||
Text(printer.string("device-uri") ?? "—")
|
||||
.monospaced()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
LabeledContent {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(printer.string("printer-make-and-model") ?? "—")
|
||||
Text(ppdPath ?? "No PPD (driverless or raw)")
|
||||
.font(.caption)
|
||||
.monospaced(ppdPath != nil)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} label: {
|
||||
Text("Model")
|
||||
}
|
||||
LabeledContent("Location", value: printer.string("printer-location").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
LabeledContent("Description", value: printer.string("printer-info").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.textSelection(.enabled)
|
||||
.frame(height: 262)
|
||||
}
|
||||
}
|
||||
|
||||
struct JobsView: View {
|
||||
@Bindable var model: PrinterDetailModel
|
||||
let store: PrinterStore
|
||||
@SceneStorage("JobsTableColumns") private var columns = TableColumnCustomization<JobSummary>()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Show", selection: $model.jobFilter) {
|
||||
ForEach(JobFilter.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.isWorking { ProgressView().controlSize(.small) }
|
||||
jobButtons(for: model.selection)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
Divider()
|
||||
|
||||
Table(model.sortedJobs, selection: $model.selection, sortOrder: $model.sortOrder,
|
||||
columnCustomization: $columns) {
|
||||
TableColumn("ID", value: \.id) { Text(String($0.id)).monospacedDigit() }
|
||||
.width(min: 40, ideal: 56, max: 80)
|
||||
.customizationID("id")
|
||||
TableColumn("Name", value: \.name)
|
||||
.width(min: 120, ideal: 220, max: 360)
|
||||
.customizationID("name")
|
||||
TableColumn("User", value: \.user)
|
||||
.width(min: 60, ideal: 110, max: 200)
|
||||
.customizationID("user")
|
||||
TableColumn("Size", value: \.sizeK) { Text(Format.kilobytes($0.sizeK)).monospacedDigit() }
|
||||
.width(min: 50, ideal: 64, max: 90)
|
||||
.customizationID("size")
|
||||
TableColumn("Pages", value: \.pagesSortKey) { Text($0.pages.map(String.init) ?? "—").monospacedDigit() }
|
||||
.width(min: 44, ideal: 54, max: 80)
|
||||
.customizationID("pages")
|
||||
TableColumn("State", value: \.state) { Text($0.stateName.capitalized) }
|
||||
.width(min: 70, ideal: 90, max: 120)
|
||||
.customizationID("state")
|
||||
// Reasons takes the spare width; the other columns are capped.
|
||||
TableColumn("Reasons", value: \.reasonsText) { Text($0.reasonsText).monospaced() }
|
||||
.width(min: 120, ideal: 320, max: .infinity)
|
||||
.customizationID("reasons")
|
||||
TableColumn("Submitted", value: \.submittedSortKey) {
|
||||
Text($0.submitted.map(Format.timestamp) ?? "—").monospacedDigit()
|
||||
}
|
||||
.width(150)
|
||||
.customizationID("submitted")
|
||||
}
|
||||
.alternatingRowBackgrounds(.disabled)
|
||||
.contextMenu(forSelectionType: Int.self) { ids in
|
||||
jobMenu(for: ids)
|
||||
}
|
||||
.onCopyCommand {
|
||||
[NSItemProvider(object: model.copyText(for: model.selection) as NSString)]
|
||||
}
|
||||
.overlay {
|
||||
if model.jobs.isEmpty {
|
||||
ContentUnavailableView(emptyTitle, systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
.padding(.bottom, StatusBar.height)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyTitle: String {
|
||||
switch model.jobFilter {
|
||||
case .active: return "No Active Jobs"
|
||||
case .completed: return "No Completed Jobs"
|
||||
case .all: return "No Jobs"
|
||||
}
|
||||
}
|
||||
|
||||
private var otherQueues: [String] {
|
||||
store.printers.map(\.name).filter { $0 != model.queue }
|
||||
}
|
||||
|
||||
@ViewBuilder private func jobButtons(for ids: Set<Int>) -> some View {
|
||||
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
|
||||
.disabled(!model.canPerform(.cancel, on: ids))
|
||||
.help("Cancel the selected jobs")
|
||||
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
|
||||
.disabled(!model.canPerform(.hold, on: ids))
|
||||
.help("Hold the selected jobs (lp -H hold)")
|
||||
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
|
||||
.disabled(!model.canPerform(.release, on: ids))
|
||||
.help("Release held jobs (lp -H resume)")
|
||||
Menu {
|
||||
ForEach(otherQueues, id: \.self) { queue in
|
||||
Button(queue) { run(.move(to: queue), ids) }
|
||||
}
|
||||
} label: {
|
||||
Label("Move To", systemImage: "arrow.right.circle")
|
||||
}
|
||||
.fixedSize()
|
||||
.disabled(!model.canPerform(.move(to: ""), on: ids) || otherQueues.isEmpty)
|
||||
.help("Move the selected jobs to another queue (lpmove)")
|
||||
}
|
||||
|
||||
@ViewBuilder private func jobMenu(for ids: Set<Int>) -> some View {
|
||||
Group {
|
||||
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
|
||||
.disabled(!model.canPerform(.cancel, on: ids))
|
||||
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
|
||||
.disabled(!model.canPerform(.hold, on: ids))
|
||||
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
|
||||
.disabled(!model.canPerform(.release, on: ids))
|
||||
Menu {
|
||||
ForEach(otherQueues, id: \.self) { queue in
|
||||
Button(queue) { run(.move(to: queue), ids) }
|
||||
}
|
||||
} label: {
|
||||
Label("Move To", systemImage: "arrow.right.circle")
|
||||
}
|
||||
.disabled(!model.canPerform(.move(to: ""), on: ids))
|
||||
Divider()
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(model.copyText(for: ids), forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(ids.isEmpty)
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
|
||||
private func run(_ action: JobAction, _ ids: Set<Int>) {
|
||||
Task { await model.perform(action, on: ids) }
|
||||
}
|
||||
}
|
||||
291
Sources/CUPSAdminApp/PrinterStore.swift
Normal file
291
Sources/CUPSAdminApp/PrinterStore.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
import AppKit
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
/// One sidebar row's worth of queue state, from CUPS-Get-Printers.
|
||||
struct PrinterSummary: Identifiable, Hashable {
|
||||
enum Status {
|
||||
case idle, processing, stopped
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .idle: return .green
|
||||
case .processing: return .blue
|
||||
case .stopped: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let name: String
|
||||
let state: Int?
|
||||
let reasons: [String]
|
||||
let stateMessage: String?
|
||||
let accepting: Bool
|
||||
let info: String?
|
||||
let location: String?
|
||||
let deviceURI: String?
|
||||
let jobCount: Int
|
||||
|
||||
var id: String { name }
|
||||
|
||||
static let requestedAttributes = [
|
||||
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
|
||||
"printer-is-accepting-jobs", "printer-info", "printer-location", "device-uri", "queued-job-count",
|
||||
]
|
||||
|
||||
init(_ attributes: IPPGroup) {
|
||||
name = attributes.string("printer-name") ?? "?"
|
||||
state = attributes.int("printer-state")
|
||||
reasons = attributes.strings("printer-state-reasons").filter { $0 != "none" }
|
||||
stateMessage = attributes.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 }
|
||||
accepting = attributes.bool("printer-is-accepting-jobs") ?? true
|
||||
info = attributes.string("printer-info").flatMap { $0.isEmpty ? nil : $0 }
|
||||
location = attributes.string("printer-location").flatMap { $0.isEmpty ? nil : $0 }
|
||||
deviceURI = attributes.string("device-uri").flatMap { $0.isEmpty ? nil : $0 }
|
||||
jobCount = attributes.int("queued-job-count") ?? 0
|
||||
}
|
||||
|
||||
var isPaused: Bool { state == 5 }
|
||||
|
||||
/// Red when stopped or rejecting jobs, blue while printing, green otherwise.
|
||||
var status: Status {
|
||||
if state == 5 || !accepting { return .stopped }
|
||||
return state == 4 ? .processing : .idle
|
||||
}
|
||||
|
||||
/// Location, else description, else nothing — never text that just restates the name, an IP or a URI.
|
||||
var secondaryLine: String? {
|
||||
[location, info].compactMap { $0 }.first { !Self.restates($0, name: name) }
|
||||
}
|
||||
|
||||
static func restates(_ text: String, name: String) -> Bool {
|
||||
func letters(_ s: String) -> String { s.lowercased().filter { $0.isLetter || $0.isNumber } }
|
||||
if letters(text) == letters(name) { return true }
|
||||
if text.contains("://") { return true }
|
||||
return text.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
/// "Idle", "Stopped — paused", "Idle, rejecting jobs".
|
||||
var stateSummary: String {
|
||||
var text = Format.printerState(state).capitalized
|
||||
if !reasons.isEmpty { text += " — " + reasons.joined(separator: ", ") }
|
||||
if !accepting { text += ", rejecting jobs" }
|
||||
return text
|
||||
}
|
||||
|
||||
func matches(search: String) -> Bool {
|
||||
guard !search.isEmpty else { return true }
|
||||
return [name, location, info, deviceURI].compactMap { $0 }
|
||||
.contains { $0.localizedCaseInsensitiveContains(search) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrinterStore {
|
||||
struct Failure: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
private(set) var printers: [PrinterSummary] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var hasLoaded = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var statusLine = ""
|
||||
/// Bumped after any write, so open detail views reload.
|
||||
private(set) var revision = 0
|
||||
/// The server default printer (CUPS-Get-Default), if any.
|
||||
private(set) var defaultPrinter: String?
|
||||
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil.
|
||||
private(set) var ppds: [String: PPD?] = [:]
|
||||
var failure: Failure?
|
||||
|
||||
let client = CupsClient()
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
|
||||
|
||||
/// "21 printers · 0 active jobs" for the status bar.
|
||||
var summaryLine: String {
|
||||
guard hasLoaded, errorMessage == nil else { return "" }
|
||||
let jobs = printers.reduce(0) { $0 + $1.jobCount }
|
||||
return "\(printers.count) printer\(printers.count == 1 ? "" : "s") · \(jobs) active job\(jobs == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
/// Last-action line for the status bar, from any view.
|
||||
func report(_ line: String) {
|
||||
statusLine = line
|
||||
}
|
||||
|
||||
func fail(_ title: String, _ message: String) {
|
||||
failure = Failure(title: title, message: Self.explain(message))
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
let started = Date()
|
||||
log.info("refresh started")
|
||||
|
||||
do {
|
||||
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
|
||||
printers = groups.map(PrinterSummary.init).sorted {
|
||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
errorMessage = nil
|
||||
defaultPrinter = try? await client.getDefaultPrinter()
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
statusLine = "Refreshed \(printers.count) printers in \(elapsedText(seconds))"
|
||||
log.info("refresh finished: \(self.printers.count) printers in \(seconds, format: .fixed(precision: 3)) s")
|
||||
} catch {
|
||||
errorMessage = String(describing: error)
|
||||
statusLine = "ERROR: \(error)"
|
||||
log.error("refresh failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
hasLoaded = true
|
||||
}
|
||||
|
||||
/// Pause/Resume/Accept/Reject/Cancel All, verified against cupsd, reported in the status bar.
|
||||
func perform(_ action: PrinterAction, on queue: String) async {
|
||||
let started = Date()
|
||||
log.info("\(action.title, privacy: .public) \(queue, privacy: .public)")
|
||||
do {
|
||||
let outcome = try await QueueActions.perform(action, queue: queue, client: client)
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
if outcome.succeeded {
|
||||
report("\(action.pastTense) \(queue) in \(elapsedText(seconds))")
|
||||
log.info("\(action.title, privacy: .public) \(queue, privacy: .public) ok in \(seconds, format: .fixed(precision: 3)) s")
|
||||
} else {
|
||||
let message = outcome.message ?? "failed"
|
||||
report("ERROR: \(action.title) \(queue): \(message)")
|
||||
fail("Couldn’t \(action.title.lowercased()) \(queue)", "\(message)\n\n\(outcome.command)")
|
||||
log.error("\(action.title, privacy: .public) \(queue, privacy: .public) failed: \(message, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: \(action.title) \(queue): \(error)")
|
||||
fail("Couldn’t \(action.title.lowercased()) \(queue)", String(describing: error))
|
||||
}
|
||||
revision += 1
|
||||
await refreshQuietly()
|
||||
}
|
||||
|
||||
/// Refresh the sidebar without replacing the last action's status line.
|
||||
func refreshQuietly() async {
|
||||
let line = statusLine
|
||||
await refresh()
|
||||
statusLine = line
|
||||
}
|
||||
|
||||
func noteChange(queue: String? = nil) {
|
||||
if let queue { ppds[queue] = nil }
|
||||
revision += 1
|
||||
}
|
||||
|
||||
// MARK: PPDs for Quick Actions
|
||||
|
||||
/// Fetches PPDs not yet cached, in the background of a refresh.
|
||||
func loadPPDs() async {
|
||||
for printer in printers where ppds[printer.name] == nil {
|
||||
let text = try? await client.getPPD(queue: printer.name)
|
||||
ppds[printer.name] = .some(text.map(PPD.init(text:)))
|
||||
}
|
||||
}
|
||||
|
||||
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
|
||||
func unavailableReason(_ action: QuickAction, queue: String) -> String? {
|
||||
if case .defaultPrinter = action.effect {
|
||||
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
|
||||
}
|
||||
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" }
|
||||
if action.isAvailable(for: cached) { return nil }
|
||||
if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) {
|
||||
return unavailable.reason
|
||||
}
|
||||
return "Not available on \(queue)"
|
||||
}
|
||||
|
||||
// MARK: per-printer actions
|
||||
|
||||
func printTestPage(_ queue: String) async {
|
||||
let started = Date()
|
||||
do {
|
||||
let (outcome, jobID) = try await QueueActions.printTestPage(queue: queue, client: client)
|
||||
if outcome.succeeded, let jobID {
|
||||
report("Sent test page to \(queue) as job \(jobID) in \(elapsedText(since: started))")
|
||||
log.info("test page \(queue, privacy: .public) job \(jobID)")
|
||||
} else {
|
||||
report("ERROR: test page on \(queue): \(outcome.message ?? "failed")")
|
||||
fail("Couldn’t print a test page on \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: test page on \(queue): \(error)")
|
||||
fail("Couldn’t print a test page on \(queue)", String(describing: error))
|
||||
}
|
||||
noteChange()
|
||||
await refreshQuietly()
|
||||
}
|
||||
|
||||
/// Returns true when the queue is gone.
|
||||
func delete(_ queue: String) async -> Bool {
|
||||
let started = Date()
|
||||
var deleted = false
|
||||
do {
|
||||
let outcome = try await QueueActions.delete(queue: queue, client: client)
|
||||
deleted = outcome.succeeded
|
||||
if deleted {
|
||||
report("Deleted \(queue) in \(elapsedText(since: started))")
|
||||
log.info("deleted \(queue, privacy: .public)")
|
||||
} else {
|
||||
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
|
||||
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: delete \(queue): \(error)")
|
||||
fail("Couldn’t delete \(queue)", String(describing: error))
|
||||
}
|
||||
ppds[queue] = nil
|
||||
noteChange()
|
||||
await refreshQuietly()
|
||||
return deleted
|
||||
}
|
||||
|
||||
func showPPDInFinder(_ queue: String) {
|
||||
let url = URL(fileURLWithPath: "/private/etc/cups/ppd/\(queue).ppd")
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
func hasPPDFile(_ queue: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: "/private/etc/cups/ppd/\(queue).ppd")
|
||||
}
|
||||
|
||||
func copyDeviceURI(_ printer: PrinterSummary) {
|
||||
guard let uri = printer.deviceURI else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(uri, forType: .string)
|
||||
report("Copied device URI for \(printer.name)")
|
||||
}
|
||||
|
||||
/// Tool/IPP output plus the fix when it's a permissions problem.
|
||||
static func explain(_ message: String) -> String {
|
||||
let lower = message.lowercased()
|
||||
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|
||||
|| lower.contains("authentication") || lower.contains("not-authorized") {
|
||||
return message + "\n\nThis needs an admin account or membership in _lpadmin:\n"
|
||||
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
/// "42 ms" under a tenth of a second, else "0.4 s".
|
||||
func elapsedText(_ seconds: TimeInterval) -> String {
|
||||
seconds < 0.1 ? "\(max(1, Int((seconds * 1000).rounded()))) ms" : String(format: "%.1f s", seconds)
|
||||
}
|
||||
|
||||
func elapsedText(since start: Date) -> String {
|
||||
elapsedText(Date().timeIntervalSince(start))
|
||||
}
|
||||
262
Sources/CUPSAdminApp/QuickActionSheet.swift
Normal file
262
Sources/CUPSAdminApp/QuickActionSheet.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
import CupsKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// One Quick Action on one printer: shows what will change, asks only for what's needed, runs lpadmin, reads back.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class QuickActionModel: Identifiable {
|
||||
enum Phase: Equatable {
|
||||
case loading, ready, running, finished(succeeded: Bool)
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let action: QuickAction
|
||||
let queue: String
|
||||
var code = ""
|
||||
var showCurrentCode = false
|
||||
private(set) var phase: Phase = .loading
|
||||
private(set) var state: OptionState?
|
||||
private(set) var currentDefault: String?
|
||||
private(set) var readBack: [String: OptionReadBack] = [:]
|
||||
private(set) var resultMessage: String?
|
||||
private let store: PrinterStore
|
||||
|
||||
init(action: QuickAction, queue: String, store: PrinterStore) {
|
||||
self.action = action
|
||||
self.queue = queue
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
state = try await OptionState.load(client: store.client, queue: queue)
|
||||
currentDefault = try await store.client.getDefaultPrinter()
|
||||
phase = .ready
|
||||
} catch {
|
||||
resultMessage = String(describing: error)
|
||||
phase = .finished(succeeded: false)
|
||||
}
|
||||
}
|
||||
|
||||
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) }
|
||||
|
||||
var validationError: String? { action.validationError(code, ppd: state?.ppd) }
|
||||
|
||||
/// What will be written, or why the action can't run here.
|
||||
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||
action.changes(for: state?.ppd, value: clearing ? "" : code)
|
||||
}
|
||||
|
||||
var command: String {
|
||||
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
|
||||
guard case .success(let changes) = plannedChanges() else { return "" }
|
||||
return OptionApplier.displayCommand(queue: queue, changes: changes)
|
||||
}
|
||||
|
||||
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
|
||||
var userDefaultNote: String? {
|
||||
guard case .defaultPrinter = action.effect,
|
||||
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
|
||||
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
|
||||
let name = line.split(separator: " ")[1].split(separator: "/")[0]
|
||||
guard name != queue else { return nil }
|
||||
let exists = store.printers.contains { $0.name == name }
|
||||
return "Your ~/.cups/lpoptions has “Default \(name)”, which overrides the server default for your account"
|
||||
+ (exists ? "." : " — and there is no queue named \(name) on this Mac.")
|
||||
}
|
||||
|
||||
func run(clearing: Bool = false) async {
|
||||
phase = .running
|
||||
let started = Date()
|
||||
do {
|
||||
let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil),
|
||||
client: store.client)
|
||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||
let seconds = elapsedText(since: started)
|
||||
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "")
|
||||
if outcome.succeeded {
|
||||
resultMessage = nil
|
||||
store.report("\(title) on \(queue) in \(seconds)")
|
||||
} else {
|
||||
resultMessage = outcome.message ?? "Not applied"
|
||||
store.report("ERROR: \(title) on \(queue): \(resultMessage!)")
|
||||
}
|
||||
phase = .finished(succeeded: outcome.succeeded)
|
||||
} catch let unavailable as QuickActionUnavailable {
|
||||
resultMessage = unavailable.reason
|
||||
phase = .finished(succeeded: false)
|
||||
} catch {
|
||||
resultMessage = PrinterStore.explain(String(describing: error))
|
||||
store.report("ERROR: \(action.title) on \(queue): \(error)")
|
||||
phase = .finished(succeeded: false)
|
||||
}
|
||||
store.noteChange(queue: queue)
|
||||
await store.refreshQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
struct QuickActionSheet: View {
|
||||
@Bindable var model: QuickActionModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
switch model.phase {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
default:
|
||||
content
|
||||
}
|
||||
|
||||
HStack {
|
||||
if model.action.input == .userCode, model.currentCode != nil, model.phase == .ready {
|
||||
Button("Clear Code", role: .destructive) { Task { await model.run(clearing: true) } }
|
||||
}
|
||||
Spacer()
|
||||
switch model.phase {
|
||||
case .finished:
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
default:
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Apply") { Task { await model.run() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!canApply)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.task { await model.load() }
|
||||
}
|
||||
|
||||
private var canApply: Bool {
|
||||
guard model.phase == .ready else { return false }
|
||||
if model.action.input == .userCode {
|
||||
return !model.code.isEmpty && model.validationError == nil
|
||||
}
|
||||
if case .failure = model.plannedChanges() { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if model.action.input == .userCode {
|
||||
Form {
|
||||
LabeledContent("Current code") {
|
||||
HStack(spacing: 8) {
|
||||
if let current = model.currentCode {
|
||||
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
Toggle("Show", isOn: $model.showCurrentCode)
|
||||
.toggleStyle(.checkbox)
|
||||
} else {
|
||||
Text("None").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
TextField("User code", text: $model.code, prompt: Text("Digits"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospacedDigit()
|
||||
.frame(width: 160)
|
||||
.disabled(model.phase != .ready)
|
||||
} label: {
|
||||
Text("New code")
|
||||
if let error = model.validationError {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.frame(height: 130)
|
||||
}
|
||||
|
||||
if case .defaultPrinter = model.action.effect {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Server default").foregroundStyle(.secondary)
|
||||
Text(model.currentDefault ?? "None")
|
||||
Image(systemName: "arrow.right").foregroundStyle(.secondary)
|
||||
Text(model.queue)
|
||||
resultIcon(applied: model.phase == .finished(succeeded: true) ? true
|
||||
: model.phase == .finished(succeeded: false) ? false : nil)
|
||||
}
|
||||
}
|
||||
if let note = model.userDefaultNote {
|
||||
Label(note, systemImage: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else {
|
||||
switch model.plannedChanges() {
|
||||
case .failure(let unavailable) where model.action.input == .none:
|
||||
Label(unavailable.reason, systemImage: "exclamationmark.triangle.fill")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
default:
|
||||
changeGrid
|
||||
}
|
||||
}
|
||||
|
||||
if !model.command.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Command").font(.callout.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let message = model.resultMessage {
|
||||
Label(message, systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var changeGrid: some View {
|
||||
let changes = (try? model.plannedChanges().get()) ?? []
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Setting"); Text("Current"); Text(""); Text("New"); Text("")
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ForEach(changes, id: \.key) { change in
|
||||
GridRow {
|
||||
Text(change.key).monospaced()
|
||||
Text(model.state?.queueValue(change.key) ?? "—").foregroundStyle(.secondary).monospaced()
|
||||
Image(systemName: "arrow.right").foregroundStyle(.secondary)
|
||||
Text(model.action.input == .userCode && change.key == "RIUserCode" && !model.code.isEmpty
|
||||
? "Custom.\(model.code)" : change.value).monospaced()
|
||||
resultIcon(applied: model.readBack[change.key]?.applied)
|
||||
.help(model.readBack[change.key]?.reason ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
@ViewBuilder private func resultIcon(applied: Bool?) -> some View {
|
||||
switch applied {
|
||||
case true?: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
case false?: Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||
case nil:
|
||||
if model.phase == .running { ProgressView().controlSize(.mini) } else { Color.clear.frame(width: 14, height: 14) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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: " ")
|
||||
}
|
||||
}
|
||||
101
Sources/CupsKit/Format.swift
Normal file
101
Sources/CupsKit/Format.swift
Normal 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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
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...])
|
||||
}
|
||||
}
|
||||
}
|
||||
477
Sources/CupsKit/OptionEditing.swift
Normal file
477
Sources/CupsKit/OptionEditing.swift
Normal 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
173
Sources/CupsKit/PPD.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
35
Sources/CupsKit/PrinterType.swift
Normal file
35
Sources/CupsKit/PrinterType.swift
Normal 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: ", ")
|
||||
}
|
||||
}
|
||||
268
Sources/CupsKit/QueueActions.swift
Normal file
268
Sources/CupsKit/QueueActions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
152
Sources/CupsKit/QueueAdmin.swift
Normal file
152
Sources/CupsKit/QueueAdmin.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
139
Sources/CupsKit/QuickActions.swift
Normal file
139
Sources/CupsKit/QuickActions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Sources/cupsadmin/Commands/CancelCommand.swift
Normal file
36
Sources/cupsadmin/Commands/CancelCommand.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
enum CancelCommand {
|
||||
/// Accepts a bare job id ("123") or lpstat's "Queue-123" form.
|
||||
static func parseJobID(_ argument: String) throws -> Int {
|
||||
let digits = argument.split(separator: "-").last.map(String.init) ?? argument
|
||||
guard let id = Int(digits), id > 0 else {
|
||||
throw CupsAdminError.usage("cancel needs a job id like 123 or Queue-123, got \(argument)")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
static func run(client: CupsClient, jobID: Int) async throws -> String {
|
||||
let requested = ["job-id", "job-name", "job-printer-uri", "job-originating-user-name", "job-state"]
|
||||
let job: IPPGroup
|
||||
do {
|
||||
job = try await client.getJobAttributes(jobID: jobID, requested: requested)
|
||||
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
|
||||
throw CupsAdminError.failed("no such job \(jobID)")
|
||||
}
|
||||
|
||||
let queue = JobsCommand.queueName(job) ?? "?"
|
||||
let name = job.string("job-name") ?? "-"
|
||||
let owner = job.string("job-originating-user-name") ?? "-"
|
||||
print("Job \(jobID) on \(queue): \"\(name)\" by \(owner), \(Format.jobState(job.int("job-state")))")
|
||||
|
||||
try await client.cancelJob(jobID: jobID)
|
||||
|
||||
// Read back rather than trusting the successful status.
|
||||
let after = try? await client.getJobAttributes(jobID: jobID, requested: ["job-state"])
|
||||
let state = Format.jobState(after?.int("job-state"))
|
||||
print("Now: \(after == nil ? "gone from job history" : state)")
|
||||
return "OK: canceled job \(jobID) on \(queue)"
|
||||
}
|
||||
}
|
||||
39
Sources/cupsadmin/Commands/JobsCommand.swift
Normal file
39
Sources/cupsadmin/Commands/JobsCommand.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
enum JobsCommand {
|
||||
static func run(client: CupsClient, queue: String?, which: String) async throws -> String {
|
||||
let jobs = try await client.getJobs(queue: queue, which: which, requested: [
|
||||
"job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-state",
|
||||
"job-state-reasons", "job-k-octets", "time-at-creation",
|
||||
])
|
||||
let rows = jobs.sorted { ($0.int("job-id") ?? 0) < ($1.int("job-id") ?? 0) }.map { j -> [String] in
|
||||
let jobQueue = queueName(j)
|
||||
return [
|
||||
j.int("job-id").map(String.init) ?? "?",
|
||||
jobQueue ?? queue ?? "?",
|
||||
j.string("job-originating-user-name") ?? "-",
|
||||
Format.jobState(j.int("job-state")),
|
||||
Format.kilobytes(j.int("job-k-octets")),
|
||||
Format.epoch(j.int("time-at-creation")),
|
||||
Format.reasons(j.strings("job-state-reasons")),
|
||||
j.string("job-name") ?? "-",
|
||||
]
|
||||
}
|
||||
|
||||
let scope = queue.map { " on \($0)" } ?? ""
|
||||
let label = which == "not-completed" ? "active" : which
|
||||
if rows.isEmpty {
|
||||
print("No \(label) jobs\(scope).")
|
||||
} else {
|
||||
printTable(headers: ["ID", "QUEUE", "OWNER", "STATE", "SIZE", "SUBMITTED", "REASONS", "NAME"], rows: rows)
|
||||
}
|
||||
return "OK: \(rows.count) \(label) job\(rows.count == 1 ? "" : "s")\(scope)"
|
||||
}
|
||||
|
||||
/// Queue name from job-printer-uri (ipp://host/printers/NAME).
|
||||
static func queueName(_ job: IPPGroup) -> String? {
|
||||
guard let uri = job.string("job-printer-uri"), let last = uri.split(separator: "/").last else { return nil }
|
||||
return last.removingPercentEncoding ?? String(last)
|
||||
}
|
||||
}
|
||||
54
Sources/cupsadmin/Commands/OptionsCommand.swift
Normal file
54
Sources/cupsadmin/Commands/OptionsCommand.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
enum OptionsCommand {
|
||||
/// Queue-level settings lpadmin -o can change that aren't named *-default.
|
||||
static let policyAttributes = ["printer-error-policy", "printer-op-policy", "printer-is-shared",
|
||||
"port-monitor", "job-quota-period", "job-k-limit", "job-page-limit"]
|
||||
|
||||
static func run(client: CupsClient, queue: String, full: Bool) async throws -> String {
|
||||
let attributes = try await client.getPrinterAttributes(queue: queue, requested: ["all"])
|
||||
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
|
||||
|
||||
if let ppd {
|
||||
let a = ppd.attributes
|
||||
let details = [a["Manufacturer"], a["PCFileName"], a["FileVersion"].map { "version \($0)" }]
|
||||
.compactMap { $0 }.joined(separator: ", ")
|
||||
print("PPD: \(a["NickName"] ?? "?")" + (details.isEmpty ? "" : " (\(details))"))
|
||||
print("PPD options (* = queue default; per-user lpoptions overrides not applied):")
|
||||
for option in ppd.options {
|
||||
let choices = option.choices.map { $0 == option.defaultChoice ? "*\($0)" : $0 }
|
||||
+ [option.customChoice].compactMap { $0 }
|
||||
var line = choices.joined(separator: " ")
|
||||
if option.choices.isEmpty, let d = option.defaultChoice { line = "*\(d)" }
|
||||
print(" \(option.keyword)/\(option.text): \(line)")
|
||||
}
|
||||
} else {
|
||||
print("PPD: none (raw queue, or cupsd could not provide one)")
|
||||
}
|
||||
|
||||
let defaultNames = attributes.attributes.map(\.name).filter { $0.hasSuffix("-default") }.sorted()
|
||||
let names = defaultNames + policyAttributes.filter { attributes[$0] != nil }
|
||||
let rows = names.map { name -> [String] in
|
||||
let base = name.hasSuffix("-default") ? String(name.dropLast("-default".count)) : name
|
||||
return [name,
|
||||
cell(attributes[name], full: full),
|
||||
cell(attributes[base + "-supported"], full: full)]
|
||||
}
|
||||
print("\nIPP defaults and queue policy:")
|
||||
printTable(headers: [" ATTRIBUTE", "DEFAULT", "SUPPORTED"], rows: rows.map { [" " + $0[0]] + $0.dropFirst() })
|
||||
if !full { print("\n(long lists truncated; use --full)") }
|
||||
|
||||
return "OK: \(queue) \(ppd?.options.count ?? 0) PPD options, \(rows.count) IPP defaults/policies"
|
||||
}
|
||||
|
||||
private static func cell(_ attribute: IPPAttribute?, full: Bool) -> String {
|
||||
guard let attribute else { return "-" }
|
||||
var values = attribute.values.map { Format.value($0, attribute: attribute.name) }
|
||||
if !full, values.count > 8 {
|
||||
values = Array(values.prefix(8)) + ["… (+\(attribute.values.count - 8) more)"]
|
||||
}
|
||||
let text = values.joined(separator: ", ")
|
||||
return !full && text.count > 100 ? String(text.prefix(99)) + "…" : text
|
||||
}
|
||||
}
|
||||
107
Sources/cupsadmin/Commands/PrinterCommands.swift
Normal file
107
Sources/cupsadmin/Commands/PrinterCommands.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
enum PrintersCommand {
|
||||
static func run(client: CupsClient) async throws -> String {
|
||||
let printers = try await client.getPrinters(requested: [
|
||||
"printer-name", "printer-type", "printer-state", "printer-state-reasons",
|
||||
"printer-is-accepting-jobs", "printer-is-shared", "queued-job-count", "device-uri",
|
||||
])
|
||||
let sorted = printers.sorted {
|
||||
($0.string("printer-name") ?? "").localizedStandardCompare($1.string("printer-name") ?? "") == .orderedAscending
|
||||
}
|
||||
let rows = sorted.map { p -> [String] in
|
||||
[
|
||||
p.string("printer-name") ?? "?",
|
||||
PrinterType.kind(p.int("printer-type")),
|
||||
Format.printerState(p.int("printer-state")),
|
||||
Format.yesNo(p.bool("printer-is-accepting-jobs")),
|
||||
Format.yesNo(p.bool("printer-is-shared")),
|
||||
p.int("queued-job-count").map(String.init) ?? "-",
|
||||
Format.reasons(p.strings("printer-state-reasons")),
|
||||
p.string("device-uri") ?? "-",
|
||||
]
|
||||
}
|
||||
if rows.isEmpty {
|
||||
print("No queues.")
|
||||
} else {
|
||||
printTable(headers: ["QUEUE", "KIND", "STATE", "ACCEPTING", "SHARED", "JOBS", "REASONS", "DEVICE URI"],
|
||||
rows: rows)
|
||||
}
|
||||
|
||||
let classes = sorted.filter { ($0.int("printer-type") ?? 0) & PrinterType.isClass != 0 }.count
|
||||
let problems = sorted.filter {
|
||||
$0.int("printer-state") == 5 || $0.bool("printer-is-accepting-jobs") == false
|
||||
}.count
|
||||
var status = "OK: \(sorted.count - classes) printers"
|
||||
if classes > 0 { status += ", \(classes) class\(classes == 1 ? "" : "es")" }
|
||||
if problems > 0 { status += ", \(problems) stopped or rejecting" }
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
enum PrinterCommand {
|
||||
static func run(client: CupsClient, queue: String, dumpAll: Bool) async throws -> String {
|
||||
// "all" in CUPS excludes a few expensive attributes (e.g. media-col-database), which is what we want.
|
||||
let p = try await client.getPrinterAttributes(queue: queue, requested: ["all"])
|
||||
|
||||
if dumpAll {
|
||||
let width = min(p.attributes.map(\.name.count).max() ?? 0, 40)
|
||||
for attribute in p.attributes.sorted(by: { $0.name < $1.name }) {
|
||||
let values = attribute.values.map { Format.value($0, attribute: attribute.name) }
|
||||
let pad = String(repeating: " ", count: max(0, width - attribute.name.count))
|
||||
print("\(attribute.name)\(pad) \(values.joined(separator: ", "))")
|
||||
}
|
||||
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), \(p.attributes.count) attributes"
|
||||
}
|
||||
|
||||
let type = p.int("printer-type")
|
||||
var lines: [(String, String)] = [
|
||||
("Queue", p.string("printer-name") ?? queue),
|
||||
("Description", p.string("printer-info") ?? "-"),
|
||||
("Location", p.string("printer-location") ?? "-"),
|
||||
("Make and model", p.string("printer-make-and-model") ?? "-"),
|
||||
("Kind", PrinterType.kind(type)),
|
||||
]
|
||||
if (type ?? 0) & PrinterType.isClass != 0 {
|
||||
lines.append(("Members", p.strings("member-names").joined(separator: ", ")))
|
||||
}
|
||||
let stateMessage = p.string("printer-state-message") ?? ""
|
||||
lines += [
|
||||
("Device URI", p.string("device-uri") ?? "-"),
|
||||
("Printer URI", p.strings("printer-uri-supported").first ?? "-"),
|
||||
("State", Format.printerState(p.int("printer-state")) + (stateMessage.isEmpty ? "" : " — \(stateMessage)")),
|
||||
("State reasons", Format.reasons(p.strings("printer-state-reasons"))),
|
||||
("State changed", Format.epoch(p.int("printer-state-change-time"))),
|
||||
("Accepting jobs", Format.yesNo(p.bool("printer-is-accepting-jobs"))),
|
||||
("Shared", Format.yesNo(p.bool("printer-is-shared"))),
|
||||
("Queued jobs", p.int("queued-job-count").map(String.init) ?? "-"),
|
||||
("Capabilities", PrinterType.capabilities(type)),
|
||||
]
|
||||
|
||||
let defaults = ["media-default", "sides-default", "print-color-mode-default", "print-quality-default",
|
||||
"output-bin-default", "copies-default", "job-sheets-default", "printer-error-policy",
|
||||
"printer-op-policy"]
|
||||
for name in defaults {
|
||||
guard let attribute = p[name] else { continue }
|
||||
lines.append((name, attribute.values.map { Format.value($0, attribute: name) }.joined(separator: ", ")))
|
||||
}
|
||||
|
||||
let markerNames = p.strings("marker-names")
|
||||
let markerLevels = p.ints("marker-levels")
|
||||
for (i, marker) in markerNames.enumerated() {
|
||||
let level = i < markerLevels.count ? markerLevels[i] : -1
|
||||
lines.append((i == 0 ? "Supplies" : "", "\(marker): " + (level >= 0 ? "\(level)%" : "unknown")))
|
||||
}
|
||||
|
||||
let width = lines.map(\.0.count).max() ?? 0
|
||||
for (label, value) in lines {
|
||||
let heading = label.isEmpty ? String(repeating: " ", count: width + 1)
|
||||
: label + ":" + String(repeating: " ", count: width - label.count)
|
||||
print("\(heading) \(value)")
|
||||
}
|
||||
print("\n(use --all for every attribute)")
|
||||
|
||||
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
|
||||
}
|
||||
}
|
||||
104
Sources/cupsadmin/Commands/QueueAdminCommands.swift
Normal file
104
Sources/cupsadmin/Commands/QueueAdminCommands.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
extension QueueSpec {
|
||||
/// Parses `<queue> [-v uri] [-m model | -P ppd] [-D text] [-L text] [-o key=value ...]`.
|
||||
static func parse(_ args: [String], command: String) throws -> (queue: String, spec: QueueSpec) {
|
||||
var spec = QueueSpec()
|
||||
var queue: String?
|
||||
var i = 0
|
||||
func value(_ flag: String) throws -> String {
|
||||
i += 1
|
||||
guard i < args.count else { throw CupsAdminError.usage("\(flag) needs a value") }
|
||||
return args[i]
|
||||
}
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
switch arg {
|
||||
case "-v": spec.deviceURI = try value(arg)
|
||||
case "-m": spec.model = try value(arg)
|
||||
case "-P": spec.ppdFile = try value(arg)
|
||||
case "-D": spec.description = try value(arg)
|
||||
case "-L": spec.location = try value(arg)
|
||||
case "-o":
|
||||
let pair = try value(arg)
|
||||
guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else {
|
||||
throw CupsAdminError.usage("-o expects key=value, got \(pair)")
|
||||
}
|
||||
spec.options.append((String(pair[..<eq]), String(pair[pair.index(after: eq)...])))
|
||||
case _ where arg.hasPrefix("-"):
|
||||
throw CupsAdminError.usage("unknown option \(arg) for \(command)")
|
||||
default:
|
||||
guard queue == nil else { throw CupsAdminError.usage("\(command) takes one queue name, got extra \(arg)") }
|
||||
queue = arg
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
guard let queue else { throw CupsAdminError.usage("\(command) needs a queue name") }
|
||||
guard spec.model == nil || spec.ppdFile == nil else { throw CupsAdminError.usage("use -m or -P, not both") }
|
||||
if let ppdFile = spec.ppdFile, !FileManager.default.fileExists(atPath: ppdFile) {
|
||||
throw CupsAdminError.usage("PPD file not found: \(ppdFile)")
|
||||
}
|
||||
return (queue, spec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Echoes the command, runs it through CupsKit, and passes its output through (lpadmin warnings etc.).
|
||||
func runTool(_ executable: String, _ arguments: [String]) throws -> Int32 {
|
||||
print("+ " + CupsTools.commandLine(executable, arguments))
|
||||
fflush(stdout)
|
||||
let result = try CupsTools.run(executable, arguments)
|
||||
FileHandle.standardOutput.write(result.standardOutput)
|
||||
FileHandle.standardError.write(result.standardError)
|
||||
return result.status
|
||||
}
|
||||
|
||||
enum AddCommand {
|
||||
static func run(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> String {
|
||||
let spec = try await QueueAdmin.prepareAdd(client: client, queue: queue, spec: spec, shared: shared)
|
||||
let status = try runTool(CupsTools.lpadminPath, QueueAdmin.addArguments(queue: queue, spec: spec))
|
||||
let after = try await QueueAdmin.confirmAdded(client: client, queue: queue, lpadminStatus: status)
|
||||
|
||||
let a = after.attributes
|
||||
print("\nAdded \(queue): \(Format.printerState(a.int("printer-state"))), "
|
||||
+ "accepting \(Format.yesNo(a.bool("printer-is-accepting-jobs"))), "
|
||||
+ "model \(a.string("printer-make-and-model") ?? "-")")
|
||||
|
||||
let settings = after.settings(for: spec)
|
||||
let failed = settings.filter { !$0.applied }
|
||||
if !settings.isEmpty {
|
||||
printTable(headers: ["SETTING", "SOURCE", "VALUE", "RESULT"], rows: settings.map {
|
||||
[$0.name, $0.source, $0.value ?? "-", $0.applied ? "ok" : "NOT APPLIED (wanted \($0.wanted))"]
|
||||
})
|
||||
}
|
||||
guard failed.isEmpty else {
|
||||
throw CupsAdminError.failed("added \(queue), but \(failed.count) setting(s) not applied: "
|
||||
+ failed.map(\.name).joined(separator: ", "))
|
||||
}
|
||||
return "OK: added \(queue), \(settings.count) setting(s) verified"
|
||||
}
|
||||
}
|
||||
|
||||
enum SetCommand {
|
||||
static func run(client: CupsClient, queue: String, spec: QueueSpec) async throws -> String {
|
||||
let before = try await QueueAdmin.prepareSet(client: client, queue: queue, spec: spec)
|
||||
let status = try runTool(CupsTools.lpadminPath, spec.lpadminArguments(queue: queue))
|
||||
let after = try await QueueAdmin.confirmSet(client: client, queue: queue, spec: spec, lpadminStatus: status)
|
||||
|
||||
print()
|
||||
let rows = zip(before, after).map { b, a -> [String] in
|
||||
[a.name, a.source, b.value ?? "-", a.value ?? "-", a.applied ? "ok" : "NOT APPLIED (wanted \(a.wanted))"]
|
||||
}
|
||||
printTable(headers: ["SETTING", "SOURCE", "BEFORE", "AFTER", "RESULT"], rows: rows)
|
||||
if spec.model != nil || spec.ppdFile != nil {
|
||||
print("(driver changed; see `cupsadmin printer \(queue)` for make-and-model)")
|
||||
}
|
||||
|
||||
let failed = after.filter { !$0.applied }
|
||||
guard failed.isEmpty else {
|
||||
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
|
||||
+ failed.map(\.name).joined(separator: ", "))
|
||||
}
|
||||
return "OK: \(queue) \(after.count) setting(s) changed and verified"
|
||||
}
|
||||
}
|
||||
73
Sources/cupsadmin/Commands/QuickCommand.swift
Normal file
73
Sources/cupsadmin/Commands/QuickCommand.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
enum QuickCommand {
|
||||
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
|
||||
guard let name = args.first else {
|
||||
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
||||
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
|
||||
})
|
||||
return "OK: \(QuickAction.all.count) quick actions"
|
||||
}
|
||||
guard let action = QuickAction.named(name) else {
|
||||
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
|
||||
}
|
||||
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
|
||||
let queue = args[1]
|
||||
let value: String?
|
||||
switch action.input {
|
||||
case .none:
|
||||
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
|
||||
value = nil
|
||||
case .userCode:
|
||||
if clear {
|
||||
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
||||
value = ""
|
||||
} else {
|
||||
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
|
||||
value = args[2]
|
||||
}
|
||||
}
|
||||
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||
|
||||
if case .defaultPrinter = action.effect {
|
||||
let before = try await client.getDefaultPrinter()
|
||||
let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
|
||||
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
|
||||
let after = try await client.getDefaultPrinter()
|
||||
print()
|
||||
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"],
|
||||
rows: [["server default", before ?? "none", after ?? "none", after == queue ? "ok" : "NOT APPLIED (wanted \(queue))"]])
|
||||
guard after == queue else { throw CupsAdminError.failed("lpadmin exited 0 but the server default is \(after ?? "none")") }
|
||||
return "OK: \(queue) is the server default printer"
|
||||
}
|
||||
|
||||
let before = try await OptionState.load(client: client, queue: queue)
|
||||
if let value, let error = action.validationError(value, ppd: before.ppd) {
|
||||
throw CupsAdminError.usage("user code \(value): \(error)")
|
||||
}
|
||||
let changes: [OptionChange]
|
||||
switch action.changes(for: before.ppd, value: value) {
|
||||
case .success(let resolved): changes = resolved
|
||||
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)")
|
||||
}
|
||||
|
||||
let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes)
|
||||
let status = try runTool(executable, arguments)
|
||||
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
|
||||
let after = try await OptionState.load(client: client, queue: queue)
|
||||
let checks = changes.map { OptionApplier.verify($0, state: after) }
|
||||
|
||||
print()
|
||||
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in
|
||||
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
|
||||
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
|
||||
})
|
||||
let failed = checks.filter { !$0.applied }
|
||||
guard failed.isEmpty else {
|
||||
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
|
||||
+ failed.map(\.key).joined(separator: ", "))
|
||||
}
|
||||
return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified"
|
||||
}
|
||||
}
|
||||
18
Sources/cupsadmin/Commands/TableOutput.swift
Normal file
18
Sources/cupsadmin/Commands/TableOutput.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
/// Left-aligned columns, two spaces apart; last column unpadded.
|
||||
func printTable(headers: [String], rows: [[String]]) {
|
||||
var widths = headers.map(\.count)
|
||||
for row in rows {
|
||||
for (i, cell) in row.enumerated() where i < widths.count {
|
||||
widths[i] = max(widths[i], cell.count)
|
||||
}
|
||||
}
|
||||
func line(_ cells: [String]) -> String {
|
||||
cells.enumerated().map { i, cell in
|
||||
i == cells.count - 1 ? cell : cell + String(repeating: " ", count: widths[i] - cell.count)
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
print(line(headers))
|
||||
rows.forEach { print(line($0)) }
|
||||
}
|
||||
138
Sources/cupsadmin/main.swift
Normal file
138
Sources/cupsadmin/main.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import CupsKit
|
||||
|
||||
// Hard rule: started/status/finished on every exit path. These go to stderr so stdout stays pipeable.
|
||||
|
||||
let startTime = Date()
|
||||
|
||||
func clock(_ date: Date) -> String {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss"
|
||||
return f.string(from: date)
|
||||
}
|
||||
|
||||
func printErr(_ line: String) {
|
||||
fflush(stdout)
|
||||
FileHandle.standardError.write(Data((line + "\n").utf8))
|
||||
}
|
||||
|
||||
func finish(_ code: Int32, _ status: String) -> Never {
|
||||
printErr(status)
|
||||
let end = Date()
|
||||
let minutes = String(format: "%.1f", end.timeIntervalSince(startTime) / 60)
|
||||
printErr("finished \(clock(end)) (total \(minutes) min)")
|
||||
exit(code)
|
||||
}
|
||||
|
||||
let usage = """
|
||||
usage: cupsadmin <command> [args]
|
||||
|
||||
printers all queues: state, reasons, accepting, shared, device URI
|
||||
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
|
||||
jobs [queue] [--completed | --all-jobs]
|
||||
active jobs (all queues unless one is given)
|
||||
cancel <job-id> cancel one job (123 or Queue-123)
|
||||
options <queue> [--full] PPD options (lpoptions -l style) and IPP *-default/*-supported
|
||||
add <queue> -v <uri> [-m model | -P ppd] [-D description] [-L location] [-o key=value ...] [--shared]
|
||||
create, enable and accept a queue via lpadmin, then verify
|
||||
(not shared unless --shared)
|
||||
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
|
||||
change a queue via lpadmin, then show before/after
|
||||
quick list quick actions (task-named queue defaults)
|
||||
quick <action> <queue> [code | --clear]
|
||||
apply one via lpadmin, then verify
|
||||
help show this help
|
||||
"""
|
||||
|
||||
printErr("started \(clock(startTime))")
|
||||
|
||||
// Ctrl-C / kill still reports finished.
|
||||
var signalSources: [DispatchSourceSignal] = []
|
||||
for sig in [SIGINT, SIGTERM] {
|
||||
signal(sig, SIG_IGN)
|
||||
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
|
||||
source.setEventHandler { finish(128 + sig, "ERROR: interrupted by signal \(sig)") }
|
||||
source.resume()
|
||||
signalSources.append(source)
|
||||
}
|
||||
|
||||
func runCommand(_ args: [String]) async throws -> String {
|
||||
guard let command = args.first else { throw CupsAdminError.usage("no command given") }
|
||||
var rest = Array(args.dropFirst())
|
||||
let client = CupsClient()
|
||||
|
||||
func takeFlag(_ flag: String) -> Bool {
|
||||
guard let i = rest.firstIndex(of: flag) else { return false }
|
||||
rest.remove(at: i)
|
||||
return true
|
||||
}
|
||||
func rejectUnknownFlags() throws {
|
||||
if let flag = rest.first(where: { $0.hasPrefix("-") }) {
|
||||
throw CupsAdminError.usage("unknown option \(flag) for \(command)")
|
||||
}
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "help", "-h", "--help":
|
||||
print(usage)
|
||||
return "OK: help"
|
||||
|
||||
case "printers":
|
||||
try rejectUnknownFlags()
|
||||
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
|
||||
return try await PrintersCommand.run(client: client)
|
||||
|
||||
case "printer":
|
||||
let dumpAll = takeFlag("--all")
|
||||
try rejectUnknownFlags()
|
||||
guard rest.count == 1 else { throw CupsAdminError.usage("printer needs exactly one queue name") }
|
||||
return try await PrinterCommand.run(client: client, queue: rest[0], dumpAll: dumpAll)
|
||||
|
||||
case "jobs":
|
||||
let completed = takeFlag("--completed")
|
||||
let all = takeFlag("--all-jobs")
|
||||
try rejectUnknownFlags()
|
||||
guard rest.count <= 1 else { throw CupsAdminError.usage("jobs takes at most one queue name") }
|
||||
guard !(completed && all) else { throw CupsAdminError.usage("use --completed or --all-jobs, not both") }
|
||||
let which = completed ? "completed" : all ? "all" : "not-completed"
|
||||
return try await JobsCommand.run(client: client, queue: rest.first, which: which)
|
||||
|
||||
case "cancel":
|
||||
try rejectUnknownFlags()
|
||||
guard rest.count == 1 else { throw CupsAdminError.usage("cancel needs exactly one job id") }
|
||||
return try await CancelCommand.run(client: client, jobID: try CancelCommand.parseJobID(rest[0]))
|
||||
|
||||
case "options":
|
||||
let full = takeFlag("--full")
|
||||
try rejectUnknownFlags()
|
||||
guard rest.count == 1 else { throw CupsAdminError.usage("options needs exactly one queue name") }
|
||||
return try await OptionsCommand.run(client: client, queue: rest[0], full: full)
|
||||
|
||||
case "add":
|
||||
let shared = takeFlag("--shared")
|
||||
let (queue, spec) = try QueueSpec.parse(rest, command: command)
|
||||
return try await AddCommand.run(client: client, queue: queue, spec: spec, shared: shared)
|
||||
|
||||
case "set":
|
||||
let (queue, spec) = try QueueSpec.parse(rest, command: command)
|
||||
return try await SetCommand.run(client: client, queue: queue, spec: spec)
|
||||
|
||||
case "quick":
|
||||
let clear = takeFlag("--clear")
|
||||
try rejectUnknownFlags()
|
||||
return try await QuickCommand.run(client: client, args: rest, clear: clear)
|
||||
|
||||
default:
|
||||
throw CupsAdminError.usage("unknown command \(command)")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let status = try await runCommand(Array(CommandLine.arguments.dropFirst()))
|
||||
finish(0, status)
|
||||
} catch CupsAdminError.usage(let message) {
|
||||
printErr(usage)
|
||||
finish(2, "ERROR: \(message)")
|
||||
} catch {
|
||||
finish(1, "ERROR: \(error)")
|
||||
}
|
||||
Reference in New Issue
Block a user