cupsadmin: CLI, CupsKit, app in progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:13:07 -05:00
commit eba94aae4e
44 changed files with 7256 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Build output
.build/
.swiftpm/
dist/
DerivedData/
*.xcodeproj
*.icns
# Compiler output that must never land in the source tree
*.o
*.d
*.dia
*.swiftdeps
*.swiftmodule
# macOS
.DS_Store
# Local only — never commit
build.env
scratch/
Claude outputs/
# Maintainer-local notes and reports
CLAUDE.local.md
*.local.md

78
CLAUDE.md Normal file
View File

@@ -0,0 +1,78 @@
# cupsadmin
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 still work; only the
browser admin pages are gone. Two front ends share one library:
- **CupsKit** (`Sources/CupsKit`) — IPP client, PPD parser, CUPS tool wrappers, read-back checks.
Pure logic: returns values or throws, never prints.
- **`cupsadmin`** (`Sources/cupsadmin`) — the CLI, a thin layer over CupsKit.
- **CUPS Admin.app** (`Sources/CUPSAdminApp`) — the SwiftUI app. The target is `CUPSAdminApp`, not
`CUPSAdmin`: APFS is case-insensitive, so `Sources/CUPSAdmin` is the same folder as `Sources/cupsadmin`
and the two executables would collide in `.build/`.
Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
## Architecture
- **Reads are IPP** with a small hand-written encoder/decoder (RFC 8010, `CupsKit/IPP`):
CUPS-Get-Printers, Get-Printer-Attributes, Get-Jobs, Get-Job-Attributes, Cancel-Job,
CUPS-Get-Default. This gives typed data (state reasons, `*-default`, `*-supported`, job state)
instead of localized `lpstat` text.
- **Transport is the Unix socket `/private/var/run/cupsd`, not TCP 631.** On macOS 27 launchd
socket-activates cupsd only on that socket; cupsd binds port 631 itself while running and exits
after about a minute idle, so TCP fails whenever it's asleep. URLSession can't use AF_UNIX, so
`UnixSocketHTTP.swift` speaks minimal HTTP/1.1 over POSIX sockets. PPDs are fetched the same way
(`GET /printers/<queue>.ppd`).
- **Writes shell out** through `CupsTools`: `lpadmin`, `cupsenable`/`cupsdisable`,
`cupsaccept`/`cupsreject`, `cancel`, `lp`, `lpmove`, `lpinfo`. `lpadmin` already handles
`_lpadmin` authorization and IPP Everywhere PPD generation.
- **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
a per-user override.
- **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).
- **Quick actions** (`QuickActions.swift`) are data: title, symbol, PPD keyword/value variants, input
type. Actions whose keywords aren't in a queue's PPD are unavailable, with the reason.
## Build and test
```
swift build # debug build of everything
.build/debug/cupsadmin printers # run the CLI
./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 # release: sign, payload-free PKG, notarize, staple, spctl
```
- Only the Command Line Tools are required. Universal builds are per-triple
(`swift build -c release --triple arm64-apple-macosx14.0` and `x86_64-…`) plus `lipo`;
`--arch arm64 --arch x86_64` needs full Xcode.
- 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
`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
any payload's "." entry would reset an existing `/usr/local/bin`'s ownership.
- 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>`,
`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
`/Library/Printers/PPDs` and skip when they aren't installed.
## Conventions
- **US spelling everywhere** — code, UI, CLI output, docs (color, canceled, gray).
- **No third-party dependencies.** No Xcode project.
- **CLI status lines on every exit path:** `started HH:MM:SS`, exactly one `OK: …` or `ERROR: …`, and
`finished HH:MM:SS (total N min)` — on stderr, so tables on stdout pipe cleanly. Exit 0 success,
1 runtime error or not applied, 2 usage error. Existing command output is a contract: don't change it
casually. Scripts (`build.sh`, `test.sh`) print the same started/finished lines.
- **Read back after every write.** Re-query cupsd and compare; a change that didn't take is reported
(`NOT APPLIED`, non-zero exit, or a red X with the reason) — never silent.
- **App feedback:** every action updates the bottom status line with its timing ("Set 3 options on
Office in 0.4 s", "ERROR: …") and logs the same through `os_log` (subsystem `edu.wku.cupsadmin.app`).
Failures show the tool output and, for permission errors, the `_lpadmin` fix. The exact command is
visible before it runs. Destructive actions confirm and name the object.
- **Secrets:** passcode/password options written as queue defaults land in a world-readable PPD — warn,
and mask them in displayed commands. Never commit `build.env`, `dist/`, notary output or `CLAUDE.local.md`.
- **Native SwiftUI and macOS idioms only:** `NavigationSplitView`, sortable/resizable `Table`,
`Form` with `.formStyle(.grouped)`, standard sheets, alerts and confirmation dialogs, SF Symbols,
system fonts, semantic colors (`.primary`, `.secondary`, `.green`/`.blue`/`.red` state dots),
`ContentUnavailableView` empty states. No custom chrome, no web-style cards, no emoji. Must look right
in light and dark mode. Tables and text are selectable and copyable.

BIN
Icon/AppIcon-1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

82
Icon/AppIcon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

91
Icon/make_icon.py Normal file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""CUPS Admin icon — old-school dot-matrix printer with green-bar fanfold paper."""
import cairosvg, os
BG = {
"graphite": ("#5b6470", "#2e343c"),
"slate": ("#6b7a8c", "#3a4655"),
"green": ("#2f8f5b", "#1b5a39"),
"cream": ("#e9e2cf", "#cbbf9f"),
}
def greenbar(x, y, w, h, bar=26, holes=True, rot=0, id_="gb"):
"""A sheet of green-bar paper: alternating pale green / white bands + tractor holes."""
out = [f'<g transform="rotate({rot} {x+w/2} {y+h/2})">']
out.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="#ffffff"/>')
yy = y
i = 0
while yy < y + h:
if i % 2 == 0:
hh = min(bar, y + h - yy)
out.append(f'<rect x="{x}" y="{yy}" width="{w}" height="{hh}" fill="#c9ebd2"/>')
yy += bar; i += 1
if holes:
# tractor-feed strips
for hx in (x + 22, x + w - 22):
out.append(f'<line x1="{hx+ (18 if hx==x+22 else -18)}" y1="{y}" x2="{hx+(18 if hx==x+22 else -18)}" y2="{y+h}" stroke="#b9c3cc" stroke-width="3" stroke-dasharray="10 8"/>')
hy = y + 18
while hy < y + h - 8:
out.append(f'<circle cx="{hx}" cy="{hy}" r="6.5" fill="#9aa4ad"/>')
hy += 40
out.append('</g>')
return "\n".join(out)
def svg(top, bottom, dark_glyph=False):
body_top, body_bot = ("#f4f6f8", "#d9dee5")
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{top}"/><stop offset="1" stop-color="{bottom}"/>
</linearGradient>
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{body_top}"/><stop offset="1" stop-color="{body_bot}"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000" flood-opacity="0.28"/>
</filter>
<filter id="soft" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#000" flood-opacity="0.22"/>
</filter>
<clipPath id="clip"><rect x="100" y="100" width="824" height="824" rx="185"/></clipPath>
<clipPath id="outclip"><path d="M300 640 h424 v250 h-424 z"/></clipPath>
</defs>
<rect x="100" y="100" width="824" height="824" rx="185" fill="url(#bg)" filter="url(#shadow)"/>
<rect x="100" y="100" width="824" height="412" rx="185" fill="#ffffff" opacity="0.06" clip-path="url(#clip)"/>
<g clip-path="url(#clip)">
<g filter="url(#soft)">
<!-- incoming fanfold sheet, tilted back -->
{greenbar(318, 250, 388, 200, rot=0)}
<!-- printer body: wide, flat, dot-matrix style -->
<rect x="212" y="436" width="600" height="220" rx="40" fill="url(#body)"/>
<!-- paper slot on top -->
<rect x="290" y="436" width="444" height="16" rx="8" fill="#a9b2bc"/>
<!-- control panel: green ready light + two grey buttons -->
<circle cx="742" cy="500" r="16" fill="#34c759"/>
<rect x="712" y="540" width="60" height="18" rx="9" fill="#b8c0c9"/>
<rect x="712" y="572" width="60" height="18" rx="9" fill="#b8c0c9"/>
<!-- front lip -->
<rect x="212" y="606" width="600" height="50" rx="25" fill="#c5ccd5"/>
<rect x="300" y="596" width="424" height="14" rx="7" fill="#9aa4ad"/>
</g>
<!-- output fanfold coming out the front, with a perforation fold -->
<g filter="url(#soft)" clip-path="url(#clip)">
{greenbar(300, 612, 424, 160, rot=0)}
<line x1="300" y1="692" x2="724" y2="692" stroke="#9aa4ad" stroke-width="3" stroke-dasharray="6 6"/>
</g>
<!-- dot-matrix text: two identical lines, end to end between the tractor strips -->
<g fill="#3a4a3f">
{"".join(f'<circle cx="{x}" cy="{y}" r="4"/>' for y in (639, 651, 663) for x in range(364, 662, 12) if ((x-364)//12) % 7 != 6)}
{"".join(f'<circle cx="{x}" cy="{y}" r="4"/>' for y in (719, 731, 743) for x in range(364, 662, 12) if ((x-364)//12) % 7 != 6)}
</g>
</g>
</svg>'''
os.makedirs("out2", exist_ok=True)
for name, (top, bottom) in BG.items():
s = svg(top, bottom)
open(f"out2/icon-{name}.svg", "w").write(s)
cairosvg.svg2png(bytestring=s.encode(), write_to=f"out2/icon-{name}-1024.png", output_width=1024, output_height=1024)
cairosvg.svg2png(bytestring=s.encode(), write_to=f"out2/icon-{name}-256.png", output_width=256, output_height=256)
print("done")

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kurt Fattic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

22
Package.swift Normal file
View File

@@ -0,0 +1,22 @@
// swift-tools-version:6.0
import PackageDescription
let package = Package(
name: "cupsadmin",
// Executable names must differ case-insensitively (cupsadmin vs CUPSAdminApp) on APFS.
// macOS 14: the app needs ContentUnavailableView/Observation, and SwiftPM has one deployment target per package.
platforms: [.macOS(.v14)],
products: [
.executable(name: "cupsadmin", targets: ["cupsadmin"]),
.executable(name: "CUPSAdminApp", targets: ["CUPSAdminApp"]),
.library(name: "CupsKit", targets: ["CupsKit"]),
],
targets: [
.target(name: "CupsKit", path: "Sources/CupsKit"),
.executableTarget(name: "cupsadmin", dependencies: ["CupsKit"], path: "Sources/cupsadmin"),
.executableTarget(name: "CUPSAdminApp", dependencies: ["CupsKit"], path: "Sources/CUPSAdminApp"),
.testTarget(name: "CupsKitTests", dependencies: ["CupsKit"], path: "Tests/CupsKitTests"),
],
// Tools 6.0 for Swift Testing with only the Command Line Tools; code stays in Swift 5 mode.
swiftLanguageModes: [.v5]
)

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
# 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:
- **`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
Download the signed, notarized PKG from the latest [release](../../releases). It installs:
- `/usr/local/bin/cupsadmin`
- `/Applications/CUPS Admin.app` (once the app ships)
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.
Requires macOS 14 or later. Universal (Apple silicon and Intel).
## CLI
```
cupsadmin printers # every queue: state, reasons, accepting, shared, URI
cupsadmin printer <queue> # one queue in detail (--all for every IPP attribute)
cupsadmin jobs [queue] # active jobs (--completed, --all-jobs)
cupsadmin cancel <job-id> # cancel one job
cupsadmin options <queue> # PPD options and IPP *-default values, like lpoptions -l
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 quick <action> <queue> [value] # task-named shortcuts, see below
```
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.
`set` is stricter than `lpadmin`: `lpadmin -o PageSzie=Letter` (misspelled) exits 0 and changes nothing; `cupsadmin set` reads the queue back afterwards and reports `NOT APPLIED` with a non-zero exit.
### Quick actions
The things admins actually do, without knowing the driver's option keywords:
```
cupsadmin quick color <queue> # always print in color
cupsadmin quick bw <queue> # always print black & white
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
cupsadmin quick duplex <queue> # default to two-sided
cupsadmin quick simplex <queue>
cupsadmin quick letter <queue> # Letter paper + fit to nearest size (no "prompt user" at the 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.
## How it works
- **Reads** go straight to cupsd over IPP, using a small hand-written IPP encoder/decoder (RFC 8010). That's the same data the web UI showed, fully typed — `printer-state-reasons`, `media-supported`, `*-default`, job state and owner — instead of parsing localized `lpstat` output.
- **Transport is the Unix socket** `/private/var/run/cupsd`, not TCP 631. On macOS 27, launchd socket-activates cupsd only on that socket, and cupsd exits after about a minute idle, so port 631 is only reachable right after something else has woken it. The socket path works cold.
- **Writes** shell out to `lpadmin`, `cupsenable`/`cupsdisable`, `cupsaccept`/`cupsreject`, `lp`, `lpmove` and `cupsctl`, then read the result back over IPP. Admin operations over raw IPP would need the same `_lpadmin` group membership anyway, and `lpadmin` already knows how to generate IPP Everywhere PPDs.
- **Options** are parsed from the queue's PPD, grouped the way the PPD groups them, with typed custom values (user codes, passcodes, numbers) handled as text and number fields. Ricoh's PS drivers are documented in `docs/ricoh-ppd-options.md`.
## Build from source
Requirements: macOS 14+, Xcode Command Line Tools (full Xcode not needed).
```
git clone https://github.com/kfattic/cupsadmin.git
cd cupsadmin
swift build
.build/debug/cupsadmin printers
```
To produce a signed, notarized package like the ones in Releases:
```
export CODESIGN_APP_IDENTITY="<SHA-1 of your Developer ID Application cert>"
export CODESIGN_PKG_IDENTITY="<SHA-1 of your Developer ID Installer cert>"
export NOTARY_PROFILE="<name from: xcrun notarytool store-credentials>"
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.
`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).
## 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.
- 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.
## License
MIT — see `LICENSE`.
Built at Western Kentucky University by Kurt Fattic, with Claude. Not affiliated with Apple or OpenPrinting.

View File

@@ -0,0 +1,331 @@
import AppKit
import CupsKit
import Observation
import SwiftUI
/// Add Printer (manual) and Modify Printer: name, device URI, driver, description, location, shared.
@MainActor
@Observable
final class AddPrinterModel: Identifiable {
enum Driver: String, CaseIterable, Identifiable {
case keep, everywhere, list, file
var id: String { rawValue }
var title: String {
switch self {
case .keep: return "Keep Current"
case .everywhere: return "IPP Everywhere"
case .list: return "Driver List"
case .file: return "PPD File"
}
}
}
struct Model: Identifiable, Hashable {
let model: String
let name: String
var id: String { model }
}
let id = UUID()
/// nil when adding.
let original: PrinterOriginal?
var name = ""
var deviceURI = ""
var driver: Driver
var modelSearch = ""
var selectedModel: String?
var ppdFile: String?
var descriptionText = ""
var location = ""
var shared = false
private(set) var models: [Model] = []
private(set) var modelsError: String?
private(set) var isRunning = false
private(set) var errorText: String?
private(set) var checks: [SettingCheck] = []
private let store: PrinterStore
struct PrinterOriginal {
let name: String
let deviceURI: String
let description: String
let location: String
let shared: Bool
}
var isModify: Bool { original != nil }
init(store: PrinterStore, modifying printer: IPPGroup? = nil) {
self.store = store
if let printer {
let original = PrinterOriginal(name: printer.string("printer-name") ?? "",
deviceURI: printer.string("device-uri") ?? "",
description: printer.string("printer-info") ?? "",
location: printer.string("printer-location") ?? "",
shared: printer.bool("printer-is-shared") ?? false)
self.original = original
name = original.name
deviceURI = original.deviceURI
descriptionText = original.description
location = original.location
shared = original.shared
driver = .keep
} else {
original = nil
driver = .everywhere
}
}
var drivers: [Driver] { isModify ? Driver.allCases : Driver.allCases.filter { $0 != .keep } }
var filteredModels: [Model] {
let terms = modelSearch.split(separator: " ")
guard !terms.isEmpty else { return models }
return models.filter { m in terms.allSatisfy { m.name.localizedCaseInsensitiveContains($0) || m.model.localizedCaseInsensitiveContains($0) } }
}
var nameError: String? {
guard !isModify, !name.isEmpty else { return nil }
do { try QueueAdmin.validateQueueName(name) } catch { return "No spaces, / \\ ? ' \" #; up to 127 bytes" }
if store.printers.contains(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame }) {
return "A queue named \(name) already exists"
}
return nil
}
/// Only what changed, for Modify (so the PPD isn't regenerated needlessly); everything for Add.
var spec: QueueSpec {
var spec = QueueSpec()
let uri = deviceURI.trimmingCharacters(in: .whitespaces)
if original?.deviceURI != uri { spec.deviceURI = uri.isEmpty ? "<device-uri>" : uri }
switch driver {
case .keep: break
case .everywhere: spec.model = "everywhere"
case .list: spec.model = selectedModel
case .file: spec.ppdFile = ppdFile
}
if original?.description != descriptionText, !(original == nil && descriptionText.isEmpty) { spec.description = descriptionText }
if original?.location != location, !(original == nil && location.isEmpty) { spec.location = location }
if let original, original.shared != shared {
spec.options.append((key: "printer-is-shared", value: shared ? "true" : "false"))
}
return spec
}
var canSubmit: Bool {
guard !isRunning, !deviceURI.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
if !isModify, name.isEmpty || nameError != nil { return false }
if driver == .list, selectedModel == nil { return false }
if driver == .file, ppdFile == nil { return false }
if isModify, spec.isEmpty { return false }
return true
}
var command: String {
let args: [String]
if isModify {
args = spec.lpadminArguments(queue: name)
} else {
var addSpec = spec
addSpec.options.append((key: "printer-is-shared", value: shared ? "true" : "false"))
args = QueueAdmin.addArguments(queue: name.isEmpty ? "<name>" : name, spec: addSpec)
}
return CupsTools.commandLine(CupsTools.lpadminPath, args)
}
func loadModels() async {
guard models.isEmpty else { return }
do {
let list = try await Task.detached { try CupsTools.models() }.value
models = list.map { Model(model: $0.model, name: $0.name) }
.filter { $0.model != "everywhere" && $0.model != "raw" }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
} catch {
modelsError = String(describing: error)
}
}
func choosePPDFile() {
let panel = NSOpenPanel()
panel.title = "Choose a PPD"
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.directoryURL = URL(fileURLWithPath: "/Library/Printers/PPDs/Contents/Resources")
if panel.runModal() == .OK, let url = panel.url { ppdFile = url.path }
}
/// Returns true when every setting read back correctly.
func submit() async -> Bool {
isRunning = true
defer { isRunning = false }
errorText = nil
checks = []
let started = Date()
let client = store.client
do {
if isModify {
let spec = self.spec
_ = try await QueueAdmin.prepareSet(client: client, queue: name, spec: spec)
let result = try await Task.detached { [name] in try CupsTools.lpadmin(spec.lpadminArguments(queue: name)) }.value
checks = try await QueueAdmin.confirmSet(client: client, queue: name, spec: spec, lpadminStatus: result.status)
if !result.succeeded { errorText = result.message }
} else {
let prepared = try await QueueAdmin.prepareAdd(client: client, queue: name, spec: spec, shared: shared)
let result = try await Task.detached { [name] in try CupsTools.lpadmin(QueueAdmin.addArguments(queue: name, spec: prepared)) }.value
if !result.succeeded {
errorText = PrinterStore.explain(result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message)
} else {
let snapshot = try await QueueAdmin.confirmAdded(client: client, queue: name, lpadminStatus: result.status)
checks = snapshot.settings(for: prepared)
}
}
} catch {
errorText = PrinterStore.explain(String(describing: error))
}
let failed = checks.filter { !$0.applied }
let verb = isModify ? "Modified" : "Added"
let ok = errorText == nil && failed.isEmpty
if ok {
store.report("\(verb) \(name) in \(elapsedText(since: started)), \(checks.count) setting\(checks.count == 1 ? "" : "s") verified")
} else {
if errorText == nil {
errorText = "Not applied: " + failed.map { "\($0.name) (wanted \($0.wanted), is \($0.value ?? "nothing"))" }.joined(separator: "; ")
}
store.report("ERROR: \(isModify ? "modify" : "add") \(name): \(errorText!)")
}
store.noteChange(queue: name)
await store.refreshQuietly()
return ok
}
}
struct AddPrinterSheet: View {
@Bindable var model: AddPrinterModel
let onFinished: (String) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(model.isModify ? "Modify \(model.name)" : "Add Printer")
.font(.title3.weight(.semibold))
.padding([.horizontal, .top], 20)
Form {
Section {
LabeledContent {
if model.isModify {
Text(model.name).monospaced()
} else {
TextField("Name", text: $model.name, prompt: Text("Queue name"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
}
} label: {
Text("Name")
if let error = model.nameError { Text(error).foregroundStyle(.red) }
}
LabeledContent("Device URI") {
TextField("Device URI", text: $model.deviceURI, prompt: Text("ipp://printer.example.edu/ipp/print"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospaced()
}
}
Section("Driver") {
Picker("Driver", selection: $model.driver) {
ForEach(model.drivers) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
switch model.driver {
case .keep:
Text("The queue keeps its PPD and all option defaults.")
.foregroundStyle(.secondary)
case .everywhere:
Text("cupsd builds the PPD from the printer (lpadmin -m everywhere). The printer must be reachable.")
.foregroundStyle(.secondary)
case .list:
TextField("Search drivers", text: $model.modelSearch, prompt: Text("Search \(model.models.count) drivers"))
.textFieldStyle(.roundedBorder)
List(model.filteredModels, selection: $model.selectedModel) { item in
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
Text(item.model).font(.caption).monospaced().foregroundStyle(.secondary)
}
.tag(item.model)
}
.frame(height: 170)
.overlay {
if let error = model.modelsError {
ContentUnavailableView("Can’t List Drivers", systemImage: "exclamationmark.triangle",
description: Text(error))
} else if model.models.isEmpty {
ProgressView()
}
}
.task { await model.loadModels() }
case .file:
LabeledContent {
Button("Choose PPD File…") { model.choosePPDFile() }
} label: {
Text(model.ppdFile ?? "No file chosen")
.monospaced(model.ppdFile != nil)
.lineLimit(1)
.truncationMode(.middle)
}
}
}
Section {
LabeledContent("Description") {
TextField("Description", text: $model.descriptionText, prompt: Text("Optional"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
}
LabeledContent("Location") {
TextField("Location", text: $model.location, prompt: Text("Optional"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
}
Toggle("Share this printer on the network", isOn: $model.shared)
}
Section("Command") {
Text(model.command)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if let error = model.errorText {
Label(error, systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.formStyle(.grouped)
HStack {
if model.isRunning { ProgressView().controlSize(.small) }
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button(model.isModify ? "Save" : "Add Printer") {
Task {
if await model.submit() {
onFinished(model.name)
dismiss()
}
}
}
.keyboardShortcut(.defaultAction)
.disabled(!model.canSubmit)
}
.padding([.horizontal, .bottom], 20)
.padding(.top, 8)
}
.frame(width: 600, height: model.driver == .list ? 760 : 600)
}
}

View File

@@ -0,0 +1,12 @@
import SwiftUI
@main
struct CUPSAdminApp: App {
var body: some Scene {
// Single-window utility; SwiftUI saves and restores the window frame.
Window("CUPS Admin", id: "main") {
ContentView()
}
.defaultSize(width: 980, height: 640)
}
}

View File

@@ -0,0 +1,378 @@
import CupsKit
import SwiftUI
struct ContentView: View {
@State private var store = PrinterStore()
// Remembered across launches.
@AppStorage("selectedQueue") private var selection: String?
@AppStorage("detailTab") private var tab: DetailTab = .jobs
@State private var search = ""
@State private var detail: PrinterDetailModel?
@State private var options: OptionsModel?
@State private var proposedSelection: String?
@State private var confirmingDiscard = false
@State private var cancelAllTarget: PrinterSummary?
@State private var deleteTarget: PrinterSummary?
@State private var quickAction: QuickActionModel?
@State private var addPrinter: AddPrinterModel?
private var selectedPrinter: PrinterSummary? {
store.printers.first { $0.name == selection }
}
var body: some View {
NavigationSplitView {
SidebarView(store: store, selection: guardedSelection, search: $search, actions: sidebarActions)
.navigationSplitViewColumnWidth(min: 220, ideal: 270, max: 400)
} detail: {
if let detail, let options, selectedPrinter != nil {
PrinterDetailView(model: detail, options: options, store: store, tab: tab)
.id(detail.queue)
} else {
ContentUnavailableView("Select a Printer", systemImage: "printer",
description: Text("Choose a queue in the sidebar."))
.padding(.bottom, StatusBar.height)
}
}
// Whole-window status bar, under both columns.
.safeAreaInset(edge: .bottom, spacing: 0) {
StatusBar(action: store.statusLine, summary: store.summaryLine)
}
.navigationTitle(selection ?? "CUPS Admin")
.toolbar { toolbar }
.task {
await store.refresh()
await store.loadPPDs()
}
.onChange(of: store.revision) { Task { await store.loadPPDs() } }
.onChange(of: selection, initial: true) { _, queue in
detail = queue.map { PrinterDetailModel(queue: $0, store: store) }
options = queue.map { queue in
OptionsModel(queue: queue) { [store] line in store.report(line) }
}
}
.confirmationDialog("Discard unsaved option changes for \(selection ?? "")?",
isPresented: $confirmingDiscard) {
Button("Discard Changes", role: .destructive) { selection = proposedSelection }
Button("Keep Editing", role: .cancel) {}
} message: {
Text("\(options?.changeCount ?? 0) change(s) haven’t been applied.")
}
.confirmationDialog("Cancel all jobs on \(cancelAllTarget?.name ?? "")?",
isPresented: Binding(get: { cancelAllTarget != nil }, set: { if !$0 { cancelAllTarget = nil } }),
presenting: cancelAllTarget) { printer in
Button("Cancel \(printer.jobCount) Job\(printer.jobCount == 1 ? "" : "s")", role: .destructive) {
Task { await store.perform(.cancelAllJobs, on: printer.name) }
}
Button("Keep Jobs", role: .cancel) {}
} message: { printer in
Text("Runs cancel -a \(printer.name). This can’t be undone.")
}
.confirmationDialog("Delete printer \(deleteTarget?.name ?? "")?",
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }),
presenting: deleteTarget) { printer in
Button("Delete \(printer.name)", role: .destructive) {
Task {
if await store.delete(printer.name), selection == printer.name { selection = nil }
}
}
Button("Keep Printer", role: .cancel) {}
} message: { printer in
Text(printer.jobCount > 0
? "Runs lpadmin -x \(printer.name). Its \(printer.jobCount) active job\(printer.jobCount == 1 ? "" : "s") will be canceled. This can’t be undone."
: "Runs lpadmin -x \(printer.name). This can’t be undone.")
}
.sheet(item: $quickAction) { model in
QuickActionSheet(model: model)
}
.sheet(item: $addPrinter) { model in
AddPrinterSheet(model: model) { name in
if !model.isModify { selection = name }
}
}
.alert(item: $store.failure) { failure in
Alert(title: Text(failure.title), message: Text(failure.message))
}
}
@ToolbarContentBuilder private var toolbar: some ToolbarContent {
if selectedPrinter != nil {
ToolbarItem(placement: .principal) {
Picker("View", selection: $tab) {
ForEach(DetailTab.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
}
}
ToolbarItemGroup(placement: .primaryAction) {
Menu {
if let printer = selectedPrinter {
QuickActionItems(store: store, queue: printer.name) { action in
quickAction = QuickActionModel(action: action, queue: printer.name, store: store)
}
} else {
Text("Select a printer first")
}
} label: {
Label("Quick Actions", systemImage: "bolt")
}
.help("Quick Actions for the selected printer")
if let printer = selectedPrinter {
let pause: PrinterAction = printer.isPaused ? .resume : .pause
Button { Task { await store.perform(pause, on: printer.name) } } label: {
Label(pause.title, systemImage: pause.systemImage)
}
.help(printer.isPaused ? "Resume printing (cupsenable)" : "Pause printing (cupsdisable)")
let accept: PrinterAction = printer.accepting ? .reject : .accept
Button { Task { await store.perform(accept, on: printer.name) } } label: {
Label(accept.title, systemImage: accept.systemImage)
}
.help(printer.accepting ? "Stop accepting new jobs (cupsreject)" : "Accept new jobs (cupsaccept)")
}
Button {
addPrinter = AddPrinterModel(store: store)
} label: {
Label("Add Printer", systemImage: "plus")
}
.help("Add a printer (lpadmin -p … -E)")
Button {
Task {
await store.refresh()
store.noteChange()
}
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.help("Refresh printers and jobs")
.keyboardShortcut("r", modifiers: .command)
.disabled(store.isRefreshing)
}
}
private var sidebarActions: SidebarActions {
SidebarActions(
cancelAll: { cancelAllTarget = $0 },
delete: { deleteTarget = $0 },
modify: { printer in
Task {
if let attributes = try? await store.client.getPrinterAttributes(queue: printer.name,
requested: PrinterDetailModel.headerAttributes) {
addPrinter = AddPrinterModel(store: store, modifying: attributes)
}
}
},
quick: { action, printer in
quickAction = QuickActionModel(action: action, queue: printer.name, store: store)
}
)
}
/// Selection that asks before leaving a printer with unapplied option edits.
private var guardedSelection: Binding<String?> {
Binding(
get: { selection },
set: { newValue in
guard newValue != selection else { return }
if options?.hasChanges == true {
proposedSelection = newValue
confirmingDiscard = true
} else {
selection = newValue
}
}
)
}
}
struct SidebarActions {
let cancelAll: (PrinterSummary) -> Void
let delete: (PrinterSummary) -> Void
let modify: (PrinterSummary) -> Void
let quick: (QuickAction, PrinterSummary) -> Void
}
struct SidebarView: View {
let store: PrinterStore
@Binding var selection: String?
@Binding var search: String
let actions: SidebarActions
private var filtered: [PrinterSummary] {
store.printers.filter { $0.matches(search: search) }
}
var body: some View {
List(selection: $selection) {
Section("Printers") {
ForEach(filtered) { printer in
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name)
.tag(printer.name)
.contextMenu { menu(for: printer) }
}
}
}
.listStyle(.sidebar)
.searchable(text: $search, placement: .sidebar, prompt: "Search printers")
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
.overlay {
if store.printers.isEmpty {
if let error = store.errorMessage {
ContentUnavailableView("Can’t Reach CUPS", systemImage: "exclamationmark.triangle",
description: Text(error))
} else if store.hasLoaded {
ContentUnavailableView("No Printers", systemImage: "printer",
description: Text("No queues are set up on this Mac."))
} else {
ProgressView()
}
} else if filtered.isEmpty {
ContentUnavailableView.search(text: search)
}
}
}
@ViewBuilder private func menu(for printer: PrinterSummary) -> some View {
Group {
let pause: PrinterAction = printer.isPaused ? .resume : .pause
Button { Task { await store.perform(pause, on: printer.name) } } label: {
Label(pause.title, systemImage: pause.systemImage)
}
let accept: PrinterAction = printer.accepting ? .reject : .accept
Button { Task { await store.perform(accept, on: printer.name) } } label: {
Label(accept.title, systemImage: accept.systemImage)
}
Button { Task { await store.printTestPage(printer.name) } } label: {
Label("Print Test Page", systemImage: "doc.text")
}
Button(role: .destructive) { actions.cancelAll(printer) } label: {
Label("Cancel All Jobs…", systemImage: "xmark.circle")
}
.disabled(printer.jobCount == 0)
Divider()
Button {
if let action = QuickAction.named("default") { actions.quick(action, printer) }
} label: {
Label("Set as Default Printer", systemImage: "star")
}
.disabled(store.defaultPrinter == printer.name)
Button { actions.modify(printer) } label: {
Label("Modify Printer…", systemImage: "slider.horizontal.3")
}
Button(role: .destructive) { actions.delete(printer) } label: {
Label("Delete Printer…", systemImage: "trash")
}
Divider()
Menu {
QuickActionItems(store: store, queue: printer.name) { actions.quick($0, printer) }
} label: {
Label("Quick Actions", systemImage: "bolt")
}
Divider()
Button { store.copyDeviceURI(printer) } label: {
Label("Copy Device URI", systemImage: "doc.on.doc")
}
.disabled(printer.deviceURI == nil)
Button { store.showPPDInFinder(printer.name) } label: {
Label("Show PPD in Finder", systemImage: "folder")
}
.disabled(!store.hasPPDFile(printer.name))
}
.labelStyle(.titleAndIcon)
}
}
/// Quick Action menu items for one queue; unavailable ones are disabled with the reason as tooltip.
struct QuickActionItems: View {
let store: PrinterStore
let queue: String
let choose: (QuickAction) -> Void
var body: some View {
ForEach(QuickAction.all) { action in
let reason = store.unavailableReason(action, queue: queue)
Button { choose(action) } label: {
Label(action.title, systemImage: action.systemImage)
}
.disabled(reason != nil)
.help(reason ?? action.summary)
}
.labelStyle(.titleAndIcon)
}
}
struct PrinterRow: View {
let printer: PrinterSummary
var isDefault = false
var body: some View {
HStack(spacing: 8) {
Circle()
.fill(printer.status.color)
.frame(width: 8, height: 8)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(printer.name)
.lineLimit(1)
if isDefault {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.secondary)
.help("Server default printer")
.accessibilityLabel("Default printer")
}
}
if let secondary = printer.secondaryLine {
Text(secondary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.badge(printer.jobCount)
.help(printer.stateSummary)
.accessibilityElement(children: .combine)
.accessibilityValue(printer.stateSummary)
}
}
struct StatusBar: View {
/// Content above the bar adds this much bottom margin so its last row isn't hidden.
static let height: CGFloat = 30
/// Last action with its timing, e.g. "Refreshed 21 printers in 42 ms" or "ERROR: ...".
let action: String
/// Totals, e.g. "21 printers · 0 active jobs".
let summary: String
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
Text(action)
.foregroundStyle(action.hasPrefix("ERROR") ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(summary)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.font(.callout)
.monospacedDigit()
.textSelection(.enabled)
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.background(.regularMaterial)
}
}

View File

@@ -0,0 +1,257 @@
import CupsKit
import Foundation
import Observation
import OSLog
/// Options tab state for one queue: queue defaults, pending edits, and per-row apply results.
/// Apply always writes queue defaults with `lpadmin -p queue -o` (never lpoptions).
@MainActor
@Observable
final class OptionsModel {
enum RowStatus: Equatable {
case applying
case applied(note: String?)
case failed(reason: String)
}
struct CustomPair: Identifiable, Equatable {
let id = UUID()
let key: String
let value: String
var status: RowStatus?
}
struct Failure: Identifiable {
let id = UUID()
let title: String
let message: String
}
let queue: String
private(set) var state: OptionState?
private(set) var sections: [OptionSection] = []
private(set) var loadError: String?
private(set) var isApplying = false
/// Row key -> new raw value.
private(set) var pending: [String: String] = [:]
/// Field text as typed, so invalid input stays visible with its error.
private(set) var fieldText: [String: String] = [:]
private(set) var rowStatus: [String: RowStatus] = [:]
private(set) var customPairs: [CustomPair] = []
var customEntry = ""
private(set) var customEntryError: String?
var failure: Failure?
private let report: (String) -> Void
private let client = CupsClient()
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "options")
init(queue: String, report: @escaping (String) -> Void) {
self.queue = queue
self.report = report
}
// MARK: loading
func load() async {
let started = Date()
do {
state = try await OptionState.load(client: client, queue: queue)
loadError = nil
rebuild()
let count = sections.reduce(0) { $0 + $1.rows.count }
report("Loaded \(count) options for \(queue) in \(elapsedText(since: started))")
log.info("loaded \(count) options for \(self.queue, privacy: .public) in \(Date().timeIntervalSince(started), format: .fixed(precision: 3)) s")
} catch {
loadError = String(describing: error)
report("ERROR: \(error)")
log.error("load failed for \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
}
}
private func rebuild() {
sections = state?.sections() ?? []
}
// MARK: values
var hasChanges: Bool { !pending.isEmpty || customPairs.contains { $0.status == nil } }
var changeCount: Int { pending.count + customPairs.filter { $0.status == nil }.count }
var hasErrors: Bool { sections.flatMap(\.rows).contains { validationError($0) != nil } }
/// The queue default before pending edits.
func currentValue(_ row: OptionRow) -> String? {
state?.queueValue(row.key)
}
/// The value shown in the control: pending edit, else current.
func value(_ row: OptionRow) -> String? {
pending[row.key] ?? currentValue(row)
}
func set(_ row: OptionRow, to newValue: String) {
rowStatus[row.key] = nil
pending[row.key] = newValue == currentValue(row) ? nil : newValue
}
func fieldText(_ row: OptionRow, field: CustomField) -> String {
fieldText[row.key] ?? field.text(fromValue: value(row))
}
func setFieldText(_ text: String, row: OptionRow, field: CustomField) {
fieldText[row.key] = text
set(row, to: field.value(fromText: text))
if pending[row.key] == nil { fieldText[row.key] = nil }
}
func validationError(_ row: OptionRow) -> String? {
guard case .field(let field) = row.control, let text = fieldText[row.key] else { return nil }
return field.validationError(text)
}
func isPending(_ row: OptionRow) -> Bool { pending[row.key] != nil }
/// Your own ~/.cups/lpoptions value wins over the queue default for your jobs; say so.
func overrideNote(_ row: OptionRow) -> String? {
guard let state, let mine = state.userOverride(row.key) else { return nil }
return "Your ~/.cups/lpoptions overrides this for your jobs: \(state.display(mine, row: row))"
}
// MARK: custom option row
func addCustomEntry() {
let entry = customEntry.trimmingCharacters(in: .whitespaces)
guard let eq = entry.firstIndex(of: "="), eq != entry.startIndex else {
customEntryError = "Use keyword=value"
return
}
let key = String(entry[..<eq])
guard !key.contains(where: \.isWhitespace) else {
customEntryError = "Keyword can't contain spaces"
return
}
customPairs.append(CustomPair(key: key, value: String(entry[entry.index(after: eq)...])))
customEntry = ""
customEntryError = nil
}
func removeCustomPair(_ pair: CustomPair) {
customPairs.removeAll { $0.id == pair.id }
}
// MARK: apply
struct ChangeSummary: Identifiable {
let id: String
let label: String
let from: String
let to: String
}
var changes: [OptionChange] {
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
let edits = pending.keys.sorted().map { key in
OptionChange(key: key, value: pending[key]!, isSecret: rows[key]?.isSecret ?? false)
}
let raw = customPairs.filter { $0.status == nil }.map {
OptionChange(key: $0.key, value: $0.value, isSecret: rows[$0.key]?.isSecret ?? false)
}
return edits + raw
}
var changeSummaries: [ChangeSummary] {
guard let state else { return [] }
let rows = Dictionary(uniqueKeysWithValues: sections.flatMap(\.rows).map { ($0.key, $0) })
return changes.map { change in
guard let row = rows[change.key] else {
return ChangeSummary(id: change.key, label: change.key, from: state.queueValue(change.key) ?? "—",
to: change.value)
}
return ChangeSummary(id: change.key, label: "\(row.label) (\(row.key))",
from: state.display(currentValue(row), row: row), to: state.display(change.value, row: row))
}
}
var command: String {
OptionApplier.displayCommand(queue: queue, changes: changes)
}
/// Shown in the apply sheet when passwords would land in a world-readable file.
var secretWarning: String? {
let secrets = changes.filter(\.isSecret)
guard !secrets.isEmpty else { return nil }
let names = secrets.map(\.key).joined(separator: ", ")
return "\(names) will be saved in plain text in /etc/cups/ppd/\(queue).ppd as the queue default. "
+ "Every user on this Mac can read that file, and every job on this queue will send it."
}
let scopeDescription = "Every user on this Mac · lpadmin"
func revert() {
pending = [:]
fieldText = [:]
customPairs.removeAll { $0.status == nil }
rowStatus = [:]
}
func apply() async {
let changes = self.changes
guard !changes.isEmpty, !isApplying else { return }
isApplying = true
defer { isApplying = false }
let started = Date()
for change in changes { rowStatus[change.key] = .applying }
for i in customPairs.indices where customPairs[i].status == nil { customPairs[i].status = .applying }
log.info("apply \(changes.count) options on \(self.queue, privacy: .public) with lpadmin")
do {
let (result, readBack, newState) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
state = newState
rebuild()
pending = [:]
fieldText = [:]
for check in readBack {
let status: RowStatus = check.applied ? .applied(note: check.reason) : .failed(reason: check.reason ?? "Not applied")
rowStatus[check.key] = status
if let i = customPairs.firstIndex(where: { $0.key == check.key && $0.status == .applying }) {
customPairs[i].status = status
}
}
let failed = readBack.filter { !$0.applied }
let seconds = Date().timeIntervalSince(started)
if failed.isEmpty {
report("Set \(changes.count) option\(changes.count == 1 ? "" : "s") on \(queue) in \(elapsedText(seconds))")
} else {
report("ERROR: \(failed.count) of \(changes.count) not applied on \(queue): "
+ failed.map(\.key).joined(separator: ", ") + " — \(failed[0].reason ?? "")")
}
log.info("apply finished on \(self.queue, privacy: .public): \(changes.count - failed.count) applied, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
if !result.succeeded {
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: Self.explain(result))
}
} catch {
for change in changes { rowStatus[change.key] = .failed(reason: String(describing: error)) }
for i in customPairs.indices where customPairs[i].status == .applying {
customPairs[i].status = .failed(reason: String(describing: error))
}
report("ERROR: \(error)")
failure = Failure(title: "Couldn’t Change Options on \(queue)", message: String(describing: error))
log.error("apply failed on \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)")
}
}
/// Tool output plus the fix when it's a permissions problem.
private static func explain(_ result: ToolResult) -> String {
let message = result.message.isEmpty ? "\(result.commandLine) exited \(result.status)" : result.message
let lower = message.lowercased()
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|| lower.contains("authentication") {
return message + "\n\nChanging queue defaults needs an admin account or membership in _lpadmin:\n"
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
}
return message
}
}

View File

@@ -0,0 +1,335 @@
import CupsKit
import SwiftUI
/// The web UI's "Set Default Options" page: every PPD option by group, IPP defaults, and queue policies.
struct OptionsView: View {
@Bindable var model: OptionsModel
@State private var showingApplySheet = false
var body: some View {
Group {
if model.state == nil, let error = model.loadError {
ContentUnavailableView("Can’t Load Options", systemImage: "exclamationmark.triangle",
description: Text(error))
} else if model.state == nil {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
form
}
}
.safeAreaInset(edge: .top, spacing: 0) { actionBar }
.sheet(isPresented: $showingApplySheet) {
ApplyOptionsSheet(model: model) {
showingApplySheet = false
Task { await model.apply() }
}
}
.alert(item: $model.failure) { failure in
Alert(title: Text(failure.title), message: Text(failure.message))
}
.task(id: model.queue) { await model.load() }
}
private var actionBar: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Text(model.scopeDescription)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 12)
if model.isApplying {
ProgressView().controlSize(.small)
} else if model.hasChanges {
Text("\(model.changeCount) unsaved change\(model.changeCount == 1 ? "" : "s")")
.foregroundStyle(.secondary)
.monospacedDigit()
.accessibilityIdentifier("options.changeCount")
}
Button("Revert") { model.revert() }
.disabled(!model.hasChanges || model.isApplying)
.accessibilityIdentifier("options.revert")
Button("Apply…") { showingApplySheet = true }
.keyboardShortcut(.defaultAction)
.disabled(!model.hasChanges || model.hasErrors || model.isApplying)
.accessibilityIdentifier("options.apply")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
}
.background(.bar)
}
private var form: some View {
Form {
ForEach(model.sections) { section in
Section {
ForEach(section.rows) { row in
OptionRowView(model: model, row: row)
}
} header: {
Text(section.title)
} footer: {
if section.title == "IPP" {
Text("Queue-wide IPP defaults. On PPD queues, media, sides, output bin, resolution and color follow their PPD option.")
.foregroundStyle(.secondary)
}
}
}
Section {
ForEach(model.customPairs) { pair in
LabeledContent {
HStack(spacing: 8) {
StatusIndicator(status: pair.status, pending: pair.status == nil)
Button {
model.removeCustomPair(pair)
} label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
.help("Remove")
.disabled(model.isApplying)
}
} label: {
Text("\(pair.key)=\(pair.value)")
.monospaced()
.textSelection(.enabled)
.accessibilityIdentifier("options.custom.\(pair.key)")
if case .failed(let reason) = pair.status {
Text(reason).foregroundStyle(.red)
} else if case .applied(let note?) = pair.status {
Text(note)
}
}
}
LabeledContent {
HStack {
TextField("Add option manually", text: $model.customEntry, prompt: Text("keyword=value"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospaced()
.frame(minWidth: 260)
.onSubmit { model.addCustomEntry() }
.accessibilityIdentifier("options.customEntry")
Button("Add") { model.addCustomEntry() }
.disabled(model.customEntry.isEmpty || model.isApplying)
.accessibilityIdentifier("options.customAdd")
}
} label: {
Text("Add option manually")
Text(model.customEntryError ?? "Any lpadmin -o pair, e.g. RIUserCode=Custom.1234")
.foregroundStyle(model.customEntryError == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red))
}
} header: {
Text("Advanced")
}
}
.formStyle(.grouped)
// Keep the last row clear of the window's status bar.
.contentMargins(.bottom, StatusBar.height, for: .scrollContent)
}
}
struct OptionRowView: View {
let model: OptionsModel
let row: OptionRow
var body: some View {
LabeledContent {
HStack(spacing: 8) {
control
.accessibilityIdentifier("option.\(row.key)")
StatusIndicator(status: model.rowStatus[row.key], pending: model.isPending(row))
}
.disabled(model.isApplying)
} label: {
Text(row.label)
Text(keywordLine)
.monospaced()
.textSelection(.enabled)
if let error = model.validationError(row) {
Text(error).foregroundStyle(.red)
} else if case .failed(let reason) = model.rowStatus[row.key] {
Text(reason).foregroundStyle(.red)
} else if case .applied(let note?) = model.rowStatus[row.key] {
Text(note)
} else if case .readOnly(let reason) = row.control {
Text(reason)
} else if let note = model.overrideNote(row) {
Text(note)
}
}
}
private var keywordLine: String {
if case .field(let field) = row.control { return "\(row.key) · \(field.hint)" }
return row.key
}
@ViewBuilder private var control: some View {
switch row.control {
case .picker(let choices):
Picker(row.label, selection: Binding(
get: { model.value(row) ?? "" },
set: { model.set(row, to: $0) }
)) {
let current = model.value(row) ?? ""
if !choices.contains(where: { $0.keyword == current }) {
Text(current.isEmpty ? "Not set" : current).tag(current)
}
ForEach(choices, id: \.keyword) { choice in
Text(choice.text).tag(choice.keyword)
}
}
.labelsHidden()
.fixedSize()
case .toggle(let on, let off):
Toggle(row.label, isOn: Binding(
get: { model.value(row) == on },
set: { model.set(row, to: $0 ? on : off) }
))
.labelsHidden()
.toggleStyle(.switch)
case .field(let field):
let text = Binding(
get: { model.fieldText(row, field: field) },
set: { model.setFieldText($0, row: row, field: field) }
)
let prompt = Text(field.choices.first ?? "")
Group {
if field.isSecret {
SecureField(row.label, text: text, prompt: prompt)
} else {
TextField(row.label, text: text, prompt: prompt)
}
}
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospacedDigit()
.frame(width: field.isNumeric ? 110 : 200)
case .readOnly:
Text(model.state?.display(model.value(row), row: row) ?? "—")
.foregroundStyle(.secondary)
.monospaced()
.textSelection(.enabled)
}
}
}
/// Unsaved dot, spinner while applying, then a green check or red X after read-back.
struct StatusIndicator: View {
let status: OptionsModel.RowStatus?
let pending: Bool
var body: some View {
Group {
switch status {
case .applying:
ProgressView().controlSize(.mini)
case .applied:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.help("Applied and verified")
case .failed(let reason):
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
.help(reason)
case nil:
if pending {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundStyle(.tint)
.help("Unsaved change")
} else {
Color.clear
}
}
}
.frame(width: 16, height: 16)
.accessibilityLabel(accessibilityText)
}
private var accessibilityText: String {
switch status {
case .applying: return "Applying"
case .applied: return "Applied"
case .failed(let reason): return "Not applied: \(reason)"
case nil: return pending ? "Unsaved change" : ""
}
}
}
struct ApplyOptionsSheet: View {
let model: OptionsModel
let apply: () -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Apply \(model.changeCount) Change\(model.changeCount == 1 ? "" : "s") to \(model.queue)?")
.font(.title3.weight(.semibold))
Text("Changes the queue default for every user on this Mac.")
.foregroundStyle(.secondary)
}
if let warning = model.secretWarning {
Label {
Text(warning)
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
}
.fixedSize(horizontal: false, vertical: true)
}
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
GridRow {
Text("Setting")
Text("Current")
Text("New")
}
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
Divider()
ForEach(model.changeSummaries) { change in
GridRow {
Text(change.label)
Text(change.from).foregroundStyle(.secondary)
Text(change.to)
}
}
}
.textSelection(.enabled)
VStack(alignment: .leading, spacing: 6) {
Text("Command")
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
Text(model.command)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("applySheet.cancel")
Button("Apply") { apply() }
.keyboardShortcut(.defaultAction)
.accessibilityIdentifier("applySheet.apply")
}
}
.padding(20)
.frame(width: 560)
}
}

View File

@@ -0,0 +1,149 @@
import CupsKit
import Foundation
import Observation
import OSLog
enum DetailTab: String, CaseIterable, Identifiable {
case jobs, options
var id: String { rawValue }
var title: String { self == .jobs ? "Jobs" : "Options" }
}
enum JobFilter: String, CaseIterable, Identifiable {
case active, completed, all
var id: String { rawValue }
var title: String {
switch self {
case .active: return "Active"
case .completed: return "Completed"
case .all: return "All"
}
}
/// Get-Jobs which-jobs.
var whichJobs: String {
switch self {
case .active: return "not-completed"
case .completed: return "completed"
case .all: return "all"
}
}
}
/// Header attributes and the Jobs table for one printer.
@MainActor
@Observable
final class PrinterDetailModel {
let queue: String
private(set) var printer: IPPGroup?
private(set) var jobs: [JobSummary] = []
private(set) var loadError: String?
private(set) var isWorking = false
var selection = Set<Int>()
var sortOrder = [KeyPathComparator(\JobSummary.id, order: .reverse)]
var jobFilter: JobFilter = .active {
didSet { if jobFilter != oldValue { Task { await loadJobs() } } }
}
private let store: PrinterStore
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "jobs")
init(queue: String, store: PrinterStore) {
self.queue = queue
self.store = store
}
static let headerAttributes = [
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
"printer-is-accepting-jobs", "printer-is-shared", "device-uri", "printer-make-and-model",
"printer-info", "printer-location", "queued-job-count",
]
var sortedJobs: [JobSummary] { jobs.sorted(using: sortOrder) }
var selectedJobs: [JobSummary] { jobs.filter { selection.contains($0.id) } }
/// The queue's PPD, if it has one (driverless and raw queues don't).
var ppdPath: String? {
let path = "/private/etc/cups/ppd/\(queue).ppd"
return FileManager.default.fileExists(atPath: path) ? path : nil
}
func load() async {
do {
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
loadError = nil
} catch {
loadError = String(describing: error)
store.report("ERROR: \(queue): \(error)")
}
await loadJobs()
}
func loadJobs() async {
do {
let groups = try await store.client.getJobs(queue: queue, which: jobFilter.whichJobs,
requested: JobSummary.requestedAttributes)
jobs = groups.map(JobSummary.init)
selection = selection.intersection(jobs.map(\.id))
} catch {
store.report("ERROR: jobs on \(queue): \(error)")
}
}
// MARK: job actions
func canPerform(_ action: JobAction, on ids: Set<Int>) -> Bool {
let targets = jobs.filter { ids.contains($0.id) }
guard !targets.isEmpty, !isWorking else { return false }
switch action {
case .cancel, .move: return targets.allSatisfy(\.isActive)
case .hold: return targets.allSatisfy { $0.isActive && !$0.isHeld }
case .release: return targets.allSatisfy(\.isHeld)
}
}
func perform(_ action: JobAction, on ids: Set<Int>) async {
let ordered = ids.sorted()
guard !ordered.isEmpty else { return }
isWorking = true
defer { isWorking = false }
let started = Date()
log.info("\(action.title, privacy: .public) jobs \(ordered.map(String.init).joined(separator: ","), privacy: .public) on \(self.queue, privacy: .public)")
let outcomes = await QueueActions.perform(action, jobIDs: ordered, client: store.client)
let failed = ordered.filter { outcomes[$0]?.succeeded != true }
let seconds = Date().timeIntervalSince(started)
let noun = ordered.count == 1 ? "job \(ordered[0])" : "\(ordered.count) jobs"
if failed.isEmpty {
store.report("\(action.pastTense) \(noun) in \(elapsedText(seconds))")
} else {
let details = failed.map { "job \($0): \(outcomes[$0]?.message ?? "failed")" }
store.report("ERROR: \(failed.count) of \(ordered.count) not applied — \(details[0])")
store.fail("\(action.title) didn’t apply to \(failed.count) job\(failed.count == 1 ? "" : "s")",
(details + failed.compactMap { outcomes[$0]?.command }.prefix(1)).joined(separator: "\n"))
}
log.info("\(action.title, privacy: .public): \(ordered.count - failed.count) ok, \(failed.count) failed in \(seconds, format: .fixed(precision: 3)) s")
store.noteChange()
await loadJobs()
await store.refreshQuietly()
}
/// Tab-separated rows for the clipboard, for pasting into tickets.
func copyText(for ids: Set<Int>) -> String {
let header = ["ID", "Name", "User", "Size", "Pages", "State", "Reasons", "Submitted"].joined(separator: "\t")
let rows = sortedJobs.filter { ids.contains($0.id) }.map { job in
[String(job.id), job.name, job.user, Format.kilobytes(job.sizeK), job.pages.map(String.init) ?? "",
job.stateName, job.reasons.joined(separator: ","), job.submitted.map(Format.timestamp) ?? ""]
.joined(separator: "\t")
}
return ([header] + rows).joined(separator: "\n")
}
}
extension JobSummary {
var pagesSortKey: Int { pages ?? -1 }
var reasonsText: String { reasons.joined(separator: ", ") }
var submittedSortKey: Date { submitted ?? .distantPast }
}

View File

@@ -0,0 +1,236 @@
import AppKit
import CupsKit
import SwiftUI
/// Printer header (grouped Form) above the Jobs or Options tab.
struct PrinterDetailView: View {
let model: PrinterDetailModel
let options: OptionsModel
let store: PrinterStore
let tab: DetailTab
var body: some View {
VStack(spacing: 0) {
if let printer = model.printer {
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath)
Divider()
} else if let error = model.loadError {
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
description: Text(error))
.frame(maxHeight: 220)
Divider()
}
switch tab {
case .jobs:
JobsView(model: model, store: store)
case .options:
OptionsView(model: options)
}
}
.task(id: model.queue) { await model.load() }
.onChange(of: store.revision) { Task { await model.load() } }
}
}
struct PrinterHeaderView: View {
let printer: IPPGroup
let ppdPath: String?
private var state: Int? { printer.int("printer-state") }
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
private var reasons: [String] { printer.strings("printer-state-reasons").filter { $0 != "none" } }
private var stateColor: Color {
if state == 5 || !accepting { return .red }
return state == 4 ? .blue : .green
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
Form {
Section("Status") {
LabeledContent("State") {
HStack(spacing: 6) {
Circle().fill(stateColor).frame(width: 8, height: 8)
Text(Format.printerState(state).capitalized)
}
}
LabeledContent("Reasons") {
Text(reasons.isEmpty ? "None" : reasons.joined(separator: ", "))
.monospaced(!reasons.isEmpty)
}
LabeledContent("Message") {
Text(printer.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
.lineLimit(2)
}
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
}
}
Form {
Section("Queue") {
LabeledContent("Device URI") {
Text(printer.string("device-uri") ?? "—")
.monospaced()
.lineLimit(1)
.truncationMode(.middle)
}
LabeledContent {
VStack(alignment: .trailing, spacing: 2) {
Text(printer.string("printer-make-and-model") ?? "—")
Text(ppdPath ?? "No PPD (driverless or raw)")
.font(.caption)
.monospaced(ppdPath != nil)
.foregroundStyle(.secondary)
}
} label: {
Text("Model")
}
LabeledContent("Location", value: printer.string("printer-location").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
LabeledContent("Description", value: printer.string("printer-info").flatMap { $0.isEmpty ? nil : $0 } ?? "—")
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.textSelection(.enabled)
.frame(height: 262)
}
}
struct JobsView: View {
@Bindable var model: PrinterDetailModel
let store: PrinterStore
@SceneStorage("JobsTableColumns") private var columns = TableColumnCustomization<JobSummary>()
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
Picker("Show", selection: $model.jobFilter) {
ForEach(JobFilter.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
Spacer(minLength: 12)
if model.isWorking { ProgressView().controlSize(.small) }
jobButtons(for: model.selection)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
Divider()
Table(model.sortedJobs, selection: $model.selection, sortOrder: $model.sortOrder,
columnCustomization: $columns) {
TableColumn("ID", value: \.id) { Text(String($0.id)).monospacedDigit() }
.width(min: 40, ideal: 56, max: 80)
.customizationID("id")
TableColumn("Name", value: \.name)
.width(min: 120, ideal: 220, max: 360)
.customizationID("name")
TableColumn("User", value: \.user)
.width(min: 60, ideal: 110, max: 200)
.customizationID("user")
TableColumn("Size", value: \.sizeK) { Text(Format.kilobytes($0.sizeK)).monospacedDigit() }
.width(min: 50, ideal: 64, max: 90)
.customizationID("size")
TableColumn("Pages", value: \.pagesSortKey) { Text($0.pages.map(String.init) ?? "—").monospacedDigit() }
.width(min: 44, ideal: 54, max: 80)
.customizationID("pages")
TableColumn("State", value: \.state) { Text($0.stateName.capitalized) }
.width(min: 70, ideal: 90, max: 120)
.customizationID("state")
// Reasons takes the spare width; the other columns are capped.
TableColumn("Reasons", value: \.reasonsText) { Text($0.reasonsText).monospaced() }
.width(min: 120, ideal: 320, max: .infinity)
.customizationID("reasons")
TableColumn("Submitted", value: \.submittedSortKey) {
Text($0.submitted.map(Format.timestamp) ?? "—").monospacedDigit()
}
.width(150)
.customizationID("submitted")
}
.alternatingRowBackgrounds(.disabled)
.contextMenu(forSelectionType: Int.self) { ids in
jobMenu(for: ids)
}
.onCopyCommand {
[NSItemProvider(object: model.copyText(for: model.selection) as NSString)]
}
.overlay {
if model.jobs.isEmpty {
ContentUnavailableView(emptyTitle, systemImage: "doc.text.magnifyingglass")
}
}
.padding(.bottom, StatusBar.height)
}
}
private var emptyTitle: String {
switch model.jobFilter {
case .active: return "No Active Jobs"
case .completed: return "No Completed Jobs"
case .all: return "No Jobs"
}
}
private var otherQueues: [String] {
store.printers.map(\.name).filter { $0 != model.queue }
}
@ViewBuilder private func jobButtons(for ids: Set<Int>) -> some View {
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
.disabled(!model.canPerform(.cancel, on: ids))
.help("Cancel the selected jobs")
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
.disabled(!model.canPerform(.hold, on: ids))
.help("Hold the selected jobs (lp -H hold)")
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
.disabled(!model.canPerform(.release, on: ids))
.help("Release held jobs (lp -H resume)")
Menu {
ForEach(otherQueues, id: \.self) { queue in
Button(queue) { run(.move(to: queue), ids) }
}
} label: {
Label("Move To", systemImage: "arrow.right.circle")
}
.fixedSize()
.disabled(!model.canPerform(.move(to: ""), on: ids) || otherQueues.isEmpty)
.help("Move the selected jobs to another queue (lpmove)")
}
@ViewBuilder private func jobMenu(for ids: Set<Int>) -> some View {
Group {
Button { run(.cancel, ids) } label: { Label("Cancel", systemImage: "xmark.circle") }
.disabled(!model.canPerform(.cancel, on: ids))
Button { run(.hold, ids) } label: { Label("Hold", systemImage: "pause.circle") }
.disabled(!model.canPerform(.hold, on: ids))
Button { run(.release, ids) } label: { Label("Release", systemImage: "play.circle") }
.disabled(!model.canPerform(.release, on: ids))
Menu {
ForEach(otherQueues, id: \.self) { queue in
Button(queue) { run(.move(to: queue), ids) }
}
} label: {
Label("Move To", systemImage: "arrow.right.circle")
}
.disabled(!model.canPerform(.move(to: ""), on: ids))
Divider()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(model.copyText(for: ids), forType: .string)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.disabled(ids.isEmpty)
}
.labelStyle(.titleAndIcon)
}
private func run(_ action: JobAction, _ ids: Set<Int>) {
Task { await model.perform(action, on: ids) }
}
}

View File

@@ -0,0 +1,291 @@
import AppKit
import CupsKit
import Foundation
import Observation
import OSLog
import SwiftUI
/// One sidebar row's worth of queue state, from CUPS-Get-Printers.
struct PrinterSummary: Identifiable, Hashable {
enum Status {
case idle, processing, stopped
var color: Color {
switch self {
case .idle: return .green
case .processing: return .blue
case .stopped: return .red
}
}
}
let name: String
let state: Int?
let reasons: [String]
let stateMessage: String?
let accepting: Bool
let info: String?
let location: String?
let deviceURI: String?
let jobCount: Int
var id: String { name }
static let requestedAttributes = [
"printer-name", "printer-state", "printer-state-reasons", "printer-state-message",
"printer-is-accepting-jobs", "printer-info", "printer-location", "device-uri", "queued-job-count",
]
init(_ attributes: IPPGroup) {
name = attributes.string("printer-name") ?? "?"
state = attributes.int("printer-state")
reasons = attributes.strings("printer-state-reasons").filter { $0 != "none" }
stateMessage = attributes.string("printer-state-message").flatMap { $0.isEmpty ? nil : $0 }
accepting = attributes.bool("printer-is-accepting-jobs") ?? true
info = attributes.string("printer-info").flatMap { $0.isEmpty ? nil : $0 }
location = attributes.string("printer-location").flatMap { $0.isEmpty ? nil : $0 }
deviceURI = attributes.string("device-uri").flatMap { $0.isEmpty ? nil : $0 }
jobCount = attributes.int("queued-job-count") ?? 0
}
var isPaused: Bool { state == 5 }
/// Red when stopped or rejecting jobs, blue while printing, green otherwise.
var status: Status {
if state == 5 || !accepting { return .stopped }
return state == 4 ? .processing : .idle
}
/// Location, else description, else nothing — never text that just restates the name, an IP or a URI.
var secondaryLine: String? {
[location, info].compactMap { $0 }.first { !Self.restates($0, name: name) }
}
static func restates(_ text: String, name: String) -> Bool {
func letters(_ s: String) -> String { s.lowercased().filter { $0.isLetter || $0.isNumber } }
if letters(text) == letters(name) { return true }
if text.contains("://") { return true }
return text.range(of: #"^\d{1,3}(\.\d{1,3}){3}$"#, options: .regularExpression) != nil
}
/// "Idle", "Stopped — paused", "Idle, rejecting jobs".
var stateSummary: String {
var text = Format.printerState(state).capitalized
if !reasons.isEmpty { text += " — " + reasons.joined(separator: ", ") }
if !accepting { text += ", rejecting jobs" }
return text
}
func matches(search: String) -> Bool {
guard !search.isEmpty else { return true }
return [name, location, info, deviceURI].compactMap { $0 }
.contains { $0.localizedCaseInsensitiveContains(search) }
}
}
@MainActor
@Observable
final class PrinterStore {
struct Failure: Identifiable {
let id = UUID()
let title: String
let message: String
}
private(set) var printers: [PrinterSummary] = []
private(set) var isRefreshing = false
private(set) var hasLoaded = false
private(set) var errorMessage: String?
private(set) var statusLine = ""
/// Bumped after any write, so open detail views reload.
private(set) var revision = 0
/// The server default printer (CUPS-Get-Default), if any.
private(set) var defaultPrinter: String?
/// Parsed PPDs, for enabling Quick Actions per printer. A queue with no PPD maps to nil.
private(set) var ppds: [String: PPD?] = [:]
var failure: Failure?
let client = CupsClient()
private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store")
/// "21 printers · 0 active jobs" for the status bar.
var summaryLine: String {
guard hasLoaded, errorMessage == nil else { return "" }
let jobs = printers.reduce(0) { $0 + $1.jobCount }
return "\(printers.count) printer\(printers.count == 1 ? "" : "s") · \(jobs) active job\(jobs == 1 ? "" : "s")"
}
/// Last-action line for the status bar, from any view.
func report(_ line: String) {
statusLine = line
}
func fail(_ title: String, _ message: String) {
failure = Failure(title: title, message: Self.explain(message))
}
func refresh() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
let started = Date()
log.info("refresh started")
do {
let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes)
printers = groups.map(PrinterSummary.init).sorted {
$0.name.localizedStandardCompare($1.name) == .orderedAscending
}
errorMessage = nil
defaultPrinter = try? await client.getDefaultPrinter()
let seconds = Date().timeIntervalSince(started)
statusLine = "Refreshed \(printers.count) printers in \(elapsedText(seconds))"
log.info("refresh finished: \(self.printers.count) printers in \(seconds, format: .fixed(precision: 3)) s")
} catch {
errorMessage = String(describing: error)
statusLine = "ERROR: \(error)"
log.error("refresh failed: \(String(describing: error), privacy: .public)")
}
hasLoaded = true
}
/// Pause/Resume/Accept/Reject/Cancel All, verified against cupsd, reported in the status bar.
func perform(_ action: PrinterAction, on queue: String) async {
let started = Date()
log.info("\(action.title, privacy: .public) \(queue, privacy: .public)")
do {
let outcome = try await QueueActions.perform(action, queue: queue, client: client)
let seconds = Date().timeIntervalSince(started)
if outcome.succeeded {
report("\(action.pastTense) \(queue) in \(elapsedText(seconds))")
log.info("\(action.title, privacy: .public) \(queue, privacy: .public) ok in \(seconds, format: .fixed(precision: 3)) s")
} else {
let message = outcome.message ?? "failed"
report("ERROR: \(action.title) \(queue): \(message)")
fail("Couldn’t \(action.title.lowercased()) \(queue)", "\(message)\n\n\(outcome.command)")
log.error("\(action.title, privacy: .public) \(queue, privacy: .public) failed: \(message, privacy: .public)")
}
} catch {
report("ERROR: \(action.title) \(queue): \(error)")
fail("Couldn’t \(action.title.lowercased()) \(queue)", String(describing: error))
}
revision += 1
await refreshQuietly()
}
/// Refresh the sidebar without replacing the last action's status line.
func refreshQuietly() async {
let line = statusLine
await refresh()
statusLine = line
}
func noteChange(queue: String? = nil) {
if let queue { ppds[queue] = nil }
revision += 1
}
// MARK: PPDs for Quick Actions
/// Fetches PPDs not yet cached, in the background of a refresh.
func loadPPDs() async {
for printer in printers where ppds[printer.name] == nil {
let text = try? await client.getPPD(queue: printer.name)
ppds[printer.name] = .some(text.map(PPD.init(text:)))
}
}
/// 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? {
if case .defaultPrinter = action.effect {
return defaultPrinter == queue ? "\(queue) is already the default printer" : nil
}
guard let cached = ppds[queue] else { return "Checking \(queue)’s PPD…" }
if action.isAvailable(for: cached) { return nil }
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
func printTestPage(_ queue: String) async {
let started = Date()
do {
let (outcome, jobID) = try await QueueActions.printTestPage(queue: queue, client: client)
if outcome.succeeded, let jobID {
report("Sent test page to \(queue) as job \(jobID) in \(elapsedText(since: started))")
log.info("test page \(queue, privacy: .public) job \(jobID)")
} else {
report("ERROR: test page on \(queue): \(outcome.message ?? "failed")")
fail("Couldn’t print a test page on \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
}
} catch {
report("ERROR: test page on \(queue): \(error)")
fail("Couldn’t print a test page on \(queue)", String(describing: error))
}
noteChange()
await refreshQuietly()
}
/// Returns true when the queue is gone.
func delete(_ queue: String) async -> Bool {
let started = Date()
var deleted = false
do {
let outcome = try await QueueActions.delete(queue: queue, client: client)
deleted = outcome.succeeded
if deleted {
report("Deleted \(queue) in \(elapsedText(since: started))")
log.info("deleted \(queue, privacy: .public)")
} else {
report("ERROR: delete \(queue): \(outcome.message ?? "failed")")
fail("Couldn’t delete \(queue)", "\(outcome.message ?? "failed")\n\n\(outcome.command)")
}
} catch {
report("ERROR: delete \(queue): \(error)")
fail("Couldn’t delete \(queue)", String(describing: error))
}
ppds[queue] = nil
noteChange()
await refreshQuietly()
return deleted
}
func showPPDInFinder(_ queue: String) {
let url = URL(fileURLWithPath: "/private/etc/cups/ppd/\(queue).ppd")
NSWorkspace.shared.activateFileViewerSelecting([url])
}
func hasPPDFile(_ queue: String) -> Bool {
FileManager.default.fileExists(atPath: "/private/etc/cups/ppd/\(queue).ppd")
}
func copyDeviceURI(_ printer: PrinterSummary) {
guard let uri = printer.deviceURI else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(uri, forType: .string)
report("Copied device URI for \(printer.name)")
}
/// Tool/IPP output plus the fix when it's a permissions problem.
static func explain(_ message: String) -> String {
let lower = message.lowercased()
if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized")
|| lower.contains("authentication") || lower.contains("not-authorized") {
return message + "\n\nThis needs an admin account or membership in _lpadmin:\n"
+ "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin"
}
return message
}
}
/// "42 ms" under a tenth of a second, else "0.4 s".
func elapsedText(_ seconds: TimeInterval) -> String {
seconds < 0.1 ? "\(max(1, Int((seconds * 1000).rounded()))) ms" : String(format: "%.1f s", seconds)
}
func elapsedText(since start: Date) -> String {
elapsedText(Date().timeIntervalSince(start))
}

View File

@@ -0,0 +1,262 @@
import CupsKit
import Observation
import SwiftUI
/// One Quick Action on one printer: shows what will change, asks only for what's needed, runs lpadmin, reads back.
@MainActor
@Observable
final class QuickActionModel: Identifiable {
enum Phase: Equatable {
case loading, ready, running, finished(succeeded: Bool)
}
let id = UUID()
let action: QuickAction
let queue: String
var code = ""
var showCurrentCode = false
private(set) var phase: Phase = .loading
private(set) var state: OptionState?
private(set) var currentDefault: String?
private(set) var readBack: [String: OptionReadBack] = [:]
private(set) var resultMessage: String?
private let store: PrinterStore
init(action: QuickAction, queue: String, store: PrinterStore) {
self.action = action
self.queue = queue
self.store = store
}
func load() async {
do {
state = try await OptionState.load(client: store.client, queue: queue)
currentDefault = try await store.client.getDefaultPrinter()
phase = .ready
} catch {
resultMessage = String(describing: error)
phase = .finished(succeeded: false)
}
}
var currentCode: String? { QuickAction.currentUserCode(state?.ppd) }
var validationError: String? { action.validationError(code, ppd: state?.ppd) }
/// What will be written, or why the action can't run here.
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
action.changes(for: state?.ppd, value: clearing ? "" : code)
}
var command: String {
if case .defaultPrinter = action.effect { return CupsTools.commandLine(CupsTools.lpadminPath, ["-d", queue]) }
guard case .success(let changes) = plannedChanges() else { return "" }
return OptionApplier.displayCommand(queue: queue, changes: changes)
}
/// `lpstat` and apps use ~/.cups/lpoptions' Default line over the server default.
var userDefaultNote: String? {
guard case .defaultPrinter = action.effect,
let text = try? String(contentsOf: LpOptionsFile.userFileURL, encoding: .utf8),
let line = text.split(whereSeparator: \.isNewline).first(where: { $0.hasPrefix("Default ") }) else { return nil }
let name = line.split(separator: " ")[1].split(separator: "/")[0]
guard name != queue else { return nil }
let exists = store.printers.contains { $0.name == name }
return "Your ~/.cups/lpoptions has “Default \(name)”, which overrides the server default for your account"
+ (exists ? "." : " — and there is no queue named \(name) on this Mac.")
}
func run(clearing: Bool = false) async {
phase = .running
let started = Date()
do {
let outcome = try await action.run(queue: queue, value: clearing ? "" : (action.input == .userCode ? code : nil),
client: store.client)
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
state = try? await OptionState.load(client: store.client, queue: queue)
let seconds = elapsedText(since: started)
let title = clearing ? "Cleared user code" : action.title.replacingOccurrences(of: "…", with: "")
if outcome.succeeded {
resultMessage = nil
store.report("\(title) on \(queue) in \(seconds)")
} else {
resultMessage = outcome.message ?? "Not applied"
store.report("ERROR: \(title) on \(queue): \(resultMessage!)")
}
phase = .finished(succeeded: outcome.succeeded)
} catch let unavailable as QuickActionUnavailable {
resultMessage = unavailable.reason
phase = .finished(succeeded: false)
} catch {
resultMessage = PrinterStore.explain(String(describing: error))
store.report("ERROR: \(action.title) on \(queue): \(error)")
phase = .finished(succeeded: false)
}
store.noteChange(queue: queue)
await store.refreshQuietly()
}
}
struct QuickActionSheet: View {
@Bindable var model: QuickActionModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Label(model.action.title.replacingOccurrences(of: "…", with: ""), systemImage: model.action.systemImage)
.font(.title3.weight(.semibold))
Text("\(model.queue) · saved as the queue default for every user on this Mac")
.foregroundStyle(.secondary)
}
switch model.phase {
case .loading:
ProgressView().frame(maxWidth: .infinity)
default:
content
}
HStack {
if model.action.input == .userCode, model.currentCode != nil, model.phase == .ready {
Button("Clear Code", role: .destructive) { Task { await model.run(clearing: true) } }
}
Spacer()
switch model.phase {
case .finished:
Button("Done") { dismiss() }
.keyboardShortcut(.defaultAction)
default:
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button("Apply") { Task { await model.run() } }
.keyboardShortcut(.defaultAction)
.disabled(!canApply)
}
}
}
.padding(20)
.frame(width: 520)
.task { await model.load() }
}
private var canApply: Bool {
guard model.phase == .ready else { return false }
if model.action.input == .userCode {
return !model.code.isEmpty && model.validationError == nil
}
if case .failure = model.plannedChanges() { return false }
return true
}
@ViewBuilder private var content: some View {
if model.action.input == .userCode {
Form {
LabeledContent("Current code") {
HStack(spacing: 8) {
if let current = model.currentCode {
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
.monospaced()
.textSelection(.enabled)
Toggle("Show", isOn: $model.showCurrentCode)
.toggleStyle(.checkbox)
} else {
Text("None").foregroundStyle(.secondary)
}
}
}
LabeledContent {
TextField("User code", text: $model.code, prompt: Text("Digits"))
.labelsHidden()
.textFieldStyle(.roundedBorder)
.monospacedDigit()
.frame(width: 160)
.disabled(model.phase != .ready)
} label: {
Text("New code")
if let error = model.validationError {
Text(error).foregroundStyle(.red)
}
}
}
.formStyle(.grouped)
.scrollDisabled(true)
.frame(height: 130)
}
if case .defaultPrinter = model.action.effect {
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
GridRow {
Text("Server default").foregroundStyle(.secondary)
Text(model.currentDefault ?? "None")
Image(systemName: "arrow.right").foregroundStyle(.secondary)
Text(model.queue)
resultIcon(applied: model.phase == .finished(succeeded: true) ? true
: model.phase == .finished(succeeded: false) ? false : nil)
}
}
if let note = model.userDefaultNote {
Label(note, systemImage: "info.circle")
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} else {
switch model.plannedChanges() {
case .failure(let unavailable) where model.action.input == .none:
Label(unavailable.reason, systemImage: "exclamationmark.triangle.fill")
.symbolRenderingMode(.multicolor)
default:
changeGrid
}
}
if !model.command.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Command").font(.callout.weight(.semibold)).foregroundStyle(.secondary)
Text(model.command)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
}
if let message = model.resultMessage {
Label(message, systemImage: "xmark.circle.fill")
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
}
@ViewBuilder private var changeGrid: some View {
let changes = (try? model.plannedChanges().get()) ?? []
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
GridRow {
Text("Setting"); Text("Current"); Text(""); Text("New"); Text("")
}
.font(.callout.weight(.semibold))
.foregroundStyle(.secondary)
Divider()
ForEach(changes, id: \.key) { change in
GridRow {
Text(change.key).monospaced()
Text(model.state?.queueValue(change.key) ?? "—").foregroundStyle(.secondary).monospaced()
Image(systemName: "arrow.right").foregroundStyle(.secondary)
Text(model.action.input == .userCode && change.key == "RIUserCode" && !model.code.isEmpty
? "Custom.\(model.code)" : change.value).monospaced()
resultIcon(applied: model.readBack[change.key]?.applied)
.help(model.readBack[change.key]?.reason ?? "")
}
}
}
.textSelection(.enabled)
}
@ViewBuilder private func resultIcon(applied: Bool?) -> some View {
switch applied {
case true?: Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
case false?: Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
case nil:
if model.phase == .running { ProgressView().controlSize(.mini) } else { Color.clear.frame(width: 14, height: 14) }
}
}
}

View File

@@ -0,0 +1,135 @@
import Foundation
/// Output of one CUPS command-line tool run.
public struct ToolResult {
public let executable: String
public let arguments: [String]
public let status: Int32
public let standardOutput: Data
public let standardError: Data
public var commandLine: String { CupsTools.commandLine(executable, arguments) }
public var succeeded: Bool { status == 0 }
/// stderr then stdout as text, trimmed — what to show a user when a tool fails.
public var message: String {
(String(decoding: standardError, as: UTF8.self) + String(decoding: standardOutput, as: UTF8.self))
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
/// Writes go through the CUPS tools (lpadmin handles _lpadmin auth and IPP Everywhere PPD generation).
/// Nothing here prints; callers decide what to show.
public enum CupsTools {
public static let lpadminPath = "/usr/sbin/lpadmin"
public static let cupsenablePath = "/usr/sbin/cupsenable"
public static let cupsdisablePath = "/usr/sbin/cupsdisable"
public static let cupsacceptPath = "/usr/sbin/cupsaccept"
public static let cupsrejectPath = "/usr/sbin/cupsreject"
public static let cancelPath = "/usr/bin/cancel"
public static let lpPath = "/usr/bin/lp"
public static let lpmovePath = "/usr/sbin/lpmove"
public static let lpinfoPath = "/usr/sbin/lpinfo"
/// The CUPS standard test page (what the web UI's Print Test Page sent).
public static let testPagePath = "/usr/share/cups/data/testprint"
public static func lpadmin(_ arguments: [String]) throws -> ToolResult {
try run(lpadminPath, arguments)
}
public static func enable(queue: String) throws -> ToolResult {
try run(cupsenablePath, [queue])
}
public static func disable(queue: String, reason: String? = nil) throws -> ToolResult {
try run(cupsdisablePath, (reason.map { ["-r", $0] } ?? []) + [queue])
}
public static func accept(queue: String) throws -> ToolResult {
try run(cupsacceptPath, [queue])
}
public static func reject(queue: String, reason: String? = nil) throws -> ToolResult {
try run(cupsrejectPath, (reason.map { ["-r", $0] } ?? []) + [queue])
}
/// `cancel -a queue`: every job on the queue.
public static func cancelAll(queue: String) throws -> ToolResult {
try run(cancelPath, ["-a", queue])
}
/// `lp -i id -H hold`.
public static func hold(jobID: Int) throws -> ToolResult {
try run(lpPath, ["-i", String(jobID), "-H", "hold"])
}
/// `lp -i id -H resume`.
public static func release(jobID: Int) throws -> ToolResult {
try run(lpPath, ["-i", String(jobID), "-H", "resume"])
}
/// `lpmove id destination`.
public static func move(jobID: Int, to destination: String) throws -> ToolResult {
try run(lpmovePath, [String(jobID), destination])
}
/// `lpadmin -d queue`: the server default printer.
public static func setDefault(queue: String) throws -> ToolResult {
try run(lpadminPath, ["-d", queue])
}
/// `lpadmin -x queue`.
public static func delete(queue: String) throws -> ToolResult {
try run(lpadminPath, ["-x", queue])
}
/// `lp -d queue -t "Test Page" file`; the job id is in stdout ("request id is Q-12 (1 file(s))").
public static func printTestPage(queue: String, file: String) throws -> ToolResult {
try run(lpPath, ["-d", queue, "-t", "Test Page", file])
}
/// `lpinfo -m`: every driver cupsd knows, as (model, name).
public static func models() throws -> [(model: String, name: String)] {
let result = try run(lpinfoPath, ["-m"])
guard result.succeeded else { throw CupsAdminError.failed("lpinfo -m: \(result.message)") }
return String(decoding: result.standardOutput, as: UTF8.self).split(whereSeparator: \.isNewline).compactMap { line in
let parts = line.split(separator: " ", maxSplits: 1)
guard parts.count == 2 else { return nil }
return (String(parts[0]), String(parts[1]))
}
}
/// Runs a tool and captures stdout/stderr. stdin is inherited; libcups password prompts use /dev/tty.
public static func run(_ executable: String, _ arguments: [String]) throws -> ToolResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
try process.run()
// Drain both pipes concurrently so a chatty tool can't block on a full pipe buffer.
final class Box: @unchecked Sendable { var data = Data() }
let out = Box(), err = Box()
let group = DispatchGroup()
for (pipe, box) in [(outPipe, out), (errPipe, err)] {
group.enter()
DispatchQueue.global().async {
box.data = pipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
}
process.waitUntilExit()
group.wait()
return ToolResult(executable: executable, arguments: arguments, status: process.terminationStatus,
standardOutput: out.data, standardError: err.data)
}
/// Shell-style display of a command, quoting arguments that contain spaces or quotes.
public static func commandLine(_ executable: String, _ arguments: [String]) -> String {
([executable] + arguments).map { $0.contains(where: { " '\"$".contains($0) }) ? "'\($0)'" : $0 }
.joined(separator: " ")
}
}

View File

@@ -0,0 +1,101 @@
import Foundation
public enum Format {
public static func printerState(_ state: Int?) -> String {
switch state {
case 3: return "idle"
case 4: return "processing"
case 5: return "stopped"
case let s?: return "state-\(s)"
case nil: return "?"
}
}
public static func jobState(_ state: Int?) -> String {
switch state {
case 3: return "pending"
case 4: return "held"
case 5: return "processing"
case 6: return "stopped"
case 7: return "canceled"
case 8: return "aborted"
case 9: return "completed"
case let s?: return "state-\(s)"
case nil: return "?"
}
}
/// CUPS reports most *-time attributes as integer seconds since the epoch.
public static func epoch(_ seconds: Int?) -> String {
guard let seconds, seconds > 0 else { return "-" }
return timestamp(Date(timeIntervalSince1970: TimeInterval(seconds)))
}
public static func timestamp(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f.string(from: date)
}
public static func kilobytes(_ k: Int?) -> String {
guard let k else { return "-" }
if k < 1024 { return "\(k)K" }
return String(format: "%.1fM", Double(k) / 1024)
}
public static func yesNo(_ b: Bool?) -> String {
guard let b else { return "?" }
return b ? "yes" : "no"
}
/// state-reasons without the "none" placeholder.
public static func reasons(_ values: [String]) -> String {
let real = values.filter { $0 != "none" }
return real.isEmpty ? "none" : real.joined(separator: ",")
}
public static func value(_ value: IPPValue, attribute: String) -> String {
switch value {
case .integer(let i):
if attribute.hasSuffix("-time") || attribute.hasPrefix("time-at-"), i > 946_684_800 {
return "\(i) (\(epoch(Int(i))))"
}
return String(i)
case .enumeration(let e):
switch attribute {
case "printer-state": return printerState(Int(e))
case "job-state": return jobState(Int(e))
case "print-quality-default", "print-quality-supported":
return [3: "draft", 4: "normal", 5: "high"][e] ?? String(e)
default: return String(e)
}
case .boolean(let b): return b ? "true" : "false"
case .string(_, let s): return s
case .stringWithLanguage(_, let language, let s): return "\(s) [\(language)]"
case .octetString(let d):
if let s = String(data: d, encoding: .utf8), !s.contains(where: { $0.isASCII && $0.asciiValue! < 0x20 }) {
return s
}
return "<\(d.count) bytes>"
case .dateTime(let date): return timestamp(date)
case .resolution(let x, let y, let units):
let unit = units == 3 ? "dpi" : units == 4 ? "dpcm" : "units\(units)"
return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)"
case .range(let lower, let upper): return "\(lower)-\(upper)"
case .collection(let members):
let inner = members.map { m in
"\(m.name)=" + m.values.map { self.value($0, attribute: m.name) }.joined(separator: ",")
}
return "{" + inner.joined(separator: " ") + "}"
case .outOfBand(let tag):
switch tag {
case .unsupported: return "(unsupported)"
case .unknown: return "(unknown)"
case .noValue: return "(no-value)"
default: return "(out-of-band 0x\(String(tag.rawValue, radix: 16)))"
}
case .other(let tag, let d):
return "<tag 0x\(String(tag.rawValue, radix: 16)), \(d.count) bytes>"
}
}
}

View File

@@ -0,0 +1,173 @@
import Foundation
public enum CupsAdminError: Error, CustomStringConvertible {
case usage(String)
case transport(String)
case ipp(status: UInt16, message: String?)
case failed(String)
public var description: String {
switch self {
case .usage(let m): return m
case .transport(let m), .failed(let m): return m
case .ipp(let status, let message):
let name = IPPStatus.name(status)
if let message, !message.isEmpty { return "\(name): \(message)" }
return name
}
}
}
public enum IPPStatus {
public static let notFound: UInt16 = 0x0406
public static func name(_ code: UInt16) -> String {
let known: [UInt16: String] = [
0x0400: "client-error-bad-request",
0x0401: "client-error-forbidden",
0x0402: "client-error-not-authenticated",
0x0403: "client-error-not-authorized",
0x0404: "client-error-not-possible",
0x0405: "client-error-timeout",
0x0406: "client-error-not-found",
0x0407: "client-error-gone",
0x040B: "client-error-attributes-or-values-not-supported",
0x0500: "server-error-internal-error",
0x0501: "server-error-operation-not-supported",
0x0502: "server-error-service-unavailable",
0x0503: "server-error-version-not-supported",
]
return known[code] ?? String(format: "ipp-status-0x%04x", code)
}
}
/// Talks IPP to the local cupsd over its Unix domain socket (see UnixSocketHTTP for why not TCP 631).
public final class CupsClient {
public let socketPath = "/private/var/run/cupsd"
private var nextRequestID: Int32 = 1
public init() {}
// MARK: operations
/// CUPS-Get-Printers: one printer-attributes group per queue (printers and classes).
public func getPrinters(requested: [String]) async throws -> [IPPGroup] {
let request = makeRequest(IPPOperation.cupsGetPrinters, target: serverURI, requested: requested)
return try await send(request, resource: "/").groups(.printerAttributes)
}
public func getPrinterAttributes(queue: String, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getPrinterAttributes, target: printerURI(queue), requested: requested)
let response = try await send(request, resource: printerResource(queue))
guard let group = response.groups(.printerAttributes).first else {
throw CupsAdminError.transport("cupsd returned no printer attributes for \(queue)")
}
return group
}
public func queueExists(_ queue: String) async throws -> Bool {
do {
_ = try await getPrinterAttributes(queue: queue, requested: ["printer-name"])
return true
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return false
}
}
/// CUPS-Get-Default: the server default printer's name, or nil when there is none.
public func getDefaultPrinter() async throws -> String? {
let request = makeRequest(IPPOperation.cupsGetDefault, target: serverURI, requested: ["printer-name"])
do {
return try await send(request, resource: "/").groups(.printerAttributes).first?.string("printer-name")
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
return nil
}
}
/// Get-Jobs. `queue` nil means all queues. `which` is "not-completed", "completed" or "all".
public func getJobs(queue: String?, which: String, requested: [String]) async throws -> [IPPGroup] {
let extra = [
IPPAttribute.string("which-jobs", .keyword, [which]),
IPPAttribute.boolean("my-jobs", false),
]
let request = makeRequest(IPPOperation.getJobs, target: queue.map(printerURI) ?? serverURI,
extra: extra, requested: requested)
return try await send(request, resource: queue.map(printerResource) ?? "/").groups(.jobAttributes)
}
public func getJobAttributes(jobID: Int, requested: [String]) async throws -> IPPGroup {
let request = makeRequest(IPPOperation.getJobAttributes, target: jobURI(jobID), requested: requested)
guard let group = try await send(request, resource: "/jobs/").groups(.jobAttributes).first else {
throw CupsAdminError.transport("cupsd returned no attributes for job \(jobID)")
}
return group
}
public func cancelJob(jobID: Int) async throws {
let request = makeRequest(IPPOperation.cancelJob, target: jobURI(jobID), requested: [])
_ = try await send(request, resource: "/jobs/")
}
/// The queue's PPD text (GET /printers/<queue>.ppd), or nil if the queue has no PPD.
public func getPPD(queue: String) async throws -> String? {
let response = try UnixSocketHTTP.get(socketPath: socketPath, resource: printerResource(queue) + ".ppd")
switch response.status {
case 200: return String(decoding: response.body, as: UTF8.self)
case 404: return nil
default: throw CupsAdminError.transport("HTTP \(response.status) fetching PPD for \(queue)")
}
}
// MARK: plumbing
private typealias Target = (name: String, uri: String)
private var serverURI: Target { ("printer-uri", "ipp://localhost/") }
private func printerURI(_ queue: String) -> Target {
("printer-uri", "ipp://localhost\(printerResource(queue))")
}
private func jobURI(_ jobID: Int) -> Target {
("job-uri", "ipp://localhost/jobs/\(jobID)")
}
private func printerResource(_ queue: String) -> String {
let escaped = queue.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(["/"])) ?? queue
return "/printers/\(escaped)"
}
private func makeRequest(_ operation: UInt16, target: Target, extra: [IPPAttribute] = [],
requested: [String]) -> IPPMessage {
// Order matters: charset, natural-language, target, then the rest (RFC 8011 §4.1.4).
var attributes: [IPPAttribute] = [
.string("attributes-charset", .charset, ["utf-8"]),
.string("attributes-natural-language", .naturalLanguage, ["en"]),
.string(target.name, .uri, [target.uri]),
.string("requesting-user-name", .nameWithoutLanguage, [NSUserName()]),
]
attributes += extra
if !requested.isEmpty {
attributes.append(.string("requested-attributes", .keyword, requested))
}
return IPPMessage(code: operation, groups: [IPPGroup(tag: .operationAttributes, attributes: attributes)])
}
private func send(_ message: IPPMessage, resource: String) async throws -> IPPMessage {
var message = message
message.requestID = nextRequestID
nextRequestID += 1
let response = try UnixSocketHTTP.post(socketPath: socketPath, resource: resource,
contentType: "application/ipp", body: IPPEncoder.encode(message))
guard response.status == 200 else {
throw CupsAdminError.transport("HTTP \(response.status) from cupsd for POST \(resource)")
}
let reply = try IPPDecoder.decode(response.body)
guard reply.isSuccess else {
throw CupsAdminError.ipp(status: reply.code, message: reply.operationGroup?.string("status-message"))
}
return reply
}
}

View File

@@ -0,0 +1,272 @@
import Foundation
/// RFC 8010 wire-format encoder.
enum IPPEncoder {
static func encode(_ message: IPPMessage) -> Data {
var out: [UInt8] = [message.versionMajor, message.versionMinor]
out.appendUInt16(message.code)
out.appendInt32(message.requestID)
for group in message.groups {
out.append(group.tag.rawValue)
for attribute in group.attributes {
for (index, value) in attribute.values.enumerated() {
// Additional values of a 1setOf carry an empty name.
write(value, name: index == 0 ? attribute.name : "", into: &out)
}
}
}
out.append(IPPTag.endOfAttributes.rawValue)
return Data(out) + message.data
}
private static func write(_ value: IPPValue, name: String, into out: inout [UInt8]) {
out.append(value.tag.rawValue)
out.appendLengthPrefixed(Array(name.utf8))
switch value {
case .integer(let i), .enumeration(let i):
out.appendUInt16(4)
out.appendInt32(i)
case .boolean(let b):
out.appendUInt16(1)
out.append(b ? 1 : 0)
case .string(_, let s):
out.appendLengthPrefixed(Array(s.utf8))
case .stringWithLanguage(_, let language, let s):
var payload: [UInt8] = []
payload.appendLengthPrefixed(Array(language.utf8))
payload.appendLengthPrefixed(Array(s.utf8))
out.appendLengthPrefixed(payload)
case .octetString(let d), .other(_, let d):
out.appendLengthPrefixed([UInt8](d))
case .dateTime(let date):
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: 0)!
let c = cal.dateComponents([.year, .month, .day, .hour, .minute, .second, .nanosecond], from: date)
var payload: [UInt8] = []
payload.appendUInt16(UInt16(c.year ?? 1970))
payload += [c.month, c.day, c.hour, c.minute, c.second].map { UInt8($0 ?? 0) }
payload += [UInt8((c.nanosecond ?? 0) / 100_000_000), UInt8(ascii: "+"), 0, 0]
out.appendLengthPrefixed(payload)
case .resolution(let x, let y, let units):
out.appendUInt16(9)
out.appendInt32(x)
out.appendInt32(y)
out.append(units)
case .range(let lower, let upper):
out.appendUInt16(8)
out.appendInt32(lower)
out.appendInt32(upper)
case .outOfBand:
out.appendUInt16(0)
case .collection(let members):
out.appendUInt16(0)
for member in members {
out.append(IPPTag.memberAttrName.rawValue)
out.appendUInt16(0)
out.appendLengthPrefixed(Array(member.name.utf8))
for memberValue in member.values {
write(memberValue, name: "", into: &out)
}
}
out.append(IPPTag.endCollection.rawValue)
out.appendUInt16(0)
out.appendUInt16(0)
}
}
}
/// RFC 8010 wire-format decoder.
struct IPPDecoder {
enum DecodeError: Error, CustomStringConvertible {
case truncated(offset: Int)
case malformed(String, offset: Int)
var description: String {
switch self {
case .truncated(let offset): return "IPP response truncated at byte \(offset)"
case .malformed(let what, let offset): return "malformed IPP response at byte \(offset): \(what)"
}
}
}
private let bytes: [UInt8]
private var offset = 0
static func decode(_ data: Data) throws -> IPPMessage {
var decoder = IPPDecoder(bytes: [UInt8](data))
return try decoder.decodeMessage()
}
private init(bytes: [UInt8]) {
self.bytes = bytes
}
private mutating func decodeMessage() throws -> IPPMessage {
let major = try readUInt8()
let minor = try readUInt8()
var message = IPPMessage(code: try readUInt16())
message.versionMajor = major
message.versionMinor = minor
message.requestID = try readInt32()
var current: IPPGroup?
while true {
let tag = IPPTag(rawValue: try readUInt8())
if tag.isDelimiter {
if let group = current { message.groups.append(group) }
current = nil
if tag == .endOfAttributes { break }
current = IPPGroup(tag: tag, attributes: [])
continue
}
guard current != nil else {
throw DecodeError.malformed("value tag 0x\(String(tag.rawValue, radix: 16)) outside a group", offset: offset)
}
let name = try readString(length: Int(try readUInt16()))
let value = try readValue(tag: tag)
if name.isEmpty {
guard !current!.attributes.isEmpty else {
throw DecodeError.malformed("additional value with no preceding attribute", offset: offset)
}
current!.attributes[current!.attributes.count - 1].values.append(value)
} else {
current!.attributes.append(IPPAttribute(name: name, values: [value]))
}
}
message.data = Data(bytes[offset...])
return message
}
/// Reads value-length + value for `tag` (the name has already been consumed).
private mutating func readValue(tag: IPPTag) throws -> IPPValue {
let length = Int(try readUInt16())
if tag == .begCollection {
_ = try readBytes(length)
return .collection(try readCollectionMembers())
}
// Rebase to 0 so v[0], v[8] etc. index the value, not the whole response.
let v = Array(try readBytes(length))[...]
switch tag {
case _ where tag.isOutOfBand:
return .outOfBand(tag)
case .integer where length == 4:
return .integer(Self.int32(v, 0))
case .enumeration where length == 4:
return .enumeration(Self.int32(v, 0))
case .boolean where length == 1:
return .boolean(v[0] != 0)
case .octetString:
return .octetString(Data(v))
case .dateTime where length == 11:
return Self.date(v).map { .dateTime($0) } ?? .other(tag, Data(v))
case .resolution where length == 9:
return .resolution(x: Self.int32(v, 0), y: Self.int32(v, 4), units: v[8])
case .rangeOfInteger where length == 8:
return .range(Self.int32(v, 0), Self.int32(v, 4))
case .textWithLanguage, .nameWithLanguage:
guard length >= 4 else { return .other(tag, Data(v)) }
let langLength = Int(UInt16(v[0]) << 8 | UInt16(v[1]))
guard 2 + langLength + 2 <= length else { return .other(tag, Data(v)) }
let language = String(decoding: v[2 ..< 2 + langLength], as: UTF8.self)
let textStart = 2 + langLength
let textLength = Int(UInt16(v[textStart]) << 8 | UInt16(v[textStart + 1]))
guard textStart + 2 + textLength <= length else { return .other(tag, Data(v)) }
let text = String(decoding: v[textStart + 2 ..< textStart + 2 + textLength], as: UTF8.self)
return .stringWithLanguage(tag, language: language, text)
case _ where tag.isCharacterString:
return .string(tag, String(decoding: v, as: UTF8.self))
default:
return .other(tag, Data(v))
}
}
/// Called just after a begCollection value; consumes through the matching endCollection.
private mutating func readCollectionMembers() throws -> [IPPAttribute] {
var members: [IPPAttribute] = []
while true {
let tag = IPPTag(rawValue: try readUInt8())
guard !tag.isDelimiter else {
throw DecodeError.malformed("delimiter inside collection", offset: offset)
}
_ = try readBytes(Int(try readUInt16())) // name, always empty inside a collection
if tag == .endCollection {
_ = try readBytes(Int(try readUInt16()))
return members
}
if tag == .memberAttrName {
let memberName = try readString(length: Int(try readUInt16()))
members.append(IPPAttribute(name: memberName, values: []))
continue
}
guard !members.isEmpty else {
throw DecodeError.malformed("collection value before memberAttrName", offset: offset)
}
let value = try readValue(tag: tag)
members[members.count - 1].values.append(value)
}
}
// MARK: primitives
private mutating func readBytes(_ count: Int) throws -> ArraySlice<UInt8> {
guard offset + count <= bytes.count else { throw DecodeError.truncated(offset: offset) }
defer { offset += count }
return bytes[offset ..< offset + count]
}
private mutating func readUInt8() throws -> UInt8 { try readBytes(1).first! }
private mutating func readUInt16() throws -> UInt16 {
let b = try readBytes(2)
return UInt16(b[b.startIndex]) << 8 | UInt16(b[b.startIndex + 1])
}
private mutating func readInt32() throws -> Int32 { Self.int32(try readBytes(4), 0) }
private mutating func readString(length: Int) throws -> String {
String(decoding: try readBytes(length), as: UTF8.self)
}
private static func int32(_ b: ArraySlice<UInt8>, _ at: Int) -> Int32 {
let i = b.startIndex + at
return Int32(bitPattern: UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]))
}
/// RFC 2579 DateAndTime (11 octets).
private static func date(_ b: ArraySlice<UInt8>) -> Date? {
let s = b.startIndex
var c = DateComponents()
c.year = Int(UInt16(b[s]) << 8 | UInt16(b[s + 1]))
c.month = Int(b[s + 2])
c.day = Int(b[s + 3])
c.hour = Int(b[s + 4])
c.minute = Int(b[s + 5])
c.second = Int(b[s + 6])
c.nanosecond = Int(b[s + 7]) * 100_000_000
let sign = b[s + 8] == UInt8(ascii: "-") ? -1 : 1
let utcOffset = sign * (Int(b[s + 9]) * 3600 + Int(b[s + 10]) * 60)
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(secondsFromGMT: utcOffset) ?? TimeZone(secondsFromGMT: 0)!
return cal.date(from: c)
}
}
private extension Array where Element == UInt8 {
mutating func appendUInt16(_ v: UInt16) {
append(UInt8(v >> 8))
append(UInt8(v & 0xFF))
}
mutating func appendInt32(_ v: Int32) {
let u = UInt32(bitPattern: v)
append(contentsOf: [UInt8(u >> 24), UInt8(u >> 16 & 0xFF), UInt8(u >> 8 & 0xFF), UInt8(u & 0xFF)])
}
mutating func appendLengthPrefixed(_ payload: [UInt8]) {
appendUInt16(UInt16(payload.count))
append(contentsOf: payload)
}
}

View File

@@ -0,0 +1,164 @@
import Foundation
/// IPP tag byte (RFC 8010 §3.5). A struct rather than an enum so unknown tags survive decoding.
public struct IPPTag: RawRepresentable, Hashable {
public let rawValue: UInt8
public init(rawValue: UInt8) { self.rawValue = rawValue }
// Delimiter tags
public static let operationAttributes = IPPTag(rawValue: 0x01)
public static let jobAttributes = IPPTag(rawValue: 0x02)
public static let endOfAttributes = IPPTag(rawValue: 0x03)
public static let printerAttributes = IPPTag(rawValue: 0x04)
public static let unsupportedAttributes = IPPTag(rawValue: 0x05)
// Out-of-band values
public static let unsupported = IPPTag(rawValue: 0x10)
public static let unknown = IPPTag(rawValue: 0x12)
public static let noValue = IPPTag(rawValue: 0x13)
// Integer values
public static let integer = IPPTag(rawValue: 0x21)
public static let boolean = IPPTag(rawValue: 0x22)
public static let enumeration = IPPTag(rawValue: 0x23)
// Octet-string values
public static let octetString = IPPTag(rawValue: 0x30)
public static let dateTime = IPPTag(rawValue: 0x31)
public static let resolution = IPPTag(rawValue: 0x32)
public static let rangeOfInteger = IPPTag(rawValue: 0x33)
public static let begCollection = IPPTag(rawValue: 0x34)
public static let textWithLanguage = IPPTag(rawValue: 0x35)
public static let nameWithLanguage = IPPTag(rawValue: 0x36)
public static let endCollection = IPPTag(rawValue: 0x37)
// Character-string values
public static let textWithoutLanguage = IPPTag(rawValue: 0x41)
public static let nameWithoutLanguage = IPPTag(rawValue: 0x42)
public static let keyword = IPPTag(rawValue: 0x44)
public static let uri = IPPTag(rawValue: 0x45)
public static let uriScheme = IPPTag(rawValue: 0x46)
public static let charset = IPPTag(rawValue: 0x47)
public static let naturalLanguage = IPPTag(rawValue: 0x48)
public static let mimeMediaType = IPPTag(rawValue: 0x49)
public static let memberAttrName = IPPTag(rawValue: 0x4A)
public static let extensionTag = IPPTag(rawValue: 0x7F)
public var isDelimiter: Bool { rawValue < 0x10 }
public var isOutOfBand: Bool { (0x10...0x1F).contains(rawValue) }
public var isCharacterString: Bool { (0x40...0x5F).contains(rawValue) }
}
public enum IPPValue {
case integer(Int32)
case boolean(Bool)
case enumeration(Int32)
/// textWithoutLanguage, nameWithoutLanguage, keyword, uri, charset, mimeMediaType, ...
case string(IPPTag, String)
/// textWithLanguage / nameWithLanguage
case stringWithLanguage(IPPTag, language: String, String)
case octetString(Data)
case dateTime(Date)
case resolution(x: Int32, y: Int32, units: UInt8)
case range(Int32, Int32)
case collection([IPPAttribute])
case outOfBand(IPPTag)
case other(IPPTag, Data)
public var tag: IPPTag {
switch self {
case .integer: return .integer
case .boolean: return .boolean
case .enumeration: return .enumeration
case .string(let t, _): return t
case .stringWithLanguage(let t, _, _): return t
case .octetString: return .octetString
case .dateTime: return .dateTime
case .resolution: return .resolution
case .range: return .rangeOfInteger
case .collection: return .begCollection
case .outOfBand(let t): return t
case .other(let t, _): return t
}
}
public var stringValue: String? {
switch self {
case .string(_, let s), .stringWithLanguage(_, _, let s): return s
case .octetString(let d): return String(data: d, encoding: .utf8)
default: return nil
}
}
public var intValue: Int? {
switch self {
case .integer(let i), .enumeration(let i): return Int(i)
default: return nil
}
}
public var boolValue: Bool? {
if case .boolean(let b) = self { return b }
return nil
}
}
public struct IPPAttribute {
public var name: String
public var values: [IPPValue]
public init(name: String, values: [IPPValue]) {
self.name = name
self.values = values
}
public static func string(_ name: String, _ tag: IPPTag, _ values: [String]) -> IPPAttribute {
IPPAttribute(name: name, values: values.map { .string(tag, $0) })
}
public static func boolean(_ name: String, _ value: Bool) -> IPPAttribute {
IPPAttribute(name: name, values: [.boolean(value)])
}
}
/// One attribute group (operation, printer, job, ...) in a message.
public struct IPPGroup {
public var tag: IPPTag
public var attributes: [IPPAttribute]
public subscript(name: String) -> IPPAttribute? {
attributes.first { $0.name == name }
}
public func string(_ name: String) -> String? { self[name]?.values.first?.stringValue }
public func strings(_ name: String) -> [String] { self[name]?.values.compactMap(\.stringValue) ?? [] }
public func int(_ name: String) -> Int? { self[name]?.values.first?.intValue }
public func ints(_ name: String) -> [Int] { self[name]?.values.compactMap(\.intValue) ?? [] }
public func bool(_ name: String) -> Bool? { self[name]?.values.first?.boolValue }
}
public struct IPPMessage {
public var versionMajor: UInt8 = 2
public var versionMinor: UInt8 = 0
/// operation-id in a request, status-code in a response
public var code: UInt16
public var requestID: Int32 = 1
public var groups: [IPPGroup] = []
public var data = Data()
/// Status codes 0x0000-0x00FF are successful-ok-*.
public var isSuccess: Bool { code < 0x0100 }
public func groups(_ tag: IPPTag) -> [IPPGroup] { groups.filter { $0.tag == tag } }
public var operationGroup: IPPGroup? { groups.first { $0.tag == .operationAttributes } }
}
public enum IPPOperation {
public static let cancelJob: UInt16 = 0x0008
public static let getJobAttributes: UInt16 = 0x0009
public static let getJobs: UInt16 = 0x000A
public static let getPrinterAttributes: UInt16 = 0x000B
public static let cupsGetDefault: UInt16 = 0x4001
public static let cupsGetPrinters: UInt16 = 0x4002
}

View File

@@ -0,0 +1,175 @@
import Foundation
/// Minimal HTTP/1.1 client (POST for IPP, GET for PPDs) over a Unix domain socket.
///
/// Why not URLSession to localhost:631: on macOS 27 launchd only socket-activates cupsd on
/// /private/var/run/cupsd. TCP 631 is bound by cupsd itself and disappears when cupsd idle-exits,
/// so connecting over TCP fails whenever the scheduler is asleep. URLSession can't do AF_UNIX.
enum UnixSocketHTTP {
struct Response {
let status: Int
let body: Data
}
static func post(socketPath: String, resource: String, contentType: String, body: Data) throws -> Response {
try request(method: "POST", socketPath: socketPath, resource: resource, contentType: contentType, body: body)
}
static func get(socketPath: String, resource: String) throws -> Response {
try request(method: "GET", socketPath: socketPath, resource: resource, contentType: nil, body: Data())
}
private static func request(method: String, socketPath: String, resource: String, contentType: String?,
body: Data, timeoutSeconds: Int = 15) throws -> Response {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw failure("socket()") }
defer { close(fd) }
var timeout = timeval(tv_sec: timeoutSeconds, tv_usec: 0)
let timevalSize = socklen_t(MemoryLayout<timeval>.size)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, timevalSize)
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, timevalSize)
var noSigPipe: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout<Int32>.size))
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let path = Array(socketPath.utf8) + [0]
let capacity = MemoryLayout.size(ofValue: address.sun_path)
guard path.count <= capacity else { throw CupsAdminError.transport("socket path too long: \(socketPath)") }
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) }
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
let connected = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connected == 0 else { throw failure("connect to \(socketPath)") }
var head = "\(method) \(resource) HTTP/1.1\r\nHost: localhost\r\n"
if let contentType {
head += "Content-Type: \(contentType)\r\nContent-Length: \(body.count)\r\n"
}
head += "Connection: close\r\n\r\n"
try writeAll(fd, [UInt8](Data(head.utf8) + body))
var reader = Reader(fd: fd)
let statusLine = try reader.readLine()
let statusParts = statusLine.split(separator: " ", maxSplits: 2)
guard statusParts.count >= 2, statusParts[0].hasPrefix("HTTP/"), let status = Int(statusParts[1]) else {
throw CupsAdminError.transport("bad HTTP status line from cupsd: \(statusLine)")
}
var headers: [String: String] = [:]
while true {
let line = try reader.readLine()
if line.isEmpty { break }
guard let colon = line.firstIndex(of: ":") else { continue }
let name = line[..<colon].lowercased()
headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
let payload: [UInt8]
if headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
var chunks: [UInt8] = []
while true {
let sizeLine = try reader.readLine()
let hex = sizeLine.split(separator: ";").first.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? ""
guard let size = Int(hex, radix: 16) else {
throw CupsAdminError.transport("bad chunk size from cupsd: \(sizeLine)")
}
if size == 0 {
while try !reader.readLine().isEmpty {} // trailers
break
}
chunks += try reader.readBytes(size)
_ = try reader.readLine()
}
payload = chunks
} else if let length = headers["content-length"].flatMap(Int.init) {
payload = try reader.readBytes(length)
} else {
payload = try reader.readToEnd()
}
return Response(status: status, body: Data(payload))
}
private static func writeAll(_ fd: Int32, _ bytes: [UInt8]) throws {
var sent = 0
while sent < bytes.count {
let n = bytes[sent...].withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw failure("write to cupsd")
}
sent += n
}
}
fileprivate static func failure(_ what: String) -> CupsAdminError {
let code = errno
let reason: String
switch code {
case ENOENT: reason = "socket not found (is CUPS installed?)"
case ECONNREFUSED: reason = "connection refused (cupsd not running?)"
case EAGAIN: reason = "timed out"
default: reason = String(cString: strerror(code))
}
return CupsAdminError.transport("\(what): \(reason)")
}
private struct Reader {
let fd: Int32
private var buffer: [UInt8] = []
private var position = 0
init(fd: Int32) { self.fd = fd }
/// Returns false at EOF.
private mutating func fill() throws -> Bool {
if position > 0 {
buffer.removeFirst(position)
position = 0
}
var chunk = [UInt8](repeating: 0, count: 65_536)
while true {
let n = chunk.withUnsafeMutableBytes { read(fd, $0.baseAddress, $0.count) }
if n < 0 {
if errno == EINTR { continue }
throw UnixSocketHTTP.failure("read from cupsd")
}
if n == 0 { return false }
buffer += chunk[0 ..< n]
return true
}
}
mutating func readLine() throws -> String {
while true {
if let lf = buffer[position...].firstIndex(of: UInt8(ascii: "\n")) {
var end = lf
if end > position, buffer[end - 1] == UInt8(ascii: "\r") { end -= 1 }
let line = String(decoding: buffer[position ..< end], as: UTF8.self)
position = lf + 1
return line
}
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
}
mutating func readBytes(_ count: Int) throws -> [UInt8] {
while buffer.count - position < count {
guard try fill() else { throw CupsAdminError.transport("cupsd closed the connection mid-response") }
}
defer { position += count }
return Array(buffer[position ..< position + count])
}
mutating func readToEnd() throws -> [UInt8] {
while try fill() {}
defer { position = buffer.count }
return Array(buffer[position...])
}
}
}

View File

@@ -0,0 +1,477 @@
import Foundation
// MARK: - Controls
/// A typed custom value (`*ParamCustomX`) edited as text, stored as `Custom.<value>`.
public struct CustomField: Hashable {
public enum Kind: String {
case string, passcode, password, int, real
}
public let kind: Kind
public let parameterName: String
public let minimum: Double?
public let maximum: Double?
/// The PPD's own choices (e.g. `None`, `0`). An empty field means the first of them.
public let choices: [String]
/// Passcodes and passwords: masked in the UI and in displayed commands.
public var isSecret: Bool { kind == .passcode || kind == .password }
public var isNumeric: Bool { kind == .int || kind == .real }
init?(parameter: PPD.CustomParameter, choices: [String]) {
guard let kind = Kind(rawValue: parameter.type) else { return nil }
self.kind = kind
parameterName = parameter.name
minimum = Double(parameter.minimum)
maximum = Double(parameter.maximum)
self.choices = choices
}
/// Stored option value -> field text. `Custom.1234` -> `1234`; a plain choice -> "" (or the choice for numbers).
public func text(fromValue value: String?) -> String {
guard let value else { return "" }
if value.hasPrefix("Custom.") { return String(value.dropFirst("Custom.".count)) }
if isNumeric, value != choices.first { return value }
return ""
}
/// Field text -> option value. "" -> the PPD's placeholder choice; text matching a choice -> that choice.
public func value(fromText text: String) -> String {
if text.isEmpty { return choices.first ?? "None" }
if choices.contains(text) { return text }
return "Custom." + text
}
/// nil when valid.
public func validationError(_ text: String) -> String? {
guard !text.isEmpty, !choices.contains(text) else { return nil }
switch kind {
case .string, .password, .passcode:
if kind == .passcode, !text.allSatisfy(\.isASCIIDigit) { return "Digits only" }
if let minimum, Double(text.count) < minimum { return "At least \(Int(minimum)) characters" }
if let maximum, Double(text.count) > maximum { return "At most \(Int(maximum)) characters" }
case .int:
guard let number = Int(text) else { return "Whole number" }
return rangeError(Double(number))
case .real:
guard let number = Double(text) else { return "Number" }
return rangeError(number)
}
return nil
}
private func rangeError(_ number: Double) -> String? {
if let minimum, number < minimum { return "\(format(minimum)) to \(format(maximum))" }
if let maximum, number > maximum { return "\(format(minimum)) to \(format(maximum))" }
return nil
}
private func format(_ n: Double?) -> String {
guard let n else { return "…" }
return n == n.rounded() ? String(Int(n)) : String(n)
}
/// "8 characters max", "4–8 digits", "0 to 23".
public var hint: String {
switch kind {
case .string, .password:
return maximum.map { "Up to \(Int($0)) characters" } ?? "Text"
case .passcode:
if let minimum, let maximum { return "\(Int(minimum))–\(Int(maximum)) digits" }
return "Digits"
case .int, .real:
return "\(format(minimum)) to \(format(maximum))"
}
}
}
public enum OptionControl: Hashable {
/// Menu of choices. `(keyword, label)` pairs.
case picker([PPD.Choice])
/// Boolean with exactly a true and a false choice; the keywords to write for each state.
case toggle(on: String, off: String)
/// Custom value typed as text; the PPD's choices are only a placeholder.
case field(CustomField)
/// Read-only, with the reason (e.g. "Set by PPD option PageSize").
case readOnly(String)
}
extension PPD.Option {
public var control: OptionControl {
// A true Boolean has exactly a True and a False choice; some PPDs mislabel PickOnes as Boolean.
if uiType == .boolean,
let on = choices.first(where: { $0.caseInsensitiveCompare("True") == .orderedSame }),
let off = choices.first(where: { $0.caseInsensitiveCompare("False") == .orderedSame }),
choices.count == 2 {
return .toggle(on: on, off: off)
}
// Ricoh codes/passwords: a placeholder choice plus one typed custom parameter.
if customParameters.count == 1, choices.count <= 1,
let field = CustomField(parameter: customParameters[0], choices: choices) {
return .field(field)
}
return .picker(choiceList)
}
}
// MARK: - Encoding
public enum OptionEncoding {
/// `key=value` for lpadmin/lpoptions `-o`, quoting values cupsParseOptions would otherwise split.
/// (Unquoted, `Custom.John Smith` is silently stored as `Custom.John`.)
public static func argument(key: String, value: String) -> String {
"\(key)=\(quoted(value))"
}
public static func quoted(_ value: String) -> String {
guard value.contains(where: { $0.isWhitespace || "\"'\\{}".contains($0) }) else { return value }
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Masks the value part of a custom secret (`Custom.1234` -> `Custom.••••`).
public static func masked(_ value: String) -> String {
if value.hasPrefix("Custom.") { return "Custom." + String(repeating: "•", count: max(4, value.count - 7)) }
return value
}
}
// MARK: - lpoptions
/// Per-user option defaults from `~/.cups/lpoptions` (read only, to show where a user overrides a queue default).
public enum LpOptionsFile {
public static var userFileURL: URL {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cups/lpoptions")
}
/// Options on the `Dest`/`Default` line for `queue` (no instance). Empty if none or no file.
public static func options(for queue: String, fileURL: URL = userFileURL) -> [String: String] {
guard let text = try? String(contentsOf: fileURL, encoding: .utf8) else { return [:] }
return options(for: queue, in: text)
}
public static func options(for queue: String, in text: String) -> [String: String] {
for line in text.split(whereSeparator: \.isNewline) {
let fields = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
guard fields.count >= 2, fields[0] == "Dest" || fields[0] == "Default",
fields[1].caseInsensitiveCompare(queue) == .orderedSame else { continue }
return fields.count > 2 ? parse(String(fields[2])) : [:]
}
return [:]
}
/// cupsParseOptions-style: `a=b c="d e" f={g=h i=j}`.
public static func parse(_ string: String) -> [String: String] {
var result: [String: String] = [:]
var chars = Array(string)
var i = 0
func skipSpaces() { while i < chars.count, chars[i].isWhitespace { i += 1 } }
while true {
skipSpaces()
guard i < chars.count else { break }
var name = ""
while i < chars.count, !chars[i].isWhitespace, chars[i] != "=" { name.append(chars[i]); i += 1 }
guard i < chars.count, chars[i] == "=" else {
if !name.isEmpty { result[name] = "true" }
continue
}
i += 1
var value = ""
var quote: Character?
var braces = 0
while i < chars.count {
let c = chars[i]
if c == "\\", i + 1 < chars.count {
value.append(chars[i + 1]); i += 2; continue
}
if let q = quote {
if c == q { quote = nil } else { value.append(c) }
} else if c == "\"" || c == "'" {
quote = c
} else if c == "{" {
braces += 1; value.append(c)
} else if c == "}" {
braces -= 1; value.append(c)
} else if c.isWhitespace, braces == 0 {
break
} else {
value.append(c)
}
i += 1
}
result[name] = value
}
chars.removeAll()
return result
}
}
// MARK: - Catalog
public struct OptionRow: Identifiable, Hashable {
public enum Source: Hashable {
case ppd, ipp, policy
}
/// The key written with `lpadmin -p queue -o key=value` (PPD keyword, `sides-default`, `job-quota-period`).
public let key: String
public let label: String
public let group: String
public let control: OptionControl
public let source: Source
public var id: String { key }
public var isSecret: Bool {
if case .field(let field) = control { return field.isSecret }
return false
}
}
public struct OptionSection: Identifiable, Hashable {
public let title: String
public let rows: [OptionRow]
public var id: String { title }
}
/// Queue defaults for one queue: IPP attributes and PPD (what lpadmin changes), plus the user's
/// ~/.cups/lpoptions, read only to point out per-user overrides.
public struct OptionState {
public let queue: String
public let snapshot: QueueSnapshot
public let userOptions: [String: String]
public static func load(client: CupsClient, queue: String) async throws -> OptionState {
OptionState(queue: queue, snapshot: try await QueueSnapshot.load(client: client, queue: queue),
userOptions: LpOptionsFile.options(for: queue))
}
public var ppd: PPD? { snapshot.ppd }
/// IPP job-template defaults an admin can set with `lpadmin -o name-default=value`.
public static let ippDefaults = ["copies", "media", "sides", "print-color-mode", "print-quality", "output-bin",
"printer-resolution", "number-up", "orientation-requested", "job-hold-until",
"job-priority", "job-cancel-after"]
/// On PPD queues cupsd derives these IPP defaults from a PPD option, and setting both makes duplicates.
public static let ppdControlled = ["media": "PageSize", "sides": "Duplex", "output-bin": "OutputBin",
"printer-resolution": "Resolution", "print-color-mode": "ColorModel",
"print-quality": "cupsPrintQuality"]
public static let integerPolicies = ["job-quota-period", "job-page-limit", "job-k-limit"]
/// PPD groups shown first, in this order; "Installable Options" goes last (hardware, rarely changed).
public static let leadingGroups = [PPD.generalGroup, "Basic", "Paper"]
public static let trailingGroup = "Installable Options"
/// General, Basic, Paper, other PPD groups in file order, IPP, Policies, Installable Options.
public func sections() -> [OptionSection] {
var ppdSections: [OptionSection] = []
if let ppd {
let ordered = Self.leadingGroups.filter(ppd.groups.contains)
+ ppd.groups.filter { !Self.leadingGroups.contains($0) && $0 != Self.trailingGroup }
for group in ordered {
let rows = ppd.options.filter { $0.group == group }.map {
OptionRow(key: $0.keyword, label: $0.text, group: group, control: $0.control, source: .ppd)
}
ppdSections.append(OptionSection(title: group, rows: rows))
}
}
var sections = ppdSections
var ipp: [OptionRow] = []
for base in Self.ippDefaults {
let name = base + "-default"
guard snapshot.attributes[name] != nil || snapshot.attributes[base + "-supported"] != nil else { continue }
var control = ippControl(base: base)
if let keyword = Self.ppdControlled[base], ppd?.option(keyword) != nil {
control = .readOnly("Set by PPD option \(keyword)")
}
ipp.append(OptionRow(key: name, label: Self.label(base), group: "IPP", control: control, source: .ipp))
}
if !ipp.isEmpty { sections.append(OptionSection(title: "IPP", rows: ipp)) }
var policies: [OptionRow] = []
for name in ["printer-error-policy", "printer-op-policy"] where snapshot.attributes[name] != nil {
let choices = supportedChoices(name + "-supported")
policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies",
control: .picker(choices), source: .policy))
}
if snapshot.attributes["job-sheets-default"] != nil {
let sheets = supportedChoices("job-sheets-supported").map(\.keyword)
var pairs: [PPD.Choice] = []
for start in sheets {
for end in sheets { pairs.append(PPD.Choice(keyword: "\(start),\(end)", text: "\(start), \(end)")) }
}
policies.append(OptionRow(key: "job-sheets-default", label: "Banner pages (start, end)",
group: "Policies", control: .picker(pairs), source: .policy))
}
for name in Self.integerPolicies where snapshot.attributes[name] != nil {
let field = CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int",
minimum: "0", maximum: "2147483647"),
choices: ["0"])!
policies.append(OptionRow(key: name, label: Self.label(name), group: "Policies", control: .field(field), source: .policy))
}
if !policies.isEmpty { sections.append(OptionSection(title: "Policies", rows: policies)) }
if let ppd, ppd.groups.contains(Self.trailingGroup) {
let rows = ppd.options.filter { $0.group == Self.trailingGroup }.map {
OptionRow(key: $0.keyword, label: $0.text, group: Self.trailingGroup, control: $0.control, source: .ppd)
}
sections.append(OptionSection(title: Self.trailingGroup, rows: rows))
}
return sections
}
/// "print-color-mode" -> "Print color mode".
static func label(_ name: String) -> String {
let words = name.replacingOccurrences(of: "-", with: " ")
return words.prefix(1).uppercased() + words.dropFirst()
}
private func ippControl(base: String) -> OptionControl {
let supported = snapshot.attributes[base + "-supported"]
if let supported, case .range(let lower, let upper)? = supported.values.first, supported.values.count == 1 {
return .field(integerField(base, lower: Int(lower), upper: Int(upper)))
}
if base == "job-priority" {
return .field(integerField(base, lower: 1, upper: 100))
}
let choices = supportedChoices(base + "-supported")
return choices.isEmpty ? .readOnly("No \(base)-supported values") : .picker(choices)
}
private func integerField(_ name: String, lower: Int, upper: Int) -> CustomField {
CustomField(parameter: PPD.CustomParameter(name: name, text: name, order: 1, type: "int",
minimum: String(lower), maximum: String(upper)), choices: [])!
}
/// `*-supported` values as choices: raw value to write, human text to show.
private func supportedChoices(_ name: String) -> [PPD.Choice] {
guard let attribute = snapshot.attributes[name] else { return [] }
let base = String(name.dropLast("-supported".count))
return attribute.values.compactMap { value in
guard let raw = Self.rawValue(value) else { return nil }
let shown = Format.value(value, attribute: base + "-default")
return PPD.Choice(keyword: raw, text: shown == raw ? raw : "\(shown) (\(raw))")
}
}
/// What lpadmin accepts for an IPP value: enums and integers as numbers, resolutions as 600dpi.
static func rawValue(_ value: IPPValue) -> String? {
switch value {
case .integer(let i), .enumeration(let i): return String(i)
case .string(_, let s), .stringWithLanguage(_, _, let s): return s
case .resolution(let x, let y, let units):
let unit = units == 4 ? "dpcm" : "dpi"
return x == y ? "\(x)\(unit)" : "\(x)x\(y)\(unit)"
case .range(let lower, let upper): return "\(lower)-\(upper)"
case .boolean(let b): return b ? "true" : "false"
default: return nil
}
}
// MARK: values
/// The queue default for a row key (PPD default, or the first IPP attribute of that name), in raw form.
public func queueValue(_ key: String) -> String? {
if let option = ppd?.option(key) { return option.defaultChoice }
guard let attribute = snapshot.attributes[key] else { return nil }
let raw = attribute.values.compactMap(Self.rawValue)
return raw.isEmpty ? nil : raw.joined(separator: ",")
}
/// This user's own lpoptions value for a row (`sides-default` is `sides` there), if they have one.
public func userOverride(_ key: String) -> String? {
let base = key.hasSuffix("-default") ? String(key.dropLast("-default".count)) : key
return userOptions[Self.ippDefaults.contains(base) ? base : key]
}
/// Human form of a raw value for a row, e.g. `Custom.1234` -> `1234`, `5` -> `high`, `DuplexNoTumble` -> `Long Edge`.
public func display(_ value: String?, row: OptionRow) -> String {
guard let value else { return "—" }
switch row.control {
case .picker(let choices):
return choices.first { $0.keyword == value }?.text ?? value
case .toggle(let on, _):
return value == on ? "On" : "Off"
case .field(let field):
let text = field.text(fromValue: value)
if text.isEmpty { return value }
return field.isSecret ? String(repeating: "•", count: text.count) : text
case .readOnly:
return value
}
}
}
// MARK: - Apply
public struct OptionChange: Hashable {
public let key: String
public let value: String
public let isSecret: Bool
public init(key: String, value: String, isSecret: Bool) {
self.key = key
self.value = value
self.isSecret = isSecret
}
}
public struct OptionReadBack: Hashable {
public let key: String
public let applied: Bool
/// Why it didn't apply.
public let reason: String?
}
/// Writes queue defaults with `lpadmin -p queue -o ...` (the PPD in /etc/cups/ppd and printers.conf),
/// the same thing the CUPS web UI's Set Default Options page did. Never lpoptions.
public enum OptionApplier {
public static func command(queue: String, changes: [OptionChange]) -> (executable: String, arguments: [String]) {
var args = ["-p", queue]
for change in changes {
args += ["-o", OptionEncoding.argument(key: change.key, value: change.value)]
}
return (CupsTools.lpadminPath, args)
}
/// The command as shown to the user, with secret values masked.
public static func displayCommand(queue: String, changes: [OptionChange]) -> String {
let masked = changes.map {
OptionChange(key: $0.key, value: $0.isSecret ? OptionEncoding.masked($0.value) : $0.value, isSecret: $0.isSecret)
}
let (executable, args) = command(queue: queue, changes: masked)
return CupsTools.commandLine(executable, args)
}
/// Runs one lpadmin call for all changes, then re-reads the queue and checks each change.
public static func apply(client: CupsClient, queue: String,
changes: [OptionChange]) async throws -> (result: ToolResult, readBack: [OptionReadBack], state: OptionState) {
let (executable, args) = command(queue: queue, changes: changes)
let result = try await Task.detached { try CupsTools.run(executable, args) }.value
let state = try await OptionState.load(client: client, queue: queue)
guard result.succeeded else {
let message = result.message.isEmpty ? "lpadmin exited \(result.status)" : result.message
return (result, changes.map { OptionReadBack(key: $0.key, applied: false, reason: message) }, state)
}
return (result, changes.map { verify($0, state: state) }, state)
}
public static func verify(_ change: OptionChange, state: OptionState) -> OptionReadBack {
let actual = state.queueValue(change.key)
// PPD choices are written verbatim; IPP values read back in their own spelling (none,none / true).
let exact = state.ppd?.option(change.key) != nil
if let actual, exact ? actual == change.value : QueueSnapshot.matches(actual, change.value) {
return OptionReadBack(key: change.key, applied: true, reason: nil)
}
guard let actual else {
return OptionReadBack(key: change.key, applied: false, reason: "Not applied: \(state.queue) has no option \(change.key)")
}
return OptionReadBack(key: change.key, applied: false,
reason: "Not applied: still \(change.isSecret ? OptionEncoding.masked(actual) : actual)")
}
}
private extension Character {
var isASCIIDigit: Bool { ("0"..."9").contains(self) }
}

173
Sources/CupsKit/PPD.swift Normal file
View File

@@ -0,0 +1,173 @@
import Foundation
/// PPD parsing for what `lpoptions -l` and the CUPS web UI's "Set Default Options" page showed:
/// UI options with their groups, UI types, choices, defaults and custom-value parameters.
public struct PPD {
public enum UIType: String {
case pickOne = "PickOne"
case pickMany = "PickMany"
case boolean = "Boolean"
case unknown
}
public struct Choice: Hashable {
public let keyword: String
/// Translation text after the slash, or the keyword when there is none.
public let text: String
public init(keyword: String, text: String) {
self.keyword = keyword
self.text = text
}
}
/// One `*ParamCustomX Name/Text: order type min max` line.
public struct CustomParameter: Hashable {
public let name: String
public let text: String
public let order: Int
/// PPD type as written: string, passcode, password, int, real, points, curve, invcurve.
public let type: String
public let minimum: String
public let maximum: String
}
public struct Option {
public let keyword: String
public let text: String
public var choices: [String] = []
public var defaultChoice: String?
/// "Custom.INTEGER", "Custom.WIDTHxHEIGHT", ... when the PPD allows a custom value.
public var customChoice: String?
/// `*OpenGroup` label (with " / subgroup" when nested); "General" for ungrouped options.
public var group: String = PPD.generalGroup
public var uiType: UIType = .unknown
public var choiceList: [Choice] = []
/// Parameters from `*ParamCustomX`, in order; empty unless the PPD has `*CustomX True`.
public var customParameters: [CustomParameter] = []
public var isJCL = false
}
/// Where libcups and the CUPS web UI put options that aren't inside an `*OpenGroup`.
public static let generalGroup = "General"
public private(set) var options: [Option] = []
/// Group names in first-appearance order ("General" where the first ungrouped option appears).
public private(set) var groups: [String] = []
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
public private(set) var attributes: [String: String] = [:]
public init(text: String) {
var defaults: [String: String] = [:]
var customKeywords: Set<String> = []
var customParams: [String: [CustomParameter]] = [:]
var groupStack: [String] = []
var current: Option?
var inQuotedValue = false
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
let line = String(rawLine)
// Multi-line quoted values (invocation code, etc.) — skip until the closing quote.
if inQuotedValue {
if line.contains("\"") { inQuotedValue = false }
continue
}
guard line.hasPrefix("*"), !line.hasPrefix("*%"), let colon = line.firstIndex(of: ":") else { continue }
let key = line[line.index(after: line.startIndex) ..< colon] // "PageSize Letter/US Letter"
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
if value.hasPrefix("\""), value.filter({ $0 == "\"" }).count == 1 { inQuotedValue = true }
let parts = key.split(separator: " ", maxSplits: 1)
let mainKey = String(parts[0])
switch mainKey {
case "OpenGroup", "OpenSubGroup":
// *OpenGroup: InstallableOptions/Installable Options
let (name, label) = Self.splitText(value)
groupStack.append(label ?? name)
case "CloseGroup", "CloseSubGroup":
_ = groupStack.popLast()
case "OpenUI", "JCLOpenUI":
// *OpenUI *PageSize/Media Size: PickOne
guard parts.count == 2 else { continue }
let spec = parts[1].trimmingCharacters(in: .whitespaces).drop { $0 == "*" }
let nameAndText = spec.split(separator: "/", maxSplits: 1)
let keyword = String(nameAndText[0])
let text = nameAndText.count > 1 ? String(nameAndText[1]) : Self.standardText[keyword] ?? keyword
current = Option(keyword: keyword, text: text)
current!.group = groupStack.isEmpty ? Self.generalGroup : groupStack.joined(separator: " / ")
current!.uiType = UIType(rawValue: value) ?? .unknown
current!.isJCL = mainKey == "JCLOpenUI"
case "CloseUI", "JCLCloseUI":
if let option = current { options.append(option) }
current = nil
default:
if mainKey.hasPrefix("ParamCustom"), parts.count == 2 {
// *ParamCustomRIAngel Angel/Text: 1 int 0 359
let fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
let (name, label) = Self.splitText(String(parts[1]))
if fields.count >= 2, let order = Int(fields[0]) {
customParams[String(mainKey.dropFirst("ParamCustom".count)), default: []].append(
CustomParameter(name: name, text: label ?? name, order: order, type: fields[1],
minimum: fields.count > 2 ? fields[2] : "",
maximum: fields.count > 3 ? fields[3] : ""))
}
} else if mainKey.hasPrefix("Custom"), parts.count == 2, Self.splitText(String(parts[1])).name == "True" {
customKeywords.insert(String(mainKey.dropFirst("Custom".count)))
} else if mainKey.hasPrefix("Default"), parts.count == 1 {
defaults[String(mainKey.dropFirst("Default".count))] = value
} else if parts.count == 2, let option = current, mainKey == option.keyword {
let (choice, label) = Self.splitText(String(parts[1]))
current!.choices.append(choice)
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
} else if parts.count == 1, attributes[mainKey] == nil {
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
}
}
}
if let option = current { options.append(option) }
// lpoptions hides PageRegion (it mirrors PageSize).
options.removeAll { $0.keyword == "PageRegion" }
for i in options.indices {
let keyword = options[i].keyword
options[i].defaultChoice = defaults[keyword]
if !groups.contains(options[i].group) { groups.append(options[i].group) }
guard customKeywords.contains(keyword) else { continue }
let params = (customParams[keyword] ?? []).sorted { $0.order < $1.order }
options[i].customParameters = params
if keyword == "PageSize" {
options[i].customChoice = "Custom.WIDTHxHEIGHT"
} else if params.count == 1 {
options[i].customChoice = "Custom.\(Self.lpoptionsTypeName(params[0].type))"
} else if params.count > 1 {
options[i].customChoice = "{" + params.map { "\($0.name)=\(Self.lpoptionsTypeName($0.type))" }
.joined(separator: " ") + "}"
}
}
}
/// Labels libcups gives options whose *OpenUI line has no translation text.
private static let standardText = ["PageSize": "Media Size", "MediaType": "Media Type",
"InputSlot": "Media Source", "ColorModel": "Output Mode",
"Resolution": "Resolution"]
/// How `lpoptions -l` spells a custom parameter type.
private static func lpoptionsTypeName(_ type: String) -> String {
type == "int" ? "INTEGER" : type.uppercased()
}
/// "Letter/US Letter" -> ("Letter", "US Letter"); "Letter" -> ("Letter", nil).
private static func splitText(_ s: String) -> (name: String, text: String?) {
let pieces = s.split(separator: "/", maxSplits: 1)
let name = pieces.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? ""
let text = pieces.count > 1 ? pieces[1].trimmingCharacters(in: .whitespaces) : nil
return (name, text)
}
public func option(_ keyword: String) -> Option? {
options.first { $0.keyword == keyword }
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// CUPS printer-type bits (cups/cups.h cups_ptype_e) we surface.
public enum PrinterType {
public static let isClass = 0x0000_0001
public static let remote = 0x0000_0002
public static let bw = 0x0000_0004
public static let color = 0x0000_0008
public static let duplex = 0x0000_0010
public static let staple = 0x0000_0020
public static let fax = 0x0004_0000
public static let discovered = 0x0100_0000
public static let scanner = 0x0200_0000
public static let mfp = 0x0400_0000
public static func kind(_ type: Int?) -> String {
guard let type else { return "?" }
var parts = [type & isClass != 0 ? "class" : "printer"]
if type & remote != 0 { parts.append("remote") }
if type & discovered != 0 { parts.append("discovered") }
if type & fax != 0 { parts.append("fax") }
return parts.joined(separator: ",")
}
public static func capabilities(_ type: Int?) -> String {
guard let type else { return "?" }
var caps: [String] = []
if type & color != 0 { caps.append("color") } else if type & bw != 0 { caps.append("b/w") }
if type & duplex != 0 { caps.append("duplex") }
if type & staple != 0 { caps.append("staple") }
if type & scanner != 0 { caps.append("scanner") }
if type & mfp != 0 { caps.append("mfp") }
return caps.isEmpty ? "-" : caps.joined(separator: ", ")
}
}

View File

@@ -0,0 +1,268 @@
import CoreGraphics
import Foundation
/// One job as the Jobs table shows it.
public struct JobSummary: Identifiable, Hashable {
public let id: Int
public let name: String
public let user: String
public let queue: String
/// Size in kilobytes (job-k-octets).
public let sizeK: Int
/// Pages printed or counted so far, when cupsd knows.
public let pages: Int?
public let state: Int
public let reasons: [String]
public let submitted: Date?
public static let requestedAttributes = [
"job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-k-octets",
"job-media-sheets-completed", "job-impressions-completed", "job-impressions",
"job-state", "job-state-reasons", "time-at-creation",
]
public init(_ job: IPPGroup) {
id = job.int("job-id") ?? 0
name = job.string("job-name") ?? ""
user = job.string("job-originating-user-name") ?? ""
queue = Self.queueName(fromPrinterURI: job.string("job-printer-uri")) ?? ""
sizeK = job.int("job-k-octets") ?? 0
pages = [job.int("job-impressions-completed"), job.int("job-media-sheets-completed"), job.int("job-impressions")]
.compactMap { $0 }.first { $0 > 0 }
state = job.int("job-state") ?? 0
reasons = job.strings("job-state-reasons").filter { $0 != "none" }
submitted = job.int("time-at-creation").flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
}
/// pending, held, processing, stopped, canceled, aborted, completed.
public var stateName: String { Format.jobState(state) }
public var isHeld: Bool { state == 4 }
/// pending / held / processing / stopped: cancel, hold, release and move still make sense.
public var isActive: Bool { state >= 3 && state <= 6 }
/// ipp://host/printers/NAME -> NAME.
public static func queueName(fromPrinterURI uri: String?) -> String? {
guard let uri, let last = uri.split(separator: "/").last else { return nil }
return last.removingPercentEncoding ?? String(last)
}
}
public enum PrinterAction: CaseIterable {
case pause, resume, accept, reject, cancelAllJobs
public var title: String {
switch self {
case .pause: return "Pause"
case .resume: return "Resume"
case .accept: return "Accept Jobs"
case .reject: return "Reject Jobs"
case .cancelAllJobs: return "Cancel All Jobs"
}
}
public var systemImage: String {
switch self {
case .pause: return "pause.circle"
case .resume: return "play.circle"
case .accept: return "checkmark.circle"
case .reject: return "nosign"
case .cancelAllJobs: return "xmark.circle"
}
}
/// "Paused", "Resumed", ... for status lines.
public var pastTense: String {
switch self {
case .pause: return "Paused"
case .resume: return "Resumed"
case .accept: return "Accepting jobs on"
case .reject: return "Rejecting jobs on"
case .cancelAllJobs: return "Canceled all jobs on"
}
}
}
public enum JobAction: Hashable {
case cancel, hold, release
case move(to: String)
public var title: String {
switch self {
case .cancel: return "Cancel"
case .hold: return "Hold"
case .release: return "Release"
case .move(let destination): return "Move to \(destination)"
}
}
public var pastTense: String {
switch self {
case .cancel: return "Canceled"
case .hold: return "Held"
case .release: return "Released"
case .move(let destination): return "Moved to \(destination):"
}
}
}
/// The outcome of an action after reading cupsd back.
public struct ActionOutcome: Hashable {
public let succeeded: Bool
/// The command that ran (shell-style), or the IPP operation.
public let command: String
/// Why it failed: tool output, IPP status, or what the read-back found.
public let message: String?
}
/// Pause/resume/accept/reject/cancel-all and job cancel/hold/release/move, each verified against cupsd.
public enum QueueActions {
public static func perform(_ action: PrinterAction, queue: String, client: CupsClient) async throws -> ActionOutcome {
let result: ToolResult = try await Task.detached {
switch action {
case .pause: return try CupsTools.disable(queue: queue)
case .resume: return try CupsTools.enable(queue: queue)
case .accept: return try CupsTools.accept(queue: queue)
case .reject: return try CupsTools.reject(queue: queue)
case .cancelAllJobs: return try CupsTools.cancelAll(queue: queue)
}
}.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine,
message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
let problem: String?
switch action {
case .pause, .resume, .accept, .reject:
let printer = try await client.getPrinterAttributes(queue: queue, requested: ["printer-state", "printer-is-accepting-jobs"])
let stopped = printer.int("printer-state") == 5
let accepting = printer.bool("printer-is-accepting-jobs") ?? true
switch action {
case .pause: problem = stopped ? nil : "still \(Format.printerState(printer.int("printer-state")))"
case .resume: problem = stopped ? "still stopped" : nil
case .accept: problem = accepting ? nil : "still rejecting jobs"
default: problem = accepting ? "still accepting jobs" : nil
}
case .cancelAllJobs:
let left = try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).count
problem = left == 0 ? nil : "\(left) job\(left == 1 ? "" : "s") still active"
}
return ActionOutcome(succeeded: problem == nil, command: result.commandLine, message: problem.map { "Not applied: \($0)" })
}
/// Runs the action on each job, then reads each job back. Returns one outcome per job id, in order.
public static func perform(_ action: JobAction, jobIDs: [Int], client: CupsClient) async -> [Int: ActionOutcome] {
var outcomes: [Int: ActionOutcome] = [:]
for id in jobIDs {
outcomes[id] = await perform(action, jobID: id, client: client)
}
return outcomes
}
static func perform(_ action: JobAction, jobID: Int, client: CupsClient) async -> ActionOutcome {
var command = ""
do {
switch action {
case .cancel:
// Same IPP Cancel-Job the CLI's `cupsadmin cancel` uses.
command = "IPP Cancel-Job \(jobID)"
try await client.cancelJob(jobID: jobID)
case .hold, .release, .move:
let result: ToolResult = try await Task.detached {
switch action {
case .hold: return try CupsTools.hold(jobID: jobID)
case .release: return try CupsTools.release(jobID: jobID)
case .move(let destination): return try CupsTools.move(jobID: jobID, to: destination)
case .cancel: fatalError("handled above")
}
}.value
command = result.commandLine
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: command,
message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
}
} catch {
return ActionOutcome(succeeded: false, command: command, message: String(describing: error))
}
do {
let job = JobSummary(try await client.getJobAttributes(jobID: jobID, requested: JobSummary.requestedAttributes))
let problem: String?
switch action {
case .cancel: problem = job.state == 7 ? nil : "still \(job.stateName)"
case .hold: problem = job.isHeld ? nil : "still \(job.stateName)"
case .release: problem = job.isHeld ? "still held" : nil
case .move(let destination): problem = job.queue == destination ? nil : "still on \(job.queue)"
}
return ActionOutcome(succeeded: problem == nil, command: command, message: problem.map { "Not applied: \($0)" })
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound && action == .cancel {
// Purged from history right away: canceled.
return ActionOutcome(succeeded: true, command: command, message: nil)
} catch {
return ActionOutcome(succeeded: false, command: command, message: "Couldn’t read job \(jobID) back: \(error)")
}
}
}
// MARK: - Default, delete, test page
extension QueueActions {
/// `lpadmin -d queue`, verified with CUPS-Get-Default.
public static func setDefault(queue: String, client: CupsClient) async throws -> ActionOutcome {
let result = try await Task.detached { try CupsTools.setDefault(queue: queue) }.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
let current = try await client.getDefaultPrinter()
return ActionOutcome(succeeded: current == queue, command: result.commandLine,
message: current == queue ? nil : "Not applied: server default is \(current ?? "none")")
}
/// `lpadmin -x queue`, verified by the queue being gone.
public static func delete(queue: String, client: CupsClient) async throws -> ActionOutcome {
let result = try await Task.detached { try CupsTools.delete(queue: queue) }.value
guard result.succeeded else {
return ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message)
}
let stillThere = try await client.queueExists(queue)
return ActionOutcome(succeeded: !stillThere, command: result.commandLine,
message: stillThere ? "Not applied: \(queue) still exists" : nil)
}
/// Sends the CUPS test page (or a generated one-page PDF when it's missing). Returns the job id when verified.
public static func printTestPage(queue: String, client: CupsClient) async throws -> (outcome: ActionOutcome, jobID: Int?) {
let file = FileManager.default.fileExists(atPath: CupsTools.testPagePath)
? CupsTools.testPagePath : try generatedTestPage(queue: queue)
let result = try await Task.detached { try CupsTools.printTestPage(queue: queue, file: file) }.value
guard result.succeeded else {
return (ActionOutcome(succeeded: false, command: result.commandLine, message: result.message.isEmpty ? "exited \(result.status)" : result.message), nil)
}
// "request id is Q-12 (1 file(s))"
let text = String(decoding: result.standardOutput, as: UTF8.self)
guard let token = text.split(separator: " ").first(where: { $0.hasPrefix(queue + "-") }),
let id = Int(token.dropFirst(queue.count + 1)) else {
return (ActionOutcome(succeeded: false, command: result.commandLine, message: "lp didn’t report a job id: \(text)"), nil)
}
let job = JobSummary(try await client.getJobAttributes(jobID: id, requested: JobSummary.requestedAttributes))
let ok = job.queue == queue
return (ActionOutcome(succeeded: ok, command: result.commandLine, message: ok ? nil : "Job \(id) isn’t on \(queue)"), id)
}
private static func generatedTestPage(queue: String) throws -> String {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-testpage.pdf")
var box = CGRect(x: 0, y: 0, width: 612, height: 792)
guard let context = CGContext(url as CFURL, mediaBox: &box, nil) else {
throw CupsAdminError.failed("couldn’t create a test page PDF")
}
context.beginPDFPage(nil)
context.setLineWidth(4)
context.stroke(box.insetBy(dx: 36, dy: 36))
context.move(to: CGPoint(x: 36, y: 36)); context.addLine(to: CGPoint(x: 576, y: 756))
context.move(to: CGPoint(x: 36, y: 756)); context.addLine(to: CGPoint(x: 576, y: 36))
context.strokePath()
context.endPDFPage()
context.closePDF()
return url.path
}
}

View File

@@ -0,0 +1,152 @@
import Foundation
/// What `add` / `set` were asked to change. Maps 1:1 onto lpadmin flags.
public struct QueueSpec {
public var deviceURI: String?
public var model: String?
public var ppdFile: String?
public var description: String?
public var location: String?
public var options: [(key: String, value: String)] = []
public init() {}
public var isEmpty: Bool {
deviceURI == nil && model == nil && ppdFile == nil && description == nil && location == nil && options.isEmpty
}
public func lpadminArguments(queue: String) -> [String] {
var args = ["-p", queue]
if let deviceURI { args += ["-v", deviceURI] }
if let model { args += ["-m", model] }
if let ppdFile { args += ["-P", ppdFile] }
if let description { args += ["-D", description] }
if let location { args += ["-L", location] }
for option in options { args += ["-o", "\(option.key)=\(option.value)"] }
return args
}
}
/// One requested change and where it landed.
public struct SettingCheck {
public let name: String
public let source: String
public let value: String?
public let wanted: String
public var applied: Bool { QueueSnapshot.matches(value, wanted) }
}
/// Queue state used to verify that lpadmin actually applied each change.
public struct QueueSnapshot {
public let attributes: IPPGroup
public let ppd: PPD?
public static func load(client: CupsClient, queue: String) async throws -> QueueSnapshot {
QueueSnapshot(attributes: try await client.getPrinterAttributes(queue: queue, requested: ["all"]),
ppd: try await client.getPPD(queue: queue).map(PPD.init(text:)))
}
/// Where an lpadmin `-o key=value` lands: an IPP attribute of that name, a PPD option, or key-default.
public func lookup(option key: String) -> (value: String, source: String)? {
if let attribute = attributes[key] { return (Self.join(attribute), "ipp") }
if let option = ppd?.option(key) { return (option.defaultChoice ?? "?", "ppd") }
if let attribute = attributes[key + "-default"] { return (Self.join(attribute), "ipp \(key)-default") }
return nil
}
public func settings(for spec: QueueSpec) -> [SettingCheck] {
var result: [SettingCheck] = []
if let uri = spec.deviceURI {
result.append(SettingCheck(name: "device-uri (-v)", source: "ipp", value: attributes.string("device-uri"), wanted: uri))
}
if let d = spec.description {
result.append(SettingCheck(name: "printer-info (-D)", source: "ipp", value: attributes.string("printer-info"), wanted: d))
}
if let l = spec.location {
result.append(SettingCheck(name: "printer-location (-L)", source: "ipp", value: attributes.string("printer-location"), wanted: l))
}
for option in spec.options {
let found = lookup(option: option.key)
result.append(SettingCheck(name: option.key, source: found?.source ?? "not found", value: found?.value, wanted: option.value))
}
return result
}
private static func join(_ attribute: IPPAttribute) -> String {
attribute.values.map { Format.value($0, attribute: attribute.name) }.joined(separator: ",")
}
/// Loose equality: case-insensitive, yes/on/true style booleans, and "none" matching "none,none".
public static func matches(_ actual: String?, _ wanted: String) -> Bool {
guard let actual else { return false }
func normalize(_ s: String) -> String {
let lower = s.lowercased()
return ["yes": "true", "on": "true", "no": "false", "off": "false"][lower] ?? lower
}
let a = normalize(actual), w = normalize(wanted)
if a == w { return true }
let parts = a.split(separator: ",").map(String.init)
return !w.contains(",") && parts.count > 1 && parts.allSatisfy { $0 == w }
}
}
/// Add/modify flows, split around the lpadmin run so a caller can show the command and its output in between.
public enum QueueAdmin {
/// Validates an add request and applies the sharing default. Returns the spec to hand to lpadmin.
public static func prepareAdd(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> QueueSpec {
try validateQueueName(queue)
guard spec.deviceURI != nil else { throw CupsAdminError.usage("add needs -v <device-uri>") }
// lpadmin creates queues shared by default; ours aren't unless asked (--shared or an explicit -o).
var spec = spec
if let explicit = spec.options.first(where: { $0.key == "printer-is-shared" }) {
if shared, !QueueSnapshot.matches(explicit.value, "true") {
throw CupsAdminError.usage("--shared conflicts with -o printer-is-shared=\(explicit.value)")
}
} else {
spec.options.append(("printer-is-shared", shared ? "true" : "false"))
}
if try await client.queueExists(queue) {
throw CupsAdminError.failed("queue \(queue) already exists (use set to change it)")
}
return spec
}
/// lpadmin arguments for add. -E after -p: enable and accept jobs, like the web UI's Add Printer did.
public static func addArguments(queue: String, spec: QueueSpec) -> [String] {
spec.lpadminArguments(queue: queue) + ["-E"]
}
/// After lpadmin ran for add: checks it worked and returns the new queue's state.
public static func confirmAdded(client: CupsClient, queue: String, lpadminStatus: Int32) async throws -> QueueSnapshot {
guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); queue not added") }
guard try await client.queueExists(queue) else {
throw CupsAdminError.failed("lpadmin exited 0 but \(queue) does not exist")
}
return try await QueueSnapshot.load(client: client, queue: queue)
}
/// Validates a set request and returns the current values of everything it will change.
public static func prepareSet(client: CupsClient, queue: String, spec: QueueSpec) async throws -> [SettingCheck] {
guard !spec.isEmpty else { throw CupsAdminError.usage("set needs at least one of -o, -v, -D, -L, -m, -P") }
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec)
}
/// After lpadmin ran for set: checks the exit status and returns the new values.
public static func confirmSet(client: CupsClient, queue: String, spec: QueueSpec, lpadminStatus: Int32) async throws -> [SettingCheck] {
guard lpadminStatus == 0 else { throw CupsAdminError.failed("lpadmin exited \(lpadminStatus); nothing verified") }
return try await QueueSnapshot.load(client: client, queue: queue).settings(for: spec)
}
/// cupsd's rules: printable, no space / slash / backslash / quote / ? / #, at most 127 bytes.
public static func validateQueueName(_ queue: String) throws {
let forbidden = Set(" \t/\\?'\"#")
guard !queue.isEmpty, queue.utf8.count <= 127,
queue.unicodeScalars.allSatisfy({ $0.value > 0x20 && $0.value != 0x7F }),
!queue.contains(where: forbidden.contains) else {
throw CupsAdminError.usage("invalid queue name \(queue): no spaces, / \\ ? ' \" #, max 127 bytes")
}
}
}

View File

@@ -0,0 +1,139 @@
import Foundation
/// 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).
/// Keywords and values come from docs/ricoh-ppd-options.md — never guessed.
public struct QuickAction: Identifiable, Hashable {
public enum Input: Hashable {
case none
/// Digits, up to the PPD's own limit. An empty value clears the code.
case userCode
}
public enum Effect: Hashable {
/// `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 title: String
public let systemImage: String
public let input: Input
public let effect: Effect
/// One line for the CLI list and tooltips.
public let summary: String
public static let all: [QuickAction] = [
QuickAction(id: "color", title: "Always Print in Color", systemImage: "paintpalette", input: .none,
effect: .options(variants: [["ColorModel": "CMYK"], ["HQColorMode": "COLOR"]], clearVariants: []),
summary: "Color by default (ColorModel=CMYK, or HQColorMode=COLOR)"),
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 (ColorModel=Gray, or HQColorMode=BW)"),
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
effect: .options(variants: [["RIEnableUserCode": "True", "RIUserCode": "Custom.{value}"]],
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,
effect: .options(variants: [["Duplex": "DuplexNoTumble"]], clearVariants: []),
summary: "Two-sided, long edge (Duplex=DuplexNoTumble)"),
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
effect: .options(variants: [["Duplex": "None"]], clearVariants: []),
summary: "One-sided (Duplex=None)"),
QuickAction(id: "letter", title: "Use Letter Paper, Fit to Nearest Size", systemImage: "arrow.up.left.and.arrow.down.right",
input: .none,
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,
effect: .defaultPrinter, summary: "Server default printer (lpadmin -d)"),
]
public static func named(_ id: String) -> QuickAction? {
all.first { $0.id == id }
}
/// The option changes for this PPD, or why the action isn't available on it.
public func changes(for ppd: PPD?, value: String? = nil) -> Result<[OptionChange], QuickActionUnavailable> {
guard case .options(let variants, let clearVariants) = effect else { return .success([]) }
let clearing = input == .userCode && (value ?? "").isEmpty
let candidates = clearing ? clearVariants : variants
guard let ppd else { return .failure(QuickActionUnavailable(reason: "\(title) needs a PPD; this queue has none")) }
for variant in candidates {
var changes: [OptionChange] = []
for key in variant.keys.sorted() {
guard let option = ppd.option(key) else { changes = []; break }
let raw = variant[key]!
if raw.contains("{value}") {
guard !option.customParameters.isEmpty else { changes = []; break }
changes.append(OptionChange(key: key, value: raw.replacingOccurrences(of: "{value}", with: value ?? ""), isSecret: false))
} else {
guard option.choices.contains(raw) else { changes = []; break }
changes.append(OptionChange(key: key, value: raw, isSecret: false))
}
}
if !changes.isEmpty { return .success(changes) }
}
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 {
if case .defaultPrinter = effect { return true }
if case .success = changes(for: ppd, value: input == .userCode ? "0" : nil) { return true }
return false
}
/// For Set User Code: digits only, within the PPD's RIUserCode length. nil when valid.
public func validationError(_ value: String, ppd: PPD?) -> String? {
guard input == .userCode, !value.isEmpty else { return nil }
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 {
return "At most \(max) digits"
}
return nil
}
/// The current user code on the queue, without the Custom. prefix (nil when none is set).
public static func currentUserCode(_ ppd: PPD?) -> String? {
guard let value = ppd?.option("RIUserCode")?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
return String(value.dropFirst("Custom.".count))
}
}
public struct QuickActionUnavailable: Error, Hashable {
public let reason: String
}
public struct QuickActionOutcome {
public let command: String
public let succeeded: Bool
/// Per-option read-back; empty for Make Default Printer.
public let readBack: [OptionReadBack]
public let changes: [OptionChange]
public let message: String?
}
extension QuickAction {
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when the PPD lacks the options.
public func run(queue: String, value: String? = nil, client: CupsClient) async throws -> QuickActionOutcome {
switch effect {
case .defaultPrinter:
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, 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)
}
}
}

