9 Commits
v1.1.1 ... main

Author SHA1 Message Date
kfattic
3665c3f5c4 Delete Printer: a queue that's already gone is a successful delete
Judge the delete by the read-back (is the queue gone?) instead of lpadmin's exit
status. Deleting a queue that was removed elsewhere before the sidebar refreshed
made lpadmin fail with 'The printer or class does not exist', and the app showed a
failure alert although the queue was gone. Now the status bar shows 'Deleted <queue>'
and os_log notes it was already deleted. Unit tests for all four outcomes, plus an
opt-in live test (CUPSKIT_LIVE_DELETE=1) on a name that never existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:24:46 -05:00
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
kfattic
d1d6c8045c README: Xerox and Konica Minolta profiles are untested on printers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:18:54 -05:00
kfattic
37a64af25a Xerox and Konica Minolta driver profiles; optional second quick action value
Xerox: XROutputColor with XRColorCorrection fallback for color/black & white; user code
sets XRAccountingSystem=XSA + AcctUserID, and an optional account ID (AcctAccountID)
via a profile-declared second input, in the sheet and as
'cupsadmin quick usercode <queue> <user> [account]'. Konica Minolta: KMDuplex,
PageSize=8.5x11, color from the generic ColorModel; user code unavailable with the
reason. Offline tests sweep every Xerox and bizhub PPD found in CUPSKIT_PPD_DIRS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:15:14 -05:00
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
kfattic
4832b74c3a README: credit opencups for the Intel-only driver check idea
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:24:14 -05:00
kfattic
366bd74225 Flag queues whose driver filters need Rosetta or are missing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:23:31 -05:00
24 changed files with 946 additions and 59 deletions

View File

