8 Commits

Author SHA1 Message Date
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
kfattic
cf0241beac Package 1.1.1: app as a payload with system ownership, CLI via postinstall
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:59:05 -05:00
kfattic
42c1381ebf README: About section
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:32:33 -05:00
kfattic
d81de4a531 README: Options screenshot, captions, describe only what exists
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:30:09 -05:00
kfattic
e0d48e49e8 Package 1.1.0: one PKG installs the CLI and the notarized app
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:24:26 -05:00
kfattic
8d39bafb6f Vendor-neutral Quick Actions: driver profiles, generic fallback, ppdreport
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:22:19 -05:00
kfattic
3af6a4e88f README leads with the app; build.sh --screenshots
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 13:16:53 -05:00
23 changed files with 1103 additions and 220 deletions

View File

@@ -29,10 +29,18 @@ 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** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input - **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason. 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
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
driver's keywords and which actions its profile enables.
## Build and test ## Build and test
``` ```
@@ -40,6 +48,7 @@ swift build # debug build of everything
.build/debug/cupsadmin printers # run the CLI .build/debug/cupsadmin printers # run the CLI
./test.sh # Swift Testing via the Command Line Tools (see flags inside) ./test.sh # Swift Testing via the Command Line Tools (see flags inside)
./build.sh --build-only # universal CLI + app bundle, ad-hoc signed, no identities needed ./build.sh --build-only # universal CLI + app bundle, ad-hoc signed, no identities needed
./build.sh --screenshots # regenerate docs/screenshots/app-jobs.png from temporary demo queues
./build.sh # release: sign, payload-free PKG, notarize, staple, spctl ./build.sh # release: sign, payload-free PKG, notarize, staple, spctl
``` ```
- Only the Command Line Tools are required. Universal builds are per-triple - Only the Command Line Tools are required. Universal builds are per-triple
@@ -48,8 +57,12 @@ swift build # debug build of everything
- Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum. - Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum.
- `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional - `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional
`CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities. `CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities.
- The PKG is payload-free (`pkgbuild --nopayload --scripts`; `pkg/postinstall` copies the binary), because - PKG layout: the app is a payload (`pkgbuild --root` with a staging `Applications/` that is really
any payload's "." entry would reset an existing `/usr/local/bin`'s ownership. root:admin 775 and `--ownership preserve`, `BundleIsRelocatable` false), so it gets a receipt and the
package records the system's own ownership. `--ownership recommended` would record Applications as
root:wheel — don't use it. The CLI is not a payload: the binary ships in Scripts and `pkg/postinstall`
copies it, because a `/usr/local/bin` payload entry would reset a Homebrew-owned folder. The build
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>`. Use throwaway queues (e.g. `lpd://127.0.0.1/…`, paused) and

View File

@@ -12,7 +12,8 @@ let package = Package(
.library(name: "CupsKit", targets: ["CupsKit"]), .library(name: "CupsKit", targets: ["CupsKit"]),
], ],
targets: [ targets: [
.target(name: "CupsKit", path: "Sources/CupsKit"), // driver-profiles.json is compiled into the code: the CLI ships as a single binary with no resource bundle.
.target(name: "CupsKit", path: "Sources/CupsKit", resources: [.embedInCode("Resources/driver-profiles.json")]),
.executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"), .executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"),
.executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"), .executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"),
.testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"), .testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"),

View File

@@ -1,24 +1,23 @@
# CUPS Admin for macOS # CUPS Admin for macOS
A native replacement for the CUPS web interface (`http://localhost:631`) that Apple removed in macOS 27. ![CUPS Admin showing the jobs on a printer](docs/screenshots/app-jobs.png)
*Jobs: hold, release, move or cancel jobs on any queue.*
`cupsd`, `lpadmin`, `lpstat`, `lpoptions` and IPP Everywhere queues all still work on macOS 27 — only the browser admin page is gone. This project puts it back, twice: ![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.*
- **`cupsadmin`** — a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. **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.app** — a native SwiftUI app that covers everything the old web pages did: printers, jobs, queue options (including per-driver options like Ricoh user codes), classes and server settings.
No third-party dependencies. Builds with the Xcode Command Line Tools alone.
> Status: the CLI is stable (1.0.x). The app is under active development — see [Releases](../../releases) for what's shipped.
## Install ## Install
> Status: The CLI and app are both usable today; the app doesn't yet cover every page of the old web interface.
Download the signed, notarized PKG from the latest [release](../../releases). It installs: Download the signed, notarized PKG from the latest [release](../../releases). It installs:
- `/usr/local/bin/cupsadmin` - `/usr/local/bin/cupsadmin`
- `/Applications/CUPS Admin.app` (once the app ships) - `/Applications/CUPS Admin.app`
The package is payload-free and installs via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe). For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin`; there is no payload file list to key off. The app is a normal payload install with a receipt (`pkgutil --files edu.wku.cupsadmin`). The CLI is installed by a postinstall script instead, so the package never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe). For Munki, only the CLI needs an `installs` array entry, pointing at `/usr/local/bin/cupsadmin`; the app is covered by the receipt.
Requires macOS 14 or later. Universal (Apple silicon and Intel). Requires macOS 14 or later. Universal (Apple silicon and Intel).
@@ -26,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
@@ -33,6 +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
``` ```
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.
@@ -53,7 +54,25 @@ cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no
cupsadmin quick default <queue> # make this the server default printer cupsadmin quick default <queue> # make this the server default printer
``` ```
Actions whose option isn't in the queue's PPD are refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal. Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Resources/driver-profiles.json`), matched on the PPD's manufacturer and model. If no profile maps an action for a queue's driver, the action is refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal.
### Supported drivers
| Driver | Quick actions |
|---|---|
| Ricoh PostScript (IM C2000/C4500, MP C2004ex/C3004ex/C307/C3504) | ✓ all |
| Ricoh MP 5055 PS (mono) | ✓ all except color / black & white |
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
| 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) |
| Canon, HP, Xerox, Konica Minolta user/department codes | no built-in profile — add one (below) |
### 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.
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`.
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
@@ -84,16 +103,19 @@ VERSION=1.2.3 ./build.sh
Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win. Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win.
`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs with the hardened runtime, builds the payload-free PKG, notarizes, staples and verifies with `spctl`. Use `./build.sh --build-only` to skip signing and notarization. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=<throwaway queue>` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests). `build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs the CLI and app with the hardened runtime, notarizes and staples the app, builds the PKG (app as payload, CLI via postinstall), notarizes and staples it, and verifies both with `spctl`. Building the PKG asks for admin rights once, to give the staged `Applications` folder the system's root:admin ownership. Use `./build.sh --build-only` to skip signing and notarization. `./build.sh --screenshots` regenerates the README screenshot from three temporary demo queues. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=<throwaway queue>` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests).
## Notes for admins ## Notes for admins
- 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.
## License ## About
MIT — see `LICENSE`. 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, with Claude. 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).

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

