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)
|
||||
}
|
||||
}
|
||||
12
Sources/CUPSAdminApp/CUPSAdminApp.swift
Normal file
12
Sources/CUPSAdminApp/CUPSAdminApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CUPSAdminApp: App {
|
||||
var body: some Scene {
|
||||
// Single-window utility; SwiftUI saves and restores the window frame.
|
||||
Window("CUPS Admin", id: "main") {
|
||||
ContentView()
|
||||
}
|
||||
.defaultSize(width: 980, height: 640)
|
||||
}
|
||||
}
|
||||
378
Sources/CUPSAdminApp/ContentView.swift
Normal file
378
Sources/CUPSAdminApp/ContentView.swift
Normal file
@@ -0,0 +1,378 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
257
Sources/CUPSAdminApp/OptionsModel.swift
Normal file
257
Sources/CUPSAdminApp/OptionsModel.swift
Normal file
@@ -0,0 +1,257 @@
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Options tab state for one queue: queue defaults, pending edits, and per-row apply results.
|
||||
/// Apply always writes queue defaults with `lpadmin -p queue -o` (never lpoptions).
|
||||
@MainActor
|
||||
@Observable
|
||||
final class OptionsModel {
|
||||
enum RowStatus: Equatable {
|
||||
case applying
|
||||
case applied(note: String?)
|
||||
case failed(reason: String)
|
||||
}
|
||||
|
||||
struct CustomPair: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let key: String
|
||||
let value: String
|
||||
var status: RowStatus?
|
||||
}
|
||||
|
||||
struct Failure: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
let queue: String
|
||||
|
||||
private(set) var state: OptionState?
|
||||
private(set) var sections: [OptionSection] = []
|
||||
private(set) var loadError: String?
|
||||
private(set) var isApplying = false
|
||||
/// Row key -> new raw value.
|
||||
private(set) var pending: [String: String] = [:]
|
||||
/// Field text as typed, so invalid input stays visible with its error.
|
||||
private(set) var fieldText: [String: String] = [:]
|
||||
private(set) var rowStatus: [String: RowStatus] = [:]
|
||||
private(set) var customPairs: [CustomPair] = []
|
||||
var customEntry = ""
|
||||
private(set) var customEntryError: String?
|
||||
var failure: Failure?
|
||||
|
||||
private let report: (String) -> Void
|
||||
private let client = CupsClient()
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "options")
|
||||
|
||||
init(queue: String, report: @escaping (String) -> Void) {
|
||||
self.queue = queue
|
||||
self.report = report
|
||||
}
|
||||
|
||||
// MARK: loading
|
||||
|
||||
func load() async {
|
||||
let started = Date()
|
||||
do {
|
||||
state = try await OptionState.load(client: client, queue: queue)
|
||||
loadError = nil
|
||||
rebuild()
|
||||
let count = sections.reduce(0) { $0 + $1.rows.count }
|
||||
report("Loaded \(count) options for \(queue) in \(elapsedText(since: started))")
|
||||
log.info("loaded \(count) options for \(self.queue, privacy: .public) in \(Date().timeIntervalSince(started), format: .fixed(precision: 3)) s")
|
||||
} catch {
|
||||
loadError = String(describing: error)
|
||||
report("ERROR: \(error)")
|
||||
log.error("load failed for \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func rebuild() {
|
||||
sections = state?.sections() ?? []
|
||||
}
|
||||
|
||||
// MARK: values
|
||||
|
||||
var hasChanges: Bool { !pending.isEmpty || customPairs.contains { $0.status == nil } }
|
||||
var changeCount: Int { pending.count + customPairs.filter { $0.status == nil }.count }
|
||||
var hasErrors: Bool { sections.flatMap(\.rows).contains { validationError($0) != nil } }
|
||||
|
||||
/// The queue default before pending edits.
|
||||
func currentValue(_ row: OptionRow) -> String? {
|
||||
state?.queueValue(row.key)
|
||||
}
|
||||
|
||||
/// The value shown in the control: pending edit, else current.
|
||||
func value(_ row: OptionRow) -> String? {
|
||||
pending[row.key] ?? currentValue(row)
|
||||
}
|
||||
|
||||
func set(_ row: OptionRow, to newValue: String) {
|
||||
rowStatus[row.key] = nil
|
||||
pending[row.key] = newValue == currentValue(row) ? nil : newValue
|
||||
}
|
||||
|
||||
func fieldText(_ row: OptionRow, field: CustomField) -> String {
|
||||
fieldText[row.key] ?? field.text(fromValue: value(row))
|
||||
}
|
||||
|
||||
func setFieldText(_ text: String, row: OptionRow, field: CustomField) {
|
||||
fieldText[row.key] = text
|
||||
set(row, to: field.value(fromText: text))
|
||||
if pending[row.key] == nil { fieldText[row.key] = nil }
|
||||
}
|
||||
|
||||
func validationError(_ row: OptionRow) -> String? {
|
||||
guard case .field(let field) = row.control, let text = fieldText[row.key] else { return nil }
|
||||
return field.validationError(text)
|
||||
}
|
||||
|
||||
func isPending(_ row: OptionRow) -> Bool { pending[row.key] != nil }
|
||||
|
||||
/// Your own ~/.cups/lpoptions value wins over the queue default for your jobs; say so.
|
||||
func overrideNote(_ row: OptionRow) -> String? {
|
||||
guard let state, let mine = state.userOverride(row.key) else { return nil }
|
||||
return "Your ~/.cups/lpoptions overrides this for your jobs: \(state.display(mine, row: row))"
|
||||
}
|
||||
|
||||
// MARK: custom option row
|
||||
|
||||
func addCustomEntry() {
|
||||
let entry = customEntry.trimmingCharacters(in: .whitespaces)
|
||||
guard let eq = entry.firstIndex(of: "="), eq != entry.startIndex else {
|
||||
customEntryError = "Use keyword=value"
|
||||
return
|
||||
}
|
||||
let key = String(entry[..<eq])
|
||||
guard !key.contains(where: \.isWhitespace) else {
|
||||
customEntryError = "Keyword can't contain spaces"
|
||||
return
|
||||
}
|
||||
customPairs.append(CustomPair(key: key, value: String(entry[entry.index(after: eq)...])))
|
||||
customEntry = ""
|
||||
customEntryError = nil
|
||||
}
|
||||
|
||||
func removeCustomPair(_ pair: CustomPair) {
|
||||
customPairs.removeAll { $0.id == pair.id }
|
||||
}
|
||||
|
||||
// MARK: apply
|
||||
|
||||
struct ChangeSummary: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let from: String
|
||||
let to: String
|
||||
}
|
||||
|
||||
var changes: [OptionChange] {
|
||||
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
|
||||
let edits = pending.keys.sorted().map { key in
|
||||
OptionChange(key: key, value: pending[key]!, isSecret: rows[key]?.isSecret ?? false)
|
||||
}
|
||||
let raw = customPairs.filter { $0.status == nil }.map {
|
||||
OptionChange(key: $0.key, value: $0.value, isSecret: rows[$0.key]?.isSecret ?? false)
|
||||
}
|
||||
return edits + raw
|
||||
}
|
||||
|
||||
var changeSummaries: [ChangeSummary] {
|
||||
guard let state else { return [] }
|
||||
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
|
||||
return changes.map { change in
|
||||
guard let row = rows[change.key] else {
|
||||
return ChangeSummary(id: change.key, label: change.key, from: state.queueValue(change.key) ?? "—",
|
||||
to: change.value)
|
||||
}
|
||||
return ChangeSummary(id: change.key, label: "\(row.label) (\(row.key))",
|
||||
from: state.display(currentValue(row), row: row), to: state.display(change.value, row: row))
|
||||
}
|
||||
}
|
||||
|
||||
var command: String {
|
||||
OptionApplier.displayCommand(queue: queue, changes: changes)
|
||||
}
|
||||
|
||||
/// Shown in the apply sheet when passwords would land in a world-readable file.
|
||||
var secretWarning: String? {
|
||||
let secrets = changes.filter(\.isSecret)
|
||||
guard !secrets.isEmpty else { return nil }
|
||||
let names = secrets.map(\.key).joined(separator: ", ")
|
||||
return "\(names) will be saved in plain text in /etc/cups/ppd/\(queue).ppd as the queue default. "
|
||||
+ "Every user on this Mac can read that file, and every job on this queue will send it."
|
||||
}
|
||||
|
||||
let scopeDescription = "Every user on this Mac · lpadmin"
|
||||
|
||||
func revert() {
|
||||
pending = [:]
|
||||
fieldText = [:]
|
||||
customPairs.removeAll { $0.status == nil }
|
||||
rowStatus = [:]
|
||||
}
|
||||
|
||||
func apply() async {
|
||||
let changes = self.changes
|
||||
guard !changes.isEmpty, !isApplying else { return }
|
||||
isApplying = true
|
||||
defer { isApplying = false }
|
||||
let started = Date()
|
||||
for change in changes { rowStatus[change.key] = .applying }
|
||||
for i in customPairs.indices where customPairs[i].status == nil { customPairs[i].status = .applying }
|
||||
log.info("apply \(changes.count) options on \(self.queue, privacy: .public) with lpadmin")
|
||||
|
||||
do {
|
||||
let (result, readBack, newState) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
|
||||
state = newState
|
||||
rebuild()
|
||||
pending = [:]
|
||||
fieldText = [:]
|
||||
for check in readBack {
|
||||
let status: RowStatus = check.applied ? .applied(note: check.reason) : .failed(reason: check.reason ?? "Not applied")
|
||||
rowStatus[check.key] = status
|
||||
if let i = customPairs.firstIndex(where: { $0.key == check.key && $0.status == .applying }) {
|
||||
customPairs[i].status = status
|
||||
}
|
||||
}
|
||||
|
||||
let failed = readBack.filter { !$0.applied }
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
if failed.isEmpty {
|
||||
report("Set \(changes.count) option\(changes.count == 1 ? "" : "s") on \(queue) in \(elapsedText(seconds))")
|
||||
} else {
|
||||
report("ERROR: \(failed.count) of \(changes.count) not applied on \(queue): "
|
||||
+ failed.map(\.key).joined(separator: ", ") + " — \(failed[0].reason ?? "")")
|
||||
}
|
||||
log.info("apply finished on \(self.queue, privacy: .public): \(changes.count - failed.count) applied, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
|
||||
|
||||
if !result.succeeded {
|
||||
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: Self.explain(result))
|
||||
}
|
||||
} catch {
|
||||
for change in changes { rowStatus[change.key] = .failed(reason: String(describing: error)) }
|
||||
for i in customPairs.indices where customPairs[i].status == .applying {
|
||||
customPairs[i].status = .failed(reason: String(describing: error))
|
||||
}
|
||||
report("ERROR: \(error)")
|
||||
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: String(describing: error))
|
||||
log.error("apply failed on \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool output plus the fix when it's a permissions problem.
|
||||
private static func explain(_ result: ToolResult) -> String {
|
||||
let message = result.message.isEmpty ? "\(result.commandLine) exited \(result.status)" : result.message
|
||||
let lower = message.lowercased()
|
||||
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|
||||
|| lower.contains("authentication") {
|
||||
return message + "\n\nChanging queue defaults needs an admin account or membership in _lpadmin:\n"
|
||||
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
335
Sources/CUPSAdminApp/OptionsView.swift
Normal file
@@ -0,0 +1,335 @@
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
/// The web UI's "Set Default Options" page: every PPD option by group, IPP defaults, and queue policies.
|
||||
struct OptionsView: View {
|
||||
@Bindable var model: OptionsModel
|
||||
@State private var showingApplySheet = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if model.state == nil, let error = model.loadError {
|
||||
ContentUnavailableView("Can’t Load Options", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
} else if model.state == nil {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
form
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) { actionBar }
|
||||
.sheet(isPresented: $showingApplySheet) {
|
||||
ApplyOptionsSheet(model: model) {
|
||||
showingApplySheet = false
|
||||
Task { await model.apply() }
|
||||
}
|
||||
}
|
||||
.alert(item: $model.failure) { failure in
|
||||
Alert(title: Text(failure.title), message: Text(failure.message))
|
||||
}
|
||||
.task(id: model.queue) { await model.load() }
|
||||
}
|
||||
|
||||
private var actionBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Text(model.scopeDescription)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.isApplying {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if model.hasChanges {
|
||||
Text("\(model.changeCount) unsaved change\(model.changeCount == 1 ? "" : "s")")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.accessibilityIdentifier("options.changeCount")
|
||||
}
|
||||
Button("Revert") { model.revert() }
|
||||
.disabled(!model.hasChanges || model.isApplying)
|
||||
.accessibilityIdentifier("options.revert")
|
||||
Button("Apply…") { showingApplySheet = true }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!model.hasChanges || model.hasErrors || model.isApplying)
|
||||
.accessibilityIdentifier("options.apply")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
Divider()
|
||||
}
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var form: some View {
|
||||
Form {
|
||||
ForEach(model.sections) { section in
|
||||
Section {
|
||||
ForEach(section.rows) { row in
|
||||
OptionRowView(model: model, row: row)
|
||||
}
|
||||
} header: {
|
||||
Text(section.title)
|
||||
} footer: {
|
||||
if section.title == "IPP" {
|
||||
Text("Queue-wide IPP defaults. On PPD queues, media, sides, output bin, resolution and color follow their PPD option.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(model.customPairs) { pair in
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
StatusIndicator(status: pair.status, pending: pair.status == nil)
|
||||
Button {
|
||||
model.removeCustomPair(pair)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove")
|
||||
.disabled(model.isApplying)
|
||||
}
|
||||
} label: {
|
||||
Text("\(pair.key)=\(pair.value)")
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
.accessibilityIdentifier("options.custom.\(pair.key)")
|
||||
if case .failed(let reason) = pair.status {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = pair.status {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
HStack {
|
||||
TextField("Add option manually", text: $model.customEntry, prompt: Text("keyword=value"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospaced()
|
||||
.frame(minWidth: 260)
|
||||
.onSubmit { model.addCustomEntry() }
|
||||
.accessibilityIdentifier("options.customEntry")
|
||||
Button("Add") { model.addCustomEntry() }
|
||||
.disabled(model.customEntry.isEmpty || model.isApplying)
|
||||
.accessibilityIdentifier("options.customAdd")
|
||||
}
|
||||
} label: {
|
||||
Text("Add option manually")
|
||||
Text(model.customEntryError ?? "Any lpadmin -o pair, e.g. RIUserCode=Custom.1234")
|
||||
.foregroundStyle(model.customEntryError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red))
|
||||
}
|
||||
} header: {
|
||||
Text("Advanced")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
// Keep the last row clear of the window's status bar.
|
||||
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
|
||||
}
|
||||
}
|
||||
|
||||
struct OptionRowView: View {
|
||||
let model: OptionsModel
|
||||
let row: OptionRow
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: 8) {
|
||||
control
|
||||
.accessibilityIdentifier("option.\(row.key)")
|
||||
StatusIndicator(status: model.rowStatus[row.key], pending: model.isPending(row))
|
||||
}
|
||||
.disabled(model.isApplying)
|
||||
} label: {
|
||||
Text(row.label)
|
||||
Text(keywordLine)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
if let error = model.validationError(row) {
|
||||
Text(error).foregroundStyle(.red)
|
||||
} else if case .failed(let reason) = model.rowStatus[row.key] {
|
||||
Text(reason).foregroundStyle(.red)
|
||||
} else if case .applied(let note?) = model.rowStatus[row.key] {
|
||||
Text(note)
|
||||
} else if case .readOnly(let reason) = row.control {
|
||||
Text(reason)
|
||||
} else if let note = model.overrideNote(row) {
|
||||
Text(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var keywordLine: String {
|
||||
if case .field(let field) = row.control { return "\(row.key) · \(field.hint)" }
|
||||
return row.key
|
||||
}
|
||||
|
||||
@ViewBuilder private var control: some View {
|
||||
switch row.control {
|
||||
case .picker(let choices):
|
||||
Picker(row.label, selection: Binding(
|
||||
get: { model.value(row) ?? "" },
|
||||
set: { model.set(row, to: $0) }
|
||||
)) {
|
||||
let current = model.value(row) ?? ""
|
||||
if !choices.contains(where: { $0.keyword == current }) {
|
||||
Text(current.isEmpty ? "Not set" : current).tag(current)
|
||||
}
|
||||
ForEach(choices, id: \.keyword) { choice in
|
||||
Text(choice.text).tag(choice.keyword)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
case .toggle(let on, let off):
|
||||
Toggle(row.label, isOn: Binding(
|
||||
get: { model.value(row) == on },
|
||||
set: { model.set(row, to: $0 ? on : off) }
|
||||
))
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
|
||||
case .field(let field):
|
||||
let text = Binding(
|
||||
get: { model.fieldText(row, field: field) },
|
||||
set: { model.setFieldText($0, row: row, field: field) }
|
||||
)
|
||||
let prompt = Text(field.choices.first ?? "")
|
||||
Group {
|
||||
if field.isSecret {
|
||||
SecureField(row.label, text: text, prompt: prompt)
|
||||
} else {
|
||||
TextField(row.label, text: text, prompt: prompt)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospacedDigit()
|
||||
.frame(width: field.isNumeric ? 110 : 200)
|
||||
|
||||
case .readOnly:
|
||||
Text(model.state?.display(model.value(row), row: row) ?? "—")
|
||||
.foregroundStyle(.secondary)
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsaved dot, spinner while applying, then a green check or red X after read-back.
|
||||
struct StatusIndicator: View {
|
||||
let status: OptionsModel.RowStatus?
|
||||
let pending: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch status {
|
||||
case .applying:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .applied:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.help("Applied and verified")
|
||||
case .failed(let reason):
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.help(reason)
|
||||
case nil:
|
||||
if pending {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(.tint)
|
||||
.help("Unsaved change")
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 16, height: 16)
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
switch status {
|
||||
case .applying: return "Applying"
|
||||
case .applied: return "Applied"
|
||||
case .failed(let reason): return "Not applied: \(reason)"
|
||||
case nil: return pending ? "Unsaved change" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyOptionsSheet: View {
|
||||
let model: OptionsModel
|
||||
let apply: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Apply \(model.changeCount) Change\(model.changeCount == 1 ? "" : "s") to \(model.queue)?")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Changes the queue default for every user on this Mac.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let warning = model.secretWarning {
|
||||
Label {
|
||||
Text(warning)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Setting")
|
||||
Text("Current")
|
||||
Text("New")
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ForEach(model.changeSummaries) { change in
|
||||
GridRow {
|
||||
Text(change.label)
|
||||
Text(change.from).foregroundStyle(.secondary)
|
||||
Text(change.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Command")
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityIdentifier("applySheet.cancel")
|
||||
Button("Apply") { apply() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.accessibilityIdentifier("applySheet.apply")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 560)
|
||||
}
|
||||
}
|
||||
149
Sources/CUPSAdminApp/PrinterDetailModel.swift
Normal file
149
Sources/CUPSAdminApp/PrinterDetailModel.swift
Normal file
@@ -0,0 +1,149 @@
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
enum DetailTab: String, CaseIterable, Identifiable {
|
||||
case jobs, options
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .jobs ? "Jobs" : "Options" }
|
||||
}
|
||||
|
||||
enum JobFilter: String, CaseIterable, Identifiable {
|
||||
case active, completed, all
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .active: return "Active"
|
||||
case .completed: return "Completed"
|
||||
case .all: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get-Jobs which-jobs.
|
||||
var whichJobs: String {
|
||||
switch self {
|
||||
case .active: return "not-completed"
|
||||
case .completed: return "completed"
|
||||
case .all: return "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header attributes and the Jobs table for one printer.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrinterDetailModel {
|
||||
let queue: String
|
||||
private(set) var printer: IPPGroup?
|
||||
private(set) var jobs: [JobSummary] = []
|
||||
private(set) var loadError: String?
|
||||
private(set) var isWorking = false
|
||||
var selection = Set<Int>()
|
||||
var sortOrder = [KeyPathComparator(\JobSummary.id, order: .reverse)]
|
||||
var jobFilter: JobFilter = .active {
|
||||
didSet { if jobFilter != oldValue { Task { await loadJobs() } } }
|
||||
}
|
||||
|
||||
private let store: PrinterStore
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "jobs")
|
||||
|
||||
init(queue: String, store: PrinterStore) {
|
||||
self.queue = queue
|
||||
self.store = store
|
||||
}
|
||||
|
||||
static let headerAttributes = [
|
||||
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
|
||||
"printer-is-accepting-jobs", "printer-is-shared", "device-uri", "printer-make-and-model",
|
||||
"printer-info", "printer-location", "queued-job-count",
|
||||
]
|
||||
|
||||
var sortedJobs: [JobSummary] { jobs.sorted(using: sortOrder) }
|
||||
var selectedJobs: [JobSummary] { jobs.filter { selection.contains($0.id) } }
|
||||
|
||||
/// The queue's PPD, if it has one (driverless and raw queues don't).
|
||||
var ppdPath: String? {
|
||||
let path = "/private/etc/cups/ppd/\(queue).ppd"
|
||||
return FileManager.default.fileExists(atPath: path) ? path : nil
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
||||
loadError = nil
|
||||
} catch {
|
||||
loadError = String(describing: error)
|
||||
store.report("ERROR: \(queue): \(error)")
|
||||
}
|
||||
await loadJobs()
|
||||
}
|
||||
|
||||
func loadJobs() async {
|
||||
do {
|
||||
let groups = try await store.client.getJobs(queue: queue, which: jobFilter.whichJobs,
|
||||
requested: JobSummary.requestedAttributes)
|
||||
jobs = groups.map(JobSummary.init)
|
||||
selection = selection.intersection(jobs.map(\.id))
|
||||
} catch {
|
||||
store.report("ERROR: jobs on \(queue): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: job actions
|
||||
|
||||
func canPerform(_ action: JobAction, on ids: Set<Int>) -> Bool {
|
||||
let targets = jobs.filter { ids.contains($0.id) }
|
||||
guard !targets.isEmpty, !isWorking else { return false }
|
||||
switch action {
|
||||
case .cancel, .move: return targets.allSatisfy(\.isActive)
|
||||
case .hold: return targets.allSatisfy { $0.isActive && !$0.isHeld }
|
||||
case .release: return targets.allSatisfy(\.isHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func perform(_ action: JobAction, on ids: Set<Int>) async {
|
||||
let ordered = ids.sorted()
|
||||
guard !ordered.isEmpty else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
let started = Date()
|
||||
log.info("\(action.title, privacy: .public) jobs \(ordered.map(String.init).joined(separator: ","), privacy: .public) on \(self.queue, privacy: .public)")
|
||||
|
||||
let outcomes = await QueueActions.perform(action, jobIDs: ordered, client: store.client)
|
||||
let failed = ordered.filter { outcomes[$0]?.succeeded != true }
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
let noun = ordered.count == 1 ? "job \(ordered[0])" : "\(ordered.count) jobs"
|
||||
if failed.isEmpty {
|
||||
store.report("\(action.pastTense) \(noun) in \(elapsedText(seconds))")
|
||||
} else {
|
||||
let details = failed.map { "job \($0): \(outcomes[$0]?.message ?? "failed")" }
|
||||
store.report("ERROR: \(failed.count) of \(ordered.count) not applied — \(details[0])")
|
||||
store.fail("\(action.title) didn’t apply to \(failed.count) job\(failed.count == 1 ? "" : "s")",
|
||||
(details + failed.compactMap { outcomes[$0]?.command }.prefix(1)).joined(separator: "\n"))
|
||||
}
|
||||
log.info("\(action.title, privacy: .public): \(ordered.count - failed.count) ok, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
|
||||
|
||||
store.noteChange()
|
||||
await loadJobs()
|
||||
await store.refreshQuietly()
|
||||
}
|
||||
|
||||
/// Tab-separated rows for the clipboard, for pasting into tickets.
|
||||
func copyText(for ids: Set<Int>) -> String {
|
||||
let header = ["ID", "Name", "User", "Size", "Pages", "State", "Reasons", "Submitted"].joined(separator: "\t")
|
||||
let rows = sortedJobs.filter { ids.contains($0.id) }.map { job in
|
||||
[String(job.id), job.name, job.user, Format.kilobytes(job.sizeK), job.pages.map(String.init) ?? "",
|
||||
job.stateName, job.reasons.joined(separator: ","), job.submitted.map(Format.timestamp) ?? ""]
|
||||
.joined(separator: "\t")
|
||||
}
|
||||
return ([header] + rows).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
extension JobSummary {
|
||||
var pagesSortKey: Int { pages ?? -1 }
|
||||
var reasonsText: String { reasons.joined(separator: ", ") }
|
||||
var submittedSortKey: Date { submitted ?? .distantPast }
|
||||
}
|
||||
236
Sources/CUPSAdminApp/PrinterDetailView.swift
Normal file
236
Sources/CUPSAdminApp/PrinterDetailView.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import AppKit
|
||||
import CupsKit
|
||||
import SwiftUI
|
||||
|
||||
/// Printer header (grouped Form) above the Jobs or Options tab.
|
||||
struct PrinterDetailView: View {
|
||||
let model: PrinterDetailModel
|
||||
let options: OptionsModel
|
||||
let store: PrinterStore
|
||||
let tab: DetailTab
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let printer = model.printer {
|
||||
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath)
|
||||
Divider()
|
||||
} else if let error = model.loadError {
|
||||
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
||||
description: Text(error))
|
||||
.frame(maxHeight: 220)
|
||||
Divider()
|
||||
}
|
||||
switch tab {
|
||||
case .jobs:
|
||||
JobsView(model: model, store: store)
|
||||
case .options:
|
||||
OptionsView(model: options)
|
||||
}
|
||||
}
|
||||
.task(id: model.queue) { await model.load() }
|
||||
.onChange(of: store.revision) { Task { await model.load() } }
|
||||
}
|
||||
}
|
||||
|
||||
struct PrinterHeaderView: View {
|
||||
let printer: IPPGroup
|
||||
let ppdPath: String?
|
||||
|
||||
private var state: Int? { printer.int("printer-state") }
|
||||
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
||||
private var reasons: [String] { printer.strings("printer-state-reasons").filter { $0 != "none" } }
|
||||
private var stateColor: Color {
|
||||
if state == 5 || !accepting { return .red }
|
||||
return state == 4 ? .blue : .green
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
Form {
|
||||
Section("Status") {
|
||||
LabeledContent("State") {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(stateColor).frame(width: 8, height: 8)
|
||||
Text(Format.printerState(state).capitalized)
|
||||
}
|
||||
}
|
||||
LabeledContent("Reasons") {
|
||||
Text(reasons.isEmpty ? "None" : reasons.joined(separator: ", "))
|
||||
.monospaced(!reasons.isEmpty)
|
||||
}
|
||||
LabeledContent("Message") {
|
||||
Text(printer.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
.lineLimit(2)
|
||||
}
|
||||
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
||||
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
||||
}
|
||||
}
|
||||
Form {
|
||||
Section("Queue") {
|
||||
LabeledContent("Device URI") {
|
||||
Text(printer.string("device-uri") ?? "—")
|
||||
.monospaced()
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
LabeledContent {
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(printer.string("printer-make-and-model") ?? "—")
|
||||
Text(ppdPath ?? "No PPD (driverless or raw)")
|
||||
.font(.caption)
|
||||
.monospaced(ppdPath != nil)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} label: {
|
||||
Text("Model")
|
||||
}
|
||||
LabeledContent("Location", value: printer.string("printer-location").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
LabeledContent("Description", value: printer.string("printer-info").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.textSelection(.enabled)
|
||||
.frame(height: 262)
|
||||
}
|
||||
}
|
||||
|
||||
struct JobsView: View {
|
||||
@Bindable var model: PrinterDetailModel
|
||||
let store: PrinterStore
|
||||
@SceneStorage("JobsTableColumns") private var columns = TableColumnCustomization<JobSummary>()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
Picker("Show", selection: $model.jobFilter) {
|
||||
ForEach(JobFilter.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.isWorking { ProgressView().controlSize(.small) }
|
||||
jobButtons(for: model.selection)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.bar)
|
||||
Divider()
|
||||
|
||||
Table(model.sortedJobs, selection: $model.selection, sortOrder: $model.sortOrder,
|
||||
columnCustomization: $columns) {
|
||||
TableColumn("ID", value: \.id) { Text(String($0.id)).monospacedDigit() }
|
||||
.width(min: 40, ideal: 56, max: 80)
|
||||
.customizationID("id")
|
||||
TableColumn("Name", value: \.name)
|
||||
.width(min: 120, ideal: 220, max: 360)
|
||||
.customizationID("name")
|
||||
TableColumn("User", value: \.user)
|
||||
.width(min: 60, ideal: 110, max: 200)
|
||||
.customizationID("user")
|
||||
TableColumn("Size", value: \.sizeK) { Text(Format.kilobytes($0.sizeK)).monospacedDigit() }
|
||||
.width(min: 50, ideal: 64, max: 90)
|
||||
.customizationID("size")
|
||||
TableColumn("Pages", value: \.pagesSortKey) { Text($0.pages.map(String.init) ?? "—").monospacedDigit() }
|
||||
.width(min: 44, ideal: 54, max: 80)
|
||||
.customizationID("pages")
|
||||
TableColumn("State", value: \.state) { Text($0.stateName.capitalized) }
|
||||
.width(min: 70, ideal: 90, max: 120)
|
||||
.customizationID("state")
|
||||
// Reasons takes the spare width; the other columns are capped.
|
||||
TableColumn("Reasons", value: \.reasonsText) { Text($0.reasonsText).monospaced() }
|
||||
.width(min: 120, ideal: 320, max: .infinity)
|
||||
.customizationID("reasons")
|
||||
TableColumn("Submitted", value: \.submittedSortKey) {
|
||||
Text($0.submitted.map(Format.timestamp) ?? "—").monospacedDigit()
|
||||
}
|
||||
.width(150)
|
||||
.customizationID("submitted")
|
||||
}
|
||||
.alternatingRowBackgrounds(.disabled)
|
||||
.contextMenu(forSelectionType: Int.self) { ids in
|
||||
jobMenu(for: ids)
|
||||
}
|
||||
.onCopyCommand {
|
||||
[NSItemProvider(object: model.copyText(for: model.selection) as NSString)]
|
||||
}
|
||||
.overlay {
|
||||
if model.jobs.isEmpty {
|
||||
ContentUnavailableView(emptyTitle, systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
.padding(.bottom, StatusBar.height)
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyTitle: String {
|
||||
switch model.jobFilter {
|
||||
case .active: return "No Active Jobs"
|
||||
case .completed: return "No Completed Jobs"
|
||||
case .all: return "No Jobs"
|
||||
}
|
||||
}
|
||||
|
||||
private var otherQueues: [String] {
|
||||
store.printers.map(\.name).filter { $0 != model.queue }
|
||||
}
|
||||
|
||||
@ViewBuilder private func jobButtons(for ids: Set<Int>) -> some View {
|
||||
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
|
||||
.disabled(!model.canPerform(.cancel, on: ids))
|
||||
.help("Cancel the selected jobs")
|
||||
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
|
||||
.disabled(!model.canPerform(.hold, on: ids))
|
||||
.help("Hold the selected jobs (lp -H hold)")
|
||||
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
|
||||
.disabled(!model.canPerform(.release, on: ids))
|
||||
.help("Release held jobs (lp -H resume)")
|
||||
Menu {
|
||||
ForEach(otherQueues, id: \.self) { queue in
|
||||
Button(queue) { run(.move(to: queue), ids) }
|
||||
}
|
||||
} label: {
|
||||
Label("Move To", systemImage: "arrow.right.circle")
|
||||
}
|
||||
.fixedSize()
|
||||
.disabled(!model.canPerform(.move(to: ""), on: ids) || otherQueues.isEmpty)
|
||||
.help("Move the selected jobs to another queue (lpmove)")
|
||||
}
|
||||
|
||||
@ViewBuilder private func jobMenu(for ids: Set<Int>) -> some View {
|
||||
Group {
|
||||
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
|
||||
.disabled(!model.canPerform(.cancel, on: ids))
|
||||
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
|
||||
.disabled(!model.canPerform(.hold, on: ids))
|
||||
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
|
||||
.disabled(!model.canPerform(.release, on: ids))
|
||||
Menu {
|
||||
ForEach(otherQueues, id: \.self) { queue in
|
||||
Button(queue) { run(.move(to: queue), ids) }
|
||||
}
|
||||
} label: {
|
||||
Label("Move To", systemImage: "arrow.right.circle")
|
||||
}
|
||||
.disabled(!model.canPerform(.move(to: ""), on: ids))
|
||||
Divider()
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(model.copyText(for: ids), forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.disabled(ids.isEmpty)
|
||||
}
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
|
||||
private func run(_ action: JobAction, _ ids: Set<Int>) {
|
||||
Task { await model.perform(action, on: ids) }
|
||||
}
|
||||
}
|
||||
291
Sources/CUPSAdminApp/PrinterStore.swift
Normal file
291
Sources/CUPSAdminApp/PrinterStore.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
import AppKit
|
||||
import CupsKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
/// One sidebar row's worth of queue state, from CUPS-Get-Printers.
|
||||
struct PrinterSummary: Identifiable, Hashable {
|
||||
enum Status {
|
||||
case idle, processing, stopped
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .idle: return .green
|
||||
case .processing: return .blue
|
||||
case .stopped: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let name: String
|
||||
let state: Int?
|
||||
let reasons: [String]
|
||||
let stateMessage: String?
|
||||
let accepting: Bool
|
||||
let info: String?
|
||||
let location: String?
|
||||
let deviceURI: String?
|
||||
let jobCount: Int
|
||||
|
||||
var id: String { name }
|
||||
|
||||
static let requestedAttributes = [
|
||||
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
|
||||
"printer-is-accepting-jobs", "printer-info", "printer-location", "device-uri", "queued-job-count",
|
||||
]
|
||||
|
||||
init(_ attributes: IPPGroup) {
|
||||
name = attributes.string("printer-name") ?? "?"
|
||||
state = attributes.int("printer-state")
|
||||
reasons = attributes.strings("printer-state-reasons").filter { $0 != "none" }
|
||||
stateMessage = attributes.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 }
|
||||
accepting = attributes.bool("printer-is-accepting-jobs") ?? true
|
||||
info = attributes.string("printer-info").flatMap { $0.isEmpty ? nil : $0 }
|
||||
location = attributes.string("printer-location").flatMap { $0.isEmpty ? nil : $0 }
|
||||
deviceURI = attributes.string("device-uri").flatMap { $0.isEmpty ? nil : $0 }
|
||||
jobCount = attributes.int("queued-job-count") ?? 0
|
||||
}
|
||||
|
||||
var isPaused: Bool { state == 5 }
|
||||
|
||||
/// Red when stopped or rejecting jobs, blue while printing, green otherwise.
|
||||
var status: Status {
|
||||
if state == 5 || !accepting { return .stopped }
|
||||
return state == 4 ? .processing : .idle
|
||||
}
|
||||
|
||||
/// Location, else description, else nothing — never text that just restates the name, an IP or a URI.
|
||||
var secondaryLine: String? {
|
||||
[location, info].compactMap { $0 }.first { !Self.restates($0, name: name) }
|
||||
}
|
||||
|
||||
static func restates(_ text: String, name: String) -> Bool {
|
||||
func letters(_ s: String) -> String { s.lowercased().filter { $0.isLetter || $0.isNumber } }
|
||||
if letters(text) == letters(name) { return true }
|
||||
if text.contains("://") { return true }
|
||||
return text.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
/// "Idle", "Stopped — paused", "Idle, rejecting jobs".
|
||||
var stateSummary: String {
|
||||
var text = Format.printerState(state).capitalized
|
||||
if !reasons.isEmpty { text += " — " + reasons.joined(separator: ", ") }
|
||||
if !accepting { text += ", rejecting jobs" }
|
||||
return text
|
||||
}
|
||||
|
||||
func matches(search: String) -> Bool {
|
||||
guard !search.isEmpty else { return true }
|
||||
return [name, location, info, deviceURI].compactMap { $0 }
|
||||
.contains { $0.localizedCaseInsensitiveContains(search) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PrinterStore {
|
||||
struct Failure: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
private(set) var printers: [PrinterSummary] = []
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var hasLoaded = false
|
||||
private(set) var errorMessage: String?
|
||||
private(set) var statusLine = ""
|
||||
/// Bumped after any write, so open detail views reload.
|
||||
private(set) var revision = 0
|
||||
/// The server default printer (CUPS-Get-Default), if any.
|
||||
private(set) var defaultPrinter: String?
|
||||
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil.
|
||||
private(set) var ppds: [String: PPD?] = [:]
|
||||
var failure: Failure?
|
||||
|
||||
let client = CupsClient()
|
||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
|
||||
|
||||
/// "21 printers · 0 active jobs" for the status bar.
|
||||
var summaryLine: String {
|
||||
guard hasLoaded, errorMessage == nil else { return "" }
|
||||
let jobs = printers.reduce(0) { $0 + $1.jobCount }
|
||||
return "\(printers.count) printer\(printers.count == 1 ? "" : "s") · \(jobs) active job\(jobs == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
/// Last-action line for the status bar, from any view.
|
||||
func report(_ line: String) {
|
||||
statusLine = line
|
||||
}
|
||||
|
||||
func fail(_ title: String, _ message: String) {
|
||||
failure = Failure(title: title, message: Self.explain(message))
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard !isRefreshing else { return }
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
let started = Date()
|
||||
log.info("refresh started")
|
||||
|
||||
do {
|
||||
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
|
||||
printers = groups.map(PrinterSummary.init).sorted {
|
||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
errorMessage = nil
|
||||
defaultPrinter = try? await client.getDefaultPrinter()
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
statusLine = "Refreshed \(printers.count) printers in \(elapsedText(seconds))"
|
||||
log.info("refresh finished: \(self.printers.count) printers in \(seconds, format: .fixed(precision: 3)) s")
|
||||
} catch {
|
||||
errorMessage = String(describing: error)
|
||||
statusLine = "ERROR: \(error)"
|
||||
log.error("refresh failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
hasLoaded = true
|
||||
}
|
||||
|
||||
/// Pause/Resume/Accept/Reject/Cancel All, verified against cupsd, reported in the status bar.
|
||||
func perform(_ action: PrinterAction, on queue: String) async {
|
||||
let started = Date()
|
||||
log.info("\(action.title, privacy: .public) \(queue, privacy: .public)")
|
||||
do {
|
||||
let outcome = try await QueueActions.perform(action, queue: queue, client: client)
|
||||
let seconds = Date().timeIntervalSince(started)
|
||||
if outcome.succeeded {
|
||||
report("\(action.pastTense) \(queue) in \(elapsedText(seconds))")
|
||||
log.info("\(action.title, privacy: .public) \(queue, privacy: .public) ok in \(seconds, format: .fixed(precision: 3)) s")
|
||||
} else {
|
||||
let message = outcome.message ?? "failed"
|
||||
report("ERROR: \(action.title) \(queue): \(message)")
|
||||
fail("Couldn’t \(action.title.lowercased()) \(queue)", "\(message)\n\n\(outcome.command)")
|
||||
log.error("\(action.title, privacy: .public) \(queue, privacy: .public) failed: \(message, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: \(action.title) \(queue): \(error)")
|
||||
fail("Couldn’t \(action.title.lowercased()) \(queue)", String(describing: error))
|
||||
}
|
||||
revision += 1
|
||||
await refreshQuietly()
|
||||
}
|
||||
|
||||
/// Refresh the sidebar without replacing the last action's status line.
|
||||
func refreshQuietly() async {
|
||||
let line = statusLine
|
||||
await refresh()
|
||||
statusLine = line
|
||||
}
|
||||
|
||||
func noteChange(queue: String? = nil) {
|
||||
if let queue { ppds[queue] = nil }
|
||||
revision += 1
|
||||
}
|
||||
|
||||
// MARK: PPDs for Quick Actions
|
||||
|
||||
/// Fetches PPDs not yet cached, in the background of a refresh.
|
||||
func loadPPDs() async {
|
||||
for printer in printers where ppds[printer.name] == nil {
|
||||
let text = try? await client.getPPD(queue: printer.name)
|
||||
ppds[printer.name] = .some(text.map(PPD.init(text:)))
|
||||
}
|
||||
}
|
||||
|
||||
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
|
||||
func unavailableReason(_ action: QuickAction, queue: String) -> String? {
|
||||
if case .defaultPrinter = action.effect {
|
||||
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
|
||||
}
|
||||
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" }
|
||||
if action.isAvailable(for: cached) { return nil }
|
||||
if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) {
|
||||
return unavailable.reason
|
||||
}
|
||||
return "Not available on \(queue)"
|
||||
}
|
||||
|
||||
// MARK: per-printer actions
|
||||
|
||||
func printTestPage(_ queue: String) async {
|
||||
let started = Date()
|
||||
do {
|
||||
let (outcome, jobID) = try await QueueActions.printTestPage(queue: queue, client: client)
|
||||
if outcome.succeeded, let jobID {
|
||||
report("Sent test page to \(queue) as job \(jobID) in \(elapsedText(since: started))")
|
||||
log.info("test page \(queue, privacy: .public) job \(jobID)")
|
||||
} else {
|
||||
report("ERROR: test page on \(queue): \(outcome.message ?? "failed")")
|
||||
fail("Couldn’t print a test page on \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: test page on \(queue): \(error)")
|
||||
fail("Couldn’t print a test page on \(queue)", String(describing: error))
|
||||
}
|
||||
noteChange()
|
||||
await refreshQuietly()
|
||||
}
|
||||
|
||||
/// Returns true when the queue is gone.
|
||||
func delete(_ queue: String) async -> Bool {
|
||||
let started = Date()
|
||||
var deleted = false
|
||||
do {
|
||||
let outcome = try await QueueActions.delete(queue: queue, client: client)
|
||||
deleted = outcome.succeeded
|
||||
if deleted {
|
||||
report("Deleted \(queue) in \(elapsedText(since: started))")
|
||||
log.info("deleted \(queue, privacy: .public)")
|
||||
} else {
|
||||
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
|
||||
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
||||
}
|
||||
} catch {
|
||||
report("ERROR: delete \(queue): \(error)")
|
||||
fail("Couldn’t delete \(queue)", String(describing: error))
|
||||
}
|
||||
ppds[queue] = nil
|
||||
noteChange()
|
||||
await refreshQuietly()
|
||||
return deleted
|
||||
}
|
||||
|
||||
func showPPDInFinder(_ queue: String) {
|
||||
let url = URL(fileURLWithPath: "/private/etc/cups/ppd/\(queue).ppd")
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
|
||||
func hasPPDFile(_ queue: String) -> Bool {
|
||||
FileManager.default.fileExists(atPath: "/private/etc/cups/ppd/\(queue).ppd")
|
||||
}
|
||||
|
||||
func copyDeviceURI(_ printer: PrinterSummary) {
|
||||
guard let uri = printer.deviceURI else { return }
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(uri, forType: .string)
|
||||
report("Copied device URI for \(printer.name)")
|
||||
}
|
||||
|
||||
/// Tool/IPP output plus the fix when it's a permissions problem.
|
||||
static func explain(_ message: String) -> String {
|
||||
let lower = message.lowercased()
|
||||
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|
||||
|| lower.contains("authentication") || lower.contains("not-authorized") {
|
||||
return message + "\n\nThis needs an admin account or membership in _lpadmin:\n"
|
||||
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
/// "42 ms" under a tenth of a second, else "0.4 s".
|
||||
func elapsedText(_ seconds: TimeInterval) -> String {
|
||||
seconds < 0.1 ? "\(max(1, Int((seconds * 1000).rounded()))) ms" : String(format: "%.1f s", seconds)
|
||||
}
|
||||
|
||||
func elapsedText(since start: Date) -> String {
|
||||
elapsedText(Date().timeIntervalSince(start))
|
||||
}
|
||||
262
Sources/CUPSAdminApp/QuickActionSheet.swift
Normal file
262
Sources/CUPSAdminApp/QuickActionSheet.swift
Normal file
@@ -0,0 +1,262 @@
|
||||
import CupsKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// One Quick Action on one printer: shows what will change, asks only for what's needed, runs lpadmin, reads back.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class QuickActionModel: Identifiable {
|
||||
enum Phase: Equatable {
|
||||
case loading, ready, running, finished(succeeded: Bool)
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let action: QuickAction
|
||||
let queue: String
|
||||
var code = ""
|
||||
var showCurrentCode = false
|
||||
private(set) var phase: Phase = .loading
|
||||
private(set) var state: OptionState?
|
||||
private(set) var currentDefault: String?
|
||||
private(set) var readBack: [String: OptionReadBack] = [:]
|
||||
private(set) var resultMessage: String?
|
||||
private let store: PrinterStore
|
||||
|
||||
init(action: QuickAction, queue: String, store: PrinterStore) {
|
||||
self.action = action
|
||||
self.queue = queue
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
state = try await OptionState.load(client: store.client, queue: queue)
|
||||
currentDefault = try await store.client.getDefaultPrinter()
|
||||
phase = .ready
|
||||
} catch {
|
||||
resultMessage = String(describing: error)
|
||||
phase = .finished(succeeded: false)
|
||||
}
|
||||
}
|
||||
|
||||
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) }
|
||||
|
||||
var validationError: String? { action.validationError(code, ppd: state?.ppd) }
|
||||
|
||||
/// What will be written, or why the action can't run here.
|
||||
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||
action.changes(for: state?.ppd, value: clearing ? "" : code)
|
||||
}
|
||||
|
||||
var command: String {
|
||||
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
|
||||
guard case .success(let changes) = plannedChanges() else { return "" }
|
||||
return OptionApplier.displayCommand(queue: queue, changes: changes)
|
||||
}
|
||||
|
||||
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
|
||||
var userDefaultNote: String? {
|
||||
guard case .defaultPrinter = action.effect,
|
||||
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
|
||||
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
|
||||
let name = line.split(separator: " ")[1].split(separator: "/")[0]
|
||||
guard name != queue else { return nil }
|
||||
let exists = store.printers.contains { $0.name == name }
|
||||
return "Your ~/.cups/lpoptions has “Default \(name)”, which overrides the server default for your account"
|
||||
+ (exists ? "." : " — and there is no queue named \(name) on this Mac.")
|
||||
}
|
||||
|
||||
func run(clearing: Bool = false) async {
|
||||
phase = .running
|
||||
let started = Date()
|
||||
do {
|
||||
let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil),
|
||||
client: store.client)
|
||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||
let seconds = elapsedText(since: started)
|
||||
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "")
|
||||
if outcome.succeeded {
|
||||
resultMessage = nil
|
||||
store.report("\(title) on \(queue) in \(seconds)")
|
||||
} else {
|
||||
resultMessage = outcome.message ?? "Not applied"
|
||||
store.report("ERROR: \(title) on \(queue): \(resultMessage!)")
|
||||
}
|
||||
phase = .finished(succeeded: outcome.succeeded)
|
||||
} catch let unavailable as QuickActionUnavailable {
|
||||
resultMessage = unavailable.reason
|
||||
phase = .finished(succeeded: false)
|
||||
} catch {
|
||||
resultMessage = PrinterStore.explain(String(describing: error))
|
||||
store.report("ERROR: \(action.title) on \(queue): \(error)")
|
||||
phase = .finished(succeeded: false)
|
||||
}
|
||||
store.noteChange(queue: queue)
|
||||
await store.refreshQuietly()
|
||||
}
|
||||
}
|
||||
|
||||
struct QuickActionSheet: View {
|
||||
@Bindable var model: QuickActionModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
switch model.phase {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
default:
|
||||
content
|
||||
}
|
||||
|
||||
HStack {
|
||||
if model.action.input == .userCode, model.currentCode != nil, model.phase == .ready {
|
||||
Button("Clear Code", role: .destructive) { Task { await model.run(clearing: true) } }
|
||||
}
|
||||
Spacer()
|
||||
switch model.phase {
|
||||
case .finished:
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
default:
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Button("Apply") { Task { await model.run() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!canApply)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 520)
|
||||
.task { await model.load() }
|
||||
}
|
||||
|
||||
private var canApply: Bool {
|
||||
guard model.phase == .ready else { return false }
|
||||
if model.action.input == .userCode {
|
||||
return !model.code.isEmpty && model.validationError == nil
|
||||
}
|
||||
if case .failure = model.plannedChanges() { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if model.action.input == .userCode {
|
||||
Form {
|
||||
LabeledContent("Current code") {
|
||||
HStack(spacing: 8) {
|
||||
if let current = model.currentCode {
|
||||
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
||||
.monospaced()
|
||||
.textSelection(.enabled)
|
||||
Toggle("Show", isOn: $model.showCurrentCode)
|
||||
.toggleStyle(.checkbox)
|
||||
} else {
|
||||
Text("None").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
TextField("User code", text: $model.code, prompt: Text("Digits"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.monospacedDigit()
|
||||
.frame(width: 160)
|
||||
.disabled(model.phase != .ready)
|
||||
} label: {
|
||||
Text("New code")
|
||||
if let error = model.validationError {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollDisabled(true)
|
||||
.frame(height: 130)
|
||||
}
|
||||
|
||||
if case .defaultPrinter = model.action.effect {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Server default").foregroundStyle(.secondary)
|
||||
Text(model.currentDefault ?? "None")
|
||||
Image(systemName: "arrow.right").foregroundStyle(.secondary)
|
||||
Text(model.queue)
|
||||
resultIcon(applied: model.phase == .finished(succeeded: true) ? true
|
||||
: model.phase == .finished(succeeded: false) ? false : nil)
|
||||
}
|
||||
}
|
||||
if let note = model.userDefaultNote {
|
||||
Label(note, systemImage: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else {
|
||||
switch model.plannedChanges() {
|
||||
case .failure(let unavailable) where model.action.input == .none:
|
||||
Label(unavailable.reason, systemImage: "exclamationmark.triangle.fill")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
default:
|
||||
changeGrid
|
||||
}
|
||||
}
|
||||
|
||||
if !model.command.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Command").font(.callout.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Text(model.command)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if let message = model.resultMessage {
|
||||
Label(message, systemImage: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var changeGrid: some View {
|
||||
let changes = (try? model.plannedChanges().get()) ?? []
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||
GridRow {
|
||||
Text("Setting"); Text("Current"); Text(""); Text("New"); Text("")
|
||||
}
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Divider()
|
||||
ForEach(changes, id: \.key) { change in
|
||||
GridRow {
|
||||
Text(change.key).monospaced()
|
||||
Text(model.state?.queueValue(change.key) ?? "—").foregroundStyle(.secondary).monospaced()
|
||||
Image(systemName: "arrow.right").foregroundStyle(.secondary)
|
||||
Text(model.action.input == .userCode && change.key == "RIUserCode" && !model.code.isEmpty
|
||||
? "Custom.\(model.code)" : change.value).monospaced()
|
||||
resultIcon(applied: model.readBack[change.key]?.applied)
|
||||
.help(model.readBack[change.key]?.reason ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
@ViewBuilder private func resultIcon(applied: Bool?) -> some View {
|
||||
switch applied {
|
||||
case true?: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
case false?: Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
||||
case nil:
|
||||
if model.phase == .running { ProgressView().controlSize(.mini) } else { Color.clear.frame(width: 14, height: 14) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user