mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
379 lines
15 KiB
Swift
379 lines
15 KiB
Swift
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)
|
||
}
|
||
}
|