cupsadmin: CLI, CupsKit, app in progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:13:07 -05:00
commit eba94aae4e
44 changed files with 7256 additions and 0 deletions

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