Files
cupsadmin/Sources/CUPSAdminApp/ContentView.swift
kfattic 795ab7a981 Canon and HP driver profiles for quick actions
Canon: CNColorMode color/mono, CNDuplex, PageSize=Letter. HP: HPColorAsGray or
HPColorMode; duplex and Letter from the generic profile. Profiles can mark an action
unavailable with a reason (Canon department IDs, HP accounting codes). Use Letter
Paper only says Fit to Nearest Size when the profile sets a fit option. Offline tests
sweep every Canon and HP PPD found in CUPSKIT_PPD_DIRS or /Library/Printers/PPDs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:04:02 -05:00

382 lines
15 KiB
Swift
Raw Permalink 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,
driverWarning: store.driverFilters[printer.name]?.headline)
.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(in: store.drivers[queue]), systemImage: action.systemImage)
}
.disabled(reason != nil)
.help(reason ?? action.summary)
}
.labelStyle(.titleAndIcon)
}
}
struct PrinterRow: View {
let printer: PrinterSummary
var isDefault = false
/// "Driver needs Rosetta" / "Driver filter missing", added to the tooltip.
var driverWarning: String?
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(driverWarning.map { "\(printer.stateSummary) · \($0)" } ?? 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)
}
}