@@ -101,13 +101,22 @@ final class PrinterStore {
private(set) var revision = 0 private(set) var revision = 0
/// The server default printer (CUPS-Get-Default), if any. /// The server default printer (CUPS-Get-Default), if any.
private(set) var defaultPrinter: String? private(set) var defaultPrinter: String?
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil. /// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
private(set) var ppds: [String: PPD?] = [:] 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()
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store") private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
/// `--only-queues a,b,c` limits the app to those queues (used by `build.sh --screenshots`).
static let onlyQueues: Set<String>? = {
let args = ProcessInfo.processInfo.arguments
guard let i = args.firstIndex(of: "--only-queues"), i + 1 < args.count else { return nil }
return Set(args[i + 1].split(separator: ",").map(String.init))
}()
/// "21 printers · 0 active jobs" for the status bar. /// "21 printers · 0 active jobs" for the status bar.
var summaryLine: String { var summaryLine: String {
guard hasLoaded, errorMessage == nil else { return "" } guard hasLoaded, errorMessage == nil else { return "" }
@@ -133,7 +142,9 @@ final class PrinterStore {
do { do {
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes) let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
printers = groups.map(PrinterSummary.init).sorted { printers = groups.map(PrinterSummary.init)
.filter { Self.onlyQueues?.contains($0.name) ?? true }
.sorted {
$0.name.localizedStandardCompare($1.name) == .orderedAscending $0.name.localizedStandardCompare($1.name) == .orderedAscending
} }
errorMessage = nil errorMessage = nil
@@ -181,31 +192,33 @@ final class PrinterStore {
} }
func noteChange(queue: String? = nil) { func noteChange(queue: String? = nil) {
if let queue { ppds[queue] = nil } if let queue { drivers[queue] = nil }
revision += 1 revision += 1
} }
// MARK: PPDs for Quick Actions // MARK: drivers for Quick Actions
/// Fetches PPDs not yet cached, in the background of a refresh. /// Loads driver info (PPD, IPP attributes) not yet cached, in the background of a refresh.
func loadPPDs() async { func loadPPDs() async {
for printer in printers where ppds[printer.name] == nil { for printer in printers where drivers[printer.name] == nil {
let text = try? await client.getPPD(queue: printer.name) if let context = try? await DriverContext.load(client: client, queue: printer.name) {
ppds[printer.name] = .some(text.map(PPD.init(text:))) drivers[printer.name] = context
if let ppd = context.ppd {
driverFilters[printer.name] = await Task.detached { DriverFilterCheck.check(ppd) }.value
} else {
driverFilters[printer.name] = nil
}
}
} }
} }
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip). /// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
func unavailableReason(_ action: QuickAction, queue: String) -> String? { func unavailableReason(_ action: QuickAction, queue: String) -> String? {
if case .defaultPrinter = action.effect { if action.isDefaultPrinter {
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
} }
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" } guard let context = drivers[queue] else { return "Checking \(queue)’s driver…" }
if action.isAvailable(for: cached) { return nil } return action.unavailableReason(in: context)
if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) {
return unavailable.reason
}
return "Not available on \(queue)"
} }
// MARK: per-printer actions // MARK: per-printer actions
@@ -247,7 +260,7 @@ final class PrinterStore {
report("ERROR: delete \(queue): \(error)") report("ERROR: delete \(queue): \(error)")
fail("Couldn’t delete \(queue)", String(describing: error)) fail("Couldn’t delete \(queue)", String(describing: error))
} }
ppds[queue] = nil drivers[queue] = nil
noteChange() noteChange()
await refreshQuietly() await refreshQuietly()
return deleted return deleted

View File

@@ -39,24 +39,34 @@ final class QuickActionModel: Identifiable {
} }
} }
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) } var context: DriverContext {
DriverContext(ppd: state?.ppd, attributes: state?.snapshot.attributes)
}
var validationError: String? { action.validationError(code, ppd: state?.ppd) } var currentCode: String? { action.currentCode(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.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> { func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.changes(for: state?.ppd, value: clearing ? "" : code) action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
}
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
var profileName: String? {
guard !action.isDefaultPrinter, case .success(let resolved) = action.resolve(context, value: code.isEmpty ? "0" : code) else { return nil }
return resolved.profile.name
} }
var command: String { var command: String {
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) } if action.isDefaultPrinter { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
guard case .success(let changes) = plannedChanges() else { return "" } guard case .success(let changes) = plannedChanges() else { return "" }
return OptionApplier.displayCommand(queue: queue, changes: changes) return OptionApplier.displayCommand(queue: queue, changes: changes)
} }
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default. /// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
var userDefaultNote: String? { var userDefaultNote: String? {
guard case .defaultPrinter = action.effect, guard action.isDefaultPrinter,
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8), let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil } let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
let name = line.split(separator: " ")[1].split(separator: "/")[0] let name = line.split(separator: " ")[1].split(separator: "/")[0]
@@ -70,8 +80,8 @@ 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: clearing ? "" : (action.input == .userCode ? code : nil), let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
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)
@@ -108,6 +118,11 @@ struct QuickActionSheet: View {
.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)
if let driver = model.state.map({ _ in model.context.driverName }), let profile = model.profileName {
Text("\(driver) · driver profile: \(profile)")
.font(.callout)
.foregroundStyle(.secondary)
}
} }
switch model.phase { switch model.phase {
@@ -184,7 +199,7 @@ struct QuickActionSheet: View {
.frame(height: 130) .frame(height: 130)
} }
if case .defaultPrinter = model.action.effect { if model.action.isDefaultPrinter {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
GridRow { GridRow {
Text("Server default").foregroundStyle(.secondary) Text("Server default").foregroundStyle(.secondary)

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

@@ -0,0 +1,109 @@
import Foundation
/// What Quick Actions need to know about a queue's driver: its PPD (if any) and IPP attributes.
public struct DriverContext {
public let ppd: PPD?
/// Printer attributes, including `*-supported` values for IPP Everywhere pairs.
public let attributes: IPPGroup?
public init(ppd: PPD?, attributes: IPPGroup?) {
self.ppd = ppd
self.attributes = attributes
}
static let requestedAttributes = [
"printer-make-and-model", "print-color-mode-supported", "print-color-mode-default",
"sides-supported", "sides-default", "media-supported", "media-default",
]
public static func load(client: CupsClient, queue: String) async throws -> DriverContext {
let attributes = try await client.getPrinterAttributes(queue: queue, requested: requestedAttributes)
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
return DriverContext(ppd: ppd, attributes: attributes)
}
public var manufacturer: String? { ppd?.attributes["Manufacturer"] }
public var nickName: String? { ppd?.attributes["NickName"] ?? attributes?.string("printer-make-and-model") }
/// For messages: "RICOH SP 3710DN".
public var driverName: String { nickName ?? manufacturer ?? "this driver" }
/// Whether this queue can take `key=value`: a PPD option with that choice (or a custom value for
/// `Custom.` values), or an IPP `name-default` whose `name-supported` lists the value.
public func supports(key: String, value: String) -> Bool {
if let option = ppd?.option(key) {
if value.hasPrefix("Custom.") { return !option.customParameters.isEmpty }
return option.choices.contains(value)
}
guard key.hasSuffix("-default"), let attributes else { return false }
let supported = String(key.dropLast("-default".count)) + "-supported"
return attributes.strings(supported).contains(value)
}
}
public struct DriverProfile: Decodable, Identifiable {
public struct Match: Decodable {
/// Regular expressions (case-insensitive) on the PPD's `*Manufacturer` / `*NickName`; all given must match.
public let manufacturer: String?
public let nickname: String?
}
public let id: String
public let name: String
public let match: Match
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
public let actions: [String: [[String]]]
enum CodingKeys: String, CodingKey { case id, name, match, actions }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
match = try c.decode(Match.self, forKey: .match)
// Accept one list of pairs or a list of alternative lists.
var actions: [String: [[String]]] = [:]
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
for (action, pairs) in raw { actions[action] = pairs.alternatives }
self.actions = actions
}
private struct FlexiblePairs: Decodable {
let alternatives: [[String]]
init(from decoder: Decoder) throws {
if let nested = try? [[String]](from: decoder) {
alternatives = nested
} else {
alternatives = [try [String](from: decoder)]
}
}
}
public var isGeneric: Bool { match.manufacturer == nil && match.nickname == nil }
public func matches(_ context: DriverContext) -> Bool {
func test(_ pattern: String?, _ value: String?) -> Bool {
guard let pattern else { return true }
guard let value else { return false }
return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
}
return test(match.manufacturer, context.manufacturer) && test(match.nickname, context.nickName)
}
}
public enum DriverProfiles {
private struct File: Decodable { let profiles: [DriverProfile] }
/// The profiles compiled in from Resources/driver-profiles.json, in match order.
public static let builtIn: [DriverProfile] = {
do {
return try JSONDecoder().decode(File.self, from: Data(PackageResources.driver_profiles_json)).profiles
} catch {
fatalError("driver-profiles.json is invalid: \(error)")
}
}()
/// Matching profiles in order: vendor profiles first, generic last.
public static func matching(_ context: DriverContext, in profiles: [DriverProfile] = builtIn) -> [DriverProfile] {
profiles.filter { $0.matches(context) }
}
}

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: "\""))
} }