@@ -29,14 +29,21 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
- **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD / - **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD /
printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out
a per-user override. a per-user override.
- **Driver filter check** (`DriverFilters.swift`): resolves each `*cupsFilter`/`*cupsFilter2` program
(bare names under `/usr/libexec/cups/filter`, skipping `maxsize(n)` and `-`) and reads its Mach-O header
directly — any arm64 CPU type counts as native, including subtypes `lipo` can't name. Intel-only →
"Driver needs Rosetta"; not found → "Driver filter missing" (shown first).
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are - **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates). written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes - **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
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); its `inputs` map relabels
the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). 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
``` ```
@@ -61,9 +68,10 @@ 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
`/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.
@@ -25,6 +25,7 @@ Requires macOS 14 or later. Universal (Apple silicon and Intel).
``` ```
cupsadmin printers # every queue: state, reasons, accepting, shared, URI cupsadmin printers # every queue: state, reasons, accepting, shared, URI
cupsadmin printers --rosetta # only queues whose driver filters are Intel-only or missing
cupsadmin printer <queue> # one queue in detail (--all for every IPP attribute) cupsadmin printer <queue> # one queue in detail (--all for every IPP attribute)
cupsadmin jobs [queue] # active jobs (--completed, --all-jobs) cupsadmin jobs [queue] # active jobs (--completed, --all-jobs)
cupsadmin cancel <job-id> # cancel one job cupsadmin cancel <job-id> # cancel one job
@@ -32,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.
@@ -47,9 +48,10 @@ The things admins actually do, without knowing the driver's option keywords:
cupsadmin quick color <queue> # always print in color cupsadmin quick color <queue> # always print in color
cupsadmin quick bw <queue> # always print black & white 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 usercode <queue> jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID
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
``` ```
@@ -64,13 +66,19 @@ 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 (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) |
| 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) |
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>` 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`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). 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
@@ -109,9 +117,12 @@ Or put those three lines (without `export`) in a `build.env` next to `build.sh`;
- Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/<queue>.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs. - Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/<queue>.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs.
- PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets. - PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets.
- New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't. - New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't.
- Driver filters named in a queue's PPD (`*cupsFilter`, `*cupsFilter2`) are checked for an arm64 slice. Queues whose filters are Intel-only show "Driver needs Rosetta" in the app's header and sidebar tooltip and a `WARNING` line in `cupsadmin printer` and `ppdreport`; filters that don't exist show "Driver filter missing". `cupsadmin printers --rosetta` lists the affected queues, for fleet reporting.
## About ## About
Built at Western Kentucky University by Kurt Fattic to replace the CUPS admin pages for a fleet of about 1,000 Macs, and written with Claude. MIT licensed — see `LICENSE`. Not affiliated with Apple or OpenPrinting. Built at Western Kentucky University by Kurt Fattic to replace the CUPS admin pages for a fleet of about 1,000 Macs, and written with Claude. MIT licensed — see `LICENSE`. Not affiliated with Apple or OpenPrinting.
The Intel-only driver check was inspired by Willem Groenewald's opencups.
Bug reports and driver profiles for other printers are welcome through [Issues](../../issues) and [Pull Requests](../../pulls). Bug reports and driver profiles for other printers are welcome through [Issues](../../issues) and [Pull Requests](../../pulls).

View File

@@ -210,7 +210,8 @@ struct SidebarView: View {
List(selection: $selection) { List(selection: $selection) {
Section("Printers") { Section("Printers") {
ForEach(filtered) { printer in ForEach(filtered) { printer in
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name) PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name,
driverWarning: store.driverFilters[printer.name]?.headline)
.tag(printer.name) .tag(printer.name)
.contextMenu { menu(for: printer) } .contextMenu { menu(for: printer) }
} }
@@ -299,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)
@@ -311,6 +312,8 @@ struct QuickActionItems: View {
struct PrinterRow: View { struct PrinterRow: View {
let printer: PrinterSummary let printer: PrinterSummary
var isDefault = false var isDefault = false
/// "Driver needs Rosetta" / "Driver filter missing", added to the tooltip.
var driverWarning: String?
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -339,7 +342,7 @@ struct PrinterRow: View {
} }
} }
.badge(printer.jobCount) .badge(printer.jobCount)
.help(printer.stateSummary) .help(driverWarning.map { "\(printer.stateSummary) · \($0)" } ?? printer.stateSummary)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityValue(printer.stateSummary) .accessibilityValue(printer.stateSummary)
} }

View File

@@ -37,6 +37,8 @@ enum JobFilter: String, CaseIterable, Identifiable {
final class PrinterDetailModel { final class PrinterDetailModel {
let queue: String let queue: String
private(set) var printer: IPPGroup? private(set) var printer: IPPGroup?
/// Intel-only or missing driver filters, from the queue's PPD.
private(set) var driverFilters: DriverFilterReport?
private(set) var jobs: [JobSummary] = [] private(set) var jobs: [JobSummary] = []
private(set) var loadError: String? private(set) var loadError: String?
private(set) var isWorking = false private(set) var isWorking = false
@@ -73,6 +75,11 @@ final class PrinterDetailModel {
do { do {
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes) printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
loadError = nil loadError = nil
if let text = try await store.client.getPPD(queue: queue) {
driverFilters = await Task.detached { DriverFilterCheck.check(PPD(text: text)) }.value
} else {
driverFilters = nil
}
} catch { } catch {
loadError = String(describing: error) loadError = String(describing: error)
store.report("ERROR: \(queue): \(error)") store.report("ERROR: \(queue): \(error)")

View File

@@ -12,7 +12,7 @@ struct PrinterDetailView: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if let printer = model.printer { if let printer = model.printer {
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath) PrinterHeaderView(printer: printer, ppdPath: model.ppdPath, driverFilters: model.driverFilters)
Divider() Divider()
} else if let error = model.loadError { } else if let error = model.loadError {
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle", ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
@@ -35,6 +35,7 @@ struct PrinterDetailView: View {
struct PrinterHeaderView: View { struct PrinterHeaderView: View {
let printer: IPPGroup let printer: IPPGroup
let ppdPath: String? let ppdPath: String?
let driverFilters: DriverFilterReport?
private var state: Int? { printer.int("printer-state") } private var state: Int? { printer.int("printer-state") }
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true } private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
@@ -64,6 +65,13 @@ struct PrinterHeaderView: View {
} }
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No") LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No") LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
if let headline = driverFilters?.headline {
LabeledContent("Driver") {
Label(headline, systemImage: "exclamationmark.triangle.fill")
.symbolRenderingMode(.multicolor)
}
.help(driverFilters?.detail ?? headline)
}
} }
} }
Form { Form {
@@ -93,7 +101,7 @@ struct PrinterHeaderView: View {
.formStyle(.grouped) .formStyle(.grouped)
.scrollDisabled(true) .scrollDisabled(true)
.textSelection(.enabled) .textSelection(.enabled)
.frame(height: 262) .frame(height: driverFilters?.isFlagged == true ? 300 : 262)
} }
} }

View File

@@ -103,6 +103,8 @@ final class PrinterStore {
private(set) var defaultPrinter: String? private(set) var defaultPrinter: String?
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions. /// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
private(set) var drivers: [String: DriverContext] = [:] private(set) var drivers: [String: DriverContext] = [:]
/// Filter check per queue (Intel-only or missing filters), from the cached PPDs.
private(set) var driverFilters: [String: DriverFilterReport] = [:]
var failure: Failure? var failure: Failure?
let client = CupsClient() let client = CupsClient()
@@ -201,6 +203,11 @@ final class PrinterStore {
for printer in printers where drivers[printer.name] == nil { for printer in printers where drivers[printer.name] == nil {
if let context = try? await DriverContext.load(client: client, queue: printer.name) { if let context = try? await DriverContext.load(client: client, queue: printer.name) {
drivers[printer.name] = context drivers[printer.name] = context
if let ppd = context.ppd {
driverFilters[printer.name] = await Task.detached { DriverFilterCheck.check(ppd) }.value
} else {
driverFilters[printer.name] = nil
}
} }
} }
} }
@@ -244,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)")

View File

@@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable {
let action: QuickAction let action: QuickAction
let queue: String let queue: String
var code = "" var code = ""
/// Optional second value (Xerox account ID), when the driver profile has one.
var secondValue = ""
var showCurrentCode = false var showCurrentCode = false
private(set) var phase: Phase = .loading private(set) var phase: Phase = .loading
private(set) var state: OptionState? private(set) var state: OptionState?
@@ -45,11 +47,20 @@ 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) }
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. /// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> { func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes) action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
clearing: clearing).map(\.changes)
} }
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords. /// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
@@ -80,12 +91,14 @@ final class QuickActionModel: Identifiable {
phase = .running phase = .running
let started = Date() let started = Date()
do { do {
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil, 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) clearing: clearing, client: store.client)
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 +112,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 +127,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)
@@ -158,7 +171,7 @@ struct QuickActionSheet: View {
private var canApply: Bool { private var canApply: Bool {
guard model.phase == .ready else { return false } guard model.phase == .ready else { return false }
if model.action.input == .userCode { if model.action.input == .userCode {
return !model.code.isEmpty && model.validationError == nil return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil
} }
if case .failure = model.plannedChanges() { return false } if case .failure = model.plannedChanges() { return false }
return true return true
@@ -167,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 code") { 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))
@@ -181,22 +194,39 @@ struct QuickActionSheet: View {
} }
} }
LabeledContent { LabeledContent {
TextField("User code", text: $model.code, prompt: Text("Digits")) TextField(model.inputLabel, text: $model.code,
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
.labelsHidden() .labelsHidden()
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.monospacedDigit() .monospacedDigit()
.frame(width: 160) .frame(width: 160)
.disabled(model.phase != .ready) .disabled(model.phase != .ready)
} label: { } label: {
Text("New code") Text("New \(model.inputLabel)")
if let error = model.validationError { if let error = model.validationError {
Text(error).foregroundStyle(.red) 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) .formStyle(.grouped)
.scrollDisabled(true) .scrollDisabled(true)
.frame(height: 130) .frame(height: model.secondInputLabel == nil ? 130 : 190)
} }
if model.action.isDefaultPrinter { if model.action.isDefaultPrinter {

View File

@@ -0,0 +1,157 @@
import Darwin
import Foundation
/// One filter program a PPD names in `*cupsFilter` / `*cupsFilter2`, and whether it can run natively.
public struct DriverFilter: Hashable {
public enum Status: Hashable {
/// Has an arm64 slice (arm64, arm64e or newer subtypes).
case native(architectures: [String])
/// Intel only: needs Rosetta on Apple silicon.
case intelOnly(architectures: [String])
/// A script (`#!`); runs wherever its interpreter does.
case script(interpreter: String)
/// `-`: no program, cupsd passes the data through.
case passthrough
case missing
/// Exists but isn't a Mach-O binary or script this check understands.
case unrecognized
}
public let keyword: String
/// The line's value, e.g. `application/vnd.cups-postscript 0 /Library/Printers/…/pstopsRV2`.
public let line: String
public let program: String
/// Resolved absolute path; nil for `-`.
public let path: String?
public let status: Status
public var name: String { (program as NSString).lastPathComponent }
}
public struct DriverFilterReport: Hashable {
public let filters: [DriverFilter]
public var intelOnly: [DriverFilter] { filters.filter { if case .intelOnly = $0.status { return true } else { return false } } }
public var missing: [DriverFilter] { filters.filter { $0.status == .missing } }
public var needsRosetta: Bool { !intelOnly.isEmpty }
public var hasMissingFilter: Bool { !missing.isEmpty }
public var isFlagged: Bool { needsRosetta || hasMissingFilter }
/// "Driver filter missing" (worse, shown first) or "Driver needs Rosetta"; nil when fine.
public var headline: String? {
if hasMissingFilter { return "Driver filter missing" }
if needsRosetta { return "Driver needs Rosetta" }
return nil
}
/// e.g. "pstopsRV2, commandfilterRV1 are Intel-only (x86_64); /Library/…/foo not found".
public var detail: String? {
var parts: [String] = []
if !missing.isEmpty {
parts.append(missing.compactMap(\.path).joined(separator: ", ") + " not found")
}
if !intelOnly.isEmpty {
let names = intelOnly.map(\.name)
let archs = Set(intelOnly.flatMap { if case .intelOnly(let a) = $0.status { return a } else { return [] } }).sorted()
parts.append("\(names.joined(separator: ", ")) \(names.count == 1 ? "is" : "are") Intel-only (\(archs.joined(separator: ", ")))")
}
return parts.isEmpty ? nil : parts.joined(separator: "; ")
}
/// One line for CLI output: "Driver needs Rosetta — pstopsRV2 is Intel-only (x86_64)".
public var warning: String? {
guard let headline, let detail else { return nil }
return "\(headline) — \(detail)"
}
}
public enum DriverFilterCheck {
/// Where cupsd looks for filters named without a path.
public static let filterDirectory = "/usr/libexec/cups/filter"
public static func check(_ ppd: PPD, filterDirectory: String = filterDirectory) -> DriverFilterReport {
var seen = Set<String>()
var filters: [DriverFilter] = []
for (keyword, value) in ppd.filterLines {
// cupsFilter: "type cost program"; cupsFilter2: "source destination cost [maxsize(n)] program".
var fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
let skip = keyword == "cupsFilter2" ? 3 : 2
guard fields.count > skip else { continue }
fields.removeFirst(skip)
if let first = fields.first, first.hasPrefix("maxsize("), first.hasSuffix(")") { fields.removeFirst() }
guard !fields.isEmpty else { continue }
let program = fields.joined(separator: " ")
let path: String? = program == "-" ? nil
: program.hasPrefix("/") ? program : (filterDirectory as NSString).appendingPathComponent(program)
guard seen.insert(path ?? "-\(keyword)").inserted else { continue }
let status: DriverFilter.Status = path.map(Self.status(atPath:)) ?? .passthrough
filters.append(DriverFilter(keyword: keyword, line: value, program: program, path: path, status: status))
}
return DriverFilterReport(filters: filters)
}
/// Reads the file's header: Mach-O (thin or universal) or `#!` script.
public static func status(atPath path: String) -> DriverFilter.Status {
guard FileManager.default.fileExists(atPath: path) else { return .missing }
guard let handle = FileHandle(forReadingAtPath: path) else { return .unrecognized }
defer { try? handle.close() }
let header = [UInt8](handle.readData(ofLength: 4096))
return status(header: header)
}
static func status(header b: [UInt8]) -> DriverFilter.Status {
guard b.count >= 8 else { return .unrecognized }
if b[0] == UInt8(ascii: "#"), b[1] == UInt8(ascii: "!") {
let line = b.prefix { $0 != UInt8(ascii: "\n") }.dropFirst(2)
return .script(interpreter: String(decoding: line, as: UTF8.self).trimmingCharacters(in: .whitespaces))
}
func be32(_ i: Int) -> UInt32 { UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]) }
func le32(_ i: Int) -> UInt32 { UInt32(b[i + 3]) << 24 | UInt32(b[i + 2]) << 16 | UInt32(b[i + 1]) << 8 | UInt32(b[i]) }
var cpuTypes: [(type: UInt32, subtype: UInt32)] = []
switch be32(0) {
case 0xCAFEBABE, 0xCAFEBABF: // universal (fat32 / fat64)
let count = Int(be32(4))
let entrySize = be32(0) == 0xCAFEBABF ? 32 : 20
guard count > 0, count < 32 else { return .unrecognized } // Java class files share the magic
for i in 0 ..< count {
let offset = 8 + i * entrySize
guard offset + 8 <= b.count else { break }
cpuTypes.append((be32(offset), be32(offset + 4) & 0x00FF_FFFF))
}
default:
switch le32(0) {
case 0xFEEDFACF, 0xFEEDFACE: // thin Mach-O, little-endian
cpuTypes.append((le32(4), le32(8) & 0x00FF_FFFF))
default:
return .unrecognized
}
}
let names = cpuTypes.map { architectureName(type: $0.type, subtype: $0.subtype) }
if cpuTypes.contains(where: { $0.type == 0x0100_000C }) { return .native(architectures: names) }
if cpuTypes.contains(where: { $0.type == 0x0100_0007 || $0.type == 7 }) { return .intelOnly(architectures: names) }
return .unrecognized
}
static func architectureName(type: UInt32, subtype: UInt32) -> String {
switch type {
case 0x0100_0007: return "x86_64"
case 7: return "i386"
case 0x0100_000C: return subtype == 2 ? "arm64e" : "arm64"
case 0x0200_000C: return "arm64_32"
case 12: return "arm"
default: return "cpu\(type)"
}
}
/// True on Apple silicon (also when this process itself runs under Rosetta).
public static var hostIsAppleSilicon: Bool {
var value: Int32 = 0
var size = MemoryLayout<Int32>.size
return sysctlbyname("hw.optional.arm64", &value, &size, nil, 0) == 0 && value == 1
}
public static var rosettaInstalled: Bool {
FileManager.default.fileExists(atPath: "/Library/Apple/usr/libexec/oah/libRosettaRuntime")
}
}

