mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3665c3f5c4 | ||
|
|
08ae1d3043 | ||
|
|
d1d6c8045c |
@@ -68,7 +68,7 @@ swift build # debug build of everything
|
|||||||
verifies both BOM ownership entries. The app is notarized and stapled before packaging; the PKG after.
|
verifies both BOM ownership entries. The app is notarized and stapled before packaging; the PKG after.
|
||||||
- The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it.
|
- The app icon master is `Icon/AppIcon-1024.png`; `build.sh` generates `AppIcon.icns` from it. Don't redraw it.
|
||||||
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
|
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
|
||||||
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
|
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`, `CUPSKIT_LIVE_DELETE=1` (deletes a queue name that never existed; writes nothing). Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
|
||||||
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
||||||
`CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not
|
`CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not
|
||||||
installed) and `/Library/Printers/PPDs`, and skip when they aren't there.
|
installed) and `/Library/Printers/PPDs`, and skip when they aren't there.
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
|
|||||||
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
|
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
|
||||||
| Others | generic only — add a profile (below) |
|
| Others | generic only — add a profile (below) |
|
||||||
|
|
||||||
|
Xerox and Konica Minolta profiles are tested against the vendor PPDs, not yet against a printer — reports welcome.
|
||||||
|
|
||||||
### Adding your printer's driver
|
### Adding your printer's driver
|
||||||
|
|
||||||
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ final class PrinterStore {
|
|||||||
deleted = outcome.succeeded
|
deleted = outcome.succeeded
|
||||||
if deleted {
|
if deleted {
|
||||||
report("Deleted \(queue) in \(elapsedText(since: started))")
|
report("Deleted \(queue) in \(elapsedText(since: started))")
|
||||||
log.info("deleted \(queue, privacy: .public)")
|
log.info("deleted \(queue, privacy: .public)\(outcome.message.map { " (\($0))" } ?? "", privacy: .public)")
|
||||||
} else {
|
} else {
|
||||||
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
|
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
|
||||||
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ struct QuickActionSheet: View {
|
|||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var content: some View {
|
||||||
if model.action.input == .userCode {
|
if model.action.input == .userCode {
|
||||||
Form {
|
Form {
|
||||||
LabeledContent("Current \(model.inputLabel.lowercased())") {
|
LabeledContent("Current \(model.inputLabel)") {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let current = model.currentCode {
|
if let current = model.currentCode {
|
||||||
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
||||||
@@ -202,7 +202,7 @@ struct QuickActionSheet: View {
|
|||||||
.frame(width: 160)
|
.frame(width: 160)
|
||||||
.disabled(model.phase != .ready)
|
.disabled(model.phase != .ready)
|
||||||
} label: {
|
} label: {
|
||||||
Text("New \(model.inputLabel.lowercased())")
|
Text("New \(model.inputLabel)")
|
||||||
if let error = model.validationError {
|
if let error = model.validationError {
|
||||||
Text(error).foregroundStyle(.red)
|
Text(error).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,12 +222,21 @@ extension QueueActions {
|
|||||||
/// `lpadmin -x queue`, verified by the queue being gone.
|
/// `lpadmin -x queue`, verified by the queue being gone.
|
||||||
public static func delete(queue: String, client: CupsClient) async throws -> ActionOutcome {
|
public static func delete(queue: String, client: CupsClient) async throws -> ActionOutcome {
|
||||||
let result = try await Task.detached { try CupsTools.delete(queue: queue) }.value
|
let result = try await Task.detached { try CupsTools.delete(queue: queue) }.value
|
||||||
guard result.succeeded else {
|
return deleteOutcome(queue: queue, result: result, stillExists: try await client.queueExists(queue))
|
||||||
return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message)
|
|
||||||
}
|
}
|
||||||
let stillThere = try await client.queueExists(queue)
|
|
||||||
return ActionOutcome(succeeded: !stillThere, command: result.commandLine,
|
/// Judged by the read-back, not lpadmin's exit status: the goal is that the queue is gone. A queue that
|
||||||
message: stillThere ? "Not applied: \(queue) still exists" : nil)
|
/// was already deleted (by another app, lpadmin, or a row the sidebar hadn't refreshed yet) makes
|
||||||
|
/// lpadmin fail with "The printer or class does not exist" — that's still a successful delete.
|
||||||
|
/// `message` is set on success only in that case ("already deleted").
|
||||||
|
static func deleteOutcome(queue: String, result: ToolResult, stillExists: Bool) -> ActionOutcome {
|
||||||
|
if stillExists {
|
||||||
|
let message = result.succeeded ? "Not applied: \(queue) still exists"
|
||||||
|
: result.message.isEmpty ? "exited \(result.status)" : result.message
|
||||||
|
return ActionOutcome(succeeded: false, command: result.commandLine, message: message)
|
||||||
|
}
|
||||||
|
return ActionOutcome(succeeded: true, command: result.commandLine,
|
||||||
|
message: result.succeeded ? nil : "\(queue) was already deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified.
|
/// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified.
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
return resolved.profile.inputs[id]
|
return resolved.profile.inputs[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "User code" (Ricoh) or the profile's label ("User ID" for Xerox).
|
/// "User Code" (Ricoh) or the profile's label ("User ID" for Xerox). Shown as written, never re-cased.
|
||||||
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" }
|
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User Code" }
|
||||||
|
|
||||||
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
|
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
|
||||||
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
|
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ enum QuickCommand {
|
|||||||
let before = try await OptionState.load(client: client, queue: queue)
|
let before = try await OptionState.load(client: client, queue: queue)
|
||||||
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
||||||
if let value, let error = action.validationError(value, context: context) {
|
if let value, let error = action.validationError(value, context: context) {
|
||||||
throw CupsAdminError.usage("\(action.inputLabel(context).lowercased()) \(value): \(error)")
|
throw CupsAdminError.usage("\(action.inputLabel(context)) \(value): \(error)")
|
||||||
}
|
}
|
||||||
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
|
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
|
||||||
throw CupsAdminError.usage("second value \(secondValue): \(error)")
|
throw CupsAdminError.usage("second value \(secondValue): \(error)")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CupsKit
|
@testable import CupsKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -57,3 +57,45 @@ struct LiveQueueActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete is judged by whether the queue is gone afterwards, not by lpadmin's exit status.
|
||||||
|
@Suite struct DeleteReadBack {
|
||||||
|
private func result(_ status: Int32, _ stderr: String = "") -> ToolResult {
|
||||||
|
ToolResult(executable: CupsTools.lpadminPath, arguments: ["-x", "Old_Queue"], status: status,
|
||||||
|
standardOutput: Data(), standardError: Data(stderr.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deletedNormally() {
|
||||||
|
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(0), stillExists: false)
|
||||||
|
#expect(outcome == ActionOutcome(succeeded: true, command: "/usr/sbin/lpadmin -x Old_Queue", message: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported bug: the queue was removed elsewhere before Delete Printer ran, lpadmin said
|
||||||
|
/// "The printer or class does not exist", and the app showed a failure alert.
|
||||||
|
@Test func alreadyGoneIsASuccess() {
|
||||||
|
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue",
|
||||||
|
result: result(1, "lpadmin: The printer or class does not exist."),
|
||||||
|
stillExists: false)
|
||||||
|
#expect(outcome.succeeded)
|
||||||
|
#expect(outcome.message == "Old_Queue was already deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func failureWhileQueueRemainsShowsToolOutput() {
|
||||||
|
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(1, "lpadmin: Forbidden"), stillExists: true)
|
||||||
|
#expect(!outcome.succeeded && outcome.message == "lpadmin: Forbidden")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func exitZeroButStillThereIsNotApplied() {
|
||||||
|
let outcome = QueueActions.deleteOutcome(queue: "Old_Queue", result: result(0), stillExists: true)
|
||||||
|
#expect(!outcome.succeeded && outcome.message == "Not applied: Old_Queue still exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opt-in (CUPSKIT_LIVE_DELETE=1): the real lpadmin and cupsd on a name that never existed. Writes nothing.
|
||||||
|
@Test(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_DELETE"] != nil))
|
||||||
|
func liveDeleteOfMissingQueue() async throws {
|
||||||
|
let name = "cupsadmin_missing_\(UInt32.random(in: 0 ... .max))"
|
||||||
|
let outcome = try await QueueActions.delete(queue: name, client: CupsClient())
|
||||||
|
#expect(outcome.succeeded, "\(outcome)")
|
||||||
|
#expect(outcome.message == "\(name) was already deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
||||||
#expect(clear.map(\.value) == ["False", "None"])
|
#expect(clear.map(\.value) == ["False", "None"])
|
||||||
// Ricoh takes one value: digits, no second input.
|
// Ricoh takes one value: digits, no second input.
|
||||||
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User code")
|
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User Code")
|
||||||
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
|
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
|
||||||
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
|
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user