View File

@@ -1,108 +1,112 @@
import Foundation import Foundation
/// Task-named shortcuts that set queue defaults without knowing option keywords. /// Task-named shortcuts that set queue defaults without knowing option keywords.
/// Each one is data: which PPD keyword/value pairs to write (first variant the PPD supports wins). /// What each one writes comes from the driver profiles in Resources/driver-profiles.json.
/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed.
public struct QuickAction: Identifiable, Hashable { public struct QuickAction: Identifiable, Hashable {
public enum Input: Hashable { public enum Input: Hashable {
case none case none
/// Digits, up to the PPD's own limit. An empty value clears the code. /// Digits, up to the PPD's own limit.
case userCode case userCode
} }
public enum Effect: Hashable { /// CLI name (`cupsadmin quick <id> <queue>`) and the key in driver-profiles.json.
/// `lpadmin -p queue -o ...` with the first variant whose keywords and values this PPD has.
/// `{value}` in a value is replaced with the input.
case options(variants: [[String: String]], clearVariants: [[String: String]])
/// `lpadmin -d queue`.
case defaultPrinter
}
/// CLI name: `cupsadmin quick <id> <queue>`.
public let id: String public let id: String
public let title: String public let title: String
public let systemImage: String public let systemImage: String
public let input: Input public let input: Input
public let effect: Effect
/// One line for the CLI list and tooltips. /// One line for the CLI list and tooltips.
public let summary: String public let summary: String
public var isDefaultPrinter: Bool { id == "default" }
public static let all: [QuickAction] = [ public static let all: [QuickAction] = [
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none, QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []), summary: "Color by default"),
summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"),
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,
effect: .options(variants: [["ColorModel": "Gray"], ["HQColorMode": "BW"]], clearVariants: []), summary: "Black & white by default"),
summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"),
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,
effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]], summary: "User/department code sent with every job (--clear to remove)"),
clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]),
summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.<code>)"),
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,
effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []), summary: "Two-sided, long edge"),
summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none, QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
effect: .options(variants: [["Duplex": "None"]], clearVariants: []), summary: "One-sided"),
summary: "One-sided (Duplex=None)"),
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, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
input: .none, input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []),
summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"),
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none, QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"), summary: "Server default printer (lpadmin -d)"),
] ]
public static func named(_ id: String) -> QuickAction? { public static func named(_ id: String) -> QuickAction? {
all.first { $0.id == id } all.first { $0.id == id }
} }
/// The option changes for this PPD, or why the action isn't available on it. /// The profile and option changes this action would write on a queue, or why it can't.
public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> { public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
guard case .options(let variants, let clearVariants) = effect else { return .success([]) } profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
let clearing = input == .userCode && (value ?? "").isEmpty let key = clearing ? id + "-clear" : id
let candidates = clearing ? clearVariants : variants var firstMissing: String?
guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) } for profile in DriverProfiles.matching(context, in: profiles) {
guard let alternatives = profile.actions[key] else { continue }
for variant in candidates { for pairs in alternatives {
var changes: [OptionChange] = [] let parsed = pairs.compactMap(Self.parse)
for key in variant.keys.sorted() { let filled = parsed.map { ($0.key, $0.value.replacingOccurrences(of: "{value}", with: value ?? "")) }
guard let option = ppd.option(key) else { changes = []; break } if let missing = filled.first(where: { !context.supports(key: $0.0, value: $0.1) }) {
let raw = variant[key]! if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
if raw.contains("{value}") { continue
guard !option.customParameters.isEmpty else { changes = []; break } }
changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false)) let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
} else { return .success(ResolvedQuickAction(profile: profile, changes: changes))
guard option.choices.contains(raw) else { changes = []; break }
changes.append(OptionChange(key: key, value: raw, isSecret: false))
} }
} }
if !changes.isEmpty { return .success(changes) } let detail = firstMissing.map { " (no \($0))" } ?? " (no driver profile maps \(key))"
} return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName)\(detail)"))
let needed = Set(candidates.flatMap { $0.keys }).sorted().joined(separator: " / ")
return .failure(QuickActionUnavailable(reason: "This printer’s PPD has no \(needed) with the needed choice"))
} }
public func isAvailable(for ppd: PPD?) -> Bool { /// nil when the action can run on this queue; otherwise the reason (for tooltips and CLI errors).
if case .defaultPrinter = effect { return true } public func unavailableReason(in context: DriverContext) -> String? {
if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true } if isDefaultPrinter { return nil }
return false switch resolve(context, value: input == .userCode ? "0" : nil) {
case .success: return nil
case .failure(let unavailable): return unavailable.reason
}
} }
/// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid. public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
public func validationError(_ value: String, ppd: PPD?) -> String? {
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
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" } guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
if let max = ppd?.option("RIUserCode")?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max { 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" return "At most \(max) digits"
} }
return nil return nil
} }
/// The current user code on the queue, without the Custom. prefix (nil when none is set). /// The code currently set on the queue, without the Custom. prefix (nil when none).
public static func currentUserCode(_ ppd: PPD?) -> String? { public func currentCode(_ context: DriverContext) -> String? {
guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil } guard let keyword = codeKeyword(context),
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count)) return String(value.dropFirst("Custom.".count))
} }
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
private func codeKeyword(_ context: DriverContext) -> String? {
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
let profilePairs = resolved.profile.actions[id]?.first ?? []
return profilePairs.compactMap(Self.parse).first { $0.value.contains("{value}") }?.key
}
static func parse(_ pair: String) -> (key: String, value: String)? {
guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else { return nil }
return (String(pair[..<eq]), String(pair[pair.index(after: eq)...]))
}
}
public struct ResolvedQuickAction {
public let profile: DriverProfile
public let changes: [OptionChange]
} }
public struct QuickActionUnavailable: Error, Hashable { public struct QuickActionUnavailable: Error, Hashable {
@@ -112,6 +116,8 @@ public struct QuickActionUnavailable: Error, Hashable {
public struct QuickActionOutcome { public struct QuickActionOutcome {
public let command: String public let command: String
public let succeeded: Bool public let succeeded: Bool
/// The driver profile used; nil for Make Default Printer.
public let profile: DriverProfile?
/// Per-option read-back; empty for Make Default Printer. /// Per-option read-back; empty for Make Default Printer.
public let readBack: [OptionReadBack] public let readBack: [OptionReadBack]
public let changes: [OptionChange] public let changes: [OptionChange]
@@ -119,21 +125,19 @@ public struct QuickActionOutcome {
} }
extension QuickAction { extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options. /// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome { public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
switch effect { if isDefaultPrinter {
case .defaultPrinter:
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, readBack: [], return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
changes: [], message: outcome.message) readBack: [], changes: [], message: outcome.message)
case .options:
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
let changes = try self.changes(for: ppd, value: value).get()
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
let failed = readBack.filter { !$0.applied }
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: changes),
succeeded: result.succeeded && failed.isEmpty, readBack: readBack, changes: changes,
message: failed.first?.reason)
} }
let context = try await DriverContext.load(client: client, queue: queue)
let resolved = try resolve(context, value: value, clearing: clearing).get()
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
let failed = readBack.filter { !$0.applied }
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
succeeded: result.succeeded && failed.isEmpty, profile: resolved.profile,
readBack: readBack, changes: resolved.changes, message: failed.first?.reason)
} }
} }

