Files
cupsadmin/Sources/CUPSAdminApp/QuickActionSheet.swift
kfattic 08ae1d3043 Quick action input labels shown as the profile writes them
"Current User ID" / "New User ID" on Xerox, "Current User Code" on Ricoh; the CLI
validation error uses the same label.

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

308 lines
13 KiB
Swift

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 = ""
/// Optional second value (Xerox account ID), when the driver profile has one.
var secondValue = ""
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 context: DriverContext {
DriverContext(ppd: state?.ppd, attributes: state?.snapshot.attributes)
}
var currentCode: String? { action.currentCode(context) }
/// "Use Letter Paper" or "Use Letter Paper, Fit to Nearest Size", depending on the driver.
var title: String { action.title(in: state == nil ? nil : context).replacingOccurrences(of: "…", with: "") }
var validationError: String? { action.validationError(code, context: context) }
var inputLabel: String { action.inputLabel(context) }
var secondInputLabel: String? { action.secondInputLabel(context) }
var currentSecondValue: String? { action.currentSecondValue(context) }
var secondValidationError: String? { action.secondValidationError(secondValue, context: context) }
/// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
clearing: clearing).map(\.changes)
}
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
var profileName: String? {
guard !action.isDefaultPrinter, case .success(let resolved) = action.resolve(context, value: code.isEmpty ? "0" : code) else { return nil }
return resolved.profile.name
}
var command: String {
if action.isDefaultPrinter { 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 action.isDefaultPrinter,
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 typed = action.input == .userCode && !clearing
let outcome = try await action.run(queue: queue, value: typed ? code : nil,
secondValue: typed && !secondValue.isEmpty ? secondValue : nil,
clearing: clearing, 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" : self.title
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: \(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.title, systemImage: model.action.systemImage)
.font(.title3.weight(.semibold))
Text("\(model.queue) · saved as the queue default for every user on this Mac")
.foregroundStyle(.secondary)
if let driver = model.state.map({ _ in model.context.driverName }), let profile = model.profileName {
Text("\(driver) · driver profile: \(profile)")
.font(.callout)
.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 && model.secondValidationError == nil
}
if case .failure = model.plannedChanges() { return false }
return true
}
@ViewBuilder private var content: some View {
if model.action.input == .userCode {
Form {
LabeledContent("Current \(model.inputLabel)") {
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(model.inputLabel, text: $model.code,
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospacedDigit()
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text("New \(model.inputLabel)")
if let error = model.validationError {
Text(error).foregroundStyle(.red)
}
}
if let secondLabel = model.secondInputLabel {
LabeledContent {
TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) })
.labelsHidden()
.textFieldStyle(.roundedBorder)
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text(secondLabel)
if let error = model.secondValidationError {
Text(error).foregroundStyle(.red)
} else if model.currentSecondValue != nil {
Text("Current value shown in the field").foregroundStyle(.secondary)
}
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(height: model.secondInputLabel == nil ? 130 : 190)
}
if model.action.isDefaultPrinter {
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) }
}
}
}