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

332 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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