View File

@@ -0,0 +1,36 @@
import Foundation
import CupsKit
enum CancelCommand {
/// Accepts a bare job id ("123") or lpstat's "Queue-123" form.
static func parseJobID(_ argument: String) throws -> Int {
let digits = argument.split(separator: "-").last.map(String.init) ?? argument
guard let id = Int(digits), id > 0 else {
throw CupsAdminError.usage("cancel needs a job id like 123 or Queue-123, got \(argument)")
}
return id
}
static func run(client: CupsClient, jobID: Int) async throws -> String {
let requested = ["job-id", "job-name", "job-printer-uri", "job-originating-user-name", "job-state"]
let job: IPPGroup
do {
job = try await client.getJobAttributes(jobID: jobID, requested: requested)
} catch CupsAdminError.ipp(let status, _) where status == IPPStatus.notFound {
throw CupsAdminError.failed("no such job \(jobID)")
}
let queue = JobsCommand.queueName(job) ?? "?"
let name = job.string("job-name") ?? "-"
let owner = job.string("job-originating-user-name") ?? "-"
print("Job \(jobID) on \(queue): \"\(name)\" by \(owner), \(Format.jobState(job.int("job-state")))")
try await client.cancelJob(jobID: jobID)
// Read back rather than trusting the successful status.
let after = try? await client.getJobAttributes(jobID: jobID, requested: ["job-state"])
let state = Format.jobState(after?.int("job-state"))
print("Now: \(after == nil ? "gone from job history" : state)")
return "OK: canceled job \(jobID) on \(queue)"
}
}