View File

@@ -0,0 +1,43 @@
{
"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>",
"profiles": [
{
"id": "ricoh-m-c251fw",
"name": "Ricoh M C251FW",
"match": { "manufacturer": "^RICOH$", "nickname": "^RICOH M C251FW" },
"actions": {
"color": ["HQColorMode=COLOR"],
"bw": ["HQColorMode=BW"],
"duplex": ["Duplex=DuplexNoTumble"],
"simplex": ["Duplex=None"],
"letter": ["PageSize=Letter"]
}
},
{
"id": "ricoh",
"name": "Ricoh",
"match": { "manufacturer": "^RICOH$" },
"actions": {
"color": ["ColorModel=CMYK"],
"bw": ["ColorModel=Gray"],
"usercode": ["RIEnableUserCode=True", "RIUserCode=Custom.{value}"],
"usercode-clear": ["RIEnableUserCode=False", "RIUserCode=None"],
"duplex": ["Duplex=DuplexNoTumble"],
"simplex": ["Duplex=None"],
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
}
},
{
"id": "generic",
"name": "Generic (standard PPD keywords, IPP Everywhere)",
"match": {},
"actions": {
"color": [["ColorModel=CMYK"], ["ColorModel=RGB"], ["ColorModel=Color"], ["print-color-mode-default=color"]],
"bw": [["ColorModel=Gray"], ["ColorModel=Grayscale"], ["print-color-mode-default=monochrome"]],
"duplex": [["Duplex=DuplexNoTumble"], ["sides-default=two-sided-long-edge"]],
"simplex": [["Duplex=None"], ["sides-default=one-sided"]],
"letter": [["PageSize=Letter"], ["media-default=na_letter_8.5x11in"]]
}
}
]
}

View File

@@ -0,0 +1,72 @@
import Foundation
import CupsKit
/// Every OpenGroup/OpenUI in a queue's PPD, so contributors can find their driver's keywords.
enum PPDReportCommand {
static func run(client: CupsClient, queue: String?, file: String?) async throws -> String {
let text: String
let source: String
var attributes: IPPGroup?
if let file {
guard let data = FileManager.default.contents(atPath: file) else { throw CupsAdminError.failed("can’t read \(file)") }
text = String(decoding: data, as: UTF8.self)
source = file
} else if let queue {
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
guard let ppdText = try await client.getPPD(queue: queue) else {
throw CupsAdminError.failed("\(queue) has no PPD (driverless or raw queue)")
}
text = ppdText
source = queue
attributes = try? await client.getPrinterAttributes(queue: queue, requested: [
"printer-make-and-model", "print-color-mode-supported", "sides-supported", "media-supported"])
} else {
throw CupsAdminError.usage("ppdreport needs a queue name or --file <path.ppd>")
}
let ppd = PPD(text: text)
let a = ppd.attributes
print("PPD: \(a["NickName"] ?? "?")")
print("Manufacturer: \(a["Manufacturer"] ?? "?")")
print("PCFileName: \(a["PCFileName"] ?? "?") version \(a["FileVersion"] ?? "?")")
let context = DriverContext(ppd: ppd, attributes: attributes)
let profiles = DriverProfiles.matching(context)
print("Profiles: \(profiles.map(\.id).joined(separator: ", "))")
let quick = QuickAction.all.filter { !$0.isDefaultPrinter }.map { action -> String in
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
}
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 {
print("\n\(group)")
for option in ppd.options where option.group == group {
let type = option.uiType == .unknown ? "?" : option.uiType.rawValue
print(" \(option.keyword) — \(option.text) (\(type)\(option.isJCL ? ", JCL" : ""), default \(option.defaultChoice ?? "none"))")
let choices = option.choiceList.map { $0.text == $0.keyword ? $0.keyword : "\($0.keyword) \"\($0.text)\"" }
if !choices.isEmpty { print(" choices: \(choices.joined(separator: ", "))") }
if !option.customParameters.isEmpty {
let params = option.customParameters.map { "\($0.name) \($0.type) \($0.minimum)–\($0.maximum)" }
print(" custom: \(params.joined(separator: "; ")) (write as \(option.keyword)=Custom.<value>)")
}
}
}
return "OK: \(source) \(ppd.options.count) options in \(ppd.groups.count) groups"
}
}

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