View File

@@ -52,8 +52,27 @@ 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]
/// Action id -> how its typed value is labeled and checked, plus an optional second value.
public let inputs: [String: Input]
enum CodingKeys: String, CodingKey { case id, name, match, actions } public struct Input: Decodable {
/// "User ID"; nil keeps the default "User code".
public let label: String?
/// Default true (Ricoh user codes). Xerox user IDs are strings.
public let digitsOnly: Bool?
public let second: SecondInput?
}
/// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}`
/// and are added only when the user fills it in.
public struct SecondInput: Decodable {
public let label: String
public let pairs: [String]
}
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs }
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 +84,8 @@ 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) ?? [:]
inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:]
} }
private struct FlexiblePairs: Decodable { private struct FlexiblePairs: Decodable {

View File

@@ -57,6 +57,8 @@ public struct PPD {
public private(set) var groups: [String] = [] public private(set) var groups: [String] = []
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins. /// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
public private(set) var attributes: [String: String] = [:] public private(set) var attributes: [String: String] = [:]
/// Every `*cupsFilter` / `*cupsFilter2` line, in order, with the quotes removed.
public private(set) var filterLines: [(keyword: String, value: String)] = []
public init(text: String) { public init(text: String) {
var defaults: [String: String] = [:] var defaults: [String: String] = [:]
@@ -122,6 +124,9 @@ public struct PPD {
let (choice, label) = Self.splitText(String(parts[1])) let (choice, label) = Self.splitText(String(parts[1]))
current!.choices.append(choice) current!.choices.append(choice)
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice)) current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
} else if parts.count == 1, mainKey == "cupsFilter" || mainKey == "cupsFilter2" {
filterLines.append((mainKey, value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))))
if attributes[mainKey] == nil { attributes[mainKey] = filterLines.last!.value }
} else if parts.count == 1, attributes[mainKey] == nil { } else if parts.count == 1, attributes[mainKey] == nil {
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
} }
@@ -171,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