View File

@@ -0,0 +1,39 @@
import Foundation
import CupsKit
enum JobsCommand {
static func run(client: CupsClient, queue: String?, which: String) async throws -> String {
let jobs = try await client.getJobs(queue: queue, which: which, requested: [
"job-id", "job-name", "job-originating-user-name", "job-printer-uri", "job-state",
"job-state-reasons", "job-k-octets", "time-at-creation",
])
let rows = jobs.sorted { ($0.int("job-id") ?? 0) < ($1.int("job-id") ?? 0) }.map { j -> [String] in
let jobQueue = queueName(j)
return [
j.int("job-id").map(String.init) ?? "?",
jobQueue ?? queue ?? "?",
j.string("job-originating-user-name") ?? "-",
Format.jobState(j.int("job-state")),
Format.kilobytes(j.int("job-k-octets")),
Format.epoch(j.int("time-at-creation")),
Format.reasons(j.strings("job-state-reasons")),
j.string("job-name") ?? "-",
]
}
let scope = queue.map { " on \($0)" } ?? ""
let label = which == "not-completed" ? "active" : which
if rows.isEmpty {
print("No \(label) jobs\(scope).")
} else {
printTable(headers: ["ID", "QUEUE", "OWNER", "STATE", "SIZE", "SUBMITTED", "REASONS", "NAME"], rows: rows)
}
return "OK: \(rows.count) \(label) job\(rows.count == 1 ? "" : "s")\(scope)"
}
/// Queue name from job-printer-uri (ipp://host/printers/NAME).
static func queueName(_ job: IPPGroup) -> String? {
guard let uri = job.string("job-printer-uri"), let last = uri.split(separator: "/").last else { return nil }
return last.removingPercentEncoding ?? String(last)
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
import CupsKit
enum OptionsCommand {
/// Queue-level settings lpadmin -o can change that aren't named *-default.
static let policyAttributes = ["printer-error-policy", "printer-op-policy", "printer-is-shared",
"port-monitor", "job-quota-period", "job-k-limit", "job-page-limit"]
static func run(client: CupsClient, queue: String, full: Bool) async throws -> String {
let attributes = try await client.getPrinterAttributes(queue: queue, requested: ["all"])
let ppd = try await client.getPPD(queue: queue).map(PPD.init(text:))
if let ppd {
let a = ppd.attributes
let details = [a["Manufacturer"], a["PCFileName"], a["FileVersion"].map { "version \($0)" }]
.compactMap { $0 }.joined(separator: ", ")
print("PPD: \(a["NickName"] ?? "?")" + (details.isEmpty ? "" : " (\(details))"))
print("PPD options (* = queue default; per-user lpoptions overrides not applied):")
for option in ppd.options {
let choices = option.choices.map { $0 == option.defaultChoice ? "*\($0)" : $0 }
+ [option.customChoice].compactMap { $0 }
var line = choices.joined(separator: " ")
if option.choices.isEmpty, let d = option.defaultChoice { line = "*\(d)" }
print(" \(option.keyword)/\(option.text): \(line)")
}
} else {
print("PPD: none (raw queue, or cupsd could not provide one)")
}
let defaultNames = attributes.attributes.map(\.name).filter { $0.hasSuffix("-default") }.sorted()
let names = defaultNames + policyAttributes.filter { attributes[$0] != nil }
let rows = names.map { name -> [String] in
let base = name.hasSuffix("-default") ? String(name.dropLast("-default".count)) : name
return [name,
cell(attributes[name], full: full),
cell(attributes[base + "-supported"], full: full)]
}
print("\nIPP defaults and queue policy:")
printTable(headers: [" ATTRIBUTE", "DEFAULT", "SUPPORTED"], rows: rows.map { [" " + $0[0]] + $0.dropFirst() })
if !full { print("\n(long lists truncated; use --full)") }
return "OK: \(queue) \(ppd?.options.count ?? 0) PPD options, \(rows.count) IPP defaults/policies"
}
private static func cell(_ attribute: IPPAttribute?, full: Bool) -> String {
guard let attribute else { return "-" }
var values = attribute.values.map { Format.value($0, attribute: attribute.name) }
if !full, values.count > 8 {
values = Array(values.prefix(8)) + ["… (+\(attribute.values.count - 8) more)"]
}
let text = values.joined(separator: ", ")
return !full && text.count > 100 ? String(text.prefix(99)) + "…" : text
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
import CupsKit
enum PrintersCommand {
static func run(client: CupsClient) async throws -> String {
let printers = try await client.getPrinters(requested: [
"printer-name", "printer-type", "printer-state", "printer-state-reasons",
"printer-is-accepting-jobs", "printer-is-shared", "queued-job-count", "device-uri",
])
let sorted = printers.sorted {
($0.string("printer-name") ?? "").localizedStandardCompare($1.string("printer-name") ?? "") == .orderedAscending
}
let rows = sorted.map { p -> [String] in
[
p.string("printer-name") ?? "?",
PrinterType.kind(p.int("printer-type")),
Format.printerState(p.int("printer-state")),
Format.yesNo(p.bool("printer-is-accepting-jobs")),
Format.yesNo(p.bool("printer-is-shared")),
p.int("queued-job-count").map(String.init) ?? "-",
Format.reasons(p.strings("printer-state-reasons")),
p.string("device-uri") ?? "-",
]
}
if rows.isEmpty {
print("No queues.")
} else {
printTable(headers: ["QUEUE", "KIND", "STATE", "ACCEPTING", "SHARED", "JOBS", "REASONS", "DEVICE URI"],
rows: rows)
}
let classes = sorted.filter { ($0.int("printer-type") ?? 0) & PrinterType.isClass != 0 }.count
let problems = sorted.filter {
$0.int("printer-state") == 5 || $0.bool("printer-is-accepting-jobs") == false
}.count
var status = "OK: \(sorted.count - classes) printers"
if classes > 0 { status += ", \(classes) class\(classes == 1 ? "" : "es")" }
if problems > 0 { status += ", \(problems) stopped or rejecting" }
return status
}
}
enum PrinterCommand {
static func run(client: CupsClient, queue: String, dumpAll: Bool) async throws -> String {
// "all" in CUPS excludes a few expensive attributes (e.g. media-col-database), which is what we want.
let p = try await client.getPrinterAttributes(queue: queue, requested: ["all"])
if dumpAll {
let width = min(p.attributes.map(\.name.count).max() ?? 0, 40)
for attribute in p.attributes.sorted(by: { $0.name < $1.name }) {
let values = attribute.values.map { Format.value($0, attribute: attribute.name) }
let pad = String(repeating: " ", count: max(0, width - attribute.name.count))
print("\(attribute.name)\(pad) \(values.joined(separator: ", "))")
}
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), \(p.attributes.count) attributes"
}
let type = p.int("printer-type")
var lines: [(String, String)] = [
("Queue", p.string("printer-name") ?? queue),
("Description", p.string("printer-info") ?? "-"),
("Location", p.string("printer-location") ?? "-"),
("Make and model", p.string("printer-make-and-model") ?? "-"),
("Kind", PrinterType.kind(type)),
]
if (type ?? 0) & PrinterType.isClass != 0 {
lines.append(("Members", p.strings("member-names").joined(separator: ", ")))
}
let stateMessage = p.string("printer-state-message") ?? ""
lines += [
("Device URI", p.string("device-uri") ?? "-"),
("Printer URI", p.strings("printer-uri-supported").first ?? "-"),
("State", Format.printerState(p.int("printer-state")) + (stateMessage.isEmpty ? "" : " — \(stateMessage)")),
("State reasons", Format.reasons(p.strings("printer-state-reasons"))),
("State changed", Format.epoch(p.int("printer-state-change-time"))),
("Accepting jobs", Format.yesNo(p.bool("printer-is-accepting-jobs"))),
("Shared", Format.yesNo(p.bool("printer-is-shared"))),
("Queued jobs", p.int("queued-job-count").map(String.init) ?? "-"),
("Capabilities", PrinterType.capabilities(type)),
]
let defaults = ["media-default", "sides-default", "print-color-mode-default", "print-quality-default",
"output-bin-default", "copies-default", "job-sheets-default", "printer-error-policy",
"printer-op-policy"]
for name in defaults {
guard let attribute = p[name] else { continue }
lines.append((name, attribute.values.map { Format.value($0, attribute: name) }.joined(separator: ", ")))
}
let markerNames = p.strings("marker-names")
let markerLevels = p.ints("marker-levels")
for (i, marker) in markerNames.enumerated() {
let level = i < markerLevels.count ? markerLevels[i] : -1
lines.append((i == 0 ? "Supplies" : "", "\(marker): " + (level >= 0 ? "\(level)%" : "unknown")))
}
let width = lines.map(\.0.count).max() ?? 0
for (label, value) in lines {
let heading = label.isEmpty ? String(repeating: " ", count: width + 1)
: label + ":" + String(repeating: " ", count: width - label.count)
print("\(heading) \(value)")
}
print("\n(use --all for every attribute)")
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
}
}

View File

@@ -0,0 +1,104 @@
import Foundation
import CupsKit
extension QueueSpec {
/// Parses `<queue> [-v uri] [-m model | -P ppd] [-D text] [-L text] [-o key=value ...]`.
static func parse(_ args: [String], command: String) throws -> (queue: String, spec: QueueSpec) {
var spec = QueueSpec()
var queue: String?
var i = 0
func value(_ flag: String) throws -> String {
i += 1
guard i < args.count else { throw CupsAdminError.usage("\(flag) needs a value") }
return args[i]
}
while i < args.count {
let arg = args[i]
switch arg {
case "-v": spec.deviceURI = try value(arg)
case "-m": spec.model = try value(arg)
case "-P": spec.ppdFile = try value(arg)
case "-D": spec.description = try value(arg)
case "-L": spec.location = try value(arg)
case "-o":
let pair = try value(arg)
guard let eq = pair.firstIndex(of: "="), eq != pair.startIndex else {
throw CupsAdminError.usage("-o expects key=value, got \(pair)")
}
spec.options.append((String(pair[..<eq]), String(pair[pair.index(after: eq)...])))
case _ where arg.hasPrefix("-"):
throw CupsAdminError.usage("unknown option \(arg) for \(command)")
default:
guard queue == nil else { throw CupsAdminError.usage("\(command) takes one queue name, got extra \(arg)") }
queue = arg
}
i += 1
}
guard let queue else { throw CupsAdminError.usage("\(command) needs a queue name") }
guard spec.model == nil || spec.ppdFile == nil else { throw CupsAdminError.usage("use -m or -P, not both") }
if let ppdFile = spec.ppdFile, !FileManager.default.fileExists(atPath: ppdFile) {
throw CupsAdminError.usage("PPD file not found: \(ppdFile)")
}
return (queue, spec)
}
}
/// Echoes the command, runs it through CupsKit, and passes its output through (lpadmin warnings etc.).
func runTool(_ executable: String, _ arguments: [String]) throws -> Int32 {
print("+ " + CupsTools.commandLine(executable, arguments))
fflush(stdout)
let result = try CupsTools.run(executable, arguments)
FileHandle.standardOutput.write(result.standardOutput)
FileHandle.standardError.write(result.standardError)
return result.status
}
enum AddCommand {
static func run(client: CupsClient, queue: String, spec: QueueSpec, shared: Bool) async throws -> String {
let spec = try await QueueAdmin.prepareAdd(client: client, queue: queue, spec: spec, shared: shared)
let status = try runTool(CupsTools.lpadminPath, QueueAdmin.addArguments(queue: queue, spec: spec))
let after = try await QueueAdmin.confirmAdded(client: client, queue: queue, lpadminStatus: status)
let a = after.attributes
print("\nAdded \(queue): \(Format.printerState(a.int("printer-state"))), "
+ "accepting \(Format.yesNo(a.bool("printer-is-accepting-jobs"))), "
+ "model \(a.string("printer-make-and-model") ?? "-")")
let settings = after.settings(for: spec)
let failed = settings.filter { !$0.applied }
if !settings.isEmpty {
printTable(headers: ["SETTING", "SOURCE", "VALUE", "RESULT"], rows: settings.map {
[$0.name, $0.source, $0.value ?? "-", $0.applied ? "ok" : "NOT APPLIED (wanted \($0.wanted))"]
})
}
guard failed.isEmpty else {
throw CupsAdminError.failed("added \(queue), but \(failed.count) setting(s) not applied: "
+ failed.map(\.name).joined(separator: ", "))
}
return "OK: added \(queue), \(settings.count) setting(s) verified"
}
}
enum SetCommand {
static func run(client: CupsClient, queue: String, spec: QueueSpec) async throws -> String {
let before = try await QueueAdmin.prepareSet(client: client, queue: queue, spec: spec)
let status = try runTool(CupsTools.lpadminPath, spec.lpadminArguments(queue: queue))
let after = try await QueueAdmin.confirmSet(client: client, queue: queue, spec: spec, lpadminStatus: status)
print()
let rows = zip(before, after).map { b, a -> [String] in
[a.name, a.source, b.value ?? "-", a.value ?? "-", a.applied ? "ok" : "NOT APPLIED (wanted \(a.wanted))"]
}
printTable(headers: ["SETTING", "SOURCE", "BEFORE", "AFTER", "RESULT"], rows: rows)
if spec.model != nil || spec.ppdFile != nil {
print("(driver changed; see `cupsadmin printer \(queue)` for make-and-model)")
}
let failed = after.filter { !$0.applied }
guard failed.isEmpty else {
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
+ failed.map(\.name).joined(separator: ", "))
}
return "OK: \(queue) \(after.count) setting(s) changed and verified"
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import CupsKit
enum QuickCommand {
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
guard let name = args.first else {
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
})
return "OK: \(QuickAction.all.count) quick actions"
}
guard let action = QuickAction.named(name) else {
throw CupsAdminError.usage("unknown quick action \(name) (one of: \(QuickAction.all.map(\.id).joined(separator: ", ")))")
}
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
let queue = args[1]
let value: String?
switch action.input {
case .none:
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
value = nil
case .userCode:
if clear {
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
value = ""
} else {
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
value = args[2]
}
}
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
if case .defaultPrinter = action.effect {
let before = try await client.getDefaultPrinter()
let status = try runTool(CupsTools.lpadminPath, ["-d", queue])
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); default not changed") }
let after = try await client.getDefaultPrinter()
print()
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"],
rows: [["server default", before ?? "none", after ?? "none", after == queue ? "ok" : "NOT APPLIED (wanted \(queue))"]])
guard after == queue else { throw CupsAdminError.failed("lpadmin exited 0 but the server default is \(after ?? "none")") }
return "OK: \(queue) is the server default printer"
}
let before = try await OptionState.load(client: client, queue: queue)
if let value, let error = action.validationError(value, ppd: before.ppd) {
throw CupsAdminError.usage("user code \(value): \(error)")
}
let changes: [OptionChange]
switch action.changes(for: before.ppd, value: value) {
case .success(let resolved): changes = resolved
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) isn’t available on \(queue): \(unavailable.reason)")
}
let (executable, arguments) = OptionApplier.command(queue: queue, changes: changes)
let status = try runTool(executable, arguments)
guard status == 0 else { throw CupsAdminError.failed("lpadmin exited \(status); nothing verified") }
let after = try await OptionState.load(client: client, queue: queue)
let checks = changes.map { OptionApplier.verify($0, state: after) }
print()
printTable(headers: ["SETTING", "BEFORE", "AFTER", "RESULT"], rows: zip(changes, checks).map { change, check in
[change.key, before.queueValue(change.key) ?? "-", after.queueValue(change.key) ?? "-",
check.applied ? "ok" : "NOT APPLIED (wanted \(change.value))"]
})
let failed = checks.filter { !$0.applied }
guard failed.isEmpty else {
throw CupsAdminError.failed("lpadmin exited 0 but \(failed.count) setting(s) not applied: "
+ failed.map(\.key).joined(separator: ", "))
}
return "OK: \(queue) \(action.id) applied, \(changes.count) setting(s) verified"
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
/// Left-aligned columns, two spaces apart; last column unpadded.
func printTable(headers: [String], rows: [[String]]) {
var widths = headers.map(\.count)
for row in rows {
for (i, cell) in row.enumerated() where i < widths.count {
widths[i] = max(widths[i], cell.count)
}
}
func line(_ cells: [String]) -> String {
cells.enumerated().map { i, cell in
i == cells.count - 1 ? cell : cell + String(repeating: " ", count: widths[i] - cell.count)
}.joined(separator: " ")
}
print(line(headers))
rows.forEach { print(line($0)) }
}