@@ -7,7 +7,10 @@ enum QuickCommand {
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> | --clear" : "", $0.summary]
}) })
return "OK: \(QuickAction.all.count) quick actions" print("\nDriver profiles (first match wins, generic last): "
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
print("See what a queue's driver offers: cupsadmin ppdreport <queue>")
return "OK: \(QuickAction.all.count) quick actions, \(DriverProfiles.builtIn.count) driver profiles"
} }
guard let action = QuickAction.named(name) else { guard let action = QuickAction.named(name) else {
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))") throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
@@ -22,7 +25,7 @@ enum QuickCommand {
case .userCode: case .userCode:
if clear { if clear {
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 = "" value = nil
} else { } else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") } guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
value = args[2] value = args[2]
@@ -30,7 +33,7 @@ enum QuickCommand {
} }
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)") }
if case .defaultPrinter = action.effect { if action.isDefaultPrinter {
let before = try await client.getDefaultPrinter() let before = try await client.getDefaultPrinter()
let status = try runTool(CupsTools.lpadminPath, ["-d", queue]) let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") } guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
@@ -43,23 +46,25 @@ enum QuickCommand {
} }
let before = try await OptionState.load(client: client, queue: queue) let before = try await OptionState.load(client: client, queue: queue)
if let value, let error = action.validationError(value, ppd: before.ppd) { let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
if let value, let error = action.validationError(value, context: context) {
throw CupsAdminError.usage("user code \(value): \(error)") throw CupsAdminError.usage("user code \(value): \(error)")
} }
let changes: [OptionChange] let resolved: ResolvedQuickAction
switch action.changes(for: before.ppd, value: value) { switch action.resolve(context, value: value, clearing: clear) {
case .success(let resolved): changes = resolved case .success(let r): resolved = r
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)") case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
} }
print("driver: \(context.driverName) · profile: \(resolved.profile.id)")
let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes) let (executable, arguments) = OptionApplier.command(queue: queue, changes: resolved.changes)
let status = try runTool(executable, arguments) let status = try runTool(executable, arguments)
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") } guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
let after = try await OptionState.load(client: client, queue: queue) let after = try await OptionState.load(client: client, queue: queue)
let checks = changes.map { OptionApplier.verify($0, state: after) } let checks = resolved.changes.map { OptionApplier.verify($0, state: after) }
print() print()
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(resolved.changes, checks).map { change, check in
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-", [change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"] check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
}) })
@@ -68,6 +73,6 @@ enum QuickCommand {
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: " throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
+ failed.map(\.key).joined(separator: ", ")) + failed.map(\.key).joined(separator: ", "))
} }
return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified" return "OK: \(queue) \(action.id) applied (\(resolved.profile.id)), \(resolved.changes.count) setting(s) verified"
} }
} }

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)
@@ -41,6 +42,8 @@ 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>
every PPD group and option: keyword, label, type, default, choices
help show this help help show this help
""" """
@@ -78,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")
@@ -117,6 +121,19 @@ func runCommand(_ args: [String]) async throws -> String {
let (queue, spec) = try QueueSpec.parse(rest, command: command) let (queue, spec) = try QueueSpec.parse(rest, command: command)
return try await SetCommand.run(client: client, queue: queue, spec: spec) return try await SetCommand.run(client: client, queue: queue, spec: spec)
case "ppdreport":
var file: String?
if let i = rest.firstIndex(of: "--file") {
guard i + 1 < rest.count else { throw CupsAdminError.usage("--file needs a path") }
file = rest[i + 1]
rest.removeSubrange(i ... i + 1)
}
try rejectUnknownFlags()
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
throw CupsAdminError.usage("ppdreport needs one queue name or --file <path.ppd>")
}
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)
case "quick": case "quick":
let clear = takeFlag("--clear") let clear = takeFlag("--clear")
try rejectUnknownFlags() try rejectUnknownFlags()

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,61 +1,127 @@
import CupsKit @testable import CupsKit
import Foundation import Foundation
import Testing import Testing
private func available(_ id: String, _ ppd: PPD?) -> Bool { private func context(_ ppdFile: String) -> DriverContext? {
QuickAction.named(id)!.isAvailable(for: ppd) vendorPPD(ppdFile).map { DriverContext(ppd: $0, attributes: nil) }
} }
@Suite struct QuickActionAvailability { private func available(_ id: String, _ context: DriverContext) -> Bool {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500"))) QuickAction.named(id)!.isAvailable(in: context)
func ricohPSHasEverything() throws { }
let ppd = try #require(vendorPPD("RICOH IM C4500"))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] { private func profileID(_ id: String, _ context: DriverContext) -> String? {
#expect(available(id, ppd), "\(id)") guard case .success(let resolved) = QuickAction.named(id)!.resolve(context, value: "0") else { return nil }
return resolved.profile.id
}
@Suite struct DriverProfileFile {
@Test func builtInProfilesAreWellFormed() {
let profiles = DriverProfiles.builtIn
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"])
#expect(profiles.last?.isGeneric == true)
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
for profile in profiles {
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
for alternatives in profile.actions.values {
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
} }
let letter = try QuickAction.named("letter")!.changes(for: ppd).get() }
}
}
/// One test per Ricoh PPD documented in docs/ricoh-ppd-options.md (vendor PPDs; skipped when not installed).
@Suite struct RicohProfileMatching {
static let postScript = ["RICOH IM C2000", "RICOH IM C4500", "RICOH MP C2004ex", "RICOH MP C3004ex",
"RICOH MP C307", "RICOH MP C3504"]
@Test(.enabled(if: postScript.allSatisfy(hasVendorPPD)), arguments: postScript)
func ricohColorPostScript(_ file: String) throws {
let c = try #require(context(file))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter"] {
#expect(profileID(id, c) == "ricoh", "\(file) \(id)")
}
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)])
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get() 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")!.changes(for: ppd, value: "").get() let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
#expect(clear.map(\.value) == ["False", "None"]) #expect(clear.map(\.value) == ["False", "None"])
} }
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd"))) @Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
func otherDriversOnlyGetWhatTheirPPDHas() throws { func ricohMonoPostScript() throws {
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex. let c = try #require(context("RICOH MP 5055"))
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd")) #expect(!available("color", c) && !available("bw", c)) // mono: no ColorModel
#expect(!available("color", sp) && !available("bw", sp)) #expect(QuickAction.named("color")!.unavailableReason(in: c)?.hasPrefix("Not available for RICOH MP 5055 PS") == true)
#expect(!available("letter", sp) && !available("usercode", sp)) for id in ["usercode", "duplex", "simplex", "letter"] { #expect(profileID(id, c) == "ricoh", "\(id)") }
#expect(available("duplex", sp) && available("simplex", sp)) }
// M C251FW: color via HQColorMode. @Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
let fw = try #require(vendorPPD("RICOH M C251FW.ppd")) func ricohMC251FWUsesHQColorMode() throws {
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)]) let c = try #require(context("RICOH M C251FW.ppd"))
#expect(profileID("color", c) == "ricoh-m-c251fw")
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
#expect(!available("usercode", c))
#expect(profileID("letter", c) == "ricoh-m-c251fw")
}
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no. @Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd")))
let airprint = PPD(text: """ func ricohPCLFallsBackToGenericLetter() throws {
let c = try #require(context("RICOH SP 3710DN.ppd"))
#expect(!available("color", c) && !available("usercode", c))
#expect(profileID("duplex", c) == "ricoh")
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
#expect(profileID("letter", c) == "generic")
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func userCodeValidationUsesThePPDLimit() throws {
let c = try #require(context("RICOH IM C4500"))
let action = QuickAction.named("usercode")!
#expect(action.validationError("12345", context: c) == nil)
#expect(action.validationError("12a", context: c) == "Digits only")
#expect(action.validationError("123456789", context: c) == "At most 8 digits")
}
}
@Suite struct GenericProfile {
static let genericPPD = "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Resources/Generic.ppd"
@Test(.enabled(if: FileManager.default.fileExists(atPath: GenericProfile.genericPPD)))
func genericPostScriptPPD() throws {
let text = try String(contentsOfFile: Self.genericPPD, encoding: .isoLatin1)
let c = DriverContext(ppd: PPD(text: text), attributes: nil)
#expect(DriverProfiles.matching(c).map(\.id) == ["generic"])
for id in ["duplex", "simplex", "letter"] { #expect(profileID(id, c) == "generic", "\(id)") }
#expect(!available("color", c) && !available("bw", c) && !available("usercode", c))
#expect(QuickAction.named("default")!.isAvailable(in: c))
}
@Test func ippEverywhereUsesIPPAttributes() throws {
let attributes = IPPGroup(tag: .printerAttributes, attributes: [
.string("printer-make-and-model", .textWithoutLanguage, ["Example Laser 5000"]),
.string("print-color-mode-supported", .keyword, ["auto", "color", "monochrome"]),
.string("sides-supported", .keyword, ["one-sided", "two-sided-long-edge"]),
.string("media-supported", .keyword, ["na_letter_8.5x11in", "iso_a4_210x297mm"]),
])
let c = DriverContext(ppd: nil, attributes: attributes)
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "print-color-mode-default", value: "color", isSecret: false)])
#expect(try QuickAction.named("duplex")!.resolve(c).get().changes == [OptionChange(key: "sides-default", value: "two-sided-long-edge", isSecret: false)])
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "media-default", value: "na_letter_8.5x11in", isSecret: false)])
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Not available for Example Laser 5000 (no driver profile maps usercode)")
// Grayscale-only AirPrint PPD: black & white yes, color no.
let gray = DriverContext(ppd: PPD(text: """
*OpenUI *ColorModel/Color Mode: PickOne *OpenUI *ColorModel/Color Mode: PickOne
*DefaultColorModel: Gray *DefaultColorModel: Gray
*ColorModel Gray/Grayscale: "" *ColorModel Gray/Grayscale: ""
*CloseUI: *ColorModel *CloseUI: *ColorModel
*OpenUI *PageSize/Media Size: PickOne """), attributes: nil)
*DefaultPageSize: Letter #expect(available("bw", gray) && !available("color", gray))
*PageSize Letter/US Letter: ""
*CloseUI: *PageSize
""")
#expect(!available("color", airprint))
#expect(available("bw", airprint))
#expect(!available("duplex", airprint))
}
@Test func userCodeValidation() {
let action = QuickAction.named("usercode")!
let ppd = vendorPPD("RICOH IM C4500")
#expect(action.validationError("12345", ppd: ppd) == nil)
#expect(action.validationError("12a", ppd: ppd) == "Digits only")
#expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits")
} }
} }
@@ -68,21 +134,24 @@ struct LiveQuickActions {
@Test func everyActionOnRicohQueue() async throws { @Test func everyActionOnRicohQueue() async throws {
try #require(queues.count == 2) try #require(queues.count == 2)
let client = CupsClient() let client = CupsClient()
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"), for (id, value, clearing) in [("letter", nil, false), ("bw", nil, false), ("color", nil, false), ("usercode", "2468", false),
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] { ("usercode", nil, true), ("simplex", nil, false), ("duplex", nil, false)] as [(String, String?, Bool)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client) let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, clearing: clearing, client: client)
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")") #expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
#expect(outcome.profile?.id == "ricoh", "\(id)")
} }
} }
@Test func testPageDefaultAndDelete() async throws { @Test func genericQueueTestPageDefaultAndDelete() async throws {
try #require(queues.count == 2) try #require(queues.count == 2)
let client = CupsClient() let client = CupsClient()
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client) let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
#expect(page.succeeded && jobID != nil, "\(page)") #expect(page.succeeded && jobID != nil, "\(page)")
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client) let letter = try await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing #expect(letter.succeeded && letter.profile?.id == "generic", "\(letter.message ?? "")")
let color = try? await QuickAction.named("usercode")!.run(queue: queues[1], value: "1", client: client)
#expect(color == nil) // generic PPD: no user code mapping, throws, writes nothing
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client) let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
#expect(made.succeeded, "\(made.message ?? "")") #expect(made.succeeded, "\(made.message ?? "")")