@@ -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) }
/// Judged by the read-back, not lpadmin's exit status: the goal is that the queue is gone. A queue that
/// 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)
} }
let stillThere = try await client.queueExists(queue) return ActionOutcome(succeeded: true, command: result.commandLine,
return ActionOutcome(succeeded: !stillThere, command: result.commandLine, message: result.succeeded ? nil : "\(queue) was already deleted")
message: stillThere ? "Not applied: \(queue) still exists" : nil)
} }
/// 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.

View File

@@ -25,13 +25,13 @@ public struct QuickAction: Identifiable, Hashable {
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none, QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
summary: "Black & white by default"), summary: "Black & white by default"),
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode, QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
summary: "User/department code sent with every job (--clear to remove)"), summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"),
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none, QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
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, secondValue: 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)
@@ -54,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable {
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" } if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
continue continue
} }
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) } var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
if !clearing, let secondValue, !secondValue.isEmpty {
guard let second = profile.inputs[id]?.second else {
return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two"))
}
for pair in second.pairs.compactMap(Self.parse) {
let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue)
guard context.supports(key: pair.key, value: value) else {
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))"))
}
changes.append(OptionChange(key: pair.key, value: value, isSecret: false))
}
}
return .success(ResolvedQuickAction(profile: profile, changes: changes)) return .success(ResolvedQuickAction(profile: profile, changes: changes))
} }
} }
@@ -73,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable {
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil } public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid. /// The profile's input settings for this action on this queue (label, digits, second value).
public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
return resolved.profile.inputs[id]
}
/// "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" }
/// 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 digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true }
/// For Set User Code: digits only unless the profile allows text, within the PPD's limit. nil when valid.
public func validationError(_ value: String, context: DriverContext) -> String? { public func validationError(_ value: String, context: DriverContext) -> String? {
guard input == .userCode, !value.isEmpty else { return nil } guard input == .userCode, !value.isEmpty else { return nil }
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" } return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context)
if let keyword = codeKeyword(context), }
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) digits" /// For the optional second value (text, within the PPD's limit). nil when valid.
public func secondValidationError(_ value: String, context: DriverContext) -> String? {
guard input == .userCode, !value.isEmpty else { return nil }
return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context)
}
private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? {
if digitsOnly {
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
} else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) {
return "No quotes, backslashes or line breaks"
}
if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
return "At most \(max) \(digitsOnly ? "digits" : "characters")"
} }
return nil return nil
} }
@@ -91,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable {
return String(value.dropFirst("Custom.".count)) return String(value.dropFirst("Custom.".count))
} }
/// The second value currently set on the queue (e.g. the Xerox account ID), without Custom.
public func currentSecondValue(_ context: DriverContext) -> String? {
guard let keyword = secondKeyword(context),
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count))
}
private func secondKeyword(_ context: DriverContext) -> String? {
inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key
}
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode). /// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
private func codeKeyword(_ context: DriverContext) -> String? { private func codeKeyword(_ context: DriverContext) -> String? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil } guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
@@ -126,14 +189,15 @@ public struct QuickActionOutcome {
extension QuickAction { extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits. /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome { public func run(queue: String, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
client: CupsClient) async throws -> QuickActionOutcome {
if isDefaultPrinter { if isDefaultPrinter {
let outcome = try await QueueActions.setDefault(queue: queue, client: client) let outcome = try await QueueActions.setDefault(queue: queue, client: client)
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil, return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
readBack: [], changes: [], message: outcome.message) readBack: [], changes: [], message: outcome.message)
} }
let context = try await DriverContext.load(client: client, queue: queue) let context = try await DriverContext.load(client: client, queue: queue)
let resolved = try resolve(context, value: value, clearing: clearing).get() let resolved = try resolve(context, value: value, secondValue: secondValue, clearing: clearing).get()
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes) let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
let failed = readBack.filter { !$0.applied } let failed = readBack.filter { !$0.applied }
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes), return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),

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. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. '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,64 @@
"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": "xerox",
"name": "Xerox",
"match": { "manufacturer": "^Xerox$" },
"actions": {
"color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]],
"bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]],
"usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"],
"usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]
},
"inputs": {
"usercode": {
"label": "User ID",
"digitsOnly": false,
"second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] }
}
}
},
{
"id": "konica-minolta",
"name": "Konica Minolta",
"match": { "manufacturer": "^KONICA MINOLTA$" },
"actions": {
"duplex": ["KMDuplex=Double"],
"simplex": ["KMDuplex=Single"],
"letter": ["PageSize=8.5x11"]
},
"unavailable": {
"usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD"
}
},
{ {
"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)") }
@@ -37,6 +36,22 @@ enum PPDReportCommand {
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)" action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
} }
print("Quick: \(quick.joined(separator: ", "))") print("Quick: \(quick.joined(separator: ", "))")
let filters = DriverFilterCheck.check(ppd)
if !filters.filters.isEmpty {
print("Filters: " + filters.filters.map { filter -> String in
switch filter.status {
case .native(let archs): return "\(filter.name) (\(archs.joined(separator: " ")))"
case .intelOnly(let archs): return "\(filter.name) (\(archs.joined(separator: " ")) only)"
case .script: return "\(filter.name) (script)"
case .passthrough: return "- (pass-through)"
case .missing: return "\(filter.name) (missing)"
case .unrecognized: return "\(filter.name) (unrecognized)"
}
}.joined(separator: ", "))
}
if let warning = filters.warning {
print("WARNING: \(warning)")
}
for group in ppd.groups { for group in ppd.groups {
print("\n\(group)") print("\n\(group)")

View File

@@ -100,8 +100,51 @@ enum PrinterCommand {
: label + ":" + String(repeating: " ", count: width - label.count) : label + ":" + String(repeating: " ", count: width - label.count)
print("\(heading) \(value)") print("\(heading) \(value)")
} }
if let ppdText = try? await client.getPPD(queue: queue),
let warning = DriverFilterCheck.check(PPD(text: ppdText)).warning {
print("\nWARNING: \(warning)")
}
print("\n(use --all for every attribute)") print("\n(use --all for every attribute)")
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))" return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
} }
} }
/// `printers --rosetta`: queues whose PPD filters have no arm64 slice or don't exist.
enum RosettaCommand {
static func run(client: CupsClient) async throws -> String {
let printers = try await client.getPrinters(requested: ["printer-name"])
let names = printers.compactMap { $0.string("printer-name") }
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
var rows: [[String]] = []
var affected = 0
for name in names {
guard let text = try await client.getPPD(queue: name) else { continue }
let report = DriverFilterCheck.check(PPD(text: text))
guard report.isFlagged else { continue }
affected += 1
let flagged = report.missing + report.intelOnly
for (i, filter) in flagged.enumerated() {
let what: String
switch filter.status {
case .intelOnly(let archs): what = archs.joined(separator: " ")
case .missing: what = "missing"
default: what = ""
}
rows.append([i == 0 ? name : "", i == 0 ? (report.headline ?? "") : "", what, filter.path ?? filter.program])
}
}
let host = DriverFilterCheck.hostIsAppleSilicon
? "Apple silicon, Rosetta \(DriverFilterCheck.rosettaInstalled ? "installed" : "NOT installed")"
: "Intel Mac (Intel-only filters run natively here)"
if rows.isEmpty {
print("No queues need Rosetta or have missing driver filters. This Mac: \(host).")
return "OK: none of \(names.count) queues need Rosetta or have missing filters"
}
printTable(headers: ["QUEUE", "PROBLEM", "ARCH", "FILTER"], rows: rows)
print("\nThis Mac: \(host).")
return "OK: \(affected) of \(names.count) queues need Rosetta or have missing filters"
}
}