View File

@@ -0,0 +1,138 @@
import Foundation
import CupsKit
// Hard rule: started/status/finished on every exit path. These go to stderr so stdout stays pipeable.
let startTime = Date()
func clock(_ date: Date) -> String {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f.string(from: date)
}
func printErr(_ line: String) {
fflush(stdout)
FileHandle.standardError.write(Data((line + "\n").utf8))
}
func finish(_ code: Int32, _ status: String) -> Never {
printErr(status)
let end = Date()
let minutes = String(format: "%.1f", end.timeIntervalSince(startTime) / 60)
printErr("finished \(clock(end)) (total \(minutes) min)")
exit(code)
}
let usage = """
usage: cupsadmin <command> [args]
printers all queues: state, reasons, accepting, shared, device URI
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
jobs [queue] [--completed | --all-jobs]
active jobs (all queues unless one is given)
cancel <job-id> cancel one job (123 or Queue-123)
options <queue> [--full] PPD options (lpoptions -l style) and IPP *-default/*-supported
add <queue> -v <uri> [-m model | -P ppd] [-D description] [-L location] [-o key=value ...] [--shared]
create, enable and accept a queue via lpadmin, then verify
(not shared unless --shared)
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
change a queue via lpadmin, then show before/after
quick list quick actions (task-named queue defaults)
quick <action> <queue> [code | --clear]
apply one via lpadmin, then verify
help show this help
"""
printErr("started \(clock(startTime))")
// Ctrl-C / kill still reports finished.
var signalSources: [DispatchSourceSignal] = []
for sig in [SIGINT, SIGTERM] {
signal(sig, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
source.setEventHandler { finish(128 + sig, "ERROR: interrupted by signal \(sig)") }
source.resume()
signalSources.append(source)
}
func runCommand(_ args: [String]) async throws -> String {
guard let command = args.first else { throw CupsAdminError.usage("no command given") }
var rest = Array(args.dropFirst())
let client = CupsClient()
func takeFlag(_ flag: String) -> Bool {
guard let i = rest.firstIndex(of: flag) else { return false }
rest.remove(at: i)
return true
}
func rejectUnknownFlags() throws {
if let flag = rest.first(where: { $0.hasPrefix("-") }) {
throw CupsAdminError.usage("unknown option \(flag) for \(command)")
}
}
switch command {
case "help", "-h", "--help":
print(usage)
return "OK: help"
case "printers":
try rejectUnknownFlags()
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
return try await PrintersCommand.run(client: client)
case "printer":
let dumpAll = takeFlag("--all")
try rejectUnknownFlags()
guard rest.count == 1 else { throw CupsAdminError.usage("printer needs exactly one queue name") }
return try await PrinterCommand.run(client: client, queue: rest[0], dumpAll: dumpAll)
case "jobs":
let completed = takeFlag("--completed")
let all = takeFlag("--all-jobs")
try rejectUnknownFlags()
guard rest.count <= 1 else { throw CupsAdminError.usage("jobs takes at most one queue name") }
guard !(completed && all) else { throw CupsAdminError.usage("use --completed or --all-jobs, not both") }
let which = completed ? "completed" : all ? "all" : "not-completed"
return try await JobsCommand.run(client: client, queue: rest.first, which: which)
case "cancel":
try rejectUnknownFlags()
guard rest.count == 1 else { throw CupsAdminError.usage("cancel needs exactly one job id") }
return try await CancelCommand.run(client: client, jobID: try CancelCommand.parseJobID(rest[0]))
case "options":
let full = takeFlag("--full")
try rejectUnknownFlags()
guard rest.count == 1 else { throw CupsAdminError.usage("options needs exactly one queue name") }
return try await OptionsCommand.run(client: client, queue: rest[0], full: full)
case "add":
let shared = takeFlag("--shared")
let (queue, spec) = try QueueSpec.parse(rest, command: command)
return try await AddCommand.run(client: client, queue: queue, spec: spec, shared: shared)
case "set":
let (queue, spec) = try QueueSpec.parse(rest, command: command)
return try await SetCommand.run(client: client, queue: queue, spec: spec)
case "quick":
let clear = takeFlag("--clear")
try rejectUnknownFlags()
return try await QuickCommand.run(client: client, args: rest, clear: clear)
default:
throw CupsAdminError.usage("unknown command \(command)")
}
}
do {
let status = try await runCommand(Array(CommandLine.arguments.dropFirst()))
finish(0, status)
} catch CupsAdminError.usage(let message) {
printErr(usage)
finish(2, "ERROR: \(message)")
} catch {
finish(1, "ERROR: \(error)")
}

View File

@@ -0,0 +1,218 @@
@testable import CupsKit
import Foundation
import Testing
/// Parser/control tests read Ricoh's vendor PPDs (read-only); each test is skipped when its PPD isn't installed.
let vendorPPDs = "/Library/Printers/PPDs/Contents/Resources/"
func vendorPPD(_ name: String) -> PPD? {
(try? String(contentsOfFile: vendorPPDs + name, encoding: .isoLatin1)).map(PPD.init(text:))
}
func hasVendorPPD(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: vendorPPDs + name)
}
@Suite struct PPDParsing {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohGroupsTypesAndCustomParameters() throws {
let ppd = try #require(vendorPPD("RICOH IM C4500"))
#expect(ppd.groups.contains("User Authentication"))
#expect(ppd.groups.contains(PPD.generalGroup))
let duplex = try #require(ppd.option("Duplex"))
#expect(duplex.group == PPD.generalGroup)
#expect(duplex.choiceList.contains(PPD.Choice(keyword: "DuplexNoTumble", text: "Long Edge")))
let enable = try #require(ppd.option("RIEnableUserCode"))
#expect(enable.group == "Job Log")
#expect(enable.control == .toggle(on: "True", off: "False"))
guard case .field(let code) = try #require(ppd.option("RIUserCode")).control else {
Issue.record("RIUserCode should be a field"); return
}
#expect(code.kind == .string)
#expect(code.maximum == 8)
#expect(!code.isSecret)
guard case .field(let pin) = try #require(ppd.option("RIPassword")).control else {
Issue.record("RIPassword should be a field"); return
}
#expect(pin.kind == .passcode)
#expect(pin.isSecret)
#expect(pin.minimum == 4 && pin.maximum == 8)
guard case .field(let loginPassword) = try #require(ppd.option("RIAuthLoginPassword")).control else {
Issue.record("RIAuthLoginPassword should be a field"); return
}
#expect(loginPassword.kind == .password)
// PageSize has a multi-parameter points custom size: stays a picker.
guard case .picker = try #require(ppd.option("PageSize")).control else {
Issue.record("PageSize should be a picker"); return
}
guard case .picker = try #require(ppd.option("ColorModel")).control else {
Issue.record("ColorModel should be a picker"); return
}
}
@Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
func booleanWithThreeChoicesFallsBackToPicker() throws {
let ppd = try #require(vendorPPD("RICOH M C251FW.ppd"))
let mode = try #require(ppd.option("HQPrintMode"))
#expect(mode.uiType == .boolean)
#expect(mode.choices.count == 3)
guard case .picker(let choices) = mode.control else { Issue.record("expected picker"); return }
#expect(choices.map(\.keyword) == ["661", "662", "664"])
}
@Test func inlineGroupsAndJCL() {
let ppd = PPD(text: """
*PPD-Adobe: "4.3"
*OpenUI *Duplex/Two-Sided: PickOne
*DefaultDuplex: None
*Duplex None/Off: ""
*Duplex DuplexNoTumble/Long Edge: ""
*CloseUI: *Duplex
*OpenGroup: Auth/Authentication
*OpenSubGroup: Codes/Codes
*JCLOpenUI *JCLCode/Code: PickOne
*DefaultJCLCode: None
*JCLCode None/None: ""
*JCLCloseUI: *JCLCode
*CustomJCLCode True/Custom: ""
*ParamCustomJCLCode Code/Account Code: 1 string 1 12
*CloseSubGroup: Codes
*CloseGroup: Auth
""")
#expect(ppd.groups == ["General", "Authentication / Codes"])
let code = ppd.option("JCLCode")
#expect(code?.isJCL == true)
#expect(code?.customParameters.first?.text == "Account Code")
#expect(code?.customChoice == "Custom.STRING")
}
}
@Suite struct CustomValues {
let field: CustomField = {
let ppd = PPD(text: """
*OpenUI *RIPassword/Password: PickOne
*DefaultRIPassword: None
*RIPassword None/None: ""
*CloseUI: *RIPassword
*CustomRIPassword True/Custom: ""
*ParamCustomRIPassword Password: 1 passcode 4 8
""")
guard case .field(let f) = ppd.option("RIPassword")!.control else { fatalError() }
return f
}()
@Test func prefixIsInvisible() {
#expect(field.text(fromValue: "Custom.1234") == "1234")
#expect(field.text(fromValue: "None") == "")
#expect(field.value(fromText: "1234") == "Custom.1234")
#expect(field.value(fromText: "") == "None")
}
@Test func passcodeValidation() {
#expect(field.validationError("123") == "At least 4 characters")
#expect(field.validationError("123456789") == "At most 8 characters")
#expect(field.validationError("12a4") == "Digits only")
#expect(field.validationError("1234") == nil)
#expect(field.validationError("") == nil)
}
@Test func quotingAndMasking() {
#expect(OptionEncoding.argument(key: "RIUserCode", value: "Custom.12345") == "RIUserCode=Custom.12345")
#expect(OptionEncoding.argument(key: "RIAuthLoginUserNameText", value: "Custom.John Smith")
== "RIAuthLoginUserNameText=\"Custom.John Smith\"")
#expect(OptionEncoding.argument(key: "k", value: "a\"b") == "k=\"a\\\"b\"")
#expect(OptionEncoding.masked("Custom.1234") == "Custom.••••")
let command = OptionApplier.displayCommand(queue: "Q", changes: [
OptionChange(key: "RIPassword", value: "Custom.9876", isSecret: true),
OptionChange(key: "RIUserCode", value: "Custom.42", isSecret: false),
])
#expect(command == "/usr/sbin/lpadmin -p Q -o RIPassword=Custom.•••• -o RIUserCode=Custom.42")
#expect(!command.contains("9876"))
}
}
@Suite struct ReadBackAndOrder {
private func state(_ ppdText: String, attributes: [IPPAttribute] = []) -> OptionState {
OptionState(queue: "Q", snapshot: QueueSnapshot(attributes: IPPGroup(tag: .printerAttributes, attributes: attributes),
ppd: PPD(text: ppdText)), userOptions: [:])
}
@Test func readBackFlagsTyposAndValuesThatDidNotStick() {
let s = state("*OpenUI *Duplex/Duplex: PickOne\n*DefaultDuplex: None\n*Duplex None/Off: \"\"\n*CloseUI: *Duplex\n",
attributes: [.string("job-sheets-default", .nameWithoutLanguage, ["none", "none"])])
#expect(OptionApplier.verify(OptionChange(key: "Duplex", value: "None", isSecret: false), state: s).applied)
#expect(!OptionApplier.verify(OptionChange(key: "Duplex", value: "DuplexTumble", isSecret: false), state: s).applied)
let typo = OptionApplier.verify(OptionChange(key: "Duplx", value: "None", isSecret: false), state: s)
#expect(!typo.applied)
#expect(typo.reason == "Not applied: Q has no option Duplx")
#expect(OptionApplier.verify(OptionChange(key: "job-sheets-default", value: "none", isSecret: false), state: s).applied)
#expect(OptionApplier.command(queue: "Q", changes: []).executable == "/usr/sbin/lpadmin")
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func generalBasicPaperFirstInstallableOptionsLast() throws {
let ppdText = try #require(try? String(contentsOfFile: vendorPPDs + "RICOH IM C4500", encoding: .isoLatin1))
let titles = state(ppdText).sections().map(\.title)
#expect(Array(titles.prefix(3)) == ["General", "Basic", "Paper"])
#expect(titles.last == "Installable Options")
#expect(titles.filter { $0 == "Installable Options" }.count == 1)
}
}
@Suite struct LpOptionsParsing {
@Test func destLineWithQuotesAndBraces() {
let text = """
Default OldPrinter
Dest Office sides=two-sided-long-edge RIAuthLoginUserNameText="Custom.My Name" PageSize={Width=100 Height=200}
Dest Office/draft print-quality=3
"""
let office = LpOptionsFile.options(for: "Office", in: text)
#expect(office["sides"] == "two-sided-long-edge")
#expect(office["RIAuthLoginUserNameText"] == "Custom.My Name")
#expect(office["PageSize"] == "{Width=100 Height=200}")
#expect(office["print-quality"] == nil) // instance line ignored
#expect(LpOptionsFile.options(for: "OldPrinter", in: text).isEmpty)
}
}
/// Live apply + read-back through the same code path the app uses.
/// Opt-in: CUPSKIT_LIVE_QUEUE=<throwaway Ricoh queue> swift test. Restores every value it changes.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] != nil))
struct LiveApply {
// Queue defaults only: the app writes with lpadmin, never lpoptions.
let queue = ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] ?? ""
@Test func queueScopeCustomCodeToggleAndIPP() async throws {
let client = CupsClient()
let before = try await OptionState.load(client: client, queue: queue)
let originals = ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "copies-default", "RIPassword"]
.map { ($0, before.queueValue($0)) }
let changes = [
OptionChange(key: "RIEnableUserCode", value: "True", isSecret: false),
OptionChange(key: "RIUserCode", value: "Custom.12345", isSecret: false),
OptionChange(key: "RIAuthLoginUserNameText", value: "Custom.John Smith", isSecret: false),
OptionChange(key: "RIPassword", value: "Custom.2468", isSecret: true),
OptionChange(key: "copies-default", value: "2", isSecret: false),
OptionChange(key: "RIUsrCode", value: "Custom.1", isSecret: false), // typo: must fail read-back
]
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
#expect(result.succeeded)
let byKey = Dictionary(uniqueKeysWithValues: readBack.map { ($0.key, $0) })
for key in ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "RIPassword", "copies-default"] {
#expect(byKey[key]?.applied == true, "\(key): \(byKey[key]?.reason ?? "")")
}
#expect(byKey["RIUsrCode"]?.applied == false)
let restore = originals.compactMap { key, value in value.map { OptionChange(key: key, value: $0, isSecret: false) } }
let (_, restored, _) = try await OptionApplier.apply(client: client, queue: queue, changes: restore)
let allRestored = restored.allSatisfy { $0.applied }
#expect(allRestored, "\(restored.filter { !$0.applied })")
}
}