243
build.sh
View File

@@ -1,8 +1,13 @@
#!/bin/bash #!/bin/bash
# Universal release build -> signed CLI binary + signed "CUPS Admin.app" -> signed payload-free PKG # Universal release build -> signed CLI + signed, notarized, stapled "CUPS Admin.app" -> signed PKG
# (postinstall installs /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl. # (payload: /Applications/CUPS Admin.app; postinstall: /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl.
# The pkg step needs admin rights once (sudo in Terminal, or an administrator password dialog) to make the
# staging Applications folder root:admin 775 so the package records the system's real ownership.
# Usage: ./build.sh full pipeline (VERSION=1.2.0 ./build.sh to set the version) # Usage: ./build.sh full pipeline (VERSION=1.2.0 ./build.sh to set the version)
# ./build.sh --build-only build CLI and app, ad-hoc signed; no identities, no pkg, no notarization # ./build.sh --build-only build CLI and app, ad-hoc signed; no identities, no pkg, no notarization
# ./build.sh --screenshots build like --build-only, then regenerate docs/screenshots/app-jobs.png and
# app-options.png (creates four demo queues on 127.0.0.1 — one on Ricoh's
# IM C4500 PPD — with held jobs, and deletes them after)
# #
# Signing settings come from the environment, or from an untracked build.env next to this script # Signing settings come from the environment, or from an untracked build.env next to this script
# (plain KEY=value lines; a variable already set in the environment wins): # (plain KEY=value lines; a variable already set in the environment wins):
@@ -13,10 +18,12 @@
set -euo pipefail set -euo pipefail
BUILD_ONLY=0 BUILD_ONLY=0
SCREENSHOTS=0
for arg in "$@"; do for arg in "$@"; do
case "$arg" in case "$arg" in
--build-only) BUILD_ONLY=1 ;; --build-only) BUILD_ONLY=1 ;;
*) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only)" --screenshots) BUILD_ONLY=1; SCREENSHOTS=1 ;;
*) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only or --screenshots)"
echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;; echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;;
esac esac
done done
@@ -27,6 +34,7 @@ STATUS="ERROR: build.sh exited unexpectedly"
finish() { finish() {
local rc=$? local rc=$?
[ "$SCREENSHOTS" -eq 1 ] && cleanup_screenshots
if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi
echo "$STATUS" echo "$STATUS"
echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)" echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)"
@@ -36,9 +44,93 @@ trap 'STATUS="ERROR: interrupted"; exit 130' INT TERM
step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; } step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; }
# --- screenshots (--screenshots) ------------------------------------------------
# Demo queues with generic names so the README never shows real queue names or users.
DEMO_QUEUES="Copy_Room Front_Office Library_Color Lab_Mono"
SHOT_JOBS="docs/screenshots/app-jobs.png"
SHOT_OPTIONS="docs/screenshots/app-options.png"
RICOH_DEMO_PPD="/Library/Printers/PPDs/Contents/Resources/RICOH IM C4500"
APP_DOMAIN="edu.wku.cupsadmin.app"
SAVED_PREFS=""
cleanup_screenshots() {
osascript -e 'quit app "CUPS Admin"' >/dev/null 2>&1 || true
for _ in 1 2 3 4 5; do pgrep -f "CUPS Admin.app/Contents/MacOS" >/dev/null || break; sleep 1; done
pkill -f "CUPS Admin.app/Contents/MacOS" 2>/dev/null || true
for queue in $DEMO_QUEUES; do lpadmin -x "$queue" 2>/dev/null || true; done
if [ -n "$SAVED_PREFS" ] && [ -f "$SAVED_PREFS" ]; then
defaults delete "$APP_DOMAIN" 2>/dev/null || true
defaults import "$APP_DOMAIN" "$SAVED_PREFS" && rm -f "$SAVED_PREFS"
SAVED_PREFS=""
fi
}
# capture_app <queue> <tab> <file>: launch the app on one queue and tab, capture its window.
capture_app() {
local queue="$1" tab="$2" file="$3" window="" finder
osascript -e 'quit app "CUPS Admin"' >/dev/null 2>&1 || true
for _ in 1 2 3 4 5; do pgrep -f "CUPS Admin.app/Contents/MacOS" >/dev/null || break; sleep 1; done
defaults write "$APP_DOMAIN" selectedQueue "$queue"
defaults write "$APP_DOMAIN" detailTab "$tab"
defaults write "$APP_DOMAIN" "NSWindow Frame main" "120 120 1400 860 $SCREEN "
open "$APP" --args --only-queues "${DEMO_QUEUES// /,}"
finder=$(mktemp -t cupsadmin-window).swift
cat > "$finder" <<'SWIFT'
import CoreGraphics
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? []
if let w = windows.first(where: { ($0[kCGWindowOwnerName as String] as? String) == "CUPS Admin" && ($0[kCGWindowLayer as String] as? Int) == 0 }),
let id = w[kCGWindowNumber as String] as? Int { print(id) }
SWIFT
for _ in $(seq 1 30); do window=$(swift "$finder" 2>/dev/null || true); [ -n "$window" ] && break; sleep 1; done
rm -f "$finder"
[ -n "$window" ] || { STATUS="ERROR: CUPS Admin window didn't appear on this Space"; exit 1; }
sleep 3 # let the sidebar, header and tab content load
# Capture it as the active window (colored traffic lights, accent-colored selection).
osascript -e "tell application id \"$APP_DOMAIN\" to activate" >/dev/null 2>&1 || true
open "$APP"
sleep 2
mkdir -p "$(dirname "$file")"
screencapture -x -o -l "$window" "$file" \
|| { STATUS="ERROR: screencapture failed (Terminal needs Screen Recording permission)"; exit 1; }
echo "captured $file ($(sips -g pixelWidth -g pixelHeight "$file" | awk '/pixel/ {print $2}' | paste -sd x -))"
}
take_screenshots() {
step "screenshots"
[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" != "Dark" ] \
|| { STATUS="ERROR: switch macOS to Light appearance first (the README screenshots are light mode)"; exit 1; }
[ -f "$RICOH_DEMO_PPD" ] \
|| { STATUS="ERROR: the Options screenshot needs Ricoh's driver ($RICOH_DEMO_PPD not found)"; exit 1; }
cleanup_screenshots
# Save the user's app settings (window frame, selection, tab); restored in cleanup.
SAVED_PREFS=$(mktemp -t cupsadmin-prefs).plist
defaults export "$APP_DOMAIN" "$SAVED_PREFS" 2>/dev/null || true
[ -s "$SAVED_PREFS" ] || printf '{}' | plutil -convert xml1 -o "$SAVED_PREFS" -
for queue in Front_Office Library_Color Lab_Mono; do
lpadmin -p "$queue" -v "lpd://127.0.0.1/$queue" -m drv:///sample.drv/generic.ppd \
-D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true
done
# A Ricoh queue for the Options tab, set to Letter + fit to nearest size (what a US site uses).
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
# Held jobs never leave the Mac; owners are generic names, not the person running the build.
lp -d Front_Office -U alex -H hold -t "Quarterly budget.pdf" /etc/hosts >/dev/null
lp -d Front_Office -U jordan -H hold -t "Staff meeting agenda" /etc/hosts >/dev/null
lp -d Front_Office -U sam -H hold -t "Parking permits.docx" /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
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 Copy_Room options "$SHOT_OPTIONS"
}
# --- configuration ------------------------------------------------------------- # --- configuration -------------------------------------------------------------
cd "$(dirname "$0")" cd "$(dirname "$0")"
VERSION="${VERSION:-1.0.1}" VERSION="${VERSION:-1.1.1}"
IDENTIFIER="edu.wku.cupsadmin" IDENTIFIER="edu.wku.cupsadmin"
# build.env fills in only what the environment doesn't already set. # build.env fills in only what the environment doesn't already set.
@@ -182,64 +274,145 @@ if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then
fi fi
echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")" echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")"
if [ $SCREENSHOTS -eq 1 ]; then
take_screenshots
STATUS="OK: regenerated $SHOT_JOBS and $SHOT_OPTIONS"
exit 0
fi
if [ $BUILD_ONLY -eq 1 ]; then if [ $BUILD_ONLY -eq 1 ]; then
STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)" STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)"
exit 0 exit 0
fi fi
# --- notarize helper --------------------------------------------------------------
# notarize <file> <label>: submits, waits, saves the log; fails the build unless Accepted.
notarize() {
local file="$1" label="$2"
local json="$DIST/notary-$label.json"
xcrun notarytool submit "$file" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$json" || true
local status id
status=$(plutil -extract status raw "$json" 2>/dev/null || echo "unknown")
id=$(plutil -extract id raw "$json" 2>/dev/null || echo "")
echo "$label submission $id: $status"
if [ -n "$id" ]; then
xcrun notarytool log "$id" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-$label-log.json" >/dev/null 2>&1 || true
fi
[ "$status" = "Accepted" ] \
|| { STATUS="ERROR: $label notarization $status (see $json and $DIST/notary-$label-log.json)"; exit 1; }
}
# spctl_check <type> <path> <log>: must be accepted as Notarized Developer ID.
spctl_check() {
local type="$1" path="$2" log="$3" out
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
echo "$out" | tee "$log"
echo "$out" | grep -q ': accepted' && echo "$out" | grep -q 'source=Notarized Developer ID' \
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
}
# --- notarize + staple app --------------------------------------------------------
step "notarize app (uploads to Apple and waits)"
APP_ZIP="$DIST/CUPS-Admin-$VERSION.zip"
rm -f "$APP_ZIP"
ditto -c -k --keepParent "$APP" "$APP_ZIP"
notarize "$APP_ZIP" app
rm -f "$APP_ZIP"
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl_check execute "$APP" "$DIST/spctl-app.txt"
# --- build + sign pkg ----------------------------------------------------------- # --- build + sign pkg -----------------------------------------------------------
# App: a normal payload with a receipt. The staging root mirrors the system — "/" root:wheel 755,
# Applications root:admin 775, the app root:wheel — and pkgbuild --ownership preserve records exactly
# that, so installing never changes /Applications' ownership. (--ownership recommended would record
# Applications as root:wheel.) Making files root-owned needs admin rights, once per build.
# CLI: stays payload-free — the binary rides in Scripts and pkg/postinstall copies it, because a
# /usr/local/bin payload entry would reset an existing Homebrew-owned /usr/local/bin.
# as_root "<command>": sudo when there's a terminal (or cached credentials), else the standard
# administrator password dialog. Never stores a password.
as_root() {
if sudo -n true 2>/dev/null || [ -t 0 ]; then
sudo /bin/sh -c "$1"
else
local escaped="${1//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
osascript -e "do shell script \"$escaped\" with administrator privileges" >/dev/null
fi
}
step "build and sign pkg" step "build and sign pkg"
rm -f "$PKG" rm -f "$PKG"
rm -rf "$SCRIPTS/CUPS Admin.app"
cp pkg/postinstall "$SCRIPTS/postinstall" cp pkg/postinstall "$SCRIPTS/postinstall"
chmod 755 "$SCRIPTS/postinstall" chmod 755 "$SCRIPTS/postinstall"
pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
PKGROOT="$PWD/$DIST/pkgroot-$$"
mkdir -p "$PKGROOT/Applications"
ditto "$APP" "$PKGROOT/Applications/CUPS Admin.app"
echo "setting staging ownership (admin rights needed once)"
# Also removes root-owned staging folders left by earlier builds.
as_root "find '$PWD/$DIST' -maxdepth 1 -name 'pkgroot-*' ! -name 'pkgroot-$$' -exec rm -rf {} + ; \
chown root:wheel '$PKGROOT' && chmod 755 '$PKGROOT' && \
chown root:admin '$PKGROOT/Applications' && chmod 775 '$PKGROOT/Applications' && \
chown -R root:wheel '$PKGROOT/Applications/CUPS Admin.app' && chmod -R go-w '$PKGROOT/Applications/CUPS Admin.app'" \
|| { STATUS="ERROR: couldn't set staging ownership (admin rights are needed for the app payload)"; exit 1; }
COMPONENTS="$DIST/components.plist"
pkgbuild --analyze --root "$PKGROOT" "$COMPONENTS" >/dev/null
# Install exactly at /Applications/CUPS Admin.app, never "relocated" to another copy LaunchServices knows about.
plutil -replace 0.BundleIsRelocatable -bool NO "$COMPONENTS"
plutil -replace 0.BundleOverwriteAction -string upgrade "$COMPONENTS"
pkgbuild --root "$PKGROOT" --install-location / --ownership preserve --component-plist "$COMPONENTS" \
--scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
--sign "$INSTALLER_SIGN_ID" --timestamp "$PKG" --sign "$INSTALLER_SIGN_ID" --timestamp "$PKG"
pkgutil --check-signature "$PKG" pkgutil --check-signature "$PKG"
step "verify pkg contents" step "verify pkg contents"
echo "pkgutil --payload-files $PKG:" PAYLOAD_FILES=$(pkgutil --payload-files "$PKG")
PAYLOAD_FILES=$(pkgutil --payload-files "$PKG" 2>&1 || true) echo "payload: $(echo "$PAYLOAD_FILES" | wc -l | tr -d ' ') entries, top level:"
echo "${PAYLOAD_FILES:-(no output)}" echo "$PAYLOAD_FILES" | awk -F/ 'NF <= 3' | sed 's/^/ /'
if [ -n "$(printf '%s' "$PAYLOAD_FILES" | grep -v -i 'no payload' || true)" ]; then if echo "$PAYLOAD_FILES" | grep -v -E '^\.$|^\./Applications$|^\./Applications/CUPS Admin\.app(/|$)' | grep -q .; then
STATUS="ERROR: pkg has payload entries; expected none" STATUS="ERROR: payload contains something other than /Applications/CUPS Admin.app"; exit 1
exit 1
fi fi
EXPANDED="$DIST/expanded" EXPANDED="$DIST/expanded"
rm -rf "$EXPANDED"
pkgutil --expand "$PKG" "$EXPANDED" pkgutil --expand "$PKG" "$EXPANDED"
# pkgutil --expand unpacks the Scripts archive into a directory. BOM_ROOT=$(lsbom "$EXPANDED/Bom" | awk -F'\t' '$1=="."{print $2, $3}')
BOM_APPS=$(lsbom "$EXPANDED/Bom" | awk -F'\t' '$1=="./Applications"{print $2, $3}')
BOM_APP=$(lsbom "$EXPANDED/Bom" | awk -F'\t' '$1=="./Applications/CUPS Admin.app"{print $2, $3}')
echo "BOM: / $BOM_ROOT · /Applications $BOM_APPS · CUPS Admin.app $BOM_APP"
[ "$BOM_ROOT" = "40755 0/0" ] && [ "$BOM_APPS" = "40775 0/80" ] && [ "$BOM_APP" = "40755 0/0" ] \
|| { STATUS="ERROR: pkg ownership doesn't match the system (expected / 40755 0/0, /Applications 40775 0/80, app 40755 0/0)"; exit 1; }
echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')" echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')"
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] \ [ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] && [ ! -e "$EXPANDED/Scripts/CUPS Admin.app" ] \
|| { STATUS="ERROR: Scripts missing postinstall or cupsadmin"; exit 1; } || { STATUS="ERROR: Scripts should hold only postinstall and cupsadmin"; exit 1; }
codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \ codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \
|| { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; } || { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; }
echo "binary inside pkg: signature intact, $(lipo -archs "$EXPANDED/Scripts/cupsadmin")"
rm -rf "$EXPANDED" rm -rf "$EXPANDED"
FULL="$DIST/expanded-full"
rm -rf "$FULL"
pkgutil --expand-full "$PKG" "$FULL"
codesign --verify --deep --strict "$FULL/Payload/Applications/CUPS Admin.app" \
|| { STATUS="ERROR: app in the payload lost its signature"; exit 1; }
xcrun stapler validate "$FULL/Payload/Applications/CUPS Admin.app" >/dev/null \
|| { STATUS="ERROR: app in the payload lost its stapled ticket"; exit 1; }
echo "inside pkg: CUPS Admin.app signed and stapled (payload), cupsadmin signed (Scripts)"
rm -rf "$FULL"
# --- notarize ------------------------------------------------------------------- # --- notarize + staple pkg ------------------------------------------------------
step "notarize (this uploads the pkg to Apple and waits)" step "notarize pkg (uploads to Apple and waits)"
SUBMIT_JSON="$DIST/notary-submit.json" notarize "$PKG" pkg
xcrun notarytool submit "$PKG" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$SUBMIT_JSON" || true
NOTARY_STATUS=$(plutil -extract status raw "$SUBMIT_JSON" 2>/dev/null || echo "unknown")
SUBMISSION_ID=$(plutil -extract id raw "$SUBMIT_JSON" 2>/dev/null || echo "")
echo "submission $SUBMISSION_ID: $NOTARY_STATUS"
if [ -n "$SUBMISSION_ID" ]; then
xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-log.json" >/dev/null 2>&1 || true
fi
if [ "$NOTARY_STATUS" != "Accepted" ]; then
STATUS="ERROR: notarization $NOTARY_STATUS (see $SUBMIT_JSON and $DIST/notary-log.json)"
exit 1
fi
# --- staple + verify ------------------------------------------------------------ step "staple pkg"
step "staple"
xcrun stapler staple "$PKG" xcrun stapler staple "$PKG"
xcrun stapler validate "$PKG" xcrun stapler validate "$PKG"
step "spctl assessment" step "spctl assessment"
spctl -a -vv -t install "$PKG" 2>&1 | tee "$DIST/spctl.txt" spctl_check install "$PKG" "$DIST/spctl.txt"
grep -q ': accepted' "$DIST/spctl.txt" && grep -q 'source=Notarized Developer ID' "$DIST/spctl.txt" \ spctl_check execute "$APP" "$DIST/spctl-app.txt"
|| { STATUS="ERROR: spctl did not accept $PKG as Notarized Developer ID"; exit 1; }
echo echo
echo "install: sudo installer -pkg $PKG -target /" echo "install: sudo installer -pkg $PKG -target /"
STATUS="OK: $PKG signed, notarized ($SUBMISSION_ID), stapled, spctl accepted" STATUS="OK: $PKG (app payload + CLI postinstall) signed, app and pkg notarized, stapled, spctl accepted"

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -1,11 +1,12 @@
#!/bin/bash #!/bin/bash
# cupsadmin postinstall (payload-free pkg): the binary ships in the Scripts archive next to this file. # cupsadmin postinstall: installs the CLI. The binary ships in the Scripts archive next to this file
# Never changes ownership/permissions of an existing /usr/local/bin (Homebrew on Intel owns it). # (not in the payload), so the package never changes ownership/permissions of an existing
# /usr/local/bin (Homebrew on Intel owns it). CUPS Admin.app is a normal payload with a receipt.
set -euo pipefail set -euo pipefail
TARGET="${3:-/}" TARGET="${3:-/}"
HERE="$(cd "$(dirname "$0")" && pwd)"
DEST_DIR="${TARGET%/}/usr/local/bin" DEST_DIR="${TARGET%/}/usr/local/bin"
SOURCE="$(cd "$(dirname "$0")" && pwd)/cupsadmin"
if [ ! -d "$DEST_DIR" ]; then if [ ! -d "$DEST_DIR" ]; then
mkdir -p -m 755 "$DEST_DIR" mkdir -p -m 755 "$DEST_DIR"
@@ -13,5 +14,5 @@ if [ ! -d "$DEST_DIR" ]; then
fi fi
# -S: copy to a temp file and rename, so a running cupsadmin is never half-written. # -S: copy to a temp file and rename, so a running cupsadmin is never half-written.
install -S -m 755 -o root -g wheel "$SOURCE" "$DEST_DIR/cupsadmin" install -S -m 755 -o root -g wheel "$HERE/cupsadmin" "$DEST_DIR/cupsadmin"
echo "cupsadmin: installed $DEST_DIR/cupsadmin" echo "cupsadmin: installed $DEST_DIR/cupsadmin"