View File

@@ -5,7 +5,7 @@ enum QuickCommand {
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String { static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
guard let name = args.first else { guard let name = args.first else {
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map { printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary] [$0.id, $0.input == .userCode ? "<code> [account] | --clear" : "", $0.summary]
}) })
print("\nDriver profiles (first match wins, generic last): " print("\nDriver profiles (first match wins, generic last): "
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", ")) + DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
@@ -18,6 +18,7 @@ enum QuickCommand {
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") } guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
let queue = args[1] let queue = args[1]
let value: String? let value: String?
var secondValue: String?
switch action.input { switch action.input {
case .none: case .none:
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") } guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
@@ -27,8 +28,11 @@ enum QuickCommand {
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") } guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
value = nil value = nil
} else { } else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } guard args.count == 3 || args.count == 4 else {
throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear")
}
value = args[2] value = args[2]
secondValue = args.count == 4 ? args[3] : nil
} }
} }
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)") }
@@ -48,10 +52,13 @@ 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("user code \(value): \(error)") throw CupsAdminError.usage("\(action.inputLabel(context)) \(value): \(error)")
}
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
throw CupsAdminError.usage("second value \(secondValue): \(error)")
} }
let resolved: ResolvedQuickAction let resolved: ResolvedQuickAction
switch action.resolve(context, value: value, clearing: clear) { switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) {
case .success(let r): resolved = r case .success(let r): resolved = r
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)") case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
} }