View File

@@ -0,0 +1,59 @@
import CupsKit
import Foundation
import Testing
/// Opt-in: CUPSKIT_LIVE_JOBS=<queueA>,<queueB> ./test.sh — two throwaway, paused queues.
/// Submits /etc/hosts as jobs, drives every action, and leaves both queues paused with no active jobs.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] != nil), .serialized)
struct LiveQueueActions {
let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] ?? "").split(separator: ",").map(String.init)
private func submit(_ queue: String) throws -> Int {
let result = try CupsTools.run(CupsTools.lpPath, ["-d", queue, "-t", "cupskit live test", "/etc/hosts"])
let text = String(decoding: result.standardOutput, as: UTF8.self) // "request id is Q-123 (1 file(s))"
guard result.succeeded, let id = text.split(separator: " ").compactMap({ $0.split(separator: "-").last.flatMap { Int($0) } }).first else {
throw CupsAdminError.failed("lp failed: \(result.message)")
}
return id
}
@Test func jobHoldReleaseMoveCancel() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let id = try submit(queues[0])
let held = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id]
#expect(held?.succeeded == true, "\(String(describing: held))")
#expect(held?.command == "/usr/bin/lp -i \(id) -H hold")
let released = await QueueActions.perform(.release, jobIDs: [id], client: client)[id]
#expect(released?.succeeded == true, "\(String(describing: released))")
let moved = await QueueActions.perform(.move(to: queues[1]), jobIDs: [id], client: client)[id]
#expect(moved?.succeeded == true, "\(String(describing: moved))")
let canceled = await QueueActions.perform(.cancel, jobIDs: [id], client: client)[id]
#expect(canceled?.succeeded == true, "\(String(describing: canceled))")
// Releasing an already-canceled job must fail, not report success.
let bogus = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id]
#expect(bogus?.succeeded == false)
}
@Test func cancelAllPauseResumeAcceptReject() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let queue = queues[1]
_ = try submit(queue)
_ = try submit(queue)
let cancelAll = try await QueueActions.perform(.cancelAllJobs, queue: queue, client: client)
#expect(cancelAll.succeeded, "\(cancelAll)")
#expect(try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).isEmpty)
for action in [PrinterAction.reject, .accept, .resume, .pause] {
let outcome = try await QueueActions.perform(action, queue: queue, client: client)
#expect(outcome.succeeded, "\(action): \(outcome)")
}
}
}

