Files
cupsadmin/Sources/CUPSAdminApp/ContentView.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

379 lines
15 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 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)
}
}