mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0d48e49e8 | ||
|
|
8d39bafb6f | ||
|
|
3af6a4e88f |
15
CLAUDE.md
15
CLAUDE.md
@@ -31,8 +31,12 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
|
|||||||
a per-user override.
|
a per-user override.
|
||||||
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
||||||
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
||||||
- **Quick actions** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input
|
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
|
||||||
type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason.
|
from `Resources/driver-profiles.json` (embedded in code — the CLI is a single binary), matched on the
|
||||||
|
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
|
||||||
|
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
|
||||||
|
value; otherwise the action is unavailable with the reason. `cupsadmin ppdreport <queue>` shows a
|
||||||
|
driver's keywords and which actions its profile enables.
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
```
|
```
|
||||||
@@ -40,6 +44,7 @@ swift build # debug build of everything
|
|||||||
.build/debug/cupsadmin printers # run the CLI
|
.build/debug/cupsadmin printers # run the CLI
|
||||||
./test.sh # Swift Testing via the Command Line Tools (see flags inside)
|
./test.sh # Swift Testing via the Command Line Tools (see flags inside)
|
||||||
./build.sh --build-only # universal CLI + app bundle, ad-hoc signed, no identities needed
|
./build.sh --build-only # universal CLI + app bundle, ad-hoc signed, no identities needed
|
||||||
|
./build.sh --screenshots # regenerate docs/screenshots/app-jobs.png from temporary demo queues
|
||||||
./build.sh # release: sign, payload-free PKG, notarize, staple, spctl
|
./build.sh # release: sign, payload-free PKG, notarize, staple, spctl
|
||||||
```
|
```
|
||||||
- Only the Command Line Tools are required. Universal builds are per-triple
|
- Only the Command Line Tools are required. Universal builds are per-triple
|
||||||
@@ -48,8 +53,10 @@ swift build # debug build of everything
|
|||||||
- Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum.
|
- Package: tools-version 6.0, Swift 5 language mode, macOS 14 minimum.
|
||||||
- `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional
|
- `build.sh` reads `CODESIGN_APP_IDENTITY`, `CODESIGN_PKG_IDENTITY`, `NOTARY_PROFILE` (and optional
|
||||||
`CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities.
|
`CODESIGN_TEAM_ID`) from the environment or an untracked `build.env`. Never hard-code identities.
|
||||||
- The PKG is payload-free (`pkgbuild --nopayload --scripts`; `pkg/postinstall` copies the binary), because
|
- The PKG is payload-free (`pkgbuild --nopayload --scripts`): `pkg/postinstall` installs the CLI to
|
||||||
any payload's "." entry would reset an existing `/usr/local/bin`'s ownership.
|
`/usr/local/bin` and the notarized, stapled app to `/Applications`. A payload's "." entry is recorded as
|
||||||
|
root:wheel for its install location, which would reset an existing `/usr/local/bin` (Homebrew) or
|
||||||
|
`/Applications` (root:admin 775). 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
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ let package = Package(
|
|||||||
.library(name: "CupsKit", targets: ["CupsKit"]),
|
.library(name: "CupsKit", targets: ["CupsKit"]),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "CupsKit", path: "Sources/CupsKit"),
|
// driver-profiles.json is compiled into the code: the CLI ships as a single binary with no resource bundle.
|
||||||
|
.target(name: "CupsKit", path: "Sources/CupsKit", resources: [.embedInCode("Resources/driver-profiles.json")]),
|
||||||
.executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"),
|
.executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"),
|
||||||
.executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"),
|
.executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"),
|
||||||
.testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"),
|
.testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"),
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -1,24 +1,19 @@
|
|||||||
# CUPS Admin for macOS
|
# CUPS Admin for macOS
|
||||||
|
|
||||||
A native replacement for the CUPS web interface (`http://localhost:631`) that Apple removed in macOS 27.
|

