mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
795ab7a981 | ||
|
|
7a98cb3bd3 | ||
|
|
a54ec0f4be | ||
|
|
4832b74c3a | ||
|
|
366bd74225 |
13
CLAUDE.md
13
CLAUDE.md
@@ -29,14 +29,20 @@ 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). 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
|
||||||
```
|
```
|
||||||
@@ -63,7 +69,8 @@ swift build # debug build of everything
|
|||||||
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
|
- Live tests are opt-in and write to queues: `CUPSKIT_LIVE_QUEUE=<queue>`, `CUPSKIT_LIVE_JOBS=<a>,<b>`,
|
||||||
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
|
`CUPSKIT_LIVE_QUICK=<ricoh>,<generic>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and
|
||||||
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
||||||
`/Library/Printers/PPDs` and skip when they aren't installed.
|
`CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not
|
||||||
|
installed) and `/Library/Printers/PPDs`, and skip when they aren't there.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).
|
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -4,7 +4,7 @@
|
|||||||
*Jobs: hold, release, move or cancel jobs on any queue.*
|
*Jobs: hold, release, move or cancel jobs on any queue.*
|
||||||
|
|
||||||

|

|
||||||
*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.
|
||||||
@@ -49,7 +50,7 @@ cupsadmin quick bw <queue> # always print black & white
|
|||||||
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
||||||
cupsadmin quick duplex <queue> # default to two-sided
|
cupsadmin quick duplex <queue> # default to two-sided
|
||||||
cupsadmin quick simplex <queue>
|
cupsadmin quick simplex <queue>
|
||||||
cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no "prompt user" at the printer)
|
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
|
||||||
cupsadmin quick default <queue> # make this the server default printer
|
cupsadmin quick default <queue> # make this the server default printer
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,13 +65,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
|
|||||||
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
|
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
|
||||||
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
|
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
|
||||||
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
||||||
| Canon, HP, Xerox, Konica Minolta user/department codes | no built-in profile — add one (below) |
|
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
|
||||||
|
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
|
||||||
|
| Xerox, Konica Minolta and others | generic only — add a profile (below) |
|
||||||
|
|
||||||
### Adding your printer's driver
|
### Adding your printer's driver
|
||||||
|
|
||||||
1. `cupsadmin ppdreport <queue>` lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
||||||
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
||||||
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`.
|
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. Keep your profile above `generic`.
|
||||||
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -109,9 +112,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).
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ final class QuickActionModel: Identifiable {
|
|||||||
|
|
||||||
var currentCode: String? { action.currentCode(context) }
|
var currentCode: String? { action.currentCode(context) }
|
||||||
|
|
||||||
|
/// "Use Letter Paper" or "Use Letter Paper, Fit to Nearest Size", depending on the driver.
|
||||||
|
var title: String { action.title(in: state == nil ? nil : context).replacingOccurrences(of: "…", with: "") }
|
||||||
|
|
||||||
var validationError: String? { action.validationError(code, context: context) }
|
var validationError: String? { action.validationError(code, context: context) }
|
||||||
|
|
||||||
/// What will be written, or why the action can't run here.
|
/// What will be written, or why the action can't run here.
|
||||||
@@ -85,7 +88,7 @@ final class QuickActionModel: Identifiable {
|
|||||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||||
let seconds = elapsedText(since: started)
|
let seconds = elapsedText(since: started)
|
||||||
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "")
|
let title = clearing ? "Cleared user code" : self.title
|
||||||
if outcome.succeeded {
|
if outcome.succeeded {
|
||||||
resultMessage = nil
|
resultMessage = nil
|
||||||
store.report("\(title) on \(queue) in \(seconds)")
|
store.report("\(title) on \(queue) in \(seconds)")
|
||||||
@@ -99,7 +102,7 @@ final class QuickActionModel: Identifiable {
|
|||||||
phase = .finished(succeeded: false)
|
phase = .finished(succeeded: false)
|
||||||
} catch {
|
} catch {
|
||||||
resultMessage = PrinterStore.explain(String(describing: error))
|
resultMessage = PrinterStore.explain(String(describing: error))
|
||||||
store.report("ERROR: \(action.title) on \(queue): \(error)")
|
store.report("ERROR: \(title) on \(queue): \(error)")
|
||||||
phase = .finished(succeeded: false)
|
phase = .finished(succeeded: false)
|
||||||
}
|
}
|
||||||
store.noteChange(queue: queue)
|
store.noteChange(queue: queue)
|
||||||
@@ -114,7 +117,7 @@ struct QuickActionSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage)
|
Label(model.title, systemImage: model.action.systemImage)
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
157
Sources/CupsKit/DriverFilters.swift
Normal file
157
Sources/CupsKit/DriverFilters.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,10 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
public let match: Match
|
public let match: Match
|
||||||
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
||||||
public let actions: [String: [[String]]]
|
public let actions: [String: [[String]]]
|
||||||
|
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
|
||||||
|
public let unavailable: [String: String]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case id, name, match, actions }
|
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -65,6 +67,7 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
||||||
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
|
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FlexiblePairs: Decodable {
|
private struct FlexiblePairs: Decodable {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
summary: "Two-sided, long edge"),
|
summary: "Two-sided, long edge"),
|
||||||
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
||||||
summary: "One-sided"),
|
summary: "One-sided"),
|
||||||
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
|
QuickAction(id: "letter", title: "Use Letter Paper", systemImage: "arrow.up.left.and.arrow.down.right",
|
||||||
input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
|
input: .none, summary: "Letter paper, and fit to the nearest size where the driver has that option (never prompt at the printer)"),
|
||||||
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
||||||
summary: "Server default printer (lpadmin -d)"),
|
summary: "Server default printer (lpadmin -d)"),
|
||||||
]
|
]
|
||||||
@@ -40,12 +40,25 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
all.first { $0.id == id }
|
all.first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The title for a queue: Use Letter Paper gains ", Fit to Nearest Size" only when its driver
|
||||||
|
/// profile also sets a fit-to-paper option (e.g. Ricoh RIPaperPolicy), not just the page size.
|
||||||
|
public func title(in context: DriverContext?) -> String {
|
||||||
|
guard id == "letter", let context, case .success(let resolved) = resolve(context),
|
||||||
|
resolved.changes.contains(where: { !Self.paperSizeKeys.contains($0.key) }) else { return title }
|
||||||
|
return title + ", Fit to Nearest Size"
|
||||||
|
}
|
||||||
|
|
||||||
|
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
|
||||||
|
|
||||||
/// The profile and option changes this action would write on a queue, or why it can't.
|
/// The profile and option changes this action would write on a queue, or why it can't.
|
||||||
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
||||||
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
||||||
let key = clearing ? id + "-clear" : id
|
let key = clearing ? id + "-clear" : id
|
||||||
var firstMissing: String?
|
var firstMissing: String?
|
||||||
for profile in DriverProfiles.matching(context, in: profiles) {
|
for profile in DriverProfiles.matching(context, in: profiles) {
|
||||||
|
if profile.actions[key] == nil, let reason = profile.unavailable[id] {
|
||||||
|
return .failure(QuickActionUnavailable(reason: reason))
|
||||||
|
}
|
||||||
guard let alternatives = profile.actions[key] else { continue }
|
guard let alternatives = profile.actions[key] else { continue }
|
||||||
for pairs in alternatives {
|
for pairs in alternatives {
|
||||||
let parsed = pairs.compactMap(Self.parse)
|
let parsed = pairs.compactMap(Self.parse)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport <queue>",
|
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
"id": "ricoh-m-c251fw",
|
"id": "ricoh-m-c251fw",
|
||||||
@@ -27,6 +27,33 @@
|
|||||||
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "canon",
|
||||||
|
"name": "Canon UFR II / LIPSLX / CARPS2",
|
||||||
|
"match": { "manufacturer": "^Canon$" },
|
||||||
|
"actions": {
|
||||||
|
"color": ["CNColorMode=color"],
|
||||||
|
"bw": ["CNColorMode=mono"],
|
||||||
|
"duplex": ["CNDuplex=DuplexFront"],
|
||||||
|
"simplex": ["CNDuplex=None"],
|
||||||
|
"letter": ["PageSize=Letter"]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "Canon department IDs are set by the Canon driver, not the PPD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hp",
|
||||||
|
"name": "HP",
|
||||||
|
"match": { "manufacturer": "^HP$" },
|
||||||
|
"actions": {
|
||||||
|
"color": [["HPColorAsGray=False"], ["HPColorMode=colorsmart"]],
|
||||||
|
"bw": [["HPColorAsGray=True"], ["HPColorMode=grayscale"]]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "HP drivers have no accounting code option"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "generic",
|
"id": "generic",
|
||||||
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -41,7 +42,7 @@ let usage = """
|
|||||||
quick list quick actions (task-named queue defaults)
|
quick list quick actions (task-named queue defaults)
|
||||||
quick <action> <queue> [code | --clear]
|
quick <action> <queue> [code | --clear]
|
||||||
apply one via lpadmin, then verify
|
apply one via lpadmin, then verify
|
||||||
ppdreport <queue> | --file <path.ppd>
|
ppdreport <queue | path.ppd[.gz]>
|
||||||
every PPD group and option: keyword, label, type, default, choices
|
every PPD group and option: keyword, label, type, default, choices
|
||||||
help show this help
|
help show this help
|
||||||
"""
|
"""
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal file
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,12 +18,13 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
@Suite struct DriverProfileFile {
|
@Suite struct DriverProfileFile {
|
||||||
@Test func builtInProfilesAreWellFormed() {
|
@Test func builtInProfilesAreWellFormed() {
|
||||||
let profiles = DriverProfiles.builtIn
|
let profiles = DriverProfiles.builtIn
|
||||||
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"])
|
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"])
|
||||||
#expect(profiles.last?.isGeneric == true)
|
#expect(profiles.last?.isGeneric == true)
|
||||||
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
||||||
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
||||||
for profile in profiles {
|
for profile in profiles {
|
||||||
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
||||||
|
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
|
||||||
for alternatives in profile.actions.values {
|
for alternatives in profile.actions.values {
|
||||||
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
|
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
|
||||||
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
||||||
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper, Fit to Nearest Size")
|
||||||
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
|
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
|
||||||
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
||||||
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
||||||
@@ -76,6 +78,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
|
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
|
||||||
#expect(profileID("letter", c) == "generic")
|
#expect(profileID("letter", c) == "generic")
|
||||||
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
|
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
||||||
|
|||||||
148
Tests/CupsKitTests/VendorProfileTests.swift
Normal file
148
Tests/CupsKitTests/VendorProfileTests.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
@testable import CupsKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
/// Canon and HP profile tests read the vendors' gzipped PPDs, read-only. They look in CUPSKIT_PPD_DIRS
|
||||||
|
/// (colon-separated, e.g. PPDs extracted with `pkgutil --expand-full` from a driver package you haven't
|
||||||
|
/// installed) and then /Library/Printers/PPDs; each test is skipped when its PPDs aren't found.
|
||||||
|
enum ExtraPPDs {
|
||||||
|
static let directories: [String] =
|
||||||
|
(ProcessInfo.processInfo.environment["CUPSKIT_PPD_DIRS"] ?? "").split(separator: ":").map(String.init) + [vendorPPDs]
|
||||||
|
|
||||||
|
static func path(_ name: String) -> String? {
|
||||||
|
directories.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.fileExists(atPath: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(prefix: String) -> [String] {
|
||||||
|
var seen: Set<String> = []
|
||||||
|
return directories.flatMap { dir in
|
||||||
|
((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
|
||||||
|
.filter { $0.hasPrefix(prefix) && seen.insert($0).inserted }
|
||||||
|
.sorted()
|
||||||
|
.map { (dir as NSString).appendingPathComponent($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func context(path: String) -> DriverContext? {
|
||||||
|
(try? PPD.text(contentsOfFile: path)).map { DriverContext(ppd: PPD(text: $0), attributes: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func context(_ name: String) -> DriverContext? { path(name).flatMap(context(path:)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolved(_ id: String, _ c: DriverContext) -> ResolvedQuickAction? {
|
||||||
|
try? QuickAction.named(id)!.resolve(c, value: "0").get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pairs(_ id: String, _ c: DriverContext) -> [String]? {
|
||||||
|
resolved(id, c)?.changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String) -> Bool {
|
||||||
|
c.ppd?.option(keyword)?.choices.contains(choice) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct CanonProfile {
|
||||||
|
static let adv = "CNPZUIRAC3530IIIZU.ppd.gz"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(adv) != nil))
|
||||||
|
func imageRunnerAdvance() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.adv))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["canon", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["CNColorMode=color"])
|
||||||
|
#expect(pairs("bw", c) == ["CNColorMode=mono"])
|
||||||
|
#expect(pairs("duplex", c) == ["CNDuplex=DuplexFront"])
|
||||||
|
#expect(pairs("simplex", c) == ["CNDuplex=None"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
for id in ["color", "bw", "duplex", "simplex", "letter"] { #expect(resolved(id, c)?.profile.id == "canon", "\(id)") }
|
||||||
|
// No fit-to-paper option in Canon's PPDs: Letter sets the page size only, and the title says so.
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
|
||||||
|
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, clearing: true).get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Canon PPD in the package: an action is offered exactly when the PPD has its keyword and choice.
|
||||||
|
@Test(.enabled(if: !ExtraPPDs.files(prefix: "CNPZU").isEmpty))
|
||||||
|
func everyCanonPPD() throws {
|
||||||
|
let files = ExtraPPDs.files(prefix: "CNPZU")
|
||||||
|
var mono = 0
|
||||||
|
for file in files {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "canon", "\(name)")
|
||||||
|
#expect(QuickAction.named("color")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "color"), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "mono"), "\(name)")
|
||||||
|
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "DuplexFront"), "\(name)")
|
||||||
|
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "None"), "\(name)")
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"], "\(name)")
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
|
||||||
|
if !hasChoice(c, "CNColorMode", "color") { mono += 1 }
|
||||||
|
}
|
||||||
|
#expect(files.count - mono > 0 && mono > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct HPProfile {
|
||||||
|
static let colorLaser = "HP Color LaserJet M750.gz"
|
||||||
|
static let officejet = "HP Officejet Pro 8600.ppd.gz"
|
||||||
|
static let monoLaser = "HP LaserJet 600 M601 M602 M603.gz"
|
||||||
|
static let reason = "HP drivers have no accounting code option"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(colorLaser) != nil))
|
||||||
|
func colorLaserJetEnterprise() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.colorLaser))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["hp", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["HPColorAsGray=False"])
|
||||||
|
#expect(pairs("bw", c) == ["HPColorAsGray=True"])
|
||||||
|
#expect(resolved("color", c)?.profile.id == "hp")
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("simplex", c) == ["Duplex=None"])
|
||||||
|
#expect(resolved("duplex", c)?.profile.id == "generic")
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(officejet) != nil))
|
||||||
|
func officejetProUsesHPColorMode() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.officejet))
|
||||||
|
#expect(pairs("color", c) == ["HPColorMode=colorsmart"])
|
||||||
|
#expect(pairs("bw", c) == ["HPColorMode=grayscale"])
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "hp")
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(monoLaser) != nil))
|
||||||
|
func monoLaserJet() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.monoLaser))
|
||||||
|
#expect(!QuickAction.named("color")!.isAvailable(in: c) && !QuickAction.named("bw")!.isAvailable(in: c))
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every HP PPD: color and black & white come from the HP profile whenever the PPD has HPColorAsGray or HPColorMode.
|
||||||
|
@Test(.enabled(if: ExtraPPDs.files(prefix: "HP ").count > 100))
|
||||||
|
func everyHPPPD() throws {
|
||||||
|
var hpColor = 0
|
||||||
|
for file in ExtraPPDs.files(prefix: "HP ") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
guard c.manufacturer == "HP" else { continue }
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "hp", "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
|
||||||
|
if hasChoice(c, "HPColorAsGray", "True") || hasChoice(c, "HPColorMode", "grayscale") {
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "hp", "\(name)")
|
||||||
|
hpColor += 1
|
||||||
|
}
|
||||||
|
if hasChoice(c, "HPColorAsGray", "False") || hasChoice(c, "HPColorMode", "colorsmart") {
|
||||||
|
#expect(resolved("color", c)?.profile.id == "hp", "\(name)")
|
||||||
|
}
|
||||||
|
if hasChoice(c, "PageSize", "Letter") { #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)") }
|
||||||
|
}
|
||||||
|
#expect(hpColor > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
build.sh
10
build.sh
@@ -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 |
Reference in New Issue
Block a user