mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37a64af25a | ||
|
|
795ab7a981 | ||
|
|
7a98cb3bd3 | ||
|
|
a54ec0f4be | ||
|
|
4832b74c3a | ||
|
|
366bd74225 | ||
|
|
cf0241beac | ||
|
|
42c1381ebf | ||
|
|
d81de4a531 |
24
CLAUDE.md
24
CLAUDE.md
@@ -29,14 +29,21 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
|
|||||||
- **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD /
|
- **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD /
|
||||||
printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out
|
printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out
|
||||||
a per-user override.
|
a per-user override.
|
||||||
|
- **Driver filter check** (`DriverFilters.swift`): resolves each `*cupsFilter`/`*cupsFilter2` program
|
||||||
|
(bare names under `/usr/libexec/cups/filter`, skipping `maxsize(n)` and `-`) and reads its Mach-O header
|
||||||
|
directly — any arm64 CPU type counts as native, including subtypes `lipo` can't name. Intel-only →
|
||||||
|
"Driver needs Rosetta"; not found → "Driver filter missing" (shown first).
|
||||||
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
||||||
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
||||||
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
|
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
|
||||||
from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the
|
from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the
|
||||||
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
|
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
|
||||||
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
|
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
|
||||||
value; otherwise the action is unavailable with the reason. `cupsadmin ppdreport <queue>` shows a
|
value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives
|
||||||
driver's keywords and which actions its profile enables.
|
the reason an action can't be done with that driver (e.g. Canon department IDs); its `inputs` map relabels
|
||||||
|
the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). Use Letter Paper is
|
||||||
|
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
|
||||||
|
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
```
|
```
|
||||||
@@ -53,15 +60,18 @@ 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` installs the CLI to
|
- PKG layout: the app is a payload (`pkgbuild --root` with a staging `Applications/` that is really
|
||||||
`/usr/local/bin` and the notarized, stapled app to `/Applications`. A payload's "." entry is recorded as
|
root:admin 775 and `--ownership preserve`, `BundleIsRelocatable` false), so it gets a receipt and the
|
||||||
root:wheel for its install location, which would reset an existing `/usr/local/bin` (Homebrew) or
|
package records the system's own ownership. `--ownership recommended` would record Applications as
|
||||||
`/Applications` (root:admin 775). The app is notarized and stapled before packaging; the PKG after.
|
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
|
||||||
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
delete them afterwards; the live tests restore what they change. Offline tests use vendor PPDs from
|
||||||
`/Library/Printers/PPDs` and skip when they aren't installed.
|
`CUPSKIT_PPD_DIRS` (colon-separated, e.g. Canon/HP PPDs extracted with `pkgutil --expand-full`, not
|
||||||
|
installed) and `/Library/Printers/PPDs`, and skip when they aren't there.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).
|
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -1,19 +1,23 @@
|
|||||||
# CUPS Admin for macOS
|
# CUPS Admin for macOS
|
||||||
|
|
||||||

|

|
||||||
|
*Jobs: hold, release, move or cancel jobs on any queue.*
|
||||||
|
|
||||||
|

|
||||||
|
*Options: the queue's driver settings as a form, applied with `lpadmin` and read back. The header warns when the driver needs Rosetta.*
|
||||||
|
|
||||||
**CUPS Admin** is a native macOS app that puts back the CUPS web interface (`http://localhost:631`) Apple removed in macOS 27 — `cupsd`, `lpadmin` and IPP Everywhere queues all still work, only the browser admin page is gone. The sidebar lists every queue with its state and active jobs; select one to see its status, hold, release, move or cancel its jobs, and change its default options the way the old "Set Default Options" page did, including per-driver settings like Ricoh user codes. Common changes (color or black & white, duplex, Letter paper, a user code, the default printer) are one-click Quick Actions, and every change is read back from cupsd so nothing fails silently. It ships with `cupsadmin`, a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. No third-party dependencies; builds with the Xcode Command Line Tools alone.
|
**CUPS Admin** is a native macOS app that puts back the CUPS web interface (`http://localhost:631`) Apple removed in macOS 27 — `cupsd`, `lpadmin` and IPP Everywhere queues all still work, only the browser admin page is gone. The sidebar lists every queue with its state and active jobs; select one to see its status, hold, release, move or cancel its jobs, and change its default options the way the old "Set Default Options" page did, including per-driver settings like Ricoh user codes. Common changes (color or black & white, duplex, Letter paper, a user code, the default printer) are one-click Quick Actions, and every change is read back from cupsd so nothing fails silently. It ships with `cupsadmin`, a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. No third-party dependencies; builds with the Xcode Command Line Tools alone.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
> Status: the CLI is stable. The app (1.1) covers printers, jobs, queue options and quick actions; printer discovery, classes, the server page and the Attributes/Log tabs are still to come — see [Releases](../../releases).
|
> 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`
|
- `/Applications/CUPS Admin.app`
|
||||||
|
|
||||||
The package is payload-free and installs both via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe) or of `/Applications`. For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin` and `/Applications/CUPS Admin.app`; 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).
|
||||||
|
|
||||||
@@ -21,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
|
||||||
@@ -28,7 +33,7 @@ cupsadmin options <queue> # PPD options and IPP *-default values, like lp
|
|||||||
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
|
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
|
||||||
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
|
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
|
||||||
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
|
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
|
||||||
cupsadmin ppdreport <queue> # every PPD group and option: keyword, label, type, default, choices
|
cupsadmin ppdreport <queue | file.ppd[.gz]> # every PPD group and option: keyword, label, type, default, choices
|
||||||
```
|
```
|
||||||
|
|
||||||
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
|
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
|
||||||
@@ -43,9 +48,10 @@ The things admins actually do, without knowing the driver's option keywords:
|
|||||||
cupsadmin quick color <queue> # always print in color
|
cupsadmin quick color <queue> # always print in color
|
||||||
cupsadmin quick bw <queue> # always print black & white
|
cupsadmin quick bw <queue> # always print black & white
|
||||||
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
||||||
|
cupsadmin quick usercode <queue> jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID
|
||||||
cupsadmin quick duplex <queue> # default to two-sided
|
cupsadmin quick duplex <queue> # default to two-sided
|
||||||
cupsadmin quick simplex <queue>
|
cupsadmin quick simplex <queue>
|
||||||
cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no "prompt user" at the printer)
|
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
|
||||||
cupsadmin quick default <queue> # make this the server default printer
|
cupsadmin quick default <queue> # make this the server default printer
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,13 +66,17 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
|
|||||||
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
|
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
|
||||||
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
|
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
|
||||||
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
||||||
| Canon, HP, Xerox, Konica Minolta user/department codes | not yet — contribute a profile |
|
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
|
||||||
|
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
|
||||||
|
| Xerox (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) |
|
||||||
|
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
|
||||||
|
| Others | generic only — add a profile (below) |
|
||||||
|
|
||||||
### Adding your printer's driver
|
### Adding your printer's driver
|
||||||
|
|
||||||
1. `cupsadmin ppdreport <queue>` lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
||||||
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
||||||
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`.
|
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). Keep your profile above `generic`.
|
||||||
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
@@ -98,16 +108,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 the CLI and app with the hardened runtime, notarizes and staples the app, builds the payload-free PKG, notarizes and staples it, and verifies both with `spctl`. 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).
|
`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).
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ struct SidebarView: View {
|
|||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
Section("Printers") {
|
Section("Printers") {
|
||||||
ForEach(filtered) { printer in
|
ForEach(filtered) { printer in
|
||||||
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name)
|
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name,
|
||||||
|
driverWarning: store.driverFilters[printer.name]?.headline)
|
||||||
.tag(printer.name)
|
.tag(printer.name)
|
||||||
.contextMenu { menu(for: printer) }
|
.contextMenu { menu(for: printer) }
|
||||||
}
|
}
|
||||||
@@ -299,7 +300,7 @@ struct QuickActionItems: View {
|
|||||||
ForEach(QuickAction.all) { action in
|
ForEach(QuickAction.all) { action in
|
||||||
let reason = store.unavailableReason(action, queue: queue)
|
let reason = store.unavailableReason(action, queue: queue)
|
||||||
Button { choose(action) } label: {
|
Button { choose(action) } label: {
|
||||||
Label(action.title, systemImage: action.systemImage)
|
Label(action.title(in: store.drivers[queue]), systemImage: action.systemImage)
|
||||||
}
|
}
|
||||||
.disabled(reason != nil)
|
.disabled(reason != nil)
|
||||||
.help(reason ?? action.summary)
|
.help(reason ?? action.summary)
|
||||||
@@ -311,6 +312,8 @@ struct QuickActionItems: View {
|
|||||||
struct PrinterRow: View {
|
struct PrinterRow: View {
|
||||||
let printer: PrinterSummary
|
let printer: PrinterSummary
|
||||||
var isDefault = false
|
var isDefault = false
|
||||||
|
/// "Driver needs Rosetta" / "Driver filter missing", added to the tooltip.
|
||||||
|
var driverWarning: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -339,7 +342,7 @@ struct PrinterRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.badge(printer.jobCount)
|
.badge(printer.jobCount)
|
||||||
.help(printer.stateSummary)
|
.help(driverWarning.map { "\(printer.stateSummary) · \($0)" } ?? printer.stateSummary)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityValue(printer.stateSummary)
|
.accessibilityValue(printer.stateSummary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ enum JobFilter: String, CaseIterable, Identifiable {
|
|||||||
final class PrinterDetailModel {
|
final class PrinterDetailModel {
|
||||||
let queue: String
|
let queue: String
|
||||||
private(set) var printer: IPPGroup?
|
private(set) var printer: IPPGroup?
|
||||||
|
/// Intel-only or missing driver filters, from the queue's PPD.
|
||||||
|
private(set) var driverFilters: DriverFilterReport?
|
||||||
private(set) var jobs: [JobSummary] = []
|
private(set) var jobs: [JobSummary] = []
|
||||||
private(set) var loadError: String?
|
private(set) var loadError: String?
|
||||||
private(set) var isWorking = false
|
private(set) var isWorking = false
|
||||||
@@ -73,6 +75,11 @@ final class PrinterDetailModel {
|
|||||||
do {
|
do {
|
||||||
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
||||||
loadError = nil
|
loadError = nil
|
||||||
|
if let text = try await store.client.getPPD(queue: queue) {
|
||||||
|
driverFilters = await Task.detached { DriverFilterCheck.check(PPD(text: text)) }.value
|
||||||
|
} else {
|
||||||
|
driverFilters = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError = String(describing: error)
|
loadError = String(describing: error)
|
||||||
store.report("ERROR: \(queue): \(error)")
|
store.report("ERROR: \(queue): \(error)")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct PrinterDetailView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let printer = model.printer {
|
if let printer = model.printer {
|
||||||
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath)
|
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath, driverFilters: model.driverFilters)
|
||||||
Divider()
|
Divider()
|
||||||
} else if let error = model.loadError {
|
} else if let error = model.loadError {
|
||||||
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
||||||
@@ -35,6 +35,7 @@ struct PrinterDetailView: View {
|
|||||||
struct PrinterHeaderView: View {
|
struct PrinterHeaderView: View {
|
||||||
let printer: IPPGroup
|
let printer: IPPGroup
|
||||||
let ppdPath: String?
|
let ppdPath: String?
|
||||||
|
let driverFilters: DriverFilterReport?
|
||||||
|
|
||||||
private var state: Int? { printer.int("printer-state") }
|
private var state: Int? { printer.int("printer-state") }
|
||||||
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
||||||
@@ -64,6 +65,13 @@ struct PrinterHeaderView: View {
|
|||||||
}
|
}
|
||||||
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
||||||
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
||||||
|
if let headline = driverFilters?.headline {
|
||||||
|
LabeledContent("Driver") {
|
||||||
|
Label(headline, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.symbolRenderingMode(.multicolor)
|
||||||
|
}
|
||||||
|
.help(driverFilters?.detail ?? headline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Form {
|
Form {
|
||||||
@@ -93,7 +101,7 @@ struct PrinterHeaderView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(height: 262)
|
.frame(height: driverFilters?.isFlagged == true ? 300 : 262)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ final class PrinterStore {
|
|||||||
private(set) var defaultPrinter: String?
|
private(set) var defaultPrinter: String?
|
||||||
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
|
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
|
||||||
private(set) var drivers: [String: DriverContext] = [:]
|
private(set) var drivers: [String: DriverContext] = [:]
|
||||||
|
/// Filter check per queue (Intel-only or missing filters), from the cached PPDs.
|
||||||
|
private(set) var driverFilters: [String: DriverFilterReport] = [:]
|
||||||
var failure: Failure?
|
var failure: Failure?
|
||||||
|
|
||||||
let client = CupsClient()
|
let client = CupsClient()
|
||||||
@@ -201,6 +203,11 @@ final class PrinterStore {
|
|||||||
for printer in printers where drivers[printer.name] == nil {
|
for printer in printers where drivers[printer.name] == nil {
|
||||||
if let context = try? await DriverContext.load(client: client, queue: printer.name) {
|
if let context = try? await DriverContext.load(client: client, queue: printer.name) {
|
||||||
drivers[printer.name] = context
|
drivers[printer.name] = context
|
||||||
|
if let ppd = context.ppd {
|
||||||
|
driverFilters[printer.name] = await Task.detached { DriverFilterCheck.check(ppd) }.value
|
||||||
|
} else {
|
||||||
|
driverFilters[printer.name] = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable {
|
|||||||
let action: QuickAction
|
let action: QuickAction
|
||||||
let queue: String
|
let queue: String
|
||||||
var code = ""
|
var code = ""
|
||||||
|
/// Optional second value (Xerox account ID), when the driver profile has one.
|
||||||
|
var secondValue = ""
|
||||||
var showCurrentCode = false
|
var showCurrentCode = false
|
||||||
private(set) var phase: Phase = .loading
|
private(set) var phase: Phase = .loading
|
||||||
private(set) var state: OptionState?
|
private(set) var state: OptionState?
|
||||||
@@ -45,11 +47,20 @@ final class QuickActionModel: Identifiable {
|
|||||||
|
|
||||||
var currentCode: String? { action.currentCode(context) }
|
var currentCode: String? { action.currentCode(context) }
|
||||||
|
|
||||||
|
/// "Use Letter Paper" or "Use Letter Paper, Fit to Nearest Size", depending on the driver.
|
||||||
|
var title: String { action.title(in: state == nil ? nil : context).replacingOccurrences(of: "…", with: "") }
|
||||||
|
|
||||||
var validationError: String? { action.validationError(code, context: context) }
|
var validationError: String? { action.validationError(code, context: context) }
|
||||||
|
|
||||||
|
var inputLabel: String { action.inputLabel(context) }
|
||||||
|
var secondInputLabel: String? { action.secondInputLabel(context) }
|
||||||
|
var currentSecondValue: String? { action.currentSecondValue(context) }
|
||||||
|
var secondValidationError: String? { action.secondValidationError(secondValue, context: context) }
|
||||||
|
|
||||||
/// What will be written, or why the action can't run here.
|
/// What will be written, or why the action can't run here.
|
||||||
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||||
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
|
action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
|
||||||
|
clearing: clearing).map(\.changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
|
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
|
||||||
@@ -80,12 +91,14 @@ final class QuickActionModel: Identifiable {
|
|||||||
phase = .running
|
phase = .running
|
||||||
let started = Date()
|
let started = Date()
|
||||||
do {
|
do {
|
||||||
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
|
let typed = action.input == .userCode && !clearing
|
||||||
|
let outcome = try await action.run(queue: queue, value: typed ? code : nil,
|
||||||
|
secondValue: typed && !secondValue.isEmpty ? secondValue : nil,
|
||||||
clearing: clearing, client: store.client)
|
clearing: clearing, client: store.client)
|
||||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||||
let seconds = elapsedText(since: started)
|
let seconds = elapsedText(since: started)
|
||||||
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "")
|
let title = clearing ? "Cleared user code" : self.title
|
||||||
if outcome.succeeded {
|
if outcome.succeeded {
|
||||||
resultMessage = nil
|
resultMessage = nil
|
||||||
store.report("\(title) on \(queue) in \(seconds)")
|
store.report("\(title) on \(queue) in \(seconds)")
|
||||||
@@ -99,7 +112,7 @@ final class QuickActionModel: Identifiable {
|
|||||||
phase = .finished(succeeded: false)
|
phase = .finished(succeeded: false)
|
||||||
} catch {
|
} catch {
|
||||||
resultMessage = PrinterStore.explain(String(describing: error))
|
resultMessage = PrinterStore.explain(String(describing: error))
|
||||||
store.report("ERROR: \(action.title) on \(queue): \(error)")
|
store.report("ERROR: \(title) on \(queue): \(error)")
|
||||||
phase = .finished(succeeded: false)
|
phase = .finished(succeeded: false)
|
||||||
}
|
}
|
||||||
store.noteChange(queue: queue)
|
store.noteChange(queue: queue)
|
||||||
@@ -114,7 +127,7 @@ struct QuickActionSheet: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage)
|
Label(model.title, systemImage: model.action.systemImage)
|
||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -158,7 +171,7 @@ struct QuickActionSheet: View {
|
|||||||
private var canApply: Bool {
|
private var canApply: Bool {
|
||||||
guard model.phase == .ready else { return false }
|
guard model.phase == .ready else { return false }
|
||||||
if model.action.input == .userCode {
|
if model.action.input == .userCode {
|
||||||
return !model.code.isEmpty && model.validationError == nil
|
return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil
|
||||||
}
|
}
|
||||||
if case .failure = model.plannedChanges() { return false }
|
if case .failure = model.plannedChanges() { return false }
|
||||||
return true
|
return true
|
||||||
@@ -167,7 +180,7 @@ struct QuickActionSheet: View {
|
|||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var content: some View {
|
||||||
if model.action.input == .userCode {
|
if model.action.input == .userCode {
|
||||||
Form {
|
Form {
|
||||||
LabeledContent("Current code") {
|
LabeledContent("Current \(model.inputLabel.lowercased())") {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let current = model.currentCode {
|
if let current = model.currentCode {
|
||||||
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
||||||
@@ -181,22 +194,39 @@ struct QuickActionSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
TextField("User code", text: $model.code, prompt: Text("Digits"))
|
TextField(model.inputLabel, text: $model.code,
|
||||||
|
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.frame(width: 160)
|
.frame(width: 160)
|
||||||
.disabled(model.phase != .ready)
|
.disabled(model.phase != .ready)
|
||||||
} label: {
|
} label: {
|
||||||
Text("New code")
|
Text("New \(model.inputLabel.lowercased())")
|
||||||
if let error = model.validationError {
|
if let error = model.validationError {
|
||||||
Text(error).foregroundStyle(.red)
|
Text(error).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let secondLabel = model.secondInputLabel {
|
||||||
|
LabeledContent {
|
||||||
|
TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) })
|
||||||
|
.labelsHidden()
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 160)
|
||||||
|
.disabled(model.phase != .ready)
|
||||||
|
} label: {
|
||||||
|
Text(secondLabel)
|
||||||
|
if let error = model.secondValidationError {
|
||||||
|
Text(error).foregroundStyle(.red)
|
||||||
|
} else if model.currentSecondValue != nil {
|
||||||
|
Text("Current value shown in the field").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
.frame(height: 130)
|
.frame(height: model.secondInputLabel == nil ? 130 : 190)
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.action.isDefaultPrinter {
|
if model.action.isDefaultPrinter {
|
||||||
|
|||||||
157
Sources/CupsKit/DriverFilters.swift
Normal file
157
Sources/CupsKit/DriverFilters.swift
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One filter program a PPD names in `*cupsFilter` / `*cupsFilter2`, and whether it can run natively.
|
||||||
|
public struct DriverFilter: Hashable {
|
||||||
|
public enum Status: Hashable {
|
||||||
|
/// Has an arm64 slice (arm64, arm64e or newer subtypes).
|
||||||
|
case native(architectures: [String])
|
||||||
|
/// Intel only: needs Rosetta on Apple silicon.
|
||||||
|
case intelOnly(architectures: [String])
|
||||||
|
/// A script (`#!`); runs wherever its interpreter does.
|
||||||
|
case script(interpreter: String)
|
||||||
|
/// `-`: no program, cupsd passes the data through.
|
||||||
|
case passthrough
|
||||||
|
case missing
|
||||||
|
/// Exists but isn't a Mach-O binary or script this check understands.
|
||||||
|
case unrecognized
|
||||||
|
}
|
||||||
|
|
||||||
|
public let keyword: String
|
||||||
|
/// The line's value, e.g. `application/vnd.cups-postscript 0 /Library/Printers/…/pstopsRV2`.
|
||||||
|
public let line: String
|
||||||
|
public let program: String
|
||||||
|
/// Resolved absolute path; nil for `-`.
|
||||||
|
public let path: String?
|
||||||
|
public let status: Status
|
||||||
|
|
||||||
|
public var name: String { (program as NSString).lastPathComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DriverFilterReport: Hashable {
|
||||||
|
public let filters: [DriverFilter]
|
||||||
|
|
||||||
|
public var intelOnly: [DriverFilter] { filters.filter { if case .intelOnly = $0.status { return true } else { return false } } }
|
||||||
|
public var missing: [DriverFilter] { filters.filter { $0.status == .missing } }
|
||||||
|
public var needsRosetta: Bool { !intelOnly.isEmpty }
|
||||||
|
public var hasMissingFilter: Bool { !missing.isEmpty }
|
||||||
|
public var isFlagged: Bool { needsRosetta || hasMissingFilter }
|
||||||
|
|
||||||
|
/// "Driver filter missing" (worse, shown first) or "Driver needs Rosetta"; nil when fine.
|
||||||
|
public var headline: String? {
|
||||||
|
if hasMissingFilter { return "Driver filter missing" }
|
||||||
|
if needsRosetta { return "Driver needs Rosetta" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. "pstopsRV2, commandfilterRV1 are Intel-only (x86_64); /Library/…/foo not found".
|
||||||
|
public var detail: String? {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !missing.isEmpty {
|
||||||
|
parts.append(missing.compactMap(\.path).joined(separator: ", ") + " not found")
|
||||||
|
}
|
||||||
|
if !intelOnly.isEmpty {
|
||||||
|
let names = intelOnly.map(\.name)
|
||||||
|
let archs = Set(intelOnly.flatMap { if case .intelOnly(let a) = $0.status { return a } else { return [] } }).sorted()
|
||||||
|
parts.append("\(names.joined(separator: ", ")) \(names.count == 1 ? "is" : "are") Intel-only (\(archs.joined(separator: ", ")))")
|
||||||
|
}
|
||||||
|
return parts.isEmpty ? nil : parts.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for CLI output: "Driver needs Rosetta — pstopsRV2 is Intel-only (x86_64)".
|
||||||
|
public var warning: String? {
|
||||||
|
guard let headline, let detail else { return nil }
|
||||||
|
return "\(headline) — \(detail)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DriverFilterCheck {
|
||||||
|
/// Where cupsd looks for filters named without a path.
|
||||||
|
public static let filterDirectory = "/usr/libexec/cups/filter"
|
||||||
|
|
||||||
|
public static func check(_ ppd: PPD, filterDirectory: String = filterDirectory) -> DriverFilterReport {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var filters: [DriverFilter] = []
|
||||||
|
for (keyword, value) in ppd.filterLines {
|
||||||
|
// cupsFilter: "type cost program"; cupsFilter2: "source destination cost [maxsize(n)] program".
|
||||||
|
var fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||||
|
let skip = keyword == "cupsFilter2" ? 3 : 2
|
||||||
|
guard fields.count > skip else { continue }
|
||||||
|
fields.removeFirst(skip)
|
||||||
|
if let first = fields.first, first.hasPrefix("maxsize("), first.hasSuffix(")") { fields.removeFirst() }
|
||||||
|
guard !fields.isEmpty else { continue }
|
||||||
|
let program = fields.joined(separator: " ")
|
||||||
|
let path: String? = program == "-" ? nil
|
||||||
|
: program.hasPrefix("/") ? program : (filterDirectory as NSString).appendingPathComponent(program)
|
||||||
|
guard seen.insert(path ?? "-\(keyword)").inserted else { continue }
|
||||||
|
let status: DriverFilter.Status = path.map(Self.status(atPath:)) ?? .passthrough
|
||||||
|
filters.append(DriverFilter(keyword: keyword, line: value, program: program, path: path, status: status))
|
||||||
|
}
|
||||||
|
return DriverFilterReport(filters: filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the file's header: Mach-O (thin or universal) or `#!` script.
|
||||||
|
public static func status(atPath path: String) -> DriverFilter.Status {
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else { return .missing }
|
||||||
|
guard let handle = FileHandle(forReadingAtPath: path) else { return .unrecognized }
|
||||||
|
defer { try? handle.close() }
|
||||||
|
let header = [UInt8](handle.readData(ofLength: 4096))
|
||||||
|
return status(header: header)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(header b: [UInt8]) -> DriverFilter.Status {
|
||||||
|
guard b.count >= 8 else { return .unrecognized }
|
||||||
|
if b[0] == UInt8(ascii: "#"), b[1] == UInt8(ascii: "!") {
|
||||||
|
let line = b.prefix { $0 != UInt8(ascii: "\n") }.dropFirst(2)
|
||||||
|
return .script(interpreter: String(decoding: line, as: UTF8.self).trimmingCharacters(in: .whitespaces))
|
||||||
|
}
|
||||||
|
func be32(_ i: Int) -> UInt32 { UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]) }
|
||||||
|
func le32(_ i: Int) -> UInt32 { UInt32(b[i + 3]) << 24 | UInt32(b[i + 2]) << 16 | UInt32(b[i + 1]) << 8 | UInt32(b[i]) }
|
||||||
|
|
||||||
|
var cpuTypes: [(type: UInt32, subtype: UInt32)] = []
|
||||||
|
switch be32(0) {
|
||||||
|
case 0xCAFEBABE, 0xCAFEBABF: // universal (fat32 / fat64)
|
||||||
|
let count = Int(be32(4))
|
||||||
|
let entrySize = be32(0) == 0xCAFEBABF ? 32 : 20
|
||||||
|
guard count > 0, count < 32 else { return .unrecognized } // Java class files share the magic
|
||||||
|
for i in 0 ..< count {
|
||||||
|
let offset = 8 + i * entrySize
|
||||||
|
guard offset + 8 <= b.count else { break }
|
||||||
|
cpuTypes.append((be32(offset), be32(offset + 4) & 0x00FF_FFFF))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
switch le32(0) {
|
||||||
|
case 0xFEEDFACF, 0xFEEDFACE: // thin Mach-O, little-endian
|
||||||
|
cpuTypes.append((le32(4), le32(8) & 0x00FF_FFFF))
|
||||||
|
default:
|
||||||
|
return .unrecognized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let names = cpuTypes.map { architectureName(type: $0.type, subtype: $0.subtype) }
|
||||||
|
if cpuTypes.contains(where: { $0.type == 0x0100_000C }) { return .native(architectures: names) }
|
||||||
|
if cpuTypes.contains(where: { $0.type == 0x0100_0007 || $0.type == 7 }) { return .intelOnly(architectures: names) }
|
||||||
|
return .unrecognized
|
||||||
|
}
|
||||||
|
|
||||||
|
static func architectureName(type: UInt32, subtype: UInt32) -> String {
|
||||||
|
switch type {
|
||||||
|
case 0x0100_0007: return "x86_64"
|
||||||
|
case 7: return "i386"
|
||||||
|
case 0x0100_000C: return subtype == 2 ? "arm64e" : "arm64"
|
||||||
|
case 0x0200_000C: return "arm64_32"
|
||||||
|
case 12: return "arm"
|
||||||
|
default: return "cpu\(type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True on Apple silicon (also when this process itself runs under Rosetta).
|
||||||
|
public static var hostIsAppleSilicon: Bool {
|
||||||
|
var value: Int32 = 0
|
||||||
|
var size = MemoryLayout<Int32>.size
|
||||||
|
return sysctlbyname("hw.optional.arm64", &value, &size, nil, 0) == 0 && value == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var rosettaInstalled: Bool {
|
||||||
|
FileManager.default.fileExists(atPath: "/Library/Apple/usr/libexec/oah/libRosettaRuntime")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,8 +52,27 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
public let match: Match
|
public let match: Match
|
||||||
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
||||||
public let actions: [String: [[String]]]
|
public let actions: [String: [[String]]]
|
||||||
|
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
|
||||||
|
public let unavailable: [String: String]
|
||||||
|
/// Action id -> how its typed value is labeled and checked, plus an optional second value.
|
||||||
|
public let inputs: [String: Input]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case id, name, match, actions }
|
public struct Input: Decodable {
|
||||||
|
/// "User ID"; nil keeps the default "User code".
|
||||||
|
public let label: String?
|
||||||
|
/// Default true (Ricoh user codes). Xerox user IDs are strings.
|
||||||
|
public let digitsOnly: Bool?
|
||||||
|
public let second: SecondInput?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}`
|
||||||
|
/// and are added only when the user fills it in.
|
||||||
|
public struct SecondInput: Decodable {
|
||||||
|
public let label: String
|
||||||
|
public let pairs: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -65,6 +84,8 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
||||||
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
|
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
|
||||||
|
inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FlexiblePairs: Decodable {
|
private struct FlexiblePairs: Decodable {
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public struct PPD {
|
|||||||
public private(set) var groups: [String] = []
|
public private(set) var groups: [String] = []
|
||||||
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
|
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
|
||||||
public private(set) var attributes: [String: String] = [:]
|
public private(set) var attributes: [String: String] = [:]
|
||||||
|
/// Every `*cupsFilter` / `*cupsFilter2` line, in order, with the quotes removed.
|
||||||
|
public private(set) var filterLines: [(keyword: String, value: String)] = []
|
||||||
|
|
||||||
public init(text: String) {
|
public init(text: String) {
|
||||||
var defaults: [String: String] = [:]
|
var defaults: [String: String] = [:]
|
||||||
@@ -122,6 +124,9 @@ public struct PPD {
|
|||||||
let (choice, label) = Self.splitText(String(parts[1]))
|
let (choice, label) = Self.splitText(String(parts[1]))
|
||||||
current!.choices.append(choice)
|
current!.choices.append(choice)
|
||||||
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
|
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
|
||||||
|
} else if parts.count == 1, mainKey == "cupsFilter" || mainKey == "cupsFilter2" {
|
||||||
|
filterLines.append((mainKey, value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))))
|
||||||
|
if attributes[mainKey] == nil { attributes[mainKey] = filterLines.last!.value }
|
||||||
} else if parts.count == 1, attributes[mainKey] == nil {
|
} else if parts.count == 1, attributes[mainKey] == nil {
|
||||||
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
}
|
}
|
||||||
@@ -171,3 +176,25 @@ public struct PPD {
|
|||||||
options.first { $0.keyword == keyword }
|
options.first { $0.keyword == keyword }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PPD {
|
||||||
|
/// Reads a PPD file, plain or gzip-compressed (vendors ship `.ppd.gz` / `.gz` under
|
||||||
|
/// /Library/Printers/PPDs). Text that isn't valid UTF-8 is read as ISO Latin-1, the PPD default.
|
||||||
|
public static func text(contentsOfFile path: String) throws -> String {
|
||||||
|
guard var data = FileManager.default.contents(atPath: path) else {
|
||||||
|
throw CupsAdminError.failed("can’t read \(path)")
|
||||||
|
}
|
||||||
|
if data.starts(with: [0x1F, 0x8B]) {
|
||||||
|
let result = try CupsTools.run("/usr/bin/gzip", ["-dc", path])
|
||||||
|
guard result.succeeded else { throw CupsAdminError.failed("can’t decompress \(path): \(result.message)") }
|
||||||
|
data = result.standardOutput
|
||||||
|
}
|
||||||
|
return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a `ppdreport` argument names a PPD file rather than a queue. Queue names can't contain "/".
|
||||||
|
public static func looksLikeFile(_ argument: String) -> Bool {
|
||||||
|
argument.contains("/") || [".ppd", ".gz", ".ppd.gz"].contains { argument.lowercased().hasSuffix($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
||||||
summary: "Black & white by default"),
|
summary: "Black & white by default"),
|
||||||
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
||||||
summary: "User/department code sent with every job (--clear to remove)"),
|
summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"),
|
||||||
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
||||||
summary: "Two-sided, long edge"),
|
summary: "Two-sided, long edge"),
|
||||||
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
||||||
summary: "One-sided"),
|
summary: "One-sided"),
|
||||||
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
|
QuickAction(id: "letter", title: "Use Letter Paper", systemImage: "arrow.up.left.and.arrow.down.right",
|
||||||
input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
|
input: .none, summary: "Letter paper, and fit to the nearest size where the driver has that option (never prompt at the printer)"),
|
||||||
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
||||||
summary: "Server default printer (lpadmin -d)"),
|
summary: "Server default printer (lpadmin -d)"),
|
||||||
]
|
]
|
||||||
@@ -40,12 +40,25 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
all.first { $0.id == id }
|
all.first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The title for a queue: Use Letter Paper gains ", Fit to Nearest Size" only when its driver
|
||||||
|
/// profile also sets a fit-to-paper option (e.g. Ricoh RIPaperPolicy), not just the page size.
|
||||||
|
public func title(in context: DriverContext?) -> String {
|
||||||
|
guard id == "letter", let context, case .success(let resolved) = resolve(context),
|
||||||
|
resolved.changes.contains(where: { !Self.paperSizeKeys.contains($0.key) }) else { return title }
|
||||||
|
return title + ", Fit to Nearest Size"
|
||||||
|
}
|
||||||
|
|
||||||
|
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
|
||||||
|
|
||||||
/// The profile and option changes this action would write on a queue, or why it can't.
|
/// The profile and option changes this action would write on a queue, or why it can't.
|
||||||
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
public func resolve(_ context: DriverContext, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
|
||||||
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
||||||
let key = clearing ? id + "-clear" : id
|
let key = clearing ? id + "-clear" : id
|
||||||
var firstMissing: String?
|
var firstMissing: String?
|
||||||
for profile in DriverProfiles.matching(context, in: profiles) {
|
for profile in DriverProfiles.matching(context, in: profiles) {
|
||||||
|
if profile.actions[key] == nil, let reason = profile.unavailable[id] {
|
||||||
|
return .failure(QuickActionUnavailable(reason: reason))
|
||||||
|
}
|
||||||
guard let alternatives = profile.actions[key] else { continue }
|
guard let alternatives = profile.actions[key] else { continue }
|
||||||
for pairs in alternatives {
|
for pairs in alternatives {
|
||||||
let parsed = pairs.compactMap(Self.parse)
|
let parsed = pairs.compactMap(Self.parse)
|
||||||
@@ -54,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
||||||
|
if !clearing, let secondValue, !secondValue.isEmpty {
|
||||||
|
guard let second = profile.inputs[id]?.second else {
|
||||||
|
return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two"))
|
||||||
|
}
|
||||||
|
for pair in second.pairs.compactMap(Self.parse) {
|
||||||
|
let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue)
|
||||||
|
guard context.supports(key: pair.key, value: value) else {
|
||||||
|
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))"))
|
||||||
|
}
|
||||||
|
changes.append(OptionChange(key: pair.key, value: value, isSecret: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
|
|
||||||
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
||||||
|
|
||||||
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
|
/// The profile's input settings for this action on this queue (label, digits, second value).
|
||||||
|
public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? {
|
||||||
|
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||||
|
return resolved.profile.inputs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "User code" (Ricoh) or the profile's label ("User ID" for Xerox).
|
||||||
|
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" }
|
||||||
|
|
||||||
|
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
|
||||||
|
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
|
||||||
|
|
||||||
|
public func digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true }
|
||||||
|
|
||||||
|
/// For Set User Code: digits only unless the profile allows text, within the PPD's limit. nil when valid.
|
||||||
public func validationError(_ value: String, context: DriverContext) -> String? {
|
public func validationError(_ value: String, context: DriverContext) -> String? {
|
||||||
guard input == .userCode, !value.isEmpty else { return nil }
|
guard input == .userCode, !value.isEmpty else { return nil }
|
||||||
|
return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For the optional second value (text, within the PPD's limit). nil when valid.
|
||||||
|
public func secondValidationError(_ value: String, context: DriverContext) -> String? {
|
||||||
|
guard input == .userCode, !value.isEmpty else { return nil }
|
||||||
|
return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? {
|
||||||
|
if digitsOnly {
|
||||||
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
||||||
if let keyword = codeKeyword(context),
|
} else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) {
|
||||||
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
return "No quotes, backslashes or line breaks"
|
||||||
return "At most \(max) digits"
|
}
|
||||||
|
if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
||||||
|
return "At most \(max) \(digitsOnly ? "digits" : "characters")"
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -91,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
return String(value.dropFirst("Custom.".count))
|
return String(value.dropFirst("Custom.".count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The second value currently set on the queue (e.g. the Xerox account ID), without Custom.
|
||||||
|
public func currentSecondValue(_ context: DriverContext) -> String? {
|
||||||
|
guard let keyword = secondKeyword(context),
|
||||||
|
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
||||||
|
return String(value.dropFirst("Custom.".count))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func secondKeyword(_ context: DriverContext) -> String? {
|
||||||
|
inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key
|
||||||
|
}
|
||||||
|
|
||||||
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
||||||
private func codeKeyword(_ context: DriverContext) -> String? {
|
private func codeKeyword(_ context: DriverContext) -> String? {
|
||||||
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||||
@@ -126,14 +189,15 @@ public struct QuickActionOutcome {
|
|||||||
|
|
||||||
extension QuickAction {
|
extension QuickAction {
|
||||||
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
||||||
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
|
public func run(queue: String, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
|
||||||
|
client: CupsClient) async throws -> QuickActionOutcome {
|
||||||
if isDefaultPrinter {
|
if isDefaultPrinter {
|
||||||
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
||||||
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
||||||
readBack: [], changes: [], message: outcome.message)
|
readBack: [], changes: [], message: outcome.message)
|
||||||
}
|
}
|
||||||
let context = try await DriverContext.load(client: client, queue: queue)
|
let context = try await DriverContext.load(client: client, queue: queue)
|
||||||
let resolved = try resolve(context, value: value, clearing: clearing).get()
|
let resolved = try resolve(context, value: value, secondValue: secondValue, clearing: clearing).get()
|
||||||
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
||||||
let failed = readBack.filter { !$0.applied }
|
let failed = readBack.filter { !$0.applied }
|
||||||
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport <queue>",
|
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
"id": "ricoh-m-c251fw",
|
"id": "ricoh-m-c251fw",
|
||||||
@@ -27,6 +27,64 @@
|
|||||||
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "canon",
|
||||||
|
"name": "Canon UFR II / LIPSLX / CARPS2",
|
||||||
|
"match": { "manufacturer": "^Canon$" },
|
||||||
|
"actions": {
|
||||||
|
"color": ["CNColorMode=color"],
|
||||||
|
"bw": ["CNColorMode=mono"],
|
||||||
|
"duplex": ["CNDuplex=DuplexFront"],
|
||||||
|
"simplex": ["CNDuplex=None"],
|
||||||
|
"letter": ["PageSize=Letter"]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "Canon department IDs are set by the Canon driver, not the PPD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hp",
|
||||||
|
"name": "HP",
|
||||||
|
"match": { "manufacturer": "^HP$" },
|
||||||
|
"actions": {
|
||||||
|
"color": [["HPColorAsGray=False"], ["HPColorMode=colorsmart"]],
|
||||||
|
"bw": [["HPColorAsGray=True"], ["HPColorMode=grayscale"]]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "HP drivers have no accounting code option"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "xerox",
|
||||||
|
"name": "Xerox",
|
||||||
|
"match": { "manufacturer": "^Xerox$" },
|
||||||
|
"actions": {
|
||||||
|
"color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]],
|
||||||
|
"bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]],
|
||||||
|
"usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"],
|
||||||
|
"usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"usercode": {
|
||||||
|
"label": "User ID",
|
||||||
|
"digitsOnly": false,
|
||||||
|
"second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "konica-minolta",
|
||||||
|
"name": "Konica Minolta",
|
||||||
|
"match": { "manufacturer": "^KONICA MINOLTA$" },
|
||||||
|
"actions": {
|
||||||
|
"duplex": ["KMDuplex=Double"],
|
||||||
|
"simplex": ["KMDuplex=Single"],
|
||||||
|
"letter": ["PageSize=8.5x11"]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "generic",
|
"id": "generic",
|
||||||
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ enum PPDReportCommand {
|
|||||||
let source: String
|
let source: String
|
||||||
var attributes: IPPGroup?
|
var attributes: IPPGroup?
|
||||||
if let file {
|
if let file {
|
||||||
guard let data = FileManager.default.contents(atPath: file) else { throw CupsAdminError.failed("can’t read \(file)") }
|
text = try PPD.text(contentsOfFile: file)
|
||||||
text = String(decoding: data, as: UTF8.self)
|
|
||||||
source = file
|
source = file
|
||||||
} else if let queue {
|
} else if let queue {
|
||||||
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||||
@@ -37,6 +36,22 @@ enum PPDReportCommand {
|
|||||||
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
|
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
|
||||||
}
|
}
|
||||||
print("Quick: \(quick.joined(separator: ", "))")
|
print("Quick: \(quick.joined(separator: ", "))")
|
||||||
|
let filters = DriverFilterCheck.check(ppd)
|
||||||
|
if !filters.filters.isEmpty {
|
||||||
|
print("Filters: " + filters.filters.map { filter -> String in
|
||||||
|
switch filter.status {
|
||||||
|
case .native(let archs): return "\(filter.name) (\(archs.joined(separator: " ")))"
|
||||||
|
case .intelOnly(let archs): return "\(filter.name) (\(archs.joined(separator: " ")) only)"
|
||||||
|
case .script: return "\(filter.name) (script)"
|
||||||
|
case .passthrough: return "- (pass-through)"
|
||||||
|
case .missing: return "\(filter.name) (missing)"
|
||||||
|
case .unrecognized: return "\(filter.name) (unrecognized)"
|
||||||
|
}
|
||||||
|
}.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
if let warning = filters.warning {
|
||||||
|
print("WARNING: \(warning)")
|
||||||
|
}
|
||||||
|
|
||||||
for group in ppd.groups {
|
for group in ppd.groups {
|
||||||
print("\n\(group)")
|
print("\n\(group)")
|
||||||
|
|||||||
@@ -100,8 +100,51 @@ enum PrinterCommand {
|
|||||||
: label + ":" + String(repeating: " ", count: width - label.count)
|
: label + ":" + String(repeating: " ", count: width - label.count)
|
||||||
print("\(heading) \(value)")
|
print("\(heading) \(value)")
|
||||||
}
|
}
|
||||||
|
if let ppdText = try? await client.getPPD(queue: queue),
|
||||||
|
let warning = DriverFilterCheck.check(PPD(text: ppdText)).warning {
|
||||||
|
print("\nWARNING: \(warning)")
|
||||||
|
}
|
||||||
print("\n(use --all for every attribute)")
|
print("\n(use --all for every attribute)")
|
||||||
|
|
||||||
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
|
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `printers --rosetta`: queues whose PPD filters have no arm64 slice or don't exist.
|
||||||
|
enum RosettaCommand {
|
||||||
|
static func run(client: CupsClient) async throws -> String {
|
||||||
|
let printers = try await client.getPrinters(requested: ["printer-name"])
|
||||||
|
let names = printers.compactMap { $0.string("printer-name") }
|
||||||
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||||
|
|
||||||
|
var rows: [[String]] = []
|
||||||
|
var affected = 0
|
||||||
|
for name in names {
|
||||||
|
guard let text = try await client.getPPD(queue: name) else { continue }
|
||||||
|
let report = DriverFilterCheck.check(PPD(text: text))
|
||||||
|
guard report.isFlagged else { continue }
|
||||||
|
affected += 1
|
||||||
|
let flagged = report.missing + report.intelOnly
|
||||||
|
for (i, filter) in flagged.enumerated() {
|
||||||
|
let what: String
|
||||||
|
switch filter.status {
|
||||||
|
case .intelOnly(let archs): what = archs.joined(separator: " ")
|
||||||
|
case .missing: what = "missing"
|
||||||
|
default: what = ""
|
||||||
|
}
|
||||||
|
rows.append([i == 0 ? name : "", i == 0 ? (report.headline ?? "") : "", what, filter.path ?? filter.program])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = DriverFilterCheck.hostIsAppleSilicon
|
||||||
|
? "Apple silicon, Rosetta \(DriverFilterCheck.rosettaInstalled ? "installed" : "NOT installed")"
|
||||||
|
: "Intel Mac (Intel-only filters run natively here)"
|
||||||
|
if rows.isEmpty {
|
||||||
|
print("No queues need Rosetta or have missing driver filters. This Mac: \(host).")
|
||||||
|
return "OK: none of \(names.count) queues need Rosetta or have missing filters"
|
||||||
|
}
|
||||||
|
printTable(headers: ["QUEUE", "PROBLEM", "ARCH", "FILTER"], rows: rows)
|
||||||
|
print("\nThis Mac: \(host).")
|
||||||
|
return "OK: \(affected) of \(names.count) queues need Rosetta or have missing filters"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ enum QuickCommand {
|
|||||||
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
|
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
|
||||||
guard let name = args.first else {
|
guard let name = args.first else {
|
||||||
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
||||||
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
|
[$0.id, $0.input == .userCode ? "<code> [account] | --clear" : "", $0.summary]
|
||||||
})
|
})
|
||||||
print("\nDriver profiles (first match wins, generic last): "
|
print("\nDriver profiles (first match wins, generic last): "
|
||||||
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
|
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
|
||||||
@@ -18,6 +18,7 @@ enum QuickCommand {
|
|||||||
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
|
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
|
||||||
let queue = args[1]
|
let queue = args[1]
|
||||||
let value: String?
|
let value: String?
|
||||||
|
var secondValue: String?
|
||||||
switch action.input {
|
switch action.input {
|
||||||
case .none:
|
case .none:
|
||||||
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
|
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
|
||||||
@@ -27,8 +28,11 @@ enum QuickCommand {
|
|||||||
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
||||||
value = nil
|
value = nil
|
||||||
} else {
|
} else {
|
||||||
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
|
guard args.count == 3 || args.count == 4 else {
|
||||||
|
throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear")
|
||||||
|
}
|
||||||
value = args[2]
|
value = args[2]
|
||||||
|
secondValue = args.count == 4 ? args[3] : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||||
@@ -48,10 +52,13 @@ enum QuickCommand {
|
|||||||
let before = try await OptionState.load(client: client, queue: queue)
|
let before = try await OptionState.load(client: client, queue: queue)
|
||||||
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
||||||
if let value, let error = action.validationError(value, context: context) {
|
if let value, let error = action.validationError(value, context: context) {
|
||||||
throw CupsAdminError.usage("user code \(value): \(error)")
|
throw CupsAdminError.usage("\(action.inputLabel(context).lowercased()) \(value): \(error)")
|
||||||
|
}
|
||||||
|
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
|
||||||
|
throw CupsAdminError.usage("second value \(secondValue): \(error)")
|
||||||
}
|
}
|
||||||
let resolved: ResolvedQuickAction
|
let resolved: ResolvedQuickAction
|
||||||
switch action.resolve(context, value: value, clearing: clear) {
|
switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) {
|
||||||
case .success(let r): resolved = r
|
case .success(let r): resolved = r
|
||||||
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
|
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ let usage = """
|
|||||||
usage: cupsadmin <command> [args]
|
usage: cupsadmin <command> [args]
|
||||||
|
|
||||||
printers all queues: state, reasons, accepting, shared, device URI
|
printers all queues: state, reasons, accepting, shared, device URI
|
||||||
|
printers --rosetta only queues whose driver filters are Intel-only or missing
|
||||||
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
|
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
|
||||||
jobs [queue] [--completed | --all-jobs]
|
jobs [queue] [--completed | --all-jobs]
|
||||||
active jobs (all queues unless one is given)
|
active jobs (all queues unless one is given)
|
||||||
@@ -39,9 +40,9 @@ let usage = """
|
|||||||
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
|
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
|
||||||
change a queue via lpadmin, then show before/after
|
change a queue via lpadmin, then show before/after
|
||||||
quick list quick actions (task-named queue defaults)
|
quick list quick actions (task-named queue defaults)
|
||||||
quick <action> <queue> [code | --clear]
|
quick <action> <queue> [code [account] | --clear]
|
||||||
apply one via lpadmin, then verify
|
apply one via lpadmin, then verify
|
||||||
ppdreport <queue> | --file <path.ppd>
|
ppdreport <queue | path.ppd[.gz]>
|
||||||
every PPD group and option: keyword, label, type, default, choices
|
every PPD group and option: keyword, label, type, default, choices
|
||||||
help show this help
|
help show this help
|
||||||
"""
|
"""
|
||||||
@@ -80,9 +81,10 @@ func runCommand(_ args: [String]) async throws -> String {
|
|||||||
return "OK: help"
|
return "OK: help"
|
||||||
|
|
||||||
case "printers":
|
case "printers":
|
||||||
|
let rosetta = takeFlag("--rosetta")
|
||||||
try rejectUnknownFlags()
|
try rejectUnknownFlags()
|
||||||
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
|
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
|
||||||
return try await PrintersCommand.run(client: client)
|
return rosetta ? try await RosettaCommand.run(client: client) : try await PrintersCommand.run(client: client)
|
||||||
|
|
||||||
case "printer":
|
case "printer":
|
||||||
let dumpAll = takeFlag("--all")
|
let dumpAll = takeFlag("--all")
|
||||||
@@ -128,7 +130,12 @@ func runCommand(_ args: [String]) async throws -> String {
|
|||||||
}
|
}
|
||||||
try rejectUnknownFlags()
|
try rejectUnknownFlags()
|
||||||
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
|
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
|
||||||
throw CupsAdminError.usage("ppdreport needs one queue name or --file <path.ppd>")
|
throw CupsAdminError.usage("ppdreport needs one queue name or PPD file path")
|
||||||
|
}
|
||||||
|
// A path (anything with "/", or ending in .ppd / .gz) is a PPD file; queue names can't contain "/".
|
||||||
|
if file == nil, let argument = rest.first, PPD.looksLikeFile(argument) {
|
||||||
|
file = argument
|
||||||
|
rest.removeAll()
|
||||||
}
|
}
|
||||||
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)
|
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)
|
||||||
|
|
||||||
|
|||||||
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal file
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
@testable import CupsKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
private func le32(_ v: UInt32) -> [UInt8] { [UInt8(v & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v >> 16 & 0xFF), UInt8(v >> 24)] }
|
||||||
|
private func be32(_ v: UInt32) -> [UInt8] { [UInt8(v >> 24), UInt8(v >> 16 & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v & 0xFF)] }
|
||||||
|
private let x86_64: UInt32 = 0x0100_0007, arm64: UInt32 = 0x0100_000C
|
||||||
|
|
||||||
|
/// Thin 64-bit Mach-O header (little-endian).
|
||||||
|
private func thin(_ cpu: UInt32, subtype: UInt32 = 0) -> [UInt8] {
|
||||||
|
var bytes: [UInt8] = le32(0xFEEDFACF)
|
||||||
|
bytes += le32(cpu)
|
||||||
|
bytes += le32(subtype)
|
||||||
|
bytes += [UInt8](repeating: 0, count: 20)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Universal (fat32) header with the given slices.
|
||||||
|
private func fat(_ slices: [(UInt32, UInt32)]) -> [UInt8] {
|
||||||
|
var bytes: [UInt8] = be32(0xCAFEBABE)
|
||||||
|
bytes += be32(UInt32(slices.count))
|
||||||
|
for (cpu, subtype) in slices {
|
||||||
|
bytes += be32(cpu)
|
||||||
|
bytes += be32(subtype)
|
||||||
|
bytes += be32(0x4000) // offset
|
||||||
|
bytes += be32(0x1000) // size
|
||||||
|
bytes += be32(14) // align
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct MachOArchitectures {
|
||||||
|
@Test func thinAndUniversalBinaries() {
|
||||||
|
#expect(DriverFilterCheck.status(header: thin(arm64)) == .native(architectures: ["arm64"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: thin(x86_64)) == .intelOnly(architectures: ["x86_64"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 2)])) == .native(architectures: ["x86_64", "arm64e"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (7, 3)])) == .intelOnly(architectures: ["x86_64", "i386"]))
|
||||||
|
// A newer arm64 subtype lipo can't name (seen on macOS 27's rastertohp) still counts as arm64.
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 12)])) == .native(architectures: ["x86_64", "arm64"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func scriptsAndOtherFiles() {
|
||||||
|
#expect(DriverFilterCheck.status(header: Array("#!/bin/sh\nexec foo\n".utf8)) == .script(interpreter: "/bin/sh"))
|
||||||
|
#expect(DriverFilterCheck.status(header: Array("%PDF-1.4 not a filter".utf8)) == .unrecognized)
|
||||||
|
#expect(DriverFilterCheck.status(header: [0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 52]) == .unrecognized) // Java class file
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: FileManager.default.fileExists(atPath: "/usr/libexec/cups/filter/rastertohp")))
|
||||||
|
func appleFilterIsNative() {
|
||||||
|
guard case .native = DriverFilterCheck.status(atPath: "/usr/libexec/cups/filter/rastertohp") else {
|
||||||
|
Issue.record("rastertohp should have an arm64 slice"); return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct FilterLines {
|
||||||
|
@Test func resolvesAndClassifiesEveryFilter() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-filters-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try Data(thin(arm64)).write(to: dir.appendingPathComponent("nativefilter"))
|
||||||
|
let intel = dir.appendingPathComponent("Vendor Filters/pstopsvendor")
|
||||||
|
try FileManager.default.createDirectory(at: intel.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data(fat([(x86_64, 3)])).write(to: intel)
|
||||||
|
|
||||||
|
let ppd = PPD(text: """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*cupsFilter: "application/vnd.cups-raster 50 nativefilter"
|
||||||
|
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
|
||||||
|
*cupsFilter2: "application/vnd.cups-pdf application/pdf 0 maxsize(0) -"
|
||||||
|
*cupsFilter: "application/vnd.cups-command 0 /Library/Printers/Nobody/Filters/gone"
|
||||||
|
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
|
||||||
|
""")
|
||||||
|
#expect(ppd.filterLines.count == 5)
|
||||||
|
|
||||||
|
let report = DriverFilterCheck.check(ppd, filterDirectory: dir.path)
|
||||||
|
#expect(report.filters.count == 4) // duplicate pstopsvendor line counted once
|
||||||
|
#expect(report.filters[0].status == .native(architectures: ["arm64"]))
|
||||||
|
#expect(report.filters[0].path == dir.appendingPathComponent("nativefilter").path)
|
||||||
|
#expect(report.filters[1].status == .intelOnly(architectures: ["x86_64"]))
|
||||||
|
#expect(report.filters[2].status == .passthrough)
|
||||||
|
#expect(report.filters[3].status == .missing)
|
||||||
|
|
||||||
|
#expect(report.needsRosetta && report.hasMissingFilter)
|
||||||
|
#expect(report.headline == "Driver filter missing") // missing outranks Rosetta
|
||||||
|
#expect(report.detail == "/Library/Printers/Nobody/Filters/gone not found; pstopsvendor is Intel-only (x86_64)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rosettaOnlyAndClean() {
|
||||||
|
let passthrough = PPD(text: "*cupsFilter2: \"application/vnd.cups-pdf application/pdf 0 maxsize(0) -\"\n*cupsFilter: \"image/urf 10 -\"\n")
|
||||||
|
let clean = DriverFilterCheck.check(passthrough)
|
||||||
|
#expect(!clean.isFlagged && clean.headline == nil && clean.warning == nil)
|
||||||
|
|
||||||
|
let none = DriverFilterCheck.check(PPD(text: "*NickName: \"No filters\"\n"))
|
||||||
|
#expect(none.filters.isEmpty && !none.isFlagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
||||||
|
func ricohVendorPPDNamesItsFilters() throws {
|
||||||
|
let report = DriverFilterCheck.check(try #require(vendorPPD("RICOH IM C4500")))
|
||||||
|
#expect(report.filters.map(\.name) == ["commandfilterRV1", "pstopsRV2"] || report.filters.map(\.name) == ["pstopsRV2", "commandfilterRV1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,12 +18,17 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
@Suite struct DriverProfileFile {
|
@Suite struct DriverProfileFile {
|
||||||
@Test func builtInProfilesAreWellFormed() {
|
@Test func builtInProfilesAreWellFormed() {
|
||||||
let profiles = DriverProfiles.builtIn
|
let profiles = DriverProfiles.builtIn
|
||||||
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"])
|
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "xerox", "konica-minolta", "generic"])
|
||||||
#expect(profiles.last?.isGeneric == true)
|
#expect(profiles.last?.isGeneric == true)
|
||||||
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
||||||
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
||||||
for profile in profiles {
|
for profile in profiles {
|
||||||
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
||||||
|
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
|
||||||
|
#expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id))))
|
||||||
|
for input in profile.inputs.values {
|
||||||
|
#expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true)
|
||||||
|
}
|
||||||
for alternatives in profile.actions.values {
|
for alternatives in profile.actions.values {
|
||||||
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
||||||
}
|
}
|
||||||
@@ -45,10 +50,15 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
|
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
|
||||||
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
||||||
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper, Fit to Nearest Size")
|
||||||
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
|
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
|
||||||
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
||||||
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
||||||
#expect(clear.map(\.value) == ["False", "None"])
|
#expect(clear.map(\.value) == ["False", "None"])
|
||||||
|
// Ricoh takes one value: digits, no second input.
|
||||||
|
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User code")
|
||||||
|
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
|
||||||
|
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
|
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
|
||||||
@@ -76,6 +86,7 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
|
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
|
||||||
#expect(profileID("letter", c) == "generic")
|
#expect(profileID("letter", c) == "generic")
|
||||||
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
|
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
||||||
|
|||||||
243
Tests/CupsKitTests/VendorProfileTests.swift
Normal file
243
Tests/CupsKitTests/VendorProfileTests.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
@testable import CupsKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
/// Canon and HP profile tests read the vendors' gzipped PPDs, read-only. They look in CUPSKIT_PPD_DIRS
|
||||||
|
/// (colon-separated, e.g. PPDs extracted with `pkgutil --expand-full` from a driver package you haven't
|
||||||
|
/// installed) and then /Library/Printers/PPDs; each test is skipped when its PPDs aren't found.
|
||||||
|
enum ExtraPPDs {
|
||||||
|
static let directories: [String] =
|
||||||
|
(ProcessInfo.processInfo.environment["CUPSKIT_PPD_DIRS"] ?? "").split(separator: ":").map(String.init) + [vendorPPDs]
|
||||||
|
|
||||||
|
static func path(_ name: String) -> String? {
|
||||||
|
directories.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.fileExists(atPath: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(prefix: String) -> [String] {
|
||||||
|
var seen: Set<String> = []
|
||||||
|
return directories.flatMap { dir in
|
||||||
|
((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
|
||||||
|
.filter { $0.hasPrefix(prefix) && seen.insert($0).inserted }
|
||||||
|
.sorted()
|
||||||
|
.map { (dir as NSString).appendingPathComponent($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func context(path: String) -> DriverContext? {
|
||||||
|
(try? PPD.text(contentsOfFile: path)).map { DriverContext(ppd: PPD(text: $0), attributes: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func context(_ name: String) -> DriverContext? { path(name).flatMap(context(path:)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolved(_ id: String, _ c: DriverContext) -> ResolvedQuickAction? {
|
||||||
|
try? QuickAction.named(id)!.resolve(c, value: "0").get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pairs(_ id: String, _ c: DriverContext) -> [String]? {
|
||||||
|
resolved(id, c)?.changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String) -> Bool {
|
||||||
|
c.ppd?.option(keyword)?.choices.contains(choice) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct CanonProfile {
|
||||||
|
static let adv = "CNPZUIRAC3530IIIZU.ppd.gz"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(adv) != nil))
|
||||||
|
func imageRunnerAdvance() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.adv))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["canon", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["CNColorMode=color"])
|
||||||
|
#expect(pairs("bw", c) == ["CNColorMode=mono"])
|
||||||
|
#expect(pairs("duplex", c) == ["CNDuplex=DuplexFront"])
|
||||||
|
#expect(pairs("simplex", c) == ["CNDuplex=None"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
for id in ["color", "bw", "duplex", "simplex", "letter"] { #expect(resolved(id, c)?.profile.id == "canon", "\(id)") }
|
||||||
|
// No fit-to-paper option in Canon's PPDs: Letter sets the page size only, and the title says so.
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
|
||||||
|
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, clearing: true).get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Canon PPD in the package: an action is offered exactly when the PPD has its keyword and choice.
|
||||||
|
@Test(.enabled(if: !ExtraPPDs.files(prefix: "CNPZU").isEmpty))
|
||||||
|
func everyCanonPPD() throws {
|
||||||
|
let files = ExtraPPDs.files(prefix: "CNPZU")
|
||||||
|
var mono = 0
|
||||||
|
for file in files {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "canon", "\(name)")
|
||||||
|
#expect(QuickAction.named("color")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "color"), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "CNColorMode", "mono"), "\(name)")
|
||||||
|
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "DuplexFront"), "\(name)")
|
||||||
|
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "CNDuplex", "None"), "\(name)")
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"], "\(name)")
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Canon department IDs are set by the Canon driver, not the PPD")
|
||||||
|
if !hasChoice(c, "CNColorMode", "color") { mono += 1 }
|
||||||
|
}
|
||||||
|
#expect(files.count - mono > 0 && mono > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct HPProfile {
|
||||||
|
static let colorLaser = "HP Color LaserJet M750.gz"
|
||||||
|
static let officejet = "HP Officejet Pro 8600.ppd.gz"
|
||||||
|
static let monoLaser = "HP LaserJet 600 M601 M602 M603.gz"
|
||||||
|
static let reason = "HP drivers have no accounting code option"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(colorLaser) != nil))
|
||||||
|
func colorLaserJetEnterprise() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.colorLaser))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["hp", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["HPColorAsGray=False"])
|
||||||
|
#expect(pairs("bw", c) == ["HPColorAsGray=True"])
|
||||||
|
#expect(resolved("color", c)?.profile.id == "hp")
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("simplex", c) == ["Duplex=None"])
|
||||||
|
#expect(resolved("duplex", c)?.profile.id == "generic")
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(officejet) != nil))
|
||||||
|
func officejetProUsesHPColorMode() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.officejet))
|
||||||
|
#expect(pairs("color", c) == ["HPColorMode=colorsmart"])
|
||||||
|
#expect(pairs("bw", c) == ["HPColorMode=grayscale"])
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "hp")
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(monoLaser) != nil))
|
||||||
|
func monoLaserJet() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.monoLaser))
|
||||||
|
#expect(!QuickAction.named("color")!.isAvailable(in: c) && !QuickAction.named("bw")!.isAvailable(in: c))
|
||||||
|
#expect(pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every HP PPD: color and black & white come from the HP profile whenever the PPD has HPColorAsGray or HPColorMode.
|
||||||
|
@Test(.enabled(if: ExtraPPDs.files(prefix: "HP ").count > 100))
|
||||||
|
func everyHPPPD() throws {
|
||||||
|
var hpColor = 0
|
||||||
|
for file in ExtraPPDs.files(prefix: "HP ") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
guard c.manufacturer == "HP" else { continue }
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "hp", "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
|
||||||
|
if hasChoice(c, "HPColorAsGray", "True") || hasChoice(c, "HPColorMode", "grayscale") {
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "hp", "\(name)")
|
||||||
|
hpColor += 1
|
||||||
|
}
|
||||||
|
if hasChoice(c, "HPColorAsGray", "False") || hasChoice(c, "HPColorMode", "colorsmart") {
|
||||||
|
#expect(resolved("color", c)?.profile.id == "hp", "\(name)")
|
||||||
|
}
|
||||||
|
if hasChoice(c, "PageSize", "Letter") { #expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper", "\(name)") }
|
||||||
|
}
|
||||||
|
#expect(hpColor > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct XeroxProfile {
|
||||||
|
static let altaLink = "Xerox AltaLink C8170.gz"
|
||||||
|
static let monoVersaLink = "Xerox VersaLink B620 Printer.gz"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(altaLink) != nil))
|
||||||
|
func altaLinkColorAndStandardAccounting() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.altaLink))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["XROutputColor=PrintAsColor"])
|
||||||
|
#expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"])
|
||||||
|
#expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
|
||||||
|
let usercode = QuickAction.named("usercode")!
|
||||||
|
#expect(usercode.inputLabel(c) == "User ID")
|
||||||
|
#expect(usercode.secondInputLabel(c) == "Account ID (optional)")
|
||||||
|
#expect(!usercode.digitsOnly(c))
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"])
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2)
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"])
|
||||||
|
#expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"])
|
||||||
|
|
||||||
|
#expect(usercode.validationError("jdoe", context: c) == nil)
|
||||||
|
#expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters")
|
||||||
|
#expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks")
|
||||||
|
#expect(usercode.secondValidationError("ART101", context: c) == nil)
|
||||||
|
#expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil))
|
||||||
|
func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.monoVersaLink))
|
||||||
|
#expect(pairs("bw", c) == ["XRColorCorrection=Gray"])
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "xerox")
|
||||||
|
#expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice
|
||||||
|
#expect(QuickAction.named("usercode")!.isAvailable(in: c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs.
|
||||||
|
@Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100))
|
||||||
|
func everyXeroxPPD() throws {
|
||||||
|
var accounting = 0
|
||||||
|
for file in ExtraPPDs.files(prefix: "Xerox ") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)")
|
||||||
|
#expect(QuickAction.named("color")!.isAvailable(in: c)
|
||||||
|
== (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c)
|
||||||
|
== (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)")
|
||||||
|
let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false
|
||||||
|
#expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)")
|
||||||
|
if xsa { accounting += 1 }
|
||||||
|
}
|
||||||
|
#expect(accounting > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct KonicaMinoltaProfile {
|
||||||
|
static let bizhub = "KONICAMINOLTAC651i.gz"
|
||||||
|
static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(bizhub) != nil))
|
||||||
|
func bizhubC651i() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.bizhub))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic")
|
||||||
|
#expect(pairs("bw", c) == ["ColorModel=Gray"])
|
||||||
|
#expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta")
|
||||||
|
#expect(pairs("simplex", c) == ["KMDuplex=Single"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=8.5x11"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants).
|
||||||
|
@Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty))
|
||||||
|
func everyKonicaMinoltaPPD() throws {
|
||||||
|
for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)")
|
||||||
|
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)")
|
||||||
|
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)")
|
||||||
|
#expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
build.sh
182
build.sh
@@ -1,10 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Universal release build -> signed CLI + signed, notarized, stapled "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 and /Applications/CUPS Admin.app) -> 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
|
# ./build.sh --screenshots build like --build-only, then regenerate docs/screenshots/app-jobs.png and
|
||||||
# (creates three demo queues on 127.0.0.1 with held jobs, deletes them after)
|
# 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):
|
||||||
@@ -43,8 +46,10 @@ step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; }
|
|||||||
|
|
||||||
# --- screenshots (--screenshots) ------------------------------------------------
|
# --- screenshots (--screenshots) ------------------------------------------------
|
||||||
# Demo queues with generic names so the README never shows real queue names or users.
|
# Demo queues with generic names so the README never shows real queue names or users.
|
||||||
DEMO_QUEUES="Front_Office Library_Color Lab_Mono"
|
DEMO_QUEUES="Copy_Room Front_Office Library_Color Lab_Mono"
|
||||||
SHOT="docs/screenshots/app-jobs.png"
|
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"
|
APP_DOMAIN="edu.wku.cupsadmin.app"
|
||||||
SAVED_PREFS=""
|
SAVED_PREFS=""
|
||||||
|
|
||||||
@@ -60,21 +65,58 @@ cleanup_screenshots() {
|
|||||||
fi
|
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() {
|
take_screenshots() {
|
||||||
step "screenshots"
|
step "screenshots"
|
||||||
[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" != "Dark" ] \
|
[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" != "Dark" ] \
|
||||||
|| { STATUS="ERROR: switch macOS to Light appearance first (the README screenshot is light mode)"; exit 1; }
|
|| { 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
|
cleanup_screenshots
|
||||||
|
|
||||||
# Save the user's app settings (window frame, selection, tab); restored in cleanup.
|
# Save the user's app settings (window frame, selection, tab); restored in cleanup.
|
||||||
SAVED_PREFS=$(mktemp -t cupsadmin-prefs).plist
|
SAVED_PREFS=$(mktemp -t cupsadmin-prefs).plist
|
||||||
defaults export "$APP_DOMAIN" "$SAVED_PREFS" 2>/dev/null || defaults export "$APP_DOMAIN" - >/dev/null 2>&1 || true
|
defaults export "$APP_DOMAIN" "$SAVED_PREFS" 2>/dev/null || true
|
||||||
[ -s "$SAVED_PREFS" ] || printf '{}' | plutil -convert xml1 -o "$SAVED_PREFS" -
|
[ -s "$SAVED_PREFS" ] || printf '{}' | plutil -convert xml1 -o "$SAVED_PREFS" -
|
||||||
|
|
||||||
for queue in $DEMO_QUEUES; do
|
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 \
|
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
|
-D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true
|
||||||
done
|
done
|
||||||
|
# A Ricoh queue for the Options tab, set to Letter + fit to nearest size (what a US site uses).
|
||||||
|
# Ricoh's filters are Intel-only, so its header also shows "Driver needs Rosetta".
|
||||||
|
lpadmin -p Copy_Room -v lpd://127.0.0.1/Copy_Room -P "$RICOH_DEMO_PPD" -D "Copy Room" -L "Building A" \
|
||||||
|
-o printer-is-shared=false -o PageSize=Letter -o RIPaperPolicy=NearestSizeAdjust -E 2>&1 | grep -v deprecated || true
|
||||||
# Held jobs never leave the Mac; owners are generic names, not the person running the build.
|
# Held jobs never leave the Mac; owners are generic names, not the person running the build.
|
||||||
lp -d Front_Office -U alex -H hold -t "Quarterly budget.pdf" /etc/hosts >/dev/null
|
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 jordan -H hold -t "Staff meeting agenda" /etc/hosts >/dev/null
|
||||||
@@ -82,38 +124,19 @@ take_screenshots() {
|
|||||||
lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null
|
lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null
|
||||||
lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null
|
lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null
|
||||||
|
|
||||||
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ')
|
# The Options shot shows the header's "Driver needs Rosetta" row: Ricoh's pstopsRV2 filter is Intel-only.
|
||||||
defaults write "$APP_DOMAIN" selectedQueue Front_Office
|
# grep without -q: under pipefail, -q exiting early would kill ppdreport and fail the pipeline.
|
||||||
defaults write "$APP_DOMAIN" detailTab jobs
|
"$BINARY" ppdreport Copy_Room 2>/dev/null | grep '^WARNING: *Driver needs Rosetta' >/dev/null \
|
||||||
defaults write "$APP_DOMAIN" "NSWindow Frame main" "120 120 1400 860 $SCREEN "
|
|| { STATUS="ERROR: Copy_Room isn't flagged \"Driver needs Rosetta\" on this Mac, so the Options screenshot wouldn't show the warning"; exit 1; }
|
||||||
open "$APP" --args --only-queues "${DEMO_QUEUES// /,}"
|
|
||||||
|
|
||||||
FINDER=$(mktemp -t cupsadmin-window).swift
|
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ')
|
||||||
cat > "$FINDER" <<'SWIFT'
|
capture_app Front_Office jobs "$SHOT_JOBS"
|
||||||
import CoreGraphics
|
capture_app Copy_Room options "$SHOT_OPTIONS"
|
||||||
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
|
|
||||||
WINDOW=""
|
|
||||||
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 jobs 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 "$SHOT")"
|
|
||||||
screencapture -x -o -l "$WINDOW" "$SHOT" \
|
|
||||||
|| { STATUS="ERROR: screencapture failed (Terminal needs Screen Recording permission)"; exit 1; }
|
|
||||||
SHOT_SIZE="$(sips -g pixelWidth -g pixelHeight "$SHOT" | awk '/pixel/ {print $2}' | paste -sd x -)"
|
|
||||||
echo "captured $SHOT ($SHOT_SIZE)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- configuration -------------------------------------------------------------
|
# --- configuration -------------------------------------------------------------
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
VERSION="${VERSION:-1.1.0}"
|
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.
|
||||||
@@ -259,7 +282,7 @@ echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")"
|
|||||||
|
|
||||||
if [ $SCREENSHOTS -eq 1 ]; then
|
if [ $SCREENSHOTS -eq 1 ]; then
|
||||||
take_screenshots
|
take_screenshots
|
||||||
STATUS="OK: regenerated $SHOT (${SHOT_SIZE})"
|
STATUS="OK: regenerated $SHOT_JOBS and $SHOT_OPTIONS"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -290,7 +313,7 @@ spctl_check() {
|
|||||||
local type="$1" path="$2" log="$3" out
|
local type="$1" path="$2" log="$3" out
|
||||||
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
|
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
|
||||||
echo "$out" | tee "$log"
|
echo "$out" | tee "$log"
|
||||||
echo "$out" | grep -q ': accepted' && echo "$out" | grep -q 'source=Notarized Developer ID' \
|
echo "$out" | grep ': accepted' >/dev/null && echo "$out" | grep 'source=Notarized Developer ID' >/dev/null \
|
||||||
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
|
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,42 +329,83 @@ xcrun stapler validate "$APP"
|
|||||||
spctl_check execute "$APP" "$DIST/spctl-app.txt"
|
spctl_check execute "$APP" "$DIST/spctl-app.txt"
|
||||||
|
|
||||||
# --- build + sign pkg -----------------------------------------------------------
|
# --- build + sign pkg -----------------------------------------------------------
|
||||||
# Payload-free for the app too: an /Applications payload's "." entry is recorded as root:wheel, but
|
# App: a normal payload with a receipt. The staging root mirrors the system — "/" root:wheel 755,
|
||||||
# /Applications is root:admin 775 — installing it could take away admins' drag-install rights.
|
# Applications root:admin 775, the app root:wheel — and pkgbuild --ownership preserve records exactly
|
||||||
# pkg/postinstall copies both the CLI and the stapled app instead.
|
# 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"
|
||||||
rm -rf "$SCRIPTS/CUPS Admin.app"
|
|
||||||
ditto "$APP" "$SCRIPTS/CUPS Admin.app"
|
PKGROOT="$PWD/$DIST/pkgroot-$$"
|
||||||
pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
|
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 . >/dev/null; 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"
|
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" ] && [ -d "$EXPANDED/Scripts/CUPS Admin.app" ] \
|
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] && [ ! -e "$EXPANDED/Scripts/CUPS Admin.app" ] \
|
||||||
|| { STATUS="ERROR: Scripts missing postinstall, cupsadmin or CUPS Admin.app"; 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; }
|
||||||
codesign --verify --deep --strict "$EXPANDED/Scripts/CUPS Admin.app" \
|
|
||||||
|| { STATUS="ERROR: app inside pkg Scripts lost its signature"; exit 1; }
|
|
||||||
xcrun stapler validate "$EXPANDED/Scripts/CUPS Admin.app" >/dev/null \
|
|
||||||
|| { STATUS="ERROR: app inside pkg Scripts lost its stapled ticket"; exit 1; }
|
|
||||||
echo "inside pkg: cupsadmin signed ($(lipo -archs "$EXPANDED/Scripts/cupsadmin")), CUPS Admin.app signed and stapled"
|
|
||||||
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 + staple pkg ------------------------------------------------------
|
# --- notarize + staple pkg ------------------------------------------------------
|
||||||
step "notarize pkg (uploads to Apple and waits)"
|
step "notarize pkg (uploads to Apple and waits)"
|
||||||
@@ -357,4 +421,4 @@ spctl_check execute "$APP" "$DIST/spctl-app.txt"
|
|||||||
|
|
||||||
echo
|
echo
|
||||||
echo "install: sudo installer -pkg $PKG -target /"
|
echo "install: sudo installer -pkg $PKG -target /"
|
||||||
STATUS="OK: $PKG (CLI + CUPS Admin.app) signed, app and pkg notarized, stapled, spctl accepted"
|
STATUS="OK: $PKG (app payload + CLI postinstall) signed, app and pkg notarized, stapled, spctl accepted"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 147 KiB |
BIN
docs/screenshots/app-options.png
Normal file
BIN
docs/screenshots/app-options.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
@@ -1,32 +1,18 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# cupsadmin postinstall (payload-free pkg): the CLI binary and CUPS Admin.app ship in the Scripts
|
# cupsadmin postinstall: installs the CLI. The binary ships in the Scripts archive next to this file
|
||||||
# archive next to this file. Never changes ownership/permissions of an existing /usr/local/bin
|
# (not in the payload), so the package never changes ownership/permissions of an existing
|
||||||
# (Homebrew on Intel owns it) or of /Applications (root:admin 775).
|
# /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)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# --- CLI -------------------------------------------------------------------------
|
|
||||||
DEST_DIR="${TARGET%/}/usr/local/bin"
|
DEST_DIR="${TARGET%/}/usr/local/bin"
|
||||||
|
|
||||||
if [ ! -d "$DEST_DIR" ]; then
|
if [ ! -d "$DEST_DIR" ]; then
|
||||||
mkdir -p -m 755 "$DEST_DIR"
|
mkdir -p -m 755 "$DEST_DIR"
|
||||||
echo "cupsadmin: created $DEST_DIR"
|
echo "cupsadmin: created $DEST_DIR"
|
||||||
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 "$HERE/cupsadmin" "$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"
|
||||||
|
|
||||||
# --- app -------------------------------------------------------------------------
|
|
||||||
if [ -d "$HERE/CUPS Admin.app" ]; then
|
|
||||||
APPS="${TARGET%/}/Applications"
|
|
||||||
STAGE="$APPS/.CUPS Admin.app.installing"
|
|
||||||
rm -rf "$STAGE"
|
|
||||||
ditto "$HERE/CUPS Admin.app" "$STAGE"
|
|
||||||
chown -R root:wheel "$STAGE"
|
|
||||||
chmod -R go-w "$STAGE"
|
|
||||||
# Swap in place so a half-copied bundle is never at the real path.
|
|
||||||
rm -rf "$APPS/CUPS Admin.app"
|
|
||||||
mv "$STAGE" "$APPS/CUPS Admin.app"
|
|
||||||
echo "cupsadmin: installed $APPS/CUPS Admin.app"
|
|
||||||
fi
|
|
||||||
|
|||||||
Reference in New Issue
Block a user