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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user