View File

@@ -0,0 +1,95 @@
import CupsKit
import Foundation
import Testing
private func available(_ id: String, _ ppd: PPD?) -> Bool {
QuickAction.named(id)!.isAvailable(for: ppd)
}
@Suite struct QuickActionAvailability {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohPSHasEverything() throws {
let ppd = try #require(vendorPPD("RICOH IM C4500"))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] {
#expect(available(id, ppd), "\(id)")
}
let letter = try QuickAction.named("letter")!.changes(for: ppd).get()
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get()
#expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.changes(for: ppd, value: "").get()
#expect(clear.map(\.value) == ["False", "None"])
}
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd")))
func otherDriversOnlyGetWhatTheirPPDHas() throws {
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex.
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd"))
#expect(!available("color", sp) && !available("bw", sp))
#expect(!available("letter", sp) && !available("usercode", sp))
#expect(available("duplex", sp) && available("simplex", sp))
// M C251FW: color via HQColorMode.
let fw = try #require(vendorPPD("RICOH M C251FW.ppd"))
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no.
let airprint = PPD(text: """
*OpenUI *ColorModel/Color Mode: PickOne
*DefaultColorModel: Gray
*ColorModel Gray/Grayscale: ""
*CloseUI: *ColorModel
*OpenUI *PageSize/Media Size: PickOne
*DefaultPageSize: Letter
*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")
}
}
/// Opt-in: CUPSKIT_LIVE_QUICK=<throwaway Ricoh PS queue>,<throwaway generic queue> ./test.sh
/// Changes both queues; the generic one becomes the server default and is DELETED at the end.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] != nil), .serialized)
struct LiveQuickActions {
let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] ?? "").split(separator: ",").map(String.init)
@Test func everyActionOnRicohQueue() async throws {
try #require(queues.count == 2)
let client = CupsClient()
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"),
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client)
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
}
}
@Test func testPageDefaultAndDelete() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
#expect(page.succeeded && jobID != nil, "\(page)")
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
#expect(made.succeeded, "\(made.message ?? "")")
#expect(try await client.getDefaultPrinter() == queues[1])
let deleted = try await QueueActions.delete(queue: queues[1], client: client)
#expect(deleted.succeeded, "\(deleted)")
#expect(try await client.queueExists(queues[1]) == false)
}
}

245
build.sh Executable file
View File

@@ -0,0 +1,245 @@
#!/bin/bash
# Universal release build -> signed CLI binary + signed "CUPS Admin.app" -> signed payload-free PKG
# (postinstall installs /usr/local/bin/cupsadmin) -> notarize -> staple -> spctl.
# 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
#
# 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):
# CODESIGN_APP_IDENTITY SHA-1 of your "Developer ID Application" certificate
# CODESIGN_PKG_IDENTITY SHA-1 of your "Developer ID Installer" certificate
# NOTARY_PROFILE keychain profile from `xcrun notarytool store-credentials`
# CODESIGN_TEAM_ID optional: fail if the signatures aren't from this team
set -euo pipefail
BUILD_ONLY=0
for arg in "$@"; do
case "$arg" in
--build-only) BUILD_ONLY=1 ;;
*) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only)"
echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;;
esac
done
START_EPOCH=$(date +%s)
echo "started $(date +%H:%M:%S)"
STATUS="ERROR: build.sh exited unexpectedly"
finish() {
local rc=$?
if [ $rc -ne 0 ] && [ "${STATUS#OK}" != "$STATUS" ]; then STATUS="ERROR: exit $rc"; fi
echo "$STATUS"
echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)"
}
trap finish EXIT
trap 'STATUS="ERROR: interrupted"; exit 130' INT TERM
step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; }
# --- configuration -------------------------------------------------------------
cd "$(dirname "$0")"
VERSION="${VERSION:-1.0.1}"
IDENTIFIER="edu.wku.cupsadmin"
# build.env fills in only what the environment doesn't already set.
SETTINGS="CODESIGN_APP_IDENTITY CODESIGN_PKG_IDENTITY NOTARY_PROFILE CODESIGN_TEAM_ID"
for name in $SETTINGS; do
if [ -n "${!name+set}" ]; then eval "FROM_ENV_$name=\"\${$name}\""; fi
done
if [ -f build.env ]; then
set -a; . ./build.env; set +a
echo "read build.env"
fi
for name in $SETTINGS; do
from_env="FROM_ENV_$name"
if [ -n "${!from_env+set}" ]; then eval "$name=\"\${$from_env}\""; fi
done
require() {
[ -n "${!1:-}" ] || { STATUS="ERROR: $1 is not set ($2). Export it or add it to build.env"; exit 1; }
}
if [ $BUILD_ONLY -eq 0 ]; then
# SHA-1 hashes rather than names: a keychain can hold several identities with the same name.
require CODESIGN_APP_IDENTITY "SHA-1 of your Developer ID Application certificate"
require CODESIGN_PKG_IDENTITY "SHA-1 of your Developer ID Installer certificate"
require NOTARY_PROFILE "keychain profile from xcrun notarytool store-credentials"
APP_SIGN_ID="$CODESIGN_APP_IDENTITY"
INSTALLER_SIGN_ID="$CODESIGN_PKG_IDENTITY"
else
APP_SIGN_ID="-" # ad-hoc: runs on this Mac, not distributable
fi
TEAM_ID="${CODESIGN_TEAM_ID:-}"
DIST="dist"
# Payload-free: pkgbuild always gives a payload a "." entry (the install-location directory, root:wheel 755),
# which would reset ownership of an existing /usr/local/bin. The binary rides in Scripts instead and
# pkg/postinstall copies it, creating /usr/local/bin only if missing.
SCRIPTS="$DIST/scripts"
BINARY="$SCRIPTS/cupsadmin"
APP="$DIST/CUPS Admin.app"
APP_IDENTIFIER="edu.wku.cupsadmin.app"
APP_EXECUTABLE="CUPSAdminApp" # can't be "CUPSAdmin": APFS is case-insensitive and would collide with cupsadmin
APP_ICON_SOURCE="Icon/AppIcon-1024.png"
PKG="$DIST/cupsadmin-$VERSION.pkg"
# --- preflight ------------------------------------------------------------------
step "preflight"
if [ $BUILD_ONLY -eq 0 ]; then
security find-identity -v -p codesigning | grep "$APP_SIGN_ID" >/dev/null \
|| { STATUS="ERROR: CODESIGN_APP_IDENTITY $APP_SIGN_ID is not a valid codesigning identity in the keychain"; exit 1; }
security find-identity -v | grep "$INSTALLER_SIGN_ID" >/dev/null \
|| { STATUS="ERROR: CODESIGN_PKG_IDENTITY $INSTALLER_SIGN_ID is not an identity in the keychain"; exit 1; }
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \
|| { STATUS="ERROR: NOTARY_PROFILE '$NOTARY_PROFILE' missing or invalid (create it with xcrun notarytool store-credentials)"; exit 1; }
echo "identities and notary profile OK"
else
echo "build only: ad-hoc signing, no identities or notary profile needed"
fi
# --- universal build ------------------------------------------------------------
# `swift build --arch arm64 --arch x86_64` needs full Xcode (XCBuild); with only the Command Line Tools
# we build each triple and lipo them together. Same result.
step "universal release build"
# Only clear what this run rebuilds; earlier notarized pkgs in dist/ stay.
rm -rf "$SCRIPTS" "$APP" "$DIST/expanded" "$DIST/AppIcon.iconset"
mkdir -p "$SCRIPTS"
for arch in arm64 x86_64; do
swift build -c release --triple "$arch-apple-macosx14.0"
done
lipo -create -output "$BINARY" \
.build/arm64-apple-macosx/release/cupsadmin \
.build/x86_64-apple-macosx/release/cupsadmin
chmod 755 "$BINARY"
echo "architectures: $(lipo -archs "$BINARY")"
# --- sign binary ----------------------------------------------------------------
step "sign binary"
if [ $BUILD_ONLY -eq 0 ]; then
codesign --force --sign "$APP_SIGN_ID" --options runtime --timestamp --identifier "$IDENTIFIER" "$BINARY"
else
codesign --force --sign - --identifier "$IDENTIFIER" "$BINARY"
fi
codesign --verify --strict --verbose=2 "$BINARY"
codesign -dvv "$BINARY" 2>&1 | grep -E '^(Identifier|Authority|TeamIdentifier|Timestamp|Runtime Version)=' || true
if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then
codesign -dvv "$BINARY" 2>&1 | grep "^TeamIdentifier=$TEAM_ID$" >/dev/null \
|| { STATUS="ERROR: binary signed with wrong team (expected CODESIGN_TEAM_ID=$TEAM_ID)"; exit 1; }
fi
"$BINARY" help >/dev/null 2>&1 || { STATUS="ERROR: signed binary failed to run"; exit 1; }
# --- assemble + sign app --------------------------------------------------------
step "assemble and sign app"
mkdir -p "$APP/Contents/MacOS"
lipo -create -output "$APP/Contents/MacOS/$APP_EXECUTABLE" \
".build/arm64-apple-macosx/release/$APP_EXECUTABLE" \
".build/x86_64-apple-macosx/release/$APP_EXECUTABLE"
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key> <string>$APP_IDENTIFIER</string>
<key>CFBundleName</key> <string>CUPS Admin</string>
<key>CFBundleDisplayName</key> <string>CUPS Admin</string>
<key>CFBundleExecutable</key> <string>$APP_EXECUTABLE</string>
<key>CFBundleIconFile</key> <string>AppIcon</string>
<key>CFBundlePackageType</key> <string>APPL</string>
<key>CFBundleShortVersionString</key> <string>$VERSION</string>
<key>CFBundleVersion</key> <string>$VERSION</string>
<key>CFBundleInfoDictionaryVersion</key> <string>6.0</string>
<key>LSMinimumSystemVersion</key> <string>14.0</string>
<key>LSApplicationCategoryType</key> <string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key> <true/>
<key>NSPrincipalClass</key> <string>NSApplication</string>
<key>NSHumanReadableCopyright</key> <string>Western Kentucky University</string>
</dict>
</plist>
PLIST
plutil -lint "$APP/Contents/Info.plist"
# App icon: every iconset size from the 1024 px PNG, then one .icns.
[ -f "$APP_ICON_SOURCE" ] || { STATUS="ERROR: app icon $APP_ICON_SOURCE not found"; exit 1; }
ICONSET="$DIST/AppIcon.iconset"
rm -rf "$ICONSET"; mkdir -p "$ICONSET" "$APP/Contents/Resources"
for size in 16 32 128 256 512; do
sips -z $size $size "$APP_ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
sips -z $((size * 2)) $((size * 2)) "$APP_ICON_SOURCE" --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
done
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns"
rm -rf "$ICONSET"
echo "icon: $(ls -l "$APP/Contents/Resources/AppIcon.icns" | awk '{print $5}') bytes"
printf 'APPL????' > "$APP/Contents/PkgInfo"
if [ $BUILD_ONLY -eq 0 ]; then
codesign --force --sign "$APP_SIGN_ID" --options runtime --timestamp "$APP"
else
codesign --force --sign - "$APP"
fi
codesign --verify --deep --strict --verbose=2 "$APP"
codesign -dvv "$APP" 2>&1 | grep -E '^(Identifier=|Authority=Developer ID|TeamIdentifier=|Runtime Version=)' || true
if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then
codesign -dvv "$APP" 2>&1 | grep "^TeamIdentifier=$TEAM_ID$" >/dev/null \
|| { STATUS="ERROR: app signed with wrong team (expected CODESIGN_TEAM_ID=$TEAM_ID)"; exit 1; }
fi
echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")"
if [ $BUILD_ONLY -eq 1 ]; then
STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)"
exit 0
fi
# --- build + sign pkg -----------------------------------------------------------
step "build and sign pkg"
rm -f "$PKG"
cp pkg/postinstall "$SCRIPTS/postinstall"
chmod 755 "$SCRIPTS/postinstall"
pkgbuild --nopayload --scripts "$SCRIPTS" --identifier "$IDENTIFIER" --version "$VERSION" \
--sign "$INSTALLER_SIGN_ID" --timestamp "$PKG"
pkgutil --check-signature "$PKG"
step "verify pkg contents"
echo "pkgutil --payload-files $PKG:"
PAYLOAD_FILES=$(pkgutil --payload-files "$PKG" 2>&1 || true)
echo "${PAYLOAD_FILES:-(no output)}"
if [ -n "$(printf '%s' "$PAYLOAD_FILES" | grep -v -i 'no payload' || true)" ]; then
STATUS="ERROR: pkg has payload entries; expected none"
exit 1
fi
EXPANDED="$DIST/expanded"
pkgutil --expand "$PKG" "$EXPANDED"
# pkgutil --expand unpacks the Scripts archive into a directory.
echo "Scripts: $(ls "$EXPANDED/Scripts" | tr '\n' ' ')"
[ -x "$EXPANDED/Scripts/postinstall" ] && [ -f "$EXPANDED/Scripts/cupsadmin" ] \
|| { STATUS="ERROR: Scripts missing postinstall or cupsadmin"; exit 1; }
codesign --verify --strict "$EXPANDED/Scripts/cupsadmin" \
|| { STATUS="ERROR: binary inside pkg Scripts lost its signature"; exit 1; }
echo "binary inside pkg: signature intact, $(lipo -archs "$EXPANDED/Scripts/cupsadmin")"
rm -rf "$EXPANDED"
# --- notarize -------------------------------------------------------------------
step "notarize (this uploads the pkg to Apple and waits)"
SUBMIT_JSON="$DIST/notary-submit.json"
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"
xcrun stapler staple "$PKG"
xcrun stapler validate "$PKG"
step "spctl assessment"
spctl -a -vv -t install "$PKG" 2>&1 | tee "$DIST/spctl.txt"
grep -q ': accepted' "$DIST/spctl.txt" && grep -q 'source=Notarized Developer ID' "$DIST/spctl.txt" \
|| { STATUS="ERROR: spctl did not accept $PKG as Notarized Developer ID"; exit 1; }
echo
echo "install: sudo installer -pkg $PKG -target /"
STATUS="OK: $PKG signed, notarized ($SUBMISSION_ID), stapled, spctl accepted"

1100
docs/ricoh-ppd-options.md Normal file

File diff suppressed because one or more lines are too long

17
pkg/postinstall Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# cupsadmin postinstall (payload-free pkg): the binary ships in the Scripts archive next to this file.
# Never changes ownership/permissions of an existing /usr/local/bin (Homebrew on Intel owns it).
set -euo pipefail
TARGET="${3:-/}"
DEST_DIR="${TARGET%/}/usr/local/bin"
SOURCE="$(cd "$(dirname "$0")" && pwd)/cupsadmin"
if [ ! -d "$DEST_DIR" ]; then
mkdir -p -m 755 "$DEST_DIR"
echo "cupsadmin: created $DEST_DIR"
fi
# -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"
echo "cupsadmin: installed $DEST_DIR/cupsadmin"

20
test.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Runs CupsKit tests with Swift Testing from the Command Line Tools (no Xcode).
# The CLT ship Testing.framework outside the default search path and an empty _Testing_Foundation
# overlay, so point at the framework, disable cross-import overlays, and load the @Test macro plugin.
# Live apply tests: CUPSKIT_LIVE_QUEUE=<throwaway Ricoh queue> ./test.sh
START_EPOCH=$(date +%s)
echo "started $(date +%H:%M:%S)"
finish() {
local rc=$?
[ $rc -eq 0 ] && echo "OK: tests passed" || echo "ERROR: swift test exited $rc"
echo "finished $(date +%H:%M:%S) (total $(awk -v s="$(( $(date +%s) - START_EPOCH ))" 'BEGIN{printf "%.1f", s/60}') min)"
}
trap finish EXIT
cd "$(dirname "$0")"
F=/Library/Developer/CommandLineTools/Library/Developer/Frameworks
P=/Library/Developer/CommandLineTools/usr/lib/swift/host/plugins/testing
swift test \
-Xswiftc -F -Xswiftc "$F" -Xswiftc -Xfrontend -Xswiftc -disable-cross-import-overlays \
-Xswiftc -plugin-path -Xswiftc "$P" \
-Xlinker -F -Xlinker "$F" -Xlinker -rpath -Xlinker "$F" "$@"