3 Commits

Author SHA1 Message Date
kfattic
795ab7a981 Canon and HP driver profiles for quick actions
Canon: CNColorMode color/mono, CNDuplex, PageSize=Letter. HP: HPColorAsGray or
HPColorMode; duplex and Letter from the generic profile. Profiles can mark an action
unavailable with a reason (Canon department IDs, HP accounting codes). Use Letter
Paper only says Fit to Nearest Size when the profile sets a fit option. Offline tests
sweep every Canon and HP PPD found in CUPSKIT_PPD_DIRS or /Library/Printers/PPDs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:04:02 -05:00
kfattic
7a98cb3bd3 ppdreport accepts a PPD file path (plain or gzipped) as well as a queue
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:04:02 -05:00
kfattic
a54ec0f4be Options screenshot shows the Rosetta warning; --screenshots checks for it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:35:54 -05:00
15 changed files with 258 additions and 24 deletions

View File

@@ -39,8 +39,10 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
value; otherwise the action is unavailable with the reason. `cupsadmin ppdreport <queue>` shows a value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives
driver's keywords and which actions its profile enables. the reason an action can't be done with that driver (e.g. Canon department IDs). Use Letter Paper is
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.
## Build and test ## Build and test
``` ```
@@ -67,7 +69,8 @@ swift build # debug build of everything
- 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>`. 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
`/Library/Printers/PPDs` and skip when they aren't installed. `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.
## Conventions ## Conventions
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray). - **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).

View File

