diff --git a/CLAUDE.md b/CLAUDE.md index bd0fd56..9ab05c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ 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 --screenshots # regenerate docs/screenshots/app-jobs.png from temporary demo queues ./build.sh # release: sign, payload-free PKG, notarize, staple, spctl ``` - Only the Command Line Tools are required. Universal builds are per-triple diff --git a/README.md b/README.md index f67ba74..2d97fee 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,13 @@ # CUPS Admin for macOS -A native replacement for the CUPS web interface (`http://localhost:631`) that Apple removed in macOS 27. +![CUPS Admin showing the jobs on a printer](docs/screenshots/app-jobs.png) -`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. +**CUPS Admin** is a native macOS app that puts back the CUPS web interface (`http://localhost:631`) Apple removed in macOS 27 — `cupsd`, `lpadmin` and IPP Everywhere queues all still work, only the browser admin page is gone. The sidebar lists every queue with its state and active jobs; select one to see its status, hold, release, move or cancel its jobs, and change its default options the way the old "Set Default Options" page did, including per-driver settings like Ricoh user codes. Common changes (color or black & white, duplex, Letter paper, a user code, the default printer) are one-click Quick Actions, and every change is read back from cupsd so nothing fails silently. It ships with `cupsadmin`, a command-line tool for scripts, Mosyle/Jamf/Munki commands and quick checks. No third-party dependencies; builds with the Xcode Command Line Tools alone. ## Install +> Status: the CLI is stable (1.0.x). The app is under active development — see [Releases](../../releases) for what's shipped. + Download the signed, notarized PKG from the latest [release](../../releases). It installs: - `/usr/local/bin/cupsadmin` @@ -84,7 +79,7 @@ VERSION=1.2.3 ./build.sh Or put those three lines (without `export`) in a `build.env` next to `build.sh`; it's read if present and gitignored. Variables already set in the environment win. -`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=` enables the live tests; `CUPSKIT_LIVE_JOBS` and `CUPSKIT_LIVE_QUICK` take two throwaway queues each for the job and quick-action tests). +`build.sh` builds a universal binary (per-architecture `swift build` + `lipo`), signs 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. `./build.sh --screenshots` regenerates the README screenshot from three temporary demo queues. Run `./test.sh` for the test suite (`CUPSKIT_LIVE_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 diff --git a/Sources/CUPSAdminApp/PrinterStore.swift b/Sources/CUPSAdminApp/PrinterStore.swift index c652c17..d6aec6a 100644 --- a/Sources/CUPSAdminApp/PrinterStore.swift +++ b/Sources/CUPSAdminApp/PrinterStore.swift @@ -108,6 +108,13 @@ final class PrinterStore { let client = CupsClient() private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "store") + /// `--only-queues a,b,c` limits the app to those queues (used by `build.sh --screenshots`). + static let onlyQueues: Set? = { + let args = ProcessInfo.processInfo.arguments + guard let i = args.firstIndex(of: "--only-queues"), i + 1 < args.count else { return nil } + return Set(args[i + 1].split(separator: ",").map(String.init)) + }() + /// "21 printers · 0 active jobs" for the status bar. var summaryLine: String { guard hasLoaded, errorMessage == nil else { return "" } @@ -133,7 +140,9 @@ final class PrinterStore { do { let groups = try await client.getPrinters(requested: PrinterSummary.requestedAttributes) - printers = groups.map(PrinterSummary.init).sorted { + printers = groups.map(PrinterSummary.init) + .filter { Self.onlyQueues?.contains($0.name) ?? true } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } errorMessage = nil diff --git a/build.sh b/build.sh index 5a2b414..bb271b1 100755 --- a/build.sh +++ b/build.sh @@ -3,6 +3,8 @@ # (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 +# ./build.sh --screenshots build like --build-only, then regenerate docs/screenshots/app-jobs.png +# (creates three demo queues on 127.0.0.1 with held jobs, deletes them after) # # Signing settings come from the environment, or from an untracked build.env next to this script # (plain KEY=value lines; a variable already set in the environment wins): @@ -13,10 +15,12 @@ set -euo pipefail BUILD_ONLY=0 +SCREENSHOTS=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)" + --screenshots) BUILD_ONLY=1; SCREENSHOTS=1 ;; + *) echo "started $(date +%H:%M:%S)"; echo "ERROR: unknown argument $arg (use --build-only or --screenshots)" echo "finished $(date +%H:%M:%S) (total 0.0 min)"; exit 2 ;; esac done @@ -27,6 +31,7 @@ STATUS="ERROR: build.sh exited unexpectedly" finish() { local rc=$? + [ "$SCREENSHOTS" -eq 1 ] && cleanup_screenshots 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)" @@ -36,6 +41,76 @@ trap 'STATUS="ERROR: interrupted"; exit 130' INT TERM step() { STATUS="ERROR: failed at: $1"; echo; echo "==> $1"; } +# --- screenshots (--screenshots) ------------------------------------------------ +# Demo queues with generic names so the README never shows real queue names or users. +DEMO_QUEUES="Front_Office Library_Color Lab_Mono" +SHOT="docs/screenshots/app-jobs.png" +APP_DOMAIN="edu.wku.cupsadmin.app" +SAVED_PREFS="" + +cleanup_screenshots() { + osascript -e 'quit app "CUPS Admin"' >/dev/null 2>&1 || true + for _ in 1 2 3 4 5; do pgrep -f "CUPS Admin.app/Contents/MacOS" >/dev/null || break; sleep 1; done + pkill -f "CUPS Admin.app/Contents/MacOS" 2>/dev/null || true + for queue in $DEMO_QUEUES; do lpadmin -x "$queue" 2>/dev/null || true; done + if [ -n "$SAVED_PREFS" ] && [ -f "$SAVED_PREFS" ]; then + defaults delete "$APP_DOMAIN" 2>/dev/null || true + defaults import "$APP_DOMAIN" "$SAVED_PREFS" && rm -f "$SAVED_PREFS" + SAVED_PREFS="" + fi +} + +take_screenshots() { + step "screenshots" + [ "$(defaults read -g AppleInterfaceStyle 2>/dev/null)" != "Dark" ] \ + || { STATUS="ERROR: switch macOS to Light appearance first (the README screenshot is light mode)"; exit 1; } + cleanup_screenshots + + # Save the user's app settings (window frame, selection, tab); restored in cleanup. + SAVED_PREFS=$(mktemp -t cupsadmin-prefs).plist + defaults export "$APP_DOMAIN" "$SAVED_PREFS" 2>/dev/null || defaults export "$APP_DOMAIN" - >/dev/null 2>&1 || true + [ -s "$SAVED_PREFS" ] || printf '{}' | plutil -convert xml1 -o "$SAVED_PREFS" - + + for queue in $DEMO_QUEUES; do + lpadmin -p "$queue" -v "lpd://127.0.0.1/$queue" -m drv:///sample.drv/generic.ppd \ + -D "${queue//_/ }" -L "Building A" -o printer-is-shared=false -E 2>&1 | grep -v deprecated || true + done + # Held jobs never leave the Mac; owners are generic names, not the person running the build. + lp -d Front_Office -U alex -H hold -t "Quarterly budget.pdf" /etc/hosts >/dev/null + lp -d Front_Office -U jordan -H hold -t "Staff meeting agenda" /etc/hosts >/dev/null + lp -d Front_Office -U sam -H hold -t "Parking permits.docx" /etc/hosts >/dev/null + lp -d Front_Office -U alex -H hold -t "Invoice 2291" /etc/hosts >/dev/null + lp -d Library_Color -U jordan -H hold -t "Event poster" /etc/hosts >/dev/null + + SCREEN=$(osascript -e 'tell application "Finder" to get bounds of window of desktop' | tr -d ' ' | tr ',' ' ') + defaults write "$APP_DOMAIN" selectedQueue Front_Office + defaults write "$APP_DOMAIN" detailTab jobs + defaults write "$APP_DOMAIN" "NSWindow Frame main" "120 120 1400 860 $SCREEN " + open "$APP" --args --only-queues "${DEMO_QUEUES// /,}" + + FINDER=$(mktemp -t cupsadmin-window).swift + cat > "$FINDER" <<'SWIFT' +import CoreGraphics +let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] +if let w = windows.first(where: { ($0[kCGWindowOwnerName as String] as? String) == "CUPS Admin" && ($0[kCGWindowLayer as String] as? Int) == 0 }), + let id = w[kCGWindowNumber as String] as? Int { print(id) } +SWIFT + WINDOW="" + for _ in $(seq 1 30); do WINDOW=$(swift "$FINDER" 2>/dev/null || true); [ -n "$WINDOW" ] && break; sleep 1; done + rm -f "$FINDER" + [ -n "$WINDOW" ] || { STATUS="ERROR: CUPS Admin window didn't appear on this Space"; exit 1; } + sleep 3 # let the sidebar, header and jobs load + # Capture it as the active window (colored traffic lights, accent-colored selection). + osascript -e "tell application id \"$APP_DOMAIN\" to activate" >/dev/null 2>&1 || true + open "$APP" + sleep 2 + mkdir -p "$(dirname "$SHOT")" + screencapture -x -o -l "$WINDOW" "$SHOT" \ + || { STATUS="ERROR: screencapture failed (Terminal needs Screen Recording permission)"; exit 1; } + SHOT_SIZE="$(sips -g pixelWidth -g pixelHeight "$SHOT" | awk '/pixel/ {print $2}' | paste -sd x -)" + echo "captured $SHOT ($SHOT_SIZE)" +} + # --- configuration ------------------------------------------------------------- cd "$(dirname "$0")" VERSION="${VERSION:-1.0.1}" @@ -182,6 +257,12 @@ if [ -n "$TEAM_ID" ] && [ $BUILD_ONLY -eq 0 ]; then fi echo "app architectures: $(lipo -archs "$APP/Contents/MacOS/$APP_EXECUTABLE")" +if [ $SCREENSHOTS -eq 1 ]; then + take_screenshots + STATUS="OK: regenerated $SHOT (${SHOT_SIZE})" + exit 0 +fi + if [ $BUILD_ONLY -eq 1 ]; then STATUS="OK: built $BINARY and $APP, ad-hoc signed (build only: no pkg, no notarization)" exit 0 diff --git a/docs/screenshots/app-jobs.png b/docs/screenshots/app-jobs.png new file mode 100644 index 0000000..9bc8d6e Binary files /dev/null and b/docs/screenshots/app-jobs.png differ