View File

@@ -28,6 +28,7 @@ let usage = """
usage: cupsadmin <command> [args] usage: cupsadmin <command> [args]
printers all queues: state, reasons, accepting, shared, device URI printers all queues: state, reasons, accepting, shared, device URI
printers --rosetta only queues whose driver filters are Intel-only or missing
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute) printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
jobs [queue] [--completed | --all-jobs] jobs [queue] [--completed | --all-jobs]
active jobs (all queues unless one is given) active jobs (all queues unless one is given)
@@ -39,9 +40,9 @@ let usage = """
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd] set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
change a queue via lpadmin, then show before/after change a queue via lpadmin, then show before/after
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 [account] | --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
""" """
@@ -80,9 +81,10 @@ func runCommand(_ args: [String]) async throws -> String {
return "OK: help" return "OK: help"
case "printers": case "printers":
let rosetta = takeFlag("--rosetta")
try rejectUnknownFlags() try rejectUnknownFlags()
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") } guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
return try await PrintersCommand.run(client: client) return rosetta ? try await RosettaCommand.run(client: client) : try await PrintersCommand.run(client: client)
case "printer": case "printer":
let dumpAll = takeFlag("--all") let dumpAll = takeFlag("--all")
@@ -128,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

@@ -0,0 +1,103 @@
@testable import CupsKit
import Foundation
import Testing
private func le32(_ v: UInt32) -> [UInt8] { [UInt8(v & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v >> 16 & 0xFF), UInt8(v >> 24)] }
private func be32(_ v: UInt32) -> [UInt8] { [UInt8(v >> 24), UInt8(v >> 16 & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v & 0xFF)] }
private let x86_64: UInt32 = 0x0100_0007, arm64: UInt32 = 0x0100_000C
/// Thin 64-bit Mach-O header (little-endian).
private func thin(_ cpu: UInt32, subtype: UInt32 = 0) -> [UInt8] {
var bytes: [UInt8] = le32(0xFEEDFACF)
bytes += le32(cpu)
bytes += le32(subtype)
bytes += [UInt8](repeating: 0, count: 20)
return bytes
}
/// Universal (fat32) header with the given slices.
private func fat(_ slices: [(UInt32, UInt32)]) -> [UInt8] {
var bytes: [UInt8] = be32(0xCAFEBABE)
bytes += be32(UInt32(slices.count))
for (cpu, subtype) in slices {
bytes += be32(cpu)
bytes += be32(subtype)
bytes += be32(0x4000) // offset
bytes += be32(0x1000) // size
bytes += be32(14) // align
}
return bytes
}
@Suite struct MachOArchitectures {
@Test func thinAndUniversalBinaries() {
#expect(DriverFilterCheck.status(header: thin(arm64)) == .native(architectures: ["arm64"]))
#expect(DriverFilterCheck.status(header: thin(x86_64)) == .intelOnly(architectures: ["x86_64"]))
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 2)])) == .native(architectures: ["x86_64", "arm64e"]))
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (7, 3)])) == .intelOnly(architectures: ["x86_64", "i386"]))
// A newer arm64 subtype lipo can't name (seen on macOS 27's rastertohp) still counts as arm64.
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 12)])) == .native(architectures: ["x86_64", "arm64"]))
}
@Test func scriptsAndOtherFiles() {
#expect(DriverFilterCheck.status(header: Array("#!/bin/sh\nexec foo\n".utf8)) == .script(interpreter: "/bin/sh"))
#expect(DriverFilterCheck.status(header: Array("%PDF-1.4 not a filter".utf8)) == .unrecognized)
#expect(DriverFilterCheck.status(header: [0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 52]) == .unrecognized) // Java class file
}
@Test(.enabled(if: FileManager.default.fileExists(atPath: "/usr/libexec/cups/filter/rastertohp")))
func appleFilterIsNative() {
guard case .native = DriverFilterCheck.status(atPath: "/usr/libexec/cups/filter/rastertohp") else {
Issue.record("rastertohp should have an arm64 slice"); return
}
}
}
@Suite struct FilterLines {
@Test func resolvesAndClassifiesEveryFilter() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-filters-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
try Data(thin(arm64)).write(to: dir.appendingPathComponent("nativefilter"))
let intel = dir.appendingPathComponent("Vendor Filters/pstopsvendor")
try FileManager.default.createDirectory(at: intel.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data(fat([(x86_64, 3)])).write(to: intel)
let ppd = PPD(text: """
*PPD-Adobe: "4.3"
*cupsFilter: "application/vnd.cups-raster 50 nativefilter"
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
*cupsFilter2: "application/vnd.cups-pdf application/pdf 0 maxsize(0) -"
*cupsFilter: "application/vnd.cups-command 0 /Library/Printers/Nobody/Filters/gone"
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
""")
#expect(ppd.filterLines.count == 5)
let report = DriverFilterCheck.check(ppd, filterDirectory: dir.path)
#expect(report.filters.count == 4) // duplicate pstopsvendor line counted once
#expect(report.filters[0].status == .native(architectures: ["arm64"]))
#expect(report.filters[0].path == dir.appendingPathComponent("nativefilter").path)
#expect(report.filters[1].status == .intelOnly(architectures: ["x86_64"]))
#expect(report.filters[2].status == .passthrough)
#expect(report.filters[3].status == .missing)
#expect(report.needsRosetta && report.hasMissingFilter)
#expect(report.headline == "Driver filter missing") // missing outranks Rosetta
#expect(report.detail == "/Library/Printers/Nobody/Filters/gone not found; pstopsvendor is Intel-only (x86_64)")
}
@Test func rosettaOnlyAndClean() {
let passthrough = PPD(text: "*cupsFilter2: \"application/vnd.cups-pdf application/pdf 0 maxsize(0) -\"\n*cupsFilter: \"image/urf 10 -\"\n")
let clean = DriverFilterCheck.check(passthrough)
#expect(!clean.isFlagged && clean.headline == nil && clean.warning == nil)
let none = DriverFilterCheck.check(PPD(text: "*NickName: \"No filters\"\n"))
#expect(none.filters.isEmpty && !none.isFlagged)
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohVendorPPDNamesItsFilters() throws {
let report = DriverFilterCheck.check(try #require(vendorPPD("RICOH IM C4500")))
#expect(report.filters.map(\.name) == ["commandfilterRV1", "pstopsRV2"] || report.filters.map(\.name) == ["pstopsRV2", "commandfilterRV1"])
}
}

View File

@@ -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")
}
}