@@ -4,7 +4,7 @@
*Jobs: hold, release, move or cancel jobs on any queue.* *Jobs: hold, release, move or cancel jobs on any queue.*
![CUPS Admin showing a Ricoh queue's options](docs/screenshots/app-options.png) ![CUPS Admin showing a Ricoh queue's options](docs/screenshots/app-options.png)
*Options: the queue's driver settings as a form, applied with `lpadmin` and read back.* *Options: the queue's driver settings as a form, applied with `lpadmin` and read back. The header warns when the driver needs Rosetta.*
**CUPS Admin** is a native macOS app that puts back the CUPS web interface (`http://localhost:631`) Apple removed in macOS 27 — `cupsd`, `lpadmin` and IPP Everywhere queues all still work, only the browser admin page is gone. The sidebar lists every queue with its state and active jobs; select one to see its status, hold, release, move or cancel its jobs, and change its default options the way the old "Set Default Options" page did, including per-driver settings like Ricoh user codes. Common changes (color or black & white, duplex, Letter paper, a user code, the default printer) are one-click Quick Actions, and every change is read back from cupsd so nothing fails silently. It ships with `cupsadmin`, a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. No third-party dependencies; builds with the Xcode Command Line Tools alone. **CUPS Admin** is a native macOS app that puts back the CUPS web interface (`http://localhost:631`) Apple removed in macOS 27 — `cupsd`, `lpadmin` and IPP Everywhere queues all still work, only the browser admin page is gone. The sidebar lists every queue with its state and active jobs; select one to see its status, hold, release, move or cancel its jobs, and change its default options the way the old "Set Default Options" page did, including per-driver settings like Ricoh user codes. Common changes (color or black & white, duplex, Letter paper, a user code, the default printer) are one-click Quick Actions, and every change is read back from cupsd so nothing fails silently. It ships with `cupsadmin`, a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. No third-party dependencies; builds with the Xcode Command Line Tools alone.
@@ -33,7 +33,7 @@ cupsadmin options <queue> # PPD options and IPP *-default values, like lp
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...] cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
cupsadmin ppdreport <queue> # every PPD group and option: keyword, label, type, default, choices cupsadmin ppdreport <queue | file.ppd[.gz]> # every PPD group and option: keyword, label, type, default, choices
``` ```
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly. Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
@@ -50,7 +50,7 @@ cupsadmin quick bw <queue> # always print black & white
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too) cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
cupsadmin quick duplex <queue> # default to two-sided cupsadmin quick duplex <queue> # default to two-sided
cupsadmin quick simplex <queue> cupsadmin quick simplex <queue>
cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no "prompt user" at the printer) cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
cupsadmin quick default <queue> # make this the server default printer cupsadmin quick default <queue> # make this the server default printer
``` ```
@@ -65,13 +65,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) | | Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) | | Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) | | Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
| Canon, HP, Xerox, Konica Minolta user/department codes | no built-in profile — add one (below) | | Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
| Xerox, Konica Minolta and others | generic only — add a profile (below) |
### Adding your printer's driver ### Adding your printer's driver
1. `cupsadmin ppdreport <queue>` 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.
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report). 2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`. 3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. Keep your profile above `generic`.
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request. 4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
## How it works ## How it works

View File

@@ -300,7 +300,7 @@ struct QuickActionItems: View {
ForEach(QuickAction.all) { action in ForEach(QuickAction.all) { action in
let reason = store.unavailableReason(action, queue: queue) let reason = store.unavailableReason(action, queue: queue)
Button { choose(action) } label: { Button { choose(action) } label: {
Label(action.title, systemImage: action.systemImage) Label(action.title(in: store.drivers[queue]), systemImage: action.systemImage)
} }
.disabled(reason != nil) .disabled(reason != nil)
.help(reason ?? action.summary) .help(reason ?? action.summary)

View File

@@ -45,6 +45,9 @@ final class QuickActionModel: Identifiable {
var currentCode: String? { action.currentCode(context) } 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 validationError: String? { action.validationError(code, context: context) }
/// What will be written, or why the action can't run here. /// What will be written, or why the action can't run here.
@@ -85,7 +88,7 @@ final class QuickActionModel: Identifiable {
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) }) readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
state = try? await OptionState.load(client: store.client, queue: queue) state = try? await OptionState.load(client: store.client, queue: queue)
let seconds = elapsedText(since: started) let seconds = elapsedText(since: started)
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "") let title = clearing ? "Cleared user code" : self.title
if outcome.succeeded { if outcome.succeeded {
resultMessage = nil resultMessage = nil
store.report("\(title) on \(queue) in \(seconds)") store.report("\(title) on \(queue) in \(seconds)")
@@ -99,7 +102,7 @@ final class QuickActionModel: Identifiable {
phase = .finished(succeeded: false) phase = .finished(succeeded: false)
} catch { } catch {
resultMessage = PrinterStore.explain(String(describing: error)) resultMessage = PrinterStore.explain(String(describing: error))
store.report("ERROR: \(action.title) on \(queue): \(error)") store.report("ERROR: \(title) on \(queue): \(error)")
phase = .finished(succeeded: false) phase = .finished(succeeded: false)
} }
store.noteChange(queue: queue) store.noteChange(queue: queue)
@@ -114,7 +117,7 @@ struct QuickActionSheet: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage) Label(model.title, systemImage: model.action.systemImage)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
Text("\(model.queue) · saved as the queue default for every user on this Mac") Text("\(model.queue) · saved as the queue default for every user on this Mac")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)

View File

@@ -52,8 +52,10 @@ public struct DriverProfile: Decodable, Identifiable {
public let match: Match public let match: Match
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together. /// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
public let actions: [String: [[String]]] public let actions: [String: [[String]]]
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
public let unavailable: [String: String]
enum CodingKeys: String, CodingKey { case id, name, match, actions } enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable }
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self) let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -65,6 +67,7 @@ public struct DriverProfile: Decodable, Identifiable {
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions) let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
for (action, pairs) in raw { actions[action] = pairs.alternatives } for (action, pairs) in raw { actions[action] = pairs.alternatives }
self.actions = actions self.actions = actions
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
} }
private struct FlexiblePairs: Decodable { private struct FlexiblePairs: Decodable {

View File

@@ -176,3 +176,25 @@ public struct PPD {
options.first { $0.keyword == keyword } options.first { $0.keyword == keyword }
} }
} }
extension PPD {
/// Reads a PPD file, plain or gzip-compressed (vendors ship `.ppd.gz` / `.gz` under
/// /Library/Printers/PPDs). Text that isn't valid UTF-8 is read as ISO Latin-1, the PPD default.
public static func text(contentsOfFile path: String) throws -> String {
guard var data = FileManager.default.contents(atPath: path) else {
throw CupsAdminError.failed("can’t read \(path)")
}
if data.starts(with: [0x1F, 0x8B]) {
let result = try CupsTools.run("/usr/bin/gzip", ["-dc", path])
guard result.succeeded else { throw CupsAdminError.failed("can’t decompress \(path): \(result.message)") }
data = result.standardOutput
}
return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? ""
}
/// True when a `ppdreport` argument names a PPD file rather than a queue. Queue names can't contain "/".
public static func looksLikeFile(_ argument: String) -> Bool {
argument.contains("/") || [".ppd", ".gz", ".ppd.gz"].contains { argument.lowercased().hasSuffix($0) }
}
}

View File

@@ -30,8 +30,8 @@ public struct QuickAction: Identifiable, Hashable {
summary: "Two-sided, long edge"), summary: "Two-sided, long edge"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
summary: "One-sided"), summary: "One-sided"),
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right", QuickAction(id: "letter", title: "Use Letter Paper", systemImage: "arrow.up.left.and.arrow.down.right",
input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"), input: .none, summary: "Letter paper, and fit to the nearest size where the driver has that option (never prompt at the printer)"),
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
summary: "Server default printer (lpadmin -d)"), summary: "Server default printer (lpadmin -d)"),
] ]
@@ -40,12 +40,25 @@ public struct QuickAction: Identifiable, Hashable {
all.first { $0.id == id } all.first { $0.id == id }
} }
/// The title for a queue: Use Letter Paper gains ", Fit to Nearest Size" only when its driver
/// profile also sets a fit-to-paper option (e.g. Ricoh RIPaperPolicy), not just the page size.
public func title(in context: DriverContext?) -> String {
guard id == "letter", let context, case .success(let resolved) = resolve(context),
resolved.changes.contains(where: { !Self.paperSizeKeys.contains($0.key) }) else { return title }
return title + ", Fit to Nearest Size"
}
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
/// The profile and option changes this action would write on a queue, or why it can't. /// The profile and option changes this action would write on a queue, or why it can't.
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false, public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> { profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
let key = clearing ? id + "-clear" : id let key = clearing ? id + "-clear" : id
var firstMissing: String? var firstMissing: String?
for profile in DriverProfiles.matching(context, in: profiles) { for profile in DriverProfiles.matching(context, in: profiles) {
if profile.actions[key] == nil, let reason = profile.unavailable[id] {
return .failure(QuickActionUnavailable(reason: reason))
}
guard let alternatives = profile.actions[key] else { continue } guard let alternatives = profile.actions[key] else { continue }
for pairs in alternatives { for pairs in alternatives {
let parsed = pairs.compactMap(Self.parse) let parsed = pairs.compactMap(Self.parse)

View File

@@ -1,5 +1,5 @@
{ {
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport <queue>", "comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
"profiles": [ "profiles": [
{ {
"id": "ricoh-m-c251fw", "id": "ricoh-m-c251fw",
@@ -27,6 +27,33 @@
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"] "letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
} }
}, },
{
"id": "canon",
"name": "Canon UFR II / LIPSLX / CARPS2",
"match": { "manufacturer": "^Canon$" },
"actions": {
"color": ["CNColorMode=color"],
"bw": ["CNColorMode=mono"],
"duplex": ["CNDuplex=DuplexFront"],
"simplex": ["CNDuplex=None"],
"letter": ["PageSize=Letter"]
},
"unavailable": {
"usercode": "Canon department IDs are set by the Canon driver, not the PPD"
}
},
{
"id": "hp",
"name": "HP",
"match": { "manufacturer": "^HP$" },
"actions": {
"color": [["HPColorAsGray=False"], ["HPColorMode=colorsmart"]],
"bw": [["HPColorAsGray=True"], ["HPColorMode=grayscale"]]
},
"unavailable": {
"usercode": "HP drivers have no accounting code option"
}
},
{ {
"id": "generic", "id": "generic",
"name": "Generic (standard PPD keywords, IPP Everywhere)", "name": "Generic (standard PPD keywords, IPP Everywhere)",

View File

@@ -8,8 +8,7 @@ enum PPDReportCommand {
let source: String let source: String
var attributes: IPPGroup? var attributes: IPPGroup?
if let file { if let file {
guard let data = FileManager.default.contents(atPath: file) else { throw CupsAdminError.failed("can’t read \(file)") } text = try PPD.text(contentsOfFile: file)
text = String(decoding: data, as: UTF8.self)
source = file source = file
} else if let queue { } else if let queue {
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") } guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }

View File

@@ -42,7 +42,7 @@ let usage = """
quick list quick actions (task-named queue defaults) quick list quick actions (task-named queue defaults)
quick <action> <queue> [code | --clear] quick <action> <queue> [code | --clear]
apply one via lpadmin, then verify apply one via lpadmin, then verify
ppdreport <queue> | --file <path.ppd> ppdreport <queue | path.ppd[.gz]>
every PPD group and option: keyword, label, type, default, choices every PPD group and option: keyword, label, type, default, choices
help show this help help show this help
""" """
@@ -130,7 +130,12 @@ func runCommand(_ args: [String]) async throws -> String {
} }
try rejectUnknownFlags() try rejectUnknownFlags()
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else { guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
throw CupsAdminError.usage("ppdreport needs one queue name or --file <path.ppd>") throw CupsAdminError.usage("ppdreport needs one queue name or PPD file path")
}
// A path (anything with "/", or ending in .ppd / .gz) is a PPD file; queue names can't contain "/".
if file == nil, let argument = rest.first, PPD.looksLikeFile(argument) {
file = argument
rest.removeAll()
} }
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file) return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)

View File

@@ -18,12 +18,13 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
@Suite struct DriverProfileFile { @Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() { @Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"]) #expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"])
#expect(profiles.last?.isGeneric == true) #expect(profiles.last?.isGeneric == true)
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric }) #expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"]) let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
for profile in profiles { for profile in profiles {
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())") #expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
for alternatives in profile.actions.values { for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } }) #expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
} }
@@ -45,6 +46,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
let letter = try QuickAction.named("letter")!.resolve(c).get().changes let letter = try QuickAction.named("letter")!.resolve(c).get().changes
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false), #expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)]) OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper, Fit to Nearest Size")
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
#expect(code.map(\.value) == ["True", "Custom.4321"]) #expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
@@ -76,6 +78,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does. // No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
#expect(profileID("letter", c) == "generic") #expect(profileID("letter", c) == "generic")
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)]) #expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
} }
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) @Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))

View File

@@ -0,0 +1,148 @@
@testable import CupsKit
import Foundation
import Testing
/// Canon and HP profile tests read the vendors' gzipped PPDs, read-only. They look in CUPSKIT_PPD_DIRS
/// (colon-separated, e.g. PPDs extracted with `pkgutil --expand-full` from a driver package you haven't
/// installed) and then /Library/Printers/PPDs; each test is skipped when its PPDs aren't found.
enum ExtraPPDs {
static let directories: [String] =
(ProcessInfo.processInfo.environment["CUPSKIT_PPD_DIRS"] ?? "").split(separator: ":").map(String.init) + [vendorPPDs]
static func path(_ name: String) -> String? {
directories.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.fileExists(atPath: $0) }
}
static func files(prefix: String) -> [String] {
var seen: Set<String> = []
return directories.flatMap { dir in
((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
.filter { $0.hasPrefix(prefix) && seen.insert($0).inserted }
.sorted()
.map { (dir as NSString).appendingPathComponent($0) }
}
}
static func context(path: String) -> DriverContext? {
(try? PPD.text(contentsOfFile: path)).map { DriverContext(ppd: PPD(text: $0), attributes: nil) }
}
static func context(_ name: String) -> DriverContext? { path(name).flatMap(context(path:)) }
}
private func resolved(_ id: String, _ c: DriverContext) -> ResolvedQuickAction? {
try? QuickAction.named(id)!.resolve(c, value: "0").get()
}
private func pairs(_ id: String, _ c: DriverContext) -> [String]? {
resolved(id, c)?.changes.map { "\($0.key)=\($0.value)" }
}
private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String) -> Bool {
c.ppd?.option(keyword)?.choices.contains(choice) == true
}
@Suite struct CanonProfile {
static let adv = "CNPZUIRAC3530IIIZU.ppd.gz"
@Test(.enabled(if: ExtraPPDs.path(adv) != nil))
func imageRunnerAdvance() throws {
let c = try #require(ExtraPPDs.context(Self.adv))
#expect(DriverProfiles.matching(c).map(\.id) == ["canon", "generic"])
#expect(pairs("color", c) == ["CNColorMode=color"])
#expect(pairs("bw", c) == ["CNColorMode=mono"])
#expect(pairs("duplex", c) == ["CNDuplex=DuplexFront"])
#expect(pairs("simplex", c) == ["CNDuplex=None"])
#expect(pairs("letter", c) == ["PageSize=Letter"])
for id in ["color", "bw", "duplex", "simplex", "letter"] { #expect(resolved(id, c)?.profile.id == "canon", "\(id)") }
// No fit-to-paper option in Canon's PPDs: Letter sets the page size only, and the title says so.
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, clearing: true).get() }
}
/// Every Canon PPD in the package: an action is offered exactly when the PPD has its keyword and choice.
@Test(.enabled(if: !ExtraPPDs.files(prefix: "CNPZU").isEmpty))
func everyCanonPPD() throws {
let files = ExtraPPDs.files(prefix: "CNPZU")
var mono = 0
for file in files {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "canon", "\(name)")
#expect(QuickAction.named("color")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "color"), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "mono"), "\(name)")
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "DuplexFront"), "\(name)")
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "None"), "\(name)")
#expect(pairs("letter", c) == ["PageSize=Letter"], "\(name)")
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
if !hasChoice(c, "CNColorMode", "color") { mono += 1 }
}
#expect(files.count - mono > 0 && mono > 0)
}
}
@Suite struct HPProfile {
static let colorLaser = "HP Color LaserJet M750.gz"
static let officejet = "HP Officejet Pro 8600.ppd.gz"
static let monoLaser = "HP LaserJet 600 M601 M602 M603.gz"
static let reason = "HP drivers have no accounting code option"
@Test(.enabled(if: ExtraPPDs.path(colorLaser) != nil))
func colorLaserJetEnterprise() throws {
let c = try #require(ExtraPPDs.context(Self.colorLaser))
#expect(DriverProfiles.matching(c).map(\.id) == ["hp", "generic"])
#expect(pairs("color", c) == ["HPColorAsGray=False"])
#expect(pairs("bw", c) == ["HPColorAsGray=True"])
#expect(resolved("color", c)?.profile.id == "hp")
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(pairs("simplex", c) == ["Duplex=None"])
#expect(resolved("duplex", c)?.profile.id == "generic")
#expect(pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
@Test(.enabled(if: ExtraPPDs.path(officejet) != nil))
func officejetProUsesHPColorMode() throws {
let c = try #require(ExtraPPDs.context(Self.officejet))
#expect(pairs("color", c) == ["HPColorMode=colorsmart"])
#expect(pairs("bw", c) == ["HPColorMode=grayscale"])
#expect(resolved("bw", c)?.profile.id == "hp")
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
@Test(.enabled(if: ExtraPPDs.path(monoLaser) != nil))
func monoLaserJet() throws {
let c = try #require(ExtraPPDs.context(Self.monoLaser))
#expect(!QuickAction.named("color")!.isAvailable(in: c) && !QuickAction.named("bw")!.isAvailable(in: c))
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
/// Every HP PPD: color and black & white come from the HP profile whenever the PPD has HPColorAsGray or HPColorMode.
@Test(.enabled(if: ExtraPPDs.files(prefix: "HP ").count > 100))
func everyHPPPD() throws {
var hpColor = 0
for file in ExtraPPDs.files(prefix: "HP ") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
guard c.manufacturer == "HP" else { continue }
#expect(DriverProfiles.matching(c).first?.id == "hp", "\(name)")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
if hasChoice(c, "HPColorAsGray", "True") || hasChoice(c, "HPColorMode", "grayscale") {
#expect(resolved("bw", c)?.profile.id == "hp", "\(name)")
hpColor += 1
}
if hasChoice(c, "HPColorAsGray", "False") || hasChoice(c, "HPColorMode", "colorsmart") {
#expect(resolved("color", c)?.profile.id == "hp", "\(name)")
}
if hasChoice(c, "PageSize", "Letter") { #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)") }
}
#expect(hpColor > 0)
}
}

View File

@@ -114,6 +114,7 @@ take_screenshots() {
-D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true -D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true
done done
# A Ricoh queue for the Options tab, set to Letter + fit to nearest size (what a US site uses). # A Ricoh queue for the Options tab, set to Letter + fit to nearest size (what a US site uses).
# Ricoh's filters are Intel-only, so its header also shows "Driver needs Rosetta".
lpadmin -p Copy_Room -v lpd://127.0.0.1/Copy_Room -P "$RICOH_DEMO_PPD" -D "Copy Room" -L "Building A" \ lpadmin -p Copy_Room -v lpd://127.0.0.1/Copy_Room -P "$RICOH_DEMO_PPD" -D "Copy Room" -L "Building A" \
-o printer-is-shared=false -o PageSize=Letter -o RIPaperPolicy=NearestSizeAdjust -E 2>&1 | grep -v deprecated || true -o printer-is-shared=false -o PageSize=Letter -o RIPaperPolicy=NearestSizeAdjust -E 2>&1 | grep -v deprecated || true
# Held jobs never leave the Mac; owners are generic names, not the person running the build. # Held jobs never leave the Mac; owners are generic names, not the person running the build.
@@ -123,6 +124,11 @@ take_screenshots() {
lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null
lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null
# The Options shot shows the header's "Driver needs Rosetta" row: Ricoh's pstopsRV2 filter is Intel-only.
# grep without -q: under pipefail, -q exiting early would kill ppdreport and fail the pipeline.
"$BINARY" ppdreport Copy_Room 2>/dev/null | grep '^WARNING: *Driver needs Rosetta' >/dev/null \
|| { STATUS="ERROR: Copy_Room isn't flagged \"Driver needs Rosetta\" on this Mac, so the Options screenshot wouldn't show the warning"; exit 1; }
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ') SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ')
capture_app Front_Office jobs "$SHOT_JOBS" capture_app Front_Office jobs "$SHOT_JOBS"
capture_app Copy_Room options "$SHOT_OPTIONS" capture_app Copy_Room options "$SHOT_OPTIONS"
@@ -307,7 +313,7 @@ spctl_check() {
local type="$1" path="$2" log="$3" out local type="$1" path="$2" log="$3" out
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true) out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
echo "$out" | tee "$log" echo "$out" | tee "$log"
echo "$out" | grep -q ': accepted' && echo "$out" | grep -q 'source=Notarized Developer ID' \ echo "$out" | grep ': accepted' >/dev/null && echo "$out" | grep 'source=Notarized Developer ID' >/dev/null \
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; } || { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
} }
@@ -373,7 +379,7 @@ step "verify pkg contents"
PAYLOAD_FILES=$(pkgutil --payload-files "$PKG") PAYLOAD_FILES=$(pkgutil --payload-files "$PKG")
echo "payload: $(echo "$PAYLOAD_FILES" | wc -l | tr -d ' ') entries, top level:" echo "payload: $(echo "$PAYLOAD_FILES" | wc -l | tr -d ' ') entries, top level:"
echo "$PAYLOAD_FILES" | awk -F/ 'NF <= 3' | sed 's/^/ /' echo "$PAYLOAD_FILES" | awk -F/ 'NF <= 3' | sed 's/^/ /'
if echo "$PAYLOAD_FILES" | grep -v -E '^\.$|^\./Applications$|^\./Applications/CUPS Admin\.app(/|$)' | grep -q .; then if echo "$PAYLOAD_FILES" | grep -v -E '^\.$|^\./Applications$|^\./Applications/CUPS Admin\.app(/|$)' | grep . >/dev/null; then
STATUS="ERROR: payload contains something other than /Applications/CUPS Admin.app"; exit 1 STATUS="ERROR: payload contains something other than /Applications/CUPS Admin.app"; exit 1
fi fi
EXPANDED="$DIST/expanded" EXPANDED="$DIST/expanded"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 135 KiB