|
||||||
|
|
||||||
`cupsd`, `lpadmin`, `lpstat`, `lpoptions` and IPP Everywhere queues all still work on macOS 27 — only the browser admin page is gone. This project puts it back, twice:
|
**CUPS Admin** 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.
|
||||||
|
|
||||||
- **`cupsadmin`** — a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks.
|
|
||||||
- **CUPS Admin.app** — a native SwiftUI app that covers everything the old web pages did: printers, jobs, queue options (including per-driver options like Ricoh user codes), classes and server settings.
|
|
||||||
|
|
||||||
No third-party dependencies. Builds with the Xcode Command Line Tools alone.
|
|
||||||
|
|
||||||
> Status: the CLI is stable (1.0.x). The app is under active development — see [Releases](../../releases) for what's shipped.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
> Status: the CLI 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).
|
||||||
|
|
||||||
Download the signed, notarized PKG from the latest [release](../../releases). It installs:
|
Download the signed, notarized PKG from the latest [release](../../releases). It installs:
|
||||||
|
|
||||||
- `/usr/local/bin/cupsadmin`
|
- `/usr/local/bin/cupsadmin`
|
||||||
- `/Applications/CUPS Admin.app` (once the app ships)
|
- `/Applications/CUPS Admin.app`
|
||||||
|
|
||||||
The package is payload-free and installs via a postinstall script, so it never changes the ownership of an existing `/usr/local/bin` (Homebrew on Intel Macs is safe). For Munki, use an `installs` array pointing at `/usr/local/bin/cupsadmin`; there is no payload file list to key off.
|
The 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.
|
||||||
|
|
||||||
Requires macOS 14 or later. Universal (Apple silicon and Intel).
|
Requires macOS 14 or later. Universal (Apple silicon and Intel).
|
||||||
|
|
||||||
@@ -33,6 +28,7 @@ cupsadmin options <queue> # PPD options and IPP *-default values, like lp
|
|||||||
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
|
cupsadmin add <queue> -v <uri> [-m everywhere | -P file.ppd] [-D desc] [-L location] [--shared] [-o k=v ...]
|
||||||
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
|
cupsadmin set <queue> -o k=v ... # change queue defaults, then read them back and report
|
||||||
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
|
cupsadmin quick <action> <queue> [value] # task-named shortcuts, see below
|
||||||
|
cupsadmin ppdreport <queue> # every PPD group and option: keyword, label, type, default, choices
|
||||||
```
|
```
|
||||||
|
|
||||||
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
|
Every run prints `started HH:MM:SS`, one status line (`OK: …` or `ERROR: …`) and `finished HH:MM:SS (total N min)` — on every exit path. Status lines go to stderr so table output pipes cleanly.
|
||||||
@@ -53,7 +49,25 @@ cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no
|
|||||||
cupsadmin quick default <queue> # make this the server default printer
|
cupsadmin quick default <queue> # make this the server default printer
|
||||||
```
|
```
|
||||||
|
|
||||||
Actions whose option isn't in the queue's PPD are refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal.
|
Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Resources/driver-profiles.json`), matched on the PPD's manufacturer and model. If no profile maps an action for a queue's driver, the action is refused with a message saying why. Because these are ordinary `lpadmin -p <queue> -o …` writes, they work in an MDM custom command exactly as they do in Terminal.
|
||||||
|
|
||||||
|
### Supported drivers
|
||||||
|
|
||||||
|
| Driver | Quick actions |
|
||||||
|
|---|---|
|
||||||
|
| Ricoh PostScript (IM C2000/C4500, MP C2004ex/C3004ex/C307/C3504) | ✓ all |
|
||||||
|
| Ricoh MP 5055 PS (mono) | ✓ all except color / black & white |
|
||||||
|
| Ricoh M C251FW PS | ✓ color, black & white, duplex, Letter (no user code) |
|
||||||
|
| Ricoh PCL (e.g. SP 3710DN) | ✓ duplex, Letter (generic) |
|
||||||
|
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
||||||
|
| Canon, HP, Xerox, Konica Minolta user/department codes | not yet — contribute a profile |
|
||||||
|
|
||||||
|
### Adding your printer's driver
|
||||||
|
|
||||||
|
1. `cupsadmin ppdreport <queue>` lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
||||||
|
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
||||||
|
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do. Keep your profile above `generic`.
|
||||||
|
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -84,7 +98,7 @@ VERSION=1.2.3 ./build.sh
|
|||||||
|
|
||||||
Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win.
|
Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win.
|
||||||
|
|
||||||
`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs with the hardened runtime, builds the payload-free PKG, notarizes, staples and verifies with `spctl`. Use `./build.sh --build-only` to skip signing and notarization. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_QUEUE=<throwaway queue>` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests).
|
`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs the CLI and app with the hardened runtime, notarizes and staples the app, builds the 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).
|
||||||
|
|
||||||
## Notes for admins
|
## Notes for admins
|
||||||
|
|
||||||
|
|||||||
@@ -101,13 +101,20 @@ final class PrinterStore {
|
|||||||
private(set) var revision = 0
|
private(set) var revision = 0
|
||||||
/// The server default printer (CUPS-Get-Default), if any.
|
/// The server default printer (CUPS-Get-Default), if any.
|
||||||
private(set) var defaultPrinter: String?
|
private(set) var defaultPrinter: String?
|
||||||
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil.
|
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
|
||||||
private(set) var ppds: [String: PPD?] = [:]
|
private(set) var drivers: [String: DriverContext] = [:]
|
||||||
var failure: Failure?
|
var failure: Failure?
|
||||||
|
|
||||||
let client = CupsClient()
|
let client = CupsClient()
|
||||||
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
|
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
|
||||||
|
|
||||||
|
/// `--only-queues a,b,c` limits the app to those queues (used by `build.sh --screenshots`).
|
||||||
|
static let onlyQueues: Set<String>? = {
|
||||||
|
let args = ProcessInfo.processInfo.arguments
|
||||||
|
guard let i = args.firstIndex(of: "--only-queues"), i + 1 < args.count else { return nil }
|
||||||
|
return Set(args[i + 1].split(separator: ",").map(String.init))
|
||||||
|
}()
|
||||||
|
|
||||||
/// "21 printers · 0 active jobs" for the status bar.
|
/// "21 printers · 0 active jobs" for the status bar.
|
||||||
var summaryLine: String {
|
var summaryLine: String {
|
||||||
guard hasLoaded, errorMessage == nil else { return "" }
|
guard hasLoaded, errorMessage == nil else { return "" }
|
||||||
@@ -133,7 +140,9 @@ final class PrinterStore {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
|
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
|
||||||
printers = groups.map(PrinterSummary.init).sorted {
|
printers = groups.map(PrinterSummary.init)
|
||||||
|
.filter { Self.onlyQueues?.contains($0.name) ?? true }
|
||||||
|
.sorted {
|
||||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||||
}
|
}
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
@@ -181,31 +190,28 @@ final class PrinterStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func noteChange(queue: String? = nil) {
|
func noteChange(queue: String? = nil) {
|
||||||
if let queue { ppds[queue] = nil }
|
if let queue { drivers[queue] = nil }
|
||||||
revision += 1
|
revision += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: PPDs for Quick Actions
|
// MARK: drivers for Quick Actions
|
||||||
|
|
||||||
/// Fetches PPDs not yet cached, in the background of a refresh.
|
/// Loads driver info (PPD, IPP attributes) not yet cached, in the background of a refresh.
|
||||||
func loadPPDs() async {
|
func loadPPDs() async {
|
||||||
for printer in printers where ppds[printer.name] == nil {
|
for printer in printers where drivers[printer.name] == nil {
|
||||||
let text = try? await client.getPPD(queue: printer.name)
|
if let context = try? await DriverContext.load(client: client, queue: printer.name) {
|
||||||
ppds[printer.name] = .some(text.map(PPD.init(text:)))
|
drivers[printer.name] = context
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
|
/// nil when the action can run on the queue, else why not (shown as the menu item's tooltip).
|
||||||
func unavailableReason(_ action: QuickAction, queue: String) -> String? {
|
func unavailableReason(_ action: QuickAction, queue: String) -> String? {
|
||||||
if case .defaultPrinter = action.effect {
|
if action.isDefaultPrinter {
|
||||||
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
|
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
|
||||||
}
|
}
|
||||||
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" }
|
guard let context = drivers[queue] else { return "Checking \(queue)’s driver…" }
|
||||||
if action.isAvailable(for: cached) { return nil }
|
return action.unavailableReason(in: context)
|
||||||
if case .failure(let unavailable) = action.changes(for: cached, value: action.input == .userCode ? "0" : nil) {
|
|
||||||
return unavailable.reason
|
|
||||||
}
|
|
||||||
return "Not available on \(queue)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: per-printer actions
|
// MARK: per-printer actions
|
||||||
@@ -247,7 +253,7 @@ final class PrinterStore {
|
|||||||
report("ERROR: delete \(queue): \(error)")
|
report("ERROR: delete \(queue): \(error)")
|
||||||
fail("Couldn’t delete \(queue)", String(describing: error))
|
fail("Couldn’t delete \(queue)", String(describing: error))
|
||||||
}
|
}
|
||||||
ppds[queue] = nil
|
drivers[queue] = nil
|
||||||
noteChange()
|
noteChange()
|
||||||
await refreshQuietly()
|
await refreshQuietly()
|
||||||
return deleted
|
return deleted
|
||||||
|
|||||||
@@ -39,24 +39,34 @@ final class QuickActionModel: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) }
|
var context: DriverContext {
|
||||||
|
DriverContext(ppd: state?.ppd, attributes: state?.snapshot.attributes)
|
||||||
|
}
|
||||||
|
|
||||||
var validationError: String? { action.validationError(code, ppd: state?.ppd) }
|
var currentCode: String? { action.currentCode(context) }
|
||||||
|
|
||||||
|
var validationError: String? { action.validationError(code, context: context) }
|
||||||
|
|
||||||
/// What will be written, or why the action can't run here.
|
/// What will be written, or why the action can't run here.
|
||||||
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||||
action.changes(for: state?.ppd, value: clearing ? "" : code)
|
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
|
||||||
|
var profileName: String? {
|
||||||
|
guard !action.isDefaultPrinter, case .success(let resolved) = action.resolve(context, value: code.isEmpty ? "0" : code) else { return nil }
|
||||||
|
return resolved.profile.name
|
||||||
}
|
}
|
||||||
|
|
||||||
var command: String {
|
var command: String {
|
||||||
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
|
if action.isDefaultPrinter { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
|
||||||
guard case .success(let changes) = plannedChanges() else { return "" }
|
guard case .success(let changes) = plannedChanges() else { return "" }
|
||||||
return OptionApplier.displayCommand(queue: queue, changes: changes)
|
return OptionApplier.displayCommand(queue: queue, changes: changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
|
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
|
||||||
var userDefaultNote: String? {
|
var userDefaultNote: String? {
|
||||||
guard case .defaultPrinter = action.effect,
|
guard action.isDefaultPrinter,
|
||||||
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
|
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
|
||||||
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
|
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
|
||||||
let name = line.split(separator: " ")[1].split(separator: "/")[0]
|
let name = line.split(separator: " ")[1].split(separator: "/")[0]
|
||||||
@@ -70,8 +80,8 @@ final class QuickActionModel: Identifiable {
|
|||||||
phase = .running
|
phase = .running
|
||||||
let started = Date()
|
let started = Date()
|
||||||
do {
|
do {
|
||||||
let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil),
|
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
|
||||||
client: store.client)
|
clearing: clearing, client: store.client)
|
||||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||||
let seconds = elapsedText(since: started)
|
let seconds = elapsedText(since: started)
|
||||||
@@ -108,6 +118,11 @@ struct QuickActionSheet: View {
|
|||||||
.font(.title3.weight(.semibold))
|
.font(.title3.weight(.semibold))
|
||||||
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
Text("\(model.queue) · saved as the queue default for every user on this Mac")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
if let driver = model.state.map({ _ in model.context.driverName }), let profile = model.profileName {
|
||||||
|
Text("\(driver) · driver profile: \(profile)")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch model.phase {
|
switch model.phase {
|
||||||
@@ -184,7 +199,7 @@ struct QuickActionSheet: View {
|
|||||||
.frame(height: 130)
|
.frame(height: 130)
|
||||||
}
|
}
|
||||||
|
|
||||||
if case .defaultPrinter = model.action.effect {
|
if model.action.isDefaultPrinter {
|
||||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Server default").foregroundStyle(.secondary)
|
Text("Server default").foregroundStyle(.secondary)
|
||||||
|
|||||||
109
Sources/CupsKit/DriverProfiles.swift
Normal file
109
Sources/CupsKit/DriverProfiles.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What Quick Actions need to know about a queue's driver: its PPD (if any) and IPP attributes.
|
||||||
|
public struct DriverContext {
|
||||||
|
public let ppd: PPD?
|
||||||
|
/// Printer attributes, including `*-supported` values for IPP Everywhere pairs.
|
||||||
|
public let attributes: IPPGroup?
|
||||||
|
|
||||||
|
public init(ppd: PPD?, attributes: IPPGroup?) {
|
||||||
|
self.ppd = ppd
|
||||||
|
self.attributes = attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
static let requestedAttributes = [
|
||||||
|
"printer-make-and-model", "print-color-mode-supported", "print-color-mode-default",
|
||||||
|
"sides-supported", "sides-default", "media-supported", "media-default",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func load(client: CupsClient, queue: String) async throws -> DriverContext {
|
||||||
|
let attributes = try await client.getPrinterAttributes(queue: queue, requested: requestedAttributes)
|
||||||
|
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
|
||||||
|
return DriverContext(ppd: ppd, attributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var manufacturer: String? { ppd?.attributes["Manufacturer"] }
|
||||||
|
public var nickName: String? { ppd?.attributes["NickName"] ?? attributes?.string("printer-make-and-model") }
|
||||||
|
/// For messages: "RICOH SP 3710DN".
|
||||||
|
public var driverName: String { nickName ?? manufacturer ?? "this driver" }
|
||||||
|
|
||||||
|
/// Whether this queue can take `key=value`: a PPD option with that choice (or a custom value for
|
||||||
|
/// `Custom.` values), or an IPP `name-default` whose `name-supported` lists the value.
|
||||||
|
public func supports(key: String, value: String) -> Bool {
|
||||||
|
if let option = ppd?.option(key) {
|
||||||
|
if value.hasPrefix("Custom.") { return !option.customParameters.isEmpty }
|
||||||
|
return option.choices.contains(value)
|
||||||
|
}
|
||||||
|
guard key.hasSuffix("-default"), let attributes else { return false }
|
||||||
|
let supported = String(key.dropLast("-default".count)) + "-supported"
|
||||||
|
return attributes.strings(supported).contains(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DriverProfile: Decodable, Identifiable {
|
||||||
|
public struct Match: Decodable {
|
||||||
|
/// Regular expressions (case-insensitive) on the PPD's `*Manufacturer` / `*NickName`; all given must match.
|
||||||
|
public let manufacturer: String?
|
||||||
|
public let nickname: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let match: Match
|
||||||
|
/// Action id -> alternatives, each a list of `keyword=value` pairs applied together.
|
||||||
|
public let actions: [String: [[String]]]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey { case id, name, match, actions }
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(String.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
match = try c.decode(Match.self, forKey: .match)
|
||||||
|
// Accept one list of pairs or a list of alternative lists.
|
||||||
|
var actions: [String: [[String]]] = [:]
|
||||||
|
let raw = try c.decode([String: FlexiblePairs].self, forKey: .actions)
|
||||||
|
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
||||||
|
self.actions = actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlexiblePairs: Decodable {
|
||||||
|
let alternatives: [[String]]
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
if let nested = try? [[String]](from: decoder) {
|
||||||
|
alternatives = nested
|
||||||
|
} else {
|
||||||
|
alternatives = [try [String](from: decoder)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isGeneric: Bool { match.manufacturer == nil && match.nickname == nil }
|
||||||
|
|
||||||
|
public func matches(_ context: DriverContext) -> Bool {
|
||||||
|
func test(_ pattern: String?, _ value: String?) -> Bool {
|
||||||
|
guard let pattern else { return true }
|
||||||
|
guard let value else { return false }
|
||||||
|
return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||||
|
}
|
||||||
|
return test(match.manufacturer, context.manufacturer) && test(match.nickname, context.nickName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DriverProfiles {
|
||||||
|
private struct File: Decodable { let profiles: [DriverProfile] }
|
||||||
|
|
||||||
|
/// The profiles compiled in from Resources/driver-profiles.json, in match order.
|
||||||
|
public static let builtIn: [DriverProfile] = {
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(File.self, from: Data(PackageResources.driver_profiles_json)).profiles
|
||||||
|
} catch {
|
||||||
|
fatalError("driver-profiles.json is invalid: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Matching profiles in order: vendor profiles first, generic last.
|
||||||
|
public static func matching(_ context: DriverContext, in profiles: [DriverProfile] = builtIn) -> [DriverProfile] {
|
||||||
|
profiles.filter { $0.matches(context) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +1,112 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Task-named shortcuts that set queue defaults without knowing option keywords.
|
/// Task-named shortcuts that set queue defaults without knowing option keywords.
|
||||||
/// Each one is data: which PPD keyword/value pairs to write (first variant the PPD supports wins).
|
/// What each one writes comes from the driver profiles in Resources/driver-profiles.json.
|
||||||
/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed.
|
|
||||||
public struct QuickAction: Identifiable, Hashable {
|
public struct QuickAction: Identifiable, Hashable {
|
||||||
public enum Input: Hashable {
|
public enum Input: Hashable {
|
||||||
case none
|
case none
|
||||||
/// Digits, up to the PPD's own limit. An empty value clears the code.
|
/// Digits, up to the PPD's own limit.
|
||||||
case userCode
|
case userCode
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Effect: Hashable {
|
/// CLI name (`cupsadmin quick <id> <queue>`) and the key in driver-profiles.json.
|
||||||
/// `lpadmin -p queue -o ...` with the first variant whose keywords and values this PPD has.
|
|
||||||
/// `{value}` in a value is replaced with the input.
|
|
||||||
case options(variants: [[String: String]], clearVariants: [[String: String]])
|
|
||||||
/// `lpadmin -d queue`.
|
|
||||||
case defaultPrinter
|
|
||||||
}
|
|
||||||
|
|
||||||
/// CLI name: `cupsadmin quick <id> <queue>`.
|
|
||||||
public let id: String
|
public let id: String
|
||||||
public let title: String
|
public let title: String
|
||||||
public let systemImage: String
|
public let systemImage: String
|
||||||
public let input: Input
|
public let input: Input
|
||||||
public let effect: Effect
|
|
||||||
/// One line for the CLI list and tooltips.
|
/// One line for the CLI list and tooltips.
|
||||||
public let summary: String
|
public let summary: String
|
||||||
|
|
||||||
|
public var isDefaultPrinter: Bool { id == "default" }
|
||||||
|
|
||||||
public static let all: [QuickAction] = [
|
public static let all: [QuickAction] = [
|
||||||
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
|
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
|
||||||
effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []),
|
summary: "Color by default"),
|
||||||
summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"),
|
|
||||||
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
||||||
effect: .options(variants: [["ColorModel": "Gray"], ["HQColorMode": "BW"]], clearVariants: []),
|
summary: "Black & white by default"),
|
||||||
summary: "Black & white by default (ColorModel=Gray, or HQColorMode=BW)"),
|
|
||||||
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
||||||
effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]],
|
summary: "User/department code sent with every job (--clear to remove)"),
|
||||||
clearVariants: [["RIEnableUserCode": "False", "RIUserCode": "None"]]),
|
|
||||||
summary: "Ricoh user code for every job (RIEnableUserCode=True, RIUserCode=Custom.<code>)"),
|
|
||||||
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
||||||
effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []),
|
summary: "Two-sided, long edge"),
|
||||||
summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"),
|
|
||||||
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
||||||
effect: .options(variants: [["Duplex": "None"]], clearVariants: []),
|
summary: "One-sided"),
|
||||||
summary: "One-sided (Duplex=None)"),
|
|
||||||
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
|
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
|
||||||
input: .none,
|
input: .none, summary: "Letter paper, and fit to the nearest size where the driver can (never prompt at the printer)"),
|
||||||
effect: .options(variants: [["PageSize": "Letter", "RIPaperPolicy": "NearestSizeAdjust"]], clearVariants: []),
|
|
||||||
summary: "Letter, never prompt at the printer (PageSize=Letter, RIPaperPolicy=NearestSizeAdjust)"),
|
|
||||||
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
QuickAction(id: "default", title: "Make Default Printer", systemImage: "star", input: .none,
|
||||||
effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"),
|
summary: "Server default printer (lpadmin -d)"),
|
||||||
]
|
]
|
||||||
|
|
||||||
public static func named(_ id: String) -> QuickAction? {
|
public static func named(_ id: String) -> QuickAction? {
|
||||||
all.first { $0.id == id }
|
all.first { $0.id == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The option changes for this PPD, or why the action isn't available on it.
|
/// The profile and option changes this action would write on a queue, or why it can't.
|
||||||
public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> {
|
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
||||||
guard case .options(let variants, let clearVariants) = effect else { return .success([]) }
|
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
||||||
let clearing = input == .userCode && (value ?? "").isEmpty
|
let key = clearing ? id + "-clear" : id
|
||||||
let candidates = clearing ? clearVariants : variants
|
var firstMissing: String?
|
||||||
guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) }
|
for profile in DriverProfiles.matching(context, in: profiles) {
|
||||||
|
guard let alternatives = profile.actions[key] else { continue }
|
||||||
for variant in candidates {
|
for pairs in alternatives {
|
||||||
var changes: [OptionChange] = []
|
let parsed = pairs.compactMap(Self.parse)
|
||||||
for key in variant.keys.sorted() {
|
let filled = parsed.map { ($0.key, $0.value.replacingOccurrences(of: "{value}", with: value ?? "")) }
|
||||||
guard let option = ppd.option(key) else { changes = []; break }
|
if let missing = filled.first(where: { !context.supports(key: $0.0, value: $0.1) }) {
|
||||||
let raw = variant[key]!
|
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
||||||
if raw.contains("{value}") {
|
continue
|
||||||
guard !option.customParameters.isEmpty else { changes = []; break }
|
}
|
||||||
changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false))
|
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
||||||
} else {
|
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
||||||
guard option.choices.contains(raw) else { changes = []; break }
|
|
||||||
changes.append(OptionChange(key: key, value: raw, isSecret: false))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !changes.isEmpty { return .success(changes) }
|
let detail = firstMissing.map { " (no \($0))" } ?? " (no driver profile maps \(key))"
|
||||||
}
|
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName)\(detail)"))
|
||||||
let needed = Set(candidates.flatMap { $0.keys }).sorted().joined(separator: " / ")
|
|
||||||
return .failure(QuickActionUnavailable(reason: "This printer’s PPD has no \(needed) with the needed choice"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func isAvailable(for ppd: PPD?) -> Bool {
|
/// nil when the action can run on this queue; otherwise the reason (for tooltips and CLI errors).
|
||||||
if case .defaultPrinter = effect { return true }
|
public func unavailableReason(in context: DriverContext) -> String? {
|
||||||
if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true }
|
if isDefaultPrinter { return nil }
|
||||||
return false
|
switch resolve(context, value: input == .userCode ? "0" : nil) {
|
||||||
|
case .success: return nil
|
||||||
|
case .failure(let unavailable): return unavailable.reason
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid.
|
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
||||||
public func validationError(_ value: String, ppd: PPD?) -> String? {
|
|
||||||
|
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
|
||||||
|
public func validationError(_ value: String, context: DriverContext) -> String? {
|
||||||
guard input == .userCode, !value.isEmpty else { return nil }
|
guard input == .userCode, !value.isEmpty else { return nil }
|
||||||
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
||||||
if let max = ppd?.option("RIUserCode")?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
if let keyword = codeKeyword(context),
|
||||||
|
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
||||||
return "At most \(max) digits"
|
return "At most \(max) digits"
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current user code on the queue, without the Custom. prefix (nil when none is set).
|
/// The code currently set on the queue, without the Custom. prefix (nil when none).
|
||||||
public static func currentUserCode(_ ppd: PPD?) -> String? {
|
public func currentCode(_ context: DriverContext) -> String? {
|
||||||
guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
guard let keyword = codeKeyword(context),
|
||||||
|
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
||||||
return String(value.dropFirst("Custom.".count))
|
return String(value.dropFirst("Custom.".count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
||||||
|
private func codeKeyword(_ context: DriverContext) -> String? {
|
||||||
|
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||||
|
let profilePairs = resolved.profile.actions[id]?.first ?? []
|
||||||
|
return profilePairs.compactMap(Self.parse).first { $0.value.contains("{value}") }?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ pair: String) -> (key: String, value: String)? {
|
||||||
|
guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else { return nil }
|
||||||
|
return (String(pair[..<eq]), String(pair[pair.index(after: eq)...]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ResolvedQuickAction {
|
||||||
|
public let profile: DriverProfile
|
||||||
|
public let changes: [OptionChange]
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct QuickActionUnavailable: Error, Hashable {
|
public struct QuickActionUnavailable: Error, Hashable {
|
||||||
@@ -112,6 +116,8 @@ public struct QuickActionUnavailable: Error, Hashable {
|
|||||||
public struct QuickActionOutcome {
|
public struct QuickActionOutcome {
|
||||||
public let command: String
|
public let command: String
|
||||||
public let succeeded: Bool
|
public let succeeded: Bool
|
||||||
|
/// The driver profile used; nil for Make Default Printer.
|
||||||
|
public let profile: DriverProfile?
|
||||||
/// Per-option read-back; empty for Make Default Printer.
|
/// Per-option read-back; empty for Make Default Printer.
|
||||||
public let readBack: [OptionReadBack]
|
public let readBack: [OptionReadBack]
|
||||||
public let changes: [OptionChange]
|
public let changes: [OptionChange]
|
||||||
@@ -119,21 +125,19 @@ public struct QuickActionOutcome {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension QuickAction {
|
extension QuickAction {
|
||||||
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options.
|
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
||||||
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome {
|
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
|
||||||
switch effect {
|
if isDefaultPrinter {
|
||||||
case .defaultPrinter:
|
|
||||||
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
||||||
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, readBack: [],
|
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
||||||
changes: [], message: outcome.message)
|
readBack: [], changes: [], message: outcome.message)
|
||||||
case .options:
|
|
||||||
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
|
|
||||||
let changes = try self.changes(for: ppd, value: value).get()
|
|
||||||
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
|
|
||||||
let failed = readBack.filter { !$0.applied }
|
|
||||||
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: changes),
|
|
||||||
succeeded: result.succeeded && failed.isEmpty, readBack: readBack, changes: changes,
|
|
||||||
message: failed.first?.reason)
|
|
||||||
}
|
}
|
||||||
|
let context = try await DriverContext.load(client: client, queue: queue)
|
||||||
|
let resolved = try resolve(context, value: value, clearing: clearing).get()
|
||||||
|
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
||||||
|
let failed = readBack.filter { !$0.applied }
|
||||||
|
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
||||||
|
succeeded: result.succeeded && failed.isEmpty, profile: resolved.profile,
|
||||||
|
readBack: readBack, changes: resolved.changes, message: failed.first?.reason)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
Sources/CupsKit/Resources/driver-profiles.json
Normal file
43
Sources/CupsKit/Resources/driver-profiles.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. Find keywords with: cupsadmin ppdreport <queue>",
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"id": "ricoh-m-c251fw",
|
||||||
|
"name": "Ricoh M C251FW",
|
||||||
|
"match": { "manufacturer": "^RICOH$", "nickname": "^RICOH M C251FW" },
|
||||||
|
"actions": {
|
||||||
|
"color": ["HQColorMode=COLOR"],
|
||||||
|
"bw": ["HQColorMode=BW"],
|
||||||
|
"duplex": ["Duplex=DuplexNoTumble"],
|
||||||
|
"simplex": ["Duplex=None"],
|
||||||
|
"letter": ["PageSize=Letter"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ricoh",
|
||||||
|
"name": "Ricoh",
|
||||||
|
"match": { "manufacturer": "^RICOH$" },
|
||||||
|
"actions": {
|
||||||
|
"color": ["ColorModel=CMYK"],
|
||||||
|
"bw": ["ColorModel=Gray"],
|
||||||
|
"usercode": ["RIEnableUserCode=True", "RIUserCode=Custom.{value}"],
|
||||||
|
"usercode-clear": ["RIEnableUserCode=False", "RIUserCode=None"],
|
||||||
|
"duplex": ["Duplex=DuplexNoTumble"],
|
||||||
|
"simplex": ["Duplex=None"],
|
||||||
|
"letter": ["PageSize=Letter", "RIPaperPolicy=NearestSizeAdjust"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "generic",
|
||||||
|
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
||||||
|
"match": {},
|
||||||
|
"actions": {
|
||||||
|
"color": [["ColorModel=CMYK"], ["ColorModel=RGB"], ["ColorModel=Color"], ["print-color-mode-default=color"]],
|
||||||
|
"bw": [["ColorModel=Gray"], ["ColorModel=Grayscale"], ["print-color-mode-default=monochrome"]],
|
||||||
|
"duplex": [["Duplex=DuplexNoTumble"], ["sides-default=two-sided-long-edge"]],
|
||||||
|
"simplex": [["Duplex=None"], ["sides-default=one-sided"]],
|
||||||
|
"letter": [["PageSize=Letter"], ["media-default=na_letter_8.5x11in"]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
56
Sources/cupsadmin/Commands/PPDReportCommand.swift
Normal file
56
Sources/cupsadmin/Commands/PPDReportCommand.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
import CupsKit
|
||||||
|
|
||||||
|
/// Every OpenGroup/OpenUI in a queue's PPD, so contributors can find their driver's keywords.
|
||||||
|
enum PPDReportCommand {
|
||||||
|
static func run(client: CupsClient, queue: String?, file: String?) async throws -> String {
|
||||||
|
let text: String
|
||||||
|
let source: String
|
||||||
|
var attributes: IPPGroup?
|
||||||
|
if let file {
|
||||||
|
guard let data = FileManager.default.contents(atPath: file) else { throw CupsAdminError.failed("can’t read \(file)") }
|
||||||
|
text = String(decoding: data, as: UTF8.self)
|
||||||
|
source = file
|
||||||
|
} else if let queue {
|
||||||
|
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||||
|
guard let ppdText = try await client.getPPD(queue: queue) else {
|
||||||
|
throw CupsAdminError.failed("\(queue) has no PPD (driverless or raw queue)")
|
||||||
|
}
|
||||||
|
text = ppdText
|
||||||
|
source = queue
|
||||||
|
attributes = try? await client.getPrinterAttributes(queue: queue, requested: [
|
||||||
|
"printer-make-and-model", "print-color-mode-supported", "sides-supported", "media-supported"])
|
||||||
|
} else {
|
||||||
|
throw CupsAdminError.usage("ppdreport needs a queue name or --file <path.ppd>")
|
||||||
|
}
|
||||||
|
|
||||||
|
let ppd = PPD(text: text)
|
||||||
|
let a = ppd.attributes
|
||||||
|
print("PPD: \(a["NickName"] ?? "?")")
|
||||||
|
print("Manufacturer: \(a["Manufacturer"] ?? "?")")
|
||||||
|
print("PCFileName: \(a["PCFileName"] ?? "?") version \(a["FileVersion"] ?? "?")")
|
||||||
|
|
||||||
|
let context = DriverContext(ppd: ppd, attributes: attributes)
|
||||||
|
let profiles = DriverProfiles.matching(context)
|
||||||
|
print("Profiles: \(profiles.map(\.id).joined(separator: ", "))")
|
||||||
|
let quick = QuickAction.all.filter { !$0.isDefaultPrinter }.map { action -> String in
|
||||||
|
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
|
||||||
|
}
|
||||||
|
print("Quick: \(quick.joined(separator: ", "))")
|
||||||
|
|
||||||
|
for group in ppd.groups {
|
||||||
|
print("\n\(group)")
|
||||||
|
for option in ppd.options where option.group == group {
|
||||||
|
let type = option.uiType == .unknown ? "?" : option.uiType.rawValue
|
||||||
|
print(" \(option.keyword) — \(option.text) (\(type)\(option.isJCL ? ", JCL" : ""), default \(option.defaultChoice ?? "none"))")
|
||||||
|
let choices = option.choiceList.map { $0.text == $0.keyword ? $0.keyword : "\($0.keyword) \"\($0.text)\"" }
|
||||||
|
if !choices.isEmpty { print(" choices: \(choices.joined(separator: ", "))") }
|
||||||
|
if !option.customParameters.isEmpty {
|
||||||
|
let params = option.customParameters.map { "\($0.name) \($0.type) \($0.minimum)–\($0.maximum)" }
|
||||||
|
print(" custom: \(params.joined(separator: "; ")) (write as \(option.keyword)=Custom.<value>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "OK: \(source) \(ppd.options.count) options in \(ppd.groups.count) groups"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ enum QuickCommand {
|
|||||||
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
||||||
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
|
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
|
||||||
})
|
})
|
||||||
return "OK: \(QuickAction.all.count) quick actions"
|
print("\nDriver profiles (first match wins, generic last): "
|
||||||
|
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
|
||||||
|
print("See what a queue's driver offers: cupsadmin ppdreport <queue>")
|
||||||
|
return "OK: \(QuickAction.all.count) quick actions, \(DriverProfiles.builtIn.count) driver profiles"
|
||||||
}
|
}
|
||||||
guard let action = QuickAction.named(name) else {
|
guard let action = QuickAction.named(name) else {
|
||||||
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
|
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
|
||||||
@@ -22,7 +25,7 @@ enum QuickCommand {
|
|||||||
case .userCode:
|
case .userCode:
|
||||||
if clear {
|
if clear {
|
||||||
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
||||||
value = ""
|
value = nil
|
||||||
} else {
|
} else {
|
||||||
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
|
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
|
||||||
value = args[2]
|
value = args[2]
|
||||||
@@ -30,7 +33,7 @@ enum QuickCommand {
|
|||||||
}
|
}
|
||||||
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||||
|
|
||||||
if case .defaultPrinter = action.effect {
|
if action.isDefaultPrinter {
|
||||||
let before = try await client.getDefaultPrinter()
|
let before = try await client.getDefaultPrinter()
|
||||||
let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
|
let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
|
||||||
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
|
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
|
||||||
@@ -43,23 +46,25 @@ enum QuickCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let before = try await OptionState.load(client: client, queue: queue)
|
let before = try await OptionState.load(client: client, queue: queue)
|
||||||
if let value, let error = action.validationError(value, ppd: before.ppd) {
|
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
||||||
|
if let value, let error = action.validationError(value, context: context) {
|
||||||
throw CupsAdminError.usage("user code \(value): \(error)")
|
throw CupsAdminError.usage("user code \(value): \(error)")
|
||||||
}
|
}
|
||||||
let changes: [OptionChange]
|
let resolved: ResolvedQuickAction
|
||||||
switch action.changes(for: before.ppd, value: value) {
|
switch action.resolve(context, value: value, clearing: clear) {
|
||||||
case .success(let resolved): changes = resolved
|
case .success(let r): resolved = r
|
||||||
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)")
|
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
|
||||||
}
|
}
|
||||||
|
print("driver: \(context.driverName) · profile: \(resolved.profile.id)")
|
||||||
|
|
||||||
let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes)
|
let (executable, arguments) = OptionApplier.command(queue: queue, changes: resolved.changes)
|
||||||
let status = try runTool(executable, arguments)
|
let status = try runTool(executable, arguments)
|
||||||
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
|
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
|
||||||
let after = try await OptionState.load(client: client, queue: queue)
|
let after = try await OptionState.load(client: client, queue: queue)
|
||||||
let checks = changes.map { OptionApplier.verify($0, state: after) }
|
let checks = resolved.changes.map { OptionApplier.verify($0, state: after) }
|
||||||
|
|
||||||
print()
|
print()
|
||||||
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in
|
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(resolved.changes, checks).map { change, check in
|
||||||
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
|
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
|
||||||
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
|
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
|
||||||
})
|
})
|
||||||
@@ -68,6 +73,6 @@ enum QuickCommand {
|
|||||||
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
|
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
|
||||||
+ failed.map(\.key).joined(separator: ", "))
|
+ failed.map(\.key).joined(separator: ", "))
|
||||||
}
|
}
|
||||||
return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified"
|
return "OK: \(queue) \(action.id) applied (\(resolved.profile.id)), \(resolved.changes.count) setting(s) verified"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ let usage = """
|
|||||||
quick list quick actions (task-named queue defaults)
|
quick list quick actions (task-named queue defaults)
|
||||||
quick <action> <queue> [code | --clear]
|
quick <action> <queue> [code | --clear]
|
||||||
apply one via lpadmin, then verify
|
apply one via lpadmin, then verify
|
||||||
|
ppdreport <queue> | --file <path.ppd>
|
||||||
|
every PPD group and option: keyword, label, type, default, choices
|
||||||
help show this help
|
help show this help
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -117,6 +119,19 @@ func runCommand(_ args: [String]) async throws -> String {
|
|||||||
let (queue, spec) = try QueueSpec.parse(rest, command: command)
|
let (queue, spec) = try QueueSpec.parse(rest, command: command)
|
||||||
return try await SetCommand.run(client: client, queue: queue, spec: spec)
|
return try await SetCommand.run(client: client, queue: queue, spec: spec)
|
||||||
|
|
||||||
|
case "ppdreport":
|
||||||
|
var file: String?
|
||||||
|
if let i = rest.firstIndex(of: "--file") {
|
||||||
|
guard i + 1 < rest.count else { throw CupsAdminError.usage("--file needs a path") }
|
||||||
|
file = rest[i + 1]
|
||||||
|
rest.removeSubrange(i ... i + 1)
|
||||||
|
}
|
||||||
|
try rejectUnknownFlags()
|
||||||
|
guard (file == nil && rest.count == 1) || (file != nil && rest.isEmpty) else {
|
||||||
|
throw CupsAdminError.usage("ppdreport needs one queue name or --file <path.ppd>")
|
||||||
|
}
|
||||||
|
return try await PPDReportCommand.run(client: client, queue: rest.first, file: file)
|
||||||
|
|
||||||
case "quick":
|
case "quick":
|
||||||
let clear = takeFlag("--clear")
|
let clear = takeFlag("--clear")
|
||||||
try rejectUnknownFlags()
|
try rejectUnknownFlags()
|
||||||
|
|||||||
@@ -1,61 +1,127 @@
|
|||||||
import CupsKit
|
@testable import CupsKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
private func available(_ id: String, _ ppd: PPD?) -> Bool {
|
private func context(_ ppdFile: String) -> DriverContext? {
|
||||||
QuickAction.named(id)!.isAvailable(for: ppd)
|
vendorPPD(ppdFile).map { DriverContext(ppd: $0, attributes: nil) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite struct QuickActionAvailability {
|
private func available(_ id: String, _ context: DriverContext) -> Bool {
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
QuickAction.named(id)!.isAvailable(in: context)
|
||||||
func ricohPSHasEverything() throws {
|
}
|
||||||
let ppd = try #require(vendorPPD("RICOH IM C4500"))
|
|
||||||
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] {
|
private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
||||||
#expect(available(id, ppd), "\(id)")
|
guard case .success(let resolved) = QuickAction.named(id)!.resolve(context, value: "0") else { return nil }
|
||||||
|
return resolved.profile.id
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct DriverProfileFile {
|
||||||
|
@Test func builtInProfilesAreWellFormed() {
|
||||||
|
let profiles = DriverProfiles.builtIn
|
||||||
|
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "generic"])
|
||||||
|
#expect(profiles.last?.isGeneric == true)
|
||||||
|
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
||||||
|
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
||||||
|
for profile in profiles {
|
||||||
|
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
||||||
|
for alternatives in profile.actions.values {
|
||||||
|
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
||||||
}
|
}
|
||||||
let letter = try QuickAction.named("letter")!.changes(for: ppd).get()
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One test per Ricoh PPD documented in docs/ricoh-ppd-options.md (vendor PPDs; skipped when not installed).
|
||||||
|
@Suite struct RicohProfileMatching {
|
||||||
|
static let postScript = ["RICOH IM C2000", "RICOH IM C4500", "RICOH MP C2004ex", "RICOH MP C3004ex",
|
||||||
|
"RICOH MP C307", "RICOH MP C3504"]
|
||||||
|
|
||||||
|
@Test(.enabled(if: postScript.allSatisfy(hasVendorPPD)), arguments: postScript)
|
||||||
|
func ricohColorPostScript(_ file: String) throws {
|
||||||
|
let c = try #require(context(file))
|
||||||
|
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter"] {
|
||||||
|
#expect(profileID(id, c) == "ricoh", "\(file) \(id)")
|
||||||
|
}
|
||||||
|
let letter = try QuickAction.named("letter")!.resolve(c).get().changes
|
||||||
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
|
||||||
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
|
||||||
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get()
|
let code = try QuickAction.named("usercode")!.resolve(c, value: "4321").get().changes
|
||||||
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
||||||
let clear = try QuickAction.named("usercode")!.changes(for: ppd, value: "").get()
|
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
||||||
#expect(clear.map(\.value) == ["False", "None"])
|
#expect(clear.map(\.value) == ["False", "None"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd")))
|
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
|
||||||
func otherDriversOnlyGetWhatTheirPPDHas() throws {
|
func ricohMonoPostScript() throws {
|
||||||
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex.
|
let c = try #require(context("RICOH MP 5055"))
|
||||||
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd"))
|
#expect(!available("color", c) && !available("bw", c)) // mono: no ColorModel
|
||||||
#expect(!available("color", sp) && !available("bw", sp))
|
#expect(QuickAction.named("color")!.unavailableReason(in: c)?.hasPrefix("Not available for RICOH MP 5055 PS") == true)
|
||||||
#expect(!available("letter", sp) && !available("usercode", sp))
|
for id in ["usercode", "duplex", "simplex", "letter"] { #expect(profileID(id, c) == "ricoh", "\(id)") }
|
||||||
#expect(available("duplex", sp) && available("simplex", sp))
|
}
|
||||||
|
|
||||||
// M C251FW: color via HQColorMode.
|
@Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
|
||||||
let fw = try #require(vendorPPD("RICOH M C251FW.ppd"))
|
func ricohMC251FWUsesHQColorMode() throws {
|
||||||
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
|
let c = try #require(context("RICOH M C251FW.ppd"))
|
||||||
|
#expect(profileID("color", c) == "ricoh-m-c251fw")
|
||||||
|
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
|
||||||
|
#expect(!available("usercode", c))
|
||||||
|
#expect(profileID("letter", c) == "ricoh-m-c251fw")
|
||||||
|
}
|
||||||
|
|
||||||
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no.
|
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd")))
|
||||||
let airprint = PPD(text: """
|
func ricohPCLFallsBackToGenericLetter() throws {
|
||||||
|
let c = try #require(context("RICOH SP 3710DN.ppd"))
|
||||||
|
#expect(!available("color", c) && !available("usercode", c))
|
||||||
|
#expect(profileID("duplex", c) == "ricoh")
|
||||||
|
// No RIPaperPolicy on this driver: the Ricoh letter mapping doesn't fit, generic Letter does.
|
||||||
|
#expect(profileID("letter", c) == "generic")
|
||||||
|
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "PageSize", value: "Letter", isSecret: false)])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
||||||
|
func userCodeValidationUsesThePPDLimit() throws {
|
||||||
|
let c = try #require(context("RICOH IM C4500"))
|
||||||
|
let action = QuickAction.named("usercode")!
|
||||||
|
#expect(action.validationError("12345", context: c) == nil)
|
||||||
|
#expect(action.validationError("12a", context: c) == "Digits only")
|
||||||
|
#expect(action.validationError("123456789", context: c) == "At most 8 digits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct GenericProfile {
|
||||||
|
static let genericPPD = "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Resources/Generic.ppd"
|
||||||
|
|
||||||
|
@Test(.enabled(if: FileManager.default.fileExists(atPath: GenericProfile.genericPPD)))
|
||||||
|
func genericPostScriptPPD() throws {
|
||||||
|
let text = try String(contentsOfFile: Self.genericPPD, encoding: .isoLatin1)
|
||||||
|
let c = DriverContext(ppd: PPD(text: text), attributes: nil)
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["generic"])
|
||||||
|
for id in ["duplex", "simplex", "letter"] { #expect(profileID(id, c) == "generic", "\(id)") }
|
||||||
|
#expect(!available("color", c) && !available("bw", c) && !available("usercode", c))
|
||||||
|
#expect(QuickAction.named("default")!.isAvailable(in: c))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ippEverywhereUsesIPPAttributes() throws {
|
||||||
|
let attributes = IPPGroup(tag: .printerAttributes, attributes: [
|
||||||
|
.string("printer-make-and-model", .textWithoutLanguage, ["Example Laser 5000"]),
|
||||||
|
.string("print-color-mode-supported", .keyword, ["auto", "color", "monochrome"]),
|
||||||
|
.string("sides-supported", .keyword, ["one-sided", "two-sided-long-edge"]),
|
||||||
|
.string("media-supported", .keyword, ["na_letter_8.5x11in", "iso_a4_210x297mm"]),
|
||||||
|
])
|
||||||
|
let c = DriverContext(ppd: nil, attributes: attributes)
|
||||||
|
#expect(try QuickAction.named("color")!.resolve(c).get().changes == [OptionChange(key: "print-color-mode-default", value: "color", isSecret: false)])
|
||||||
|
#expect(try QuickAction.named("duplex")!.resolve(c).get().changes == [OptionChange(key: "sides-default", value: "two-sided-long-edge", isSecret: false)])
|
||||||
|
#expect(try QuickAction.named("letter")!.resolve(c).get().changes == [OptionChange(key: "media-default", value: "na_letter_8.5x11in", isSecret: false)])
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == "Not available for Example Laser 5000 (no driver profile maps usercode)")
|
||||||
|
|
||||||
|
// Grayscale-only AirPrint PPD: black & white yes, color no.
|
||||||
|
let gray = DriverContext(ppd: PPD(text: """
|
||||||
*OpenUI *ColorModel/Color Mode: PickOne
|
*OpenUI *ColorModel/Color Mode: PickOne
|
||||||
*DefaultColorModel: Gray
|
*DefaultColorModel: Gray
|
||||||
*ColorModel Gray/Grayscale: ""
|
*ColorModel Gray/Grayscale: ""
|
||||||
*CloseUI: *ColorModel
|
*CloseUI: *ColorModel
|
||||||
*OpenUI *PageSize/Media Size: PickOne
|
"""), attributes: nil)
|
||||||
*DefaultPageSize: Letter
|
#expect(available("bw", gray) && !available("color", gray))
|
||||||
*PageSize Letter/US Letter: ""
|
|
||||||
*CloseUI: *PageSize
|
|
||||||
""")
|
|
||||||
#expect(!available("color", airprint))
|
|
||||||
#expect(available("bw", airprint))
|
|
||||||
#expect(!available("duplex", airprint))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func userCodeValidation() {
|
|
||||||
let action = QuickAction.named("usercode")!
|
|
||||||
let ppd = vendorPPD("RICOH IM C4500")
|
|
||||||
#expect(action.validationError("12345", ppd: ppd) == nil)
|
|
||||||
#expect(action.validationError("12a", ppd: ppd) == "Digits only")
|
|
||||||
#expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,21 +134,24 @@ struct LiveQuickActions {
|
|||||||
@Test func everyActionOnRicohQueue() async throws {
|
@Test func everyActionOnRicohQueue() async throws {
|
||||||
try #require(queues.count == 2)
|
try #require(queues.count == 2)
|
||||||
let client = CupsClient()
|
let client = CupsClient()
|
||||||
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"),
|
for (id, value, clearing) in [("letter", nil, false), ("bw", nil, false), ("color", nil, false), ("usercode", "2468", false),
|
||||||
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] {
|
("usercode", nil, true), ("simplex", nil, false), ("duplex", nil, false)] as [(String, String?, Bool)] {
|
||||||
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client)
|
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, clearing: clearing, client: client)
|
||||||
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
|
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
|
||||||
|
#expect(outcome.profile?.id == "ricoh", "\(id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func testPageDefaultAndDelete() async throws {
|
@Test func genericQueueTestPageDefaultAndDelete() async throws {
|
||||||
try #require(queues.count == 2)
|
try #require(queues.count == 2)
|
||||||
let client = CupsClient()
|
let client = CupsClient()
|
||||||
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
|
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
|
||||||
#expect(page.succeeded && jobID != nil, "\(page)")
|
#expect(page.succeeded && jobID != nil, "\(page)")
|
||||||
|
|
||||||
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client)
|
let letter = try await QuickAction.named("letter")!.run(queue: queues[1], client: client)
|
||||||
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing
|
#expect(letter.succeeded && letter.profile?.id == "generic", "\(letter.message ?? "")")
|
||||||
|
let color = try? await QuickAction.named("usercode")!.run(queue: queues[1], value: "1", client: client)
|
||||||
|
#expect(color == nil) // generic PPD: no user code mapping, throws, writes nothing
|
||||||
|
|
||||||
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
|
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
|
||||||
#expect(made.succeeded, "\(made.message ?? "")")
|
#expect(made.succeeded, "\(made.message ?? "")")
|
||||||
|
|||||||
169
build.sh
169
build.sh
@@ -1,8 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Universal release build -> signed CLI binary + signed "CUPS Admin.app" -> signed payload-free PKG
|
# Universal release build -> signed CLI + signed, notarized, stapled "CUPS Admin.app" -> signed payload-free PKG
|
||||||
# (postinstall installs /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl.
|
# (postinstall installs /usr/local/bin/cupsadmin and /Applications/CUPS Admin.app) -> notarize -> staple -> spctl.
|
||||||
# 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
|
||||||
|
# (creates three demo queues on 127.0.0.1 with held jobs, deletes them after)
|
||||||
#
|
#
|
||||||
# Signing settings come from the environment, or from an untracked build.env next to this script
|
# Signing settings come from the environment, or from an untracked build.env next to this script
|
||||||
# (plain KEY=value lines; a variable already set in the environment wins):
|
# (plain KEY=value lines; a variable already set in the environment wins):
|
||||||
@@ -13,10 +15,12 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BUILD_ONLY=0
|
BUILD_ONLY=0
|
||||||
|
SCREENSHOTS=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--build-only) BUILD_ONLY=1 ;;
|
--build-only) BUILD_ONLY=1 ;;
|
||||||
*) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only)"
|
--screenshots) BUILD_ONLY=1; SCREENSHOTS=1 ;;
|
||||||
|
*) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only or --screenshots)"
|
||||||
echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;;
|
echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -27,6 +31,7 @@ STATUS="ERROR: build.sh exited unexpectedly"
|
|||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
local rc=$?
|
local rc=$?
|
||||||
|
[ "$SCREENSHOTS" -eq 1 ] && cleanup_screenshots
|
||||||
if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi
|
if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi
|
||||||
echo "$STATUS"
|
echo "$STATUS"
|
||||||
echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)"
|
echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)"
|
||||||
@@ -36,9 +41,79 @@ trap 'STATUS="ERROR: interrupted"; exit 130' INT TERM
|
|||||||
|
|
||||||
step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; }
|
step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; }
|
||||||
|
|
||||||
|
# --- screenshots (--screenshots) ------------------------------------------------
|
||||||
|
# Demo queues with generic names so the README never shows real queue names or users.
|
||||||
|
DEMO_QUEUES="Front_Office Library_Color Lab_Mono"
|
||||||
|
SHOT="docs/screenshots/app-jobs.png"
|
||||||
|
APP_DOMAIN="edu.wku.cupsadmin.app"
|
||||||
|
SAVED_PREFS=""
|
||||||
|
|
||||||
|
cleanup_screenshots() {
|
||||||
|
osascript -e 'quit app "CUPS Admin"' >/dev/null 2>&1 || true
|
||||||
|
for _ in 1 2 3 4 5; do pgrep -f "CUPS Admin.app/Contents/MacOS" >/dev/null || break; sleep 1; done
|
||||||
|
pkill -f "CUPS Admin.app/Contents/MacOS" 2>/dev/null || true
|
||||||
|
for queue in $DEMO_QUEUES; do lpadmin -x "$queue" 2>/dev/null || true; done
|
||||||
|
if [ -n "$SAVED_PREFS" ] && [ -f "$SAVED_PREFS" ]; then
|
||||||
|
defaults delete "$APP_DOMAIN" 2>/dev/null || true
|
||||||
|
defaults import "$APP_DOMAIN" "$SAVED_PREFS" && rm -f "$SAVED_PREFS"
|
||||||
|
SAVED_PREFS=""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
take_screenshots() {
|
||||||
|
step "screenshots"
|
||||||
|
[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" != "Dark" ] \
|
||||||
|
|| { STATUS="ERROR: switch macOS to Light appearance first (the README screenshot is light mode)"; exit 1; }
|
||||||
|
cleanup_screenshots
|
||||||
|
|
||||||
|
# Save the user's app settings (window frame, selection, tab); restored in cleanup.
|
||||||
|
SAVED_PREFS=$(mktemp -t cupsadmin-prefs).plist
|
||||||
|
defaults export "$APP_DOMAIN" "$SAVED_PREFS" 2>/dev/null || defaults export "$APP_DOMAIN" - >/dev/null 2>&1 || true
|
||||||
|
[ -s "$SAVED_PREFS" ] || printf '{}' | plutil -convert xml1 -o "$SAVED_PREFS" -
|
||||||
|
|
||||||
|
for queue in $DEMO_QUEUES; do
|
||||||
|
lpadmin -p "$queue" -v "lpd://127.0.0.1/$queue" -m drv:///sample.drv/generic.ppd \
|
||||||
|
-D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true
|
||||||
|
done
|
||||||
|
# Held jobs never leave the Mac; owners are generic names, not the person running the build.
|
||||||
|
lp -d Front_Office -U alex -H hold -t "Quarterly budget.pdf" /etc/hosts >/dev/null
|
||||||
|
lp -d Front_Office -U jordan -H hold -t "Staff meeting agenda" /etc/hosts >/dev/null
|
||||||
|
lp -d Front_Office -U sam -H hold -t "Parking permits.docx" /etc/hosts >/dev/null
|
||||||
|
lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null
|
||||||
|
lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null
|
||||||
|
|
||||||
|
SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ')
|
||||||
|
defaults write "$APP_DOMAIN" selectedQueue Front_Office
|
||||||
|
defaults write "$APP_DOMAIN" detailTab jobs
|
||||||
|
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
|
||||||
|
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.0.1}"
|
VERSION="${VERSION:-1.1.0}"
|
||||||
IDENTIFIER="edu.wku.cupsadmin"
|
IDENTIFIER="edu.wku.cupsadmin"
|
||||||
|
|
||||||
# build.env fills in only what the environment doesn't already set.
|
# build.env fills in only what the environment doesn't already set.
|
||||||
@@ -182,16 +257,64 @@ if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then
|
|||||||
fi
|
fi
|
||||||
echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")"
|
echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")"
|
||||||
|
|
||||||
|
if [ $SCREENSHOTS -eq 1 ]; then
|
||||||
|
take_screenshots
|
||||||
|
STATUS="OK: regenerated $SHOT (${SHOT_SIZE})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $BUILD_ONLY -eq 1 ]; then
|
if [ $BUILD_ONLY -eq 1 ]; then
|
||||||
STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)"
|
STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- notarize helper --------------------------------------------------------------
|
||||||
|
# notarize <file> <label>: submits, waits, saves the log; fails the build unless Accepted.
|
||||||
|
notarize() {
|
||||||
|
local file="$1" label="$2"
|
||||||
|
local json="$DIST/notary-$label.json"
|
||||||
|
xcrun notarytool submit "$file" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$json" || true
|
||||||
|
local status id
|
||||||
|
status=$(plutil -extract status raw "$json" 2>/dev/null || echo "unknown")
|
||||||
|
id=$(plutil -extract id raw "$json" 2>/dev/null || echo "")
|
||||||
|
echo "$label submission $id: $status"
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
xcrun notarytool log "$id" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-$label-log.json" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
[ "$status" = "Accepted" ] \
|
||||||
|
|| { STATUS="ERROR: $label notarization $status (see $json and $DIST/notary-$label-log.json)"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# spctl_check <type> <path> <log>: must be accepted as Notarized Developer ID.
|
||||||
|
spctl_check() {
|
||||||
|
local type="$1" path="$2" log="$3" out
|
||||||
|
out=$(spctl -a -vv -t "$type" "$path" 2>&1 || true)
|
||||||
|
echo "$out" | tee "$log"
|
||||||
|
echo "$out" | grep -q ': accepted' && echo "$out" | grep -q 'source=Notarized Developer ID' \
|
||||||
|
|| { STATUS="ERROR: spctl did not accept $path as Notarized Developer ID"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- notarize + staple app --------------------------------------------------------
|
||||||
|
step "notarize app (uploads to Apple and waits)"
|
||||||
|
APP_ZIP="$DIST/CUPS-Admin-$VERSION.zip"
|
||||||
|
rm -f "$APP_ZIP"
|
||||||
|
ditto -c -k --keepParent "$APP" "$APP_ZIP"
|
||||||
|
notarize "$APP_ZIP" app
|
||||||
|
rm -f "$APP_ZIP"
|
||||||
|
xcrun stapler staple "$APP"
|
||||||
|
xcrun stapler validate "$APP"
|
||||||
|
spctl_check execute "$APP" "$DIST/spctl-app.txt"
|
||||||
|
|
||||||
# --- build + sign pkg -----------------------------------------------------------
|
# --- build + sign pkg -----------------------------------------------------------
|
||||||
|
# Payload-free for the app too: an /Applications payload's "." entry is recorded as root:wheel, but
|
||||||
|
# /Applications is root:admin 775 — installing it could take away admins' drag-install rights.
|
||||||
|
# pkg/postinstall copies both the CLI and the stapled app instead.
|
||||||
step "build and sign pkg"
|
step "build and sign pkg"
|
||||||
rm -f "$PKG"
|
rm -f "$PKG"
|
||||||
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"
|
||||||
pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
|
pkgbuild --nopayload --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"
|
||||||
@@ -205,41 +328,33 @@ if [ -n "$(printf '%s' "$PAYLOAD_FILES" | grep -v -i 'no payload' || true)" ]; t
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
EXPANDED="$DIST/expanded"
|
EXPANDED="$DIST/expanded"
|
||||||
|
rm -rf "$EXPANDED"
|
||||||
pkgutil --expand "$PKG" "$EXPANDED"
|
pkgutil --expand "$PKG" "$EXPANDED"
|
||||||
# pkgutil --expand unpacks the Scripts archive into a directory.
|
# pkgutil --expand unpacks the Scripts archive into a directory.
|
||||||
echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')"
|
echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')"
|
||||||
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] \
|
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] && [ -d "$EXPANDED/Scripts/CUPS Admin.app" ] \
|
||||||
|| { STATUS="ERROR: Scripts missing postinstall or cupsadmin"; exit 1; }
|
|| { STATUS="ERROR: Scripts missing postinstall, cupsadmin or CUPS Admin.app"; exit 1; }
|
||||||
codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \
|
codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \
|
||||||
|| { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; }
|
|| { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; }
|
||||||
echo "binary inside pkg: signature intact, $(lipo -archs "$EXPANDED/Scripts/cupsadmin")"
|
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"
|
||||||
|
|
||||||
# --- notarize -------------------------------------------------------------------
|
# --- notarize + staple pkg ------------------------------------------------------
|
||||||
step "notarize (this uploads the pkg to Apple and waits)"
|
step "notarize pkg (uploads to Apple and waits)"
|
||||||
SUBMIT_JSON="$DIST/notary-submit.json"
|
notarize "$PKG" pkg
|
||||||
xcrun notarytool submit "$PKG" --keychain-profile "$NOTARY_PROFILE" --wait --output-format json > "$SUBMIT_JSON" || true
|
|
||||||
NOTARY_STATUS=$(plutil -extract status raw "$SUBMIT_JSON" 2>/dev/null || echo "unknown")
|
|
||||||
SUBMISSION_ID=$(plutil -extract id raw "$SUBMIT_JSON" 2>/dev/null || echo "")
|
|
||||||
echo "submission $SUBMISSION_ID: $NOTARY_STATUS"
|
|
||||||
if [ -n "$SUBMISSION_ID" ]; then
|
|
||||||
xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "$NOTARY_PROFILE" "$DIST/notary-log.json" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if [ "$NOTARY_STATUS" != "Accepted" ]; then
|
|
||||||
STATUS="ERROR: notarization $NOTARY_STATUS (see $SUBMIT_JSON and $DIST/notary-log.json)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- staple + verify ------------------------------------------------------------
|
step "staple pkg"
|
||||||
step "staple"
|
|
||||||
xcrun stapler staple "$PKG"
|
xcrun stapler staple "$PKG"
|
||||||
xcrun stapler validate "$PKG"
|
xcrun stapler validate "$PKG"
|
||||||
|
|
||||||
step "spctl assessment"
|
step "spctl assessment"
|
||||||
spctl -a -vv -t install "$PKG" 2>&1 | tee "$DIST/spctl.txt"
|
spctl_check install "$PKG" "$DIST/spctl.txt"
|
||||||
grep -q ': accepted' "$DIST/spctl.txt" && grep -q 'source=Notarized Developer ID' "$DIST/spctl.txt" \
|
spctl_check execute "$APP" "$DIST/spctl-app.txt"
|
||||||
|| { STATUS="ERROR: spctl did not accept $PKG as Notarized Developer ID"; exit 1; }
|
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "install: sudo installer -pkg $PKG -target /"
|
echo "install: sudo installer -pkg $PKG -target /"
|
||||||
STATUS="OK: $PKG signed, notarized ($SUBMISSION_ID), stapled, spctl accepted"
|
STATUS="OK: $PKG (CLI + CUPS Admin.app) signed, app and pkg notarized, stapled, spctl accepted"
|
||||||
|
|||||||
BIN
docs/screenshots/app-jobs.png
Normal file
BIN
docs/screenshots/app-jobs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
@@ -1,17 +1,32 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# cupsadmin postinstall (payload-free pkg): the binary ships in the Scripts archive next to this file.
|
# cupsadmin postinstall (payload-free pkg): the CLI binary and CUPS Admin.app ship in the Scripts
|
||||||
# Never changes ownership/permissions of an existing /usr/local/bin (Homebrew on Intel owns it).
|
# archive next to this file. Never changes ownership/permissions of an existing /usr/local/bin
|
||||||
|
# (Homebrew on Intel owns it) or of /Applications (root:admin 775).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TARGET="${3:-/}"
|
TARGET="${3:-/}"
|
||||||
DEST_DIR="${TARGET%/}/usr/local/bin"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SOURCE="$(cd "$(dirname "$0")" && pwd)/cupsadmin"
|
|
||||||
|
|
||||||
|
# --- CLI -------------------------------------------------------------------------
|
||||||
|
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 "$SOURCE" "$DEST_DIR/cupsadmin"
|
install -S -m 755 -o root -g wheel "$HERE/cupsadmin" "$DEST_DIR/cupsadmin"
|
||||||
echo "cupsadmin: installed $DEST_DIR/cupsadmin"
|
echo "cupsadmin: installed $DEST_DIR/cupsadmin"
|
||||||
|
|
||||||
|
# --- 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