View File

@@ -18,12 +18,17 @@ 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", "xerox", "konica-minolta", "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())")
#expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id))))
for input in profile.inputs.values {
#expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true)
}
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,10 +50,15 @@ 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
#expect(clear.map(\.value) == ["False", "None"]) #expect(clear.map(\.value) == ["False", "None"])
// Ricoh takes one value: digits, no second input.
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User Code")
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
} }
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055"))) @Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
@@ -76,6 +86,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,243 @@
@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)
}
}
@Suite struct XeroxProfile {
static let altaLink = "Xerox AltaLink C8170.gz"
static let monoVersaLink = "Xerox VersaLink B620 Printer.gz"
@Test(.enabled(if: ExtraPPDs.path(altaLink) != nil))
func altaLinkColorAndStandardAccounting() throws {
let c = try #require(ExtraPPDs.context(Self.altaLink))
#expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"])
#expect(pairs("color", c) == ["XROutputColor=PrintAsColor"])
#expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"])
#expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
#expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
let usercode = QuickAction.named("usercode")!
#expect(usercode.inputLabel(c) == "User ID")
#expect(usercode.secondInputLabel(c) == "Account ID (optional)")
#expect(!usercode.digitsOnly(c))
#expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"])
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2)
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"])
#expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" }
== ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"])
#expect(usercode.validationError("jdoe", context: c) == nil)
#expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters")
#expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks")
#expect(usercode.secondValidationError("ART101", context: c) == nil)
#expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters")
}
@Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil))
func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws {
let c = try #require(ExtraPPDs.context(Self.monoVersaLink))
#expect(pairs("bw", c) == ["XRColorCorrection=Gray"])
#expect(resolved("bw", c)?.profile.id == "xerox")
#expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice
#expect(QuickAction.named("usercode")!.isAvailable(in: c))
}
/// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs.
@Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100))
func everyXeroxPPD() throws {
var accounting = 0
for file in ExtraPPDs.files(prefix: "Xerox ") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)")
#expect(QuickAction.named("color")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c)
== (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)")
let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false
#expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)")
if xsa { accounting += 1 }
}
#expect(accounting > 0)
}
}
@Suite struct KonicaMinoltaProfile {
static let bizhub = "KONICAMINOLTAC651i.gz"
static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD"
@Test(.enabled(if: ExtraPPDs.path(bizhub) != nil))
func bizhubC651i() throws {
let c = try #require(ExtraPPDs.context(Self.bizhub))
#expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"])
#expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic")
#expect(pairs("bw", c) == ["ColorModel=Gray"])
#expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta")
#expect(pairs("simplex", c) == ["KMDuplex=Single"])
#expect(pairs("letter", c) == ["PageSize=8.5x11"])
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
}
/// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants).
@Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty))
func everyKonicaMinoltaPPD() throws {
for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") {
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
let name = (file as NSString).lastPathComponent
#expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)")
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)")
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)")
#expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)")
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)")
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
}
}
}

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