mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Flag queues whose driver filters need Rosetta or are missing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,10 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
|
|||||||
- **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD /
|
- **Queue defaults are written with `lpadmin -p <queue> -o key=value`** (into the queue's PPD /
|
||||||
printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out
|
printers.conf, for every user) — never `lpoptions`. `~/.cups/lpoptions` is only read, to point out
|
||||||
a per-user override.
|
a per-user override.
|
||||||
|
- **Driver filter check** (`DriverFilters.swift`): resolves each `*cupsFilter`/`*cupsFilter2` program
|
||||||
|
(bare names under `/usr/libexec/cups/filter`, skipping `maxsize(n)` and `-`) and reads its Mach-O header
|
||||||
|
directly — any arm64 CPU type counts as native, including subtypes `lipo` can't name. Intel-only →
|
||||||
|
"Driver needs Rosetta"; not found → "Driver filter missing" (shown first).
|
||||||
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
- **PPD options** keep their `*OpenGroup`, UI type and `*ParamCustom` type/range. Custom values are
|
||||||
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
written as `Custom.<value>` and quoted when they contain spaces (unquoted, `lpadmin` silently truncates).
|
||||||
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
|
- **Quick actions** are vendor-neutral. `QuickActions.swift` defines the actions; what each writes comes
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Requires macOS 14 or later. Universal (Apple silicon and Intel).
|
|||||||
|
|
||||||
```
|
```
|
||||||
cupsadmin printers # every queue: state, reasons, accepting, shared, URI
|
cupsadmin printers # every queue: state, reasons, accepting, shared, URI
|
||||||
|
cupsadmin printers --rosetta # only queues whose driver filters are Intel-only or missing
|
||||||
cupsadmin printer <queue> # one queue in detail (--all for every IPP attribute)
|
cupsadmin printer <queue> # one queue in detail (--all for every IPP attribute)
|
||||||
cupsadmin jobs [queue] # active jobs (--completed, --all-jobs)
|
cupsadmin jobs [queue] # active jobs (--completed, --all-jobs)
|
||||||
cupsadmin cancel <job-id> # cancel one job
|
cupsadmin cancel <job-id> # cancel one job
|
||||||
@@ -109,6 +110,7 @@ Or put those three lines (without `export`) in a `build.env` next to `build.sh`;
|
|||||||
- Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/<queue>.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs.
|
- Queue defaults written by `set`, `quick` and the app's Options page go into `/etc/cups/ppd/<queue>.ppd` via `lpadmin`, so they apply to every user on the Mac — the same thing the web UI's "Set Default Options" did. Per-user `~/.cups/lpoptions` overrides still win for that user's own jobs.
|
||||||
- PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets.
|
- PPD files are world-readable. A locked-print password or login password set as a queue default is stored in plain text; the tool warns before doing that. User codes are accounting codes, not secrets.
|
||||||
- New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't.
|
- New queues default to `printer-is-shared=false`. `lpadmin` defaults to shared; this tool doesn't.
|
||||||
|
- Driver filters named in a queue's PPD (`*cupsFilter`, `*cupsFilter2`) are checked for an arm64 slice. Queues whose filters are Intel-only show "Driver needs Rosetta" in the app's header and sidebar tooltip and a `WARNING` line in `cupsadmin printer` and `ppdreport`; filters that don't exist show "Driver filter missing". `cupsadmin printers --rosetta` lists the affected queues, for fleet reporting.
|
||||||
|
|
||||||
## About
|
## About
|
||||||
|
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ struct SidebarView: View {
|
|||||||
List(selection: $selection) {
|
List(selection: $selection) {
|
||||||
Section("Printers") {
|
Section("Printers") {
|
||||||
ForEach(filtered) { printer in
|
ForEach(filtered) { printer in
|
||||||
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name)
|
PrinterRow(printer: printer, isDefault: store.defaultPrinter == printer.name,
|
||||||
|
driverWarning: store.driverFilters[printer.name]?.headline)
|
||||||
.tag(printer.name)
|
.tag(printer.name)
|
||||||
.contextMenu { menu(for: printer) }
|
.contextMenu { menu(for: printer) }
|
||||||
}
|
}
|
||||||
@@ -311,6 +312,8 @@ struct QuickActionItems: View {
|
|||||||
struct PrinterRow: View {
|
struct PrinterRow: View {
|
||||||
let printer: PrinterSummary
|
let printer: PrinterSummary
|
||||||
var isDefault = false
|
var isDefault = false
|
||||||
|
/// "Driver needs Rosetta" / "Driver filter missing", added to the tooltip.
|
||||||
|
var driverWarning: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
@@ -339,7 +342,7 @@ struct PrinterRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.badge(printer.jobCount)
|
.badge(printer.jobCount)
|
||||||
.help(printer.stateSummary)
|
.help(driverWarning.map { "\(printer.stateSummary) · \($0)" } ?? printer.stateSummary)
|
||||||
.accessibilityElement(children: .combine)
|
.accessibilityElement(children: .combine)
|
||||||
.accessibilityValue(printer.stateSummary)
|
.accessibilityValue(printer.stateSummary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ enum JobFilter: String, CaseIterable, Identifiable {
|
|||||||
final class PrinterDetailModel {
|
final class PrinterDetailModel {
|
||||||
let queue: String
|
let queue: String
|
||||||
private(set) var printer: IPPGroup?
|
private(set) var printer: IPPGroup?
|
||||||
|
/// Intel-only or missing driver filters, from the queue's PPD.
|
||||||
|
private(set) var driverFilters: DriverFilterReport?
|
||||||
private(set) var jobs: [JobSummary] = []
|
private(set) var jobs: [JobSummary] = []
|
||||||
private(set) var loadError: String?
|
private(set) var loadError: String?
|
||||||
private(set) var isWorking = false
|
private(set) var isWorking = false
|
||||||
@@ -73,6 +75,11 @@ final class PrinterDetailModel {
|
|||||||
do {
|
do {
|
||||||
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
printer = try await store.client.getPrinterAttributes(queue: queue, requested: Self.headerAttributes)
|
||||||
loadError = nil
|
loadError = nil
|
||||||
|
if let text = try await store.client.getPPD(queue: queue) {
|
||||||
|
driverFilters = await Task.detached { DriverFilterCheck.check(PPD(text: text)) }.value
|
||||||
|
} else {
|
||||||
|
driverFilters = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
loadError = String(describing: error)
|
loadError = String(describing: error)
|
||||||
store.report("ERROR: \(queue): \(error)")
|
store.report("ERROR: \(queue): \(error)")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ struct PrinterDetailView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if let printer = model.printer {
|
if let printer = model.printer {
|
||||||
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath)
|
PrinterHeaderView(printer: printer, ppdPath: model.ppdPath, driverFilters: model.driverFilters)
|
||||||
Divider()
|
Divider()
|
||||||
} else if let error = model.loadError {
|
} else if let error = model.loadError {
|
||||||
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
ContentUnavailableView("Can’t Load \(model.queue)", systemImage: "exclamationmark.triangle",
|
||||||
@@ -35,6 +35,7 @@ struct PrinterDetailView: View {
|
|||||||
struct PrinterHeaderView: View {
|
struct PrinterHeaderView: View {
|
||||||
let printer: IPPGroup
|
let printer: IPPGroup
|
||||||
let ppdPath: String?
|
let ppdPath: String?
|
||||||
|
let driverFilters: DriverFilterReport?
|
||||||
|
|
||||||
private var state: Int? { printer.int("printer-state") }
|
private var state: Int? { printer.int("printer-state") }
|
||||||
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
private var accepting: Bool { printer.bool("printer-is-accepting-jobs") ?? true }
|
||||||
@@ -64,6 +65,13 @@ struct PrinterHeaderView: View {
|
|||||||
}
|
}
|
||||||
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
LabeledContent("Accepting jobs", value: accepting ? "Yes" : "No")
|
||||||
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
LabeledContent("Shared", value: printer.bool("printer-is-shared") == true ? "Yes" : "No")
|
||||||
|
if let headline = driverFilters?.headline {
|
||||||
|
LabeledContent("Driver") {
|
||||||
|
Label(headline, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.symbolRenderingMode(.multicolor)
|
||||||
|
}
|
||||||
|
.help(driverFilters?.detail ?? headline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Form {
|
Form {
|
||||||
@@ -93,7 +101,7 @@ struct PrinterHeaderView: View {
|
|||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.frame(height: 262)
|
.frame(height: driverFilters?.isFlagged == true ? 300 : 262)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ final class PrinterStore {
|
|||||||
private(set) var defaultPrinter: String?
|
private(set) var defaultPrinter: String?
|
||||||
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
|
/// Driver PPD + IPP attributes per queue, for enabling Quick Actions.
|
||||||
private(set) var drivers: [String: DriverContext] = [:]
|
private(set) var drivers: [String: DriverContext] = [:]
|
||||||
|
/// Filter check per queue (Intel-only or missing filters), from the cached PPDs.
|
||||||
|
private(set) var driverFilters: [String: DriverFilterReport] = [:]
|
||||||
var failure: Failure?
|
var failure: Failure?
|
||||||
|
|
||||||
let client = CupsClient()
|
let client = CupsClient()
|
||||||
@@ -201,6 +203,11 @@ final class PrinterStore {
|
|||||||
for printer in printers where drivers[printer.name] == nil {
|
for printer in printers where drivers[printer.name] == nil {
|
||||||
if let context = try? await DriverContext.load(client: client, queue: printer.name) {
|
if let context = try? await DriverContext.load(client: client, queue: printer.name) {
|
||||||
drivers[printer.name] = context
|
drivers[printer.name] = context
|
||||||
|
if let ppd = context.ppd {
|
||||||
|
driverFilters[printer.name] = await Task.detached { DriverFilterCheck.check(ppd) }.value
|
||||||
|
} else {
|
||||||
|
driverFilters[printer.name] = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
157
Sources/CupsKit/DriverFilters.swift
Normal file
157
Sources/CupsKit/DriverFilters.swift
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One filter program a PPD names in `*cupsFilter` / `*cupsFilter2`, and whether it can run natively.
|
||||||
|
public struct DriverFilter: Hashable {
|
||||||
|
public enum Status: Hashable {
|
||||||
|
/// Has an arm64 slice (arm64, arm64e or newer subtypes).
|
||||||
|
case native(architectures: [String])
|
||||||
|
/// Intel only: needs Rosetta on Apple silicon.
|
||||||
|
case intelOnly(architectures: [String])
|
||||||
|
/// A script (`#!`); runs wherever its interpreter does.
|
||||||
|
case script(interpreter: String)
|
||||||
|
/// `-`: no program, cupsd passes the data through.
|
||||||
|
case passthrough
|
||||||
|
case missing
|
||||||
|
/// Exists but isn't a Mach-O binary or script this check understands.
|
||||||
|
case unrecognized
|
||||||
|
}
|
||||||
|
|
||||||
|
public let keyword: String
|
||||||
|
/// The line's value, e.g. `application/vnd.cups-postscript 0 /Library/Printers/…/pstopsRV2`.
|
||||||
|
public let line: String
|
||||||
|
public let program: String
|
||||||
|
/// Resolved absolute path; nil for `-`.
|
||||||
|
public let path: String?
|
||||||
|
public let status: Status
|
||||||
|
|
||||||
|
public var name: String { (program as NSString).lastPathComponent }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DriverFilterReport: Hashable {
|
||||||
|
public let filters: [DriverFilter]
|
||||||
|
|
||||||
|
public var intelOnly: [DriverFilter] { filters.filter { if case .intelOnly = $0.status { return true } else { return false } } }
|
||||||
|
public var missing: [DriverFilter] { filters.filter { $0.status == .missing } }
|
||||||
|
public var needsRosetta: Bool { !intelOnly.isEmpty }
|
||||||
|
public var hasMissingFilter: Bool { !missing.isEmpty }
|
||||||
|
public var isFlagged: Bool { needsRosetta || hasMissingFilter }
|
||||||
|
|
||||||
|
/// "Driver filter missing" (worse, shown first) or "Driver needs Rosetta"; nil when fine.
|
||||||
|
public var headline: String? {
|
||||||
|
if hasMissingFilter { return "Driver filter missing" }
|
||||||
|
if needsRosetta { return "Driver needs Rosetta" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// e.g. "pstopsRV2, commandfilterRV1 are Intel-only (x86_64); /Library/…/foo not found".
|
||||||
|
public var detail: String? {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !missing.isEmpty {
|
||||||
|
parts.append(missing.compactMap(\.path).joined(separator: ", ") + " not found")
|
||||||
|
}
|
||||||
|
if !intelOnly.isEmpty {
|
||||||
|
let names = intelOnly.map(\.name)
|
||||||
|
let archs = Set(intelOnly.flatMap { if case .intelOnly(let a) = $0.status { return a } else { return [] } }).sorted()
|
||||||
|
parts.append("\(names.joined(separator: ", ")) \(names.count == 1 ? "is" : "are") Intel-only (\(archs.joined(separator: ", ")))")
|
||||||
|
}
|
||||||
|
return parts.isEmpty ? nil : parts.joined(separator: "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for CLI output: "Driver needs Rosetta — pstopsRV2 is Intel-only (x86_64)".
|
||||||
|
public var warning: String? {
|
||||||
|
guard let headline, let detail else { return nil }
|
||||||
|
return "\(headline) — \(detail)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DriverFilterCheck {
|
||||||
|
/// Where cupsd looks for filters named without a path.
|
||||||
|
public static let filterDirectory = "/usr/libexec/cups/filter"
|
||||||
|
|
||||||
|
public static func check(_ ppd: PPD, filterDirectory: String = filterDirectory) -> DriverFilterReport {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var filters: [DriverFilter] = []
|
||||||
|
for (keyword, value) in ppd.filterLines {
|
||||||
|
// cupsFilter: "type cost program"; cupsFilter2: "source destination cost [maxsize(n)] program".
|
||||||
|
var fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||||
|
let skip = keyword == "cupsFilter2" ? 3 : 2
|
||||||
|
guard fields.count > skip else { continue }
|
||||||
|
fields.removeFirst(skip)
|
||||||
|
if let first = fields.first, first.hasPrefix("maxsize("), first.hasSuffix(")") { fields.removeFirst() }
|
||||||
|
guard !fields.isEmpty else { continue }
|
||||||
|
let program = fields.joined(separator: " ")
|
||||||
|
let path: String? = program == "-" ? nil
|
||||||
|
: program.hasPrefix("/") ? program : (filterDirectory as NSString).appendingPathComponent(program)
|
||||||
|
guard seen.insert(path ?? "-\(keyword)").inserted else { continue }
|
||||||
|
let status: DriverFilter.Status = path.map(Self.status(atPath:)) ?? .passthrough
|
||||||
|
filters.append(DriverFilter(keyword: keyword, line: value, program: program, path: path, status: status))
|
||||||
|
}
|
||||||
|
return DriverFilterReport(filters: filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the file's header: Mach-O (thin or universal) or `#!` script.
|
||||||
|
public static func status(atPath path: String) -> DriverFilter.Status {
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else { return .missing }
|
||||||
|
guard let handle = FileHandle(forReadingAtPath: path) else { return .unrecognized }
|
||||||
|
defer { try? handle.close() }
|
||||||
|
let header = [UInt8](handle.readData(ofLength: 4096))
|
||||||
|
return status(header: header)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func status(header b: [UInt8]) -> DriverFilter.Status {
|
||||||
|
guard b.count >= 8 else { return .unrecognized }
|
||||||
|
if b[0] == UInt8(ascii: "#"), b[1] == UInt8(ascii: "!") {
|
||||||
|
let line = b.prefix { $0 != UInt8(ascii: "\n") }.dropFirst(2)
|
||||||
|
return .script(interpreter: String(decoding: line, as: UTF8.self).trimmingCharacters(in: .whitespaces))
|
||||||
|
}
|
||||||
|
func be32(_ i: Int) -> UInt32 { UInt32(b[i]) << 24 | UInt32(b[i + 1]) << 16 | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]) }
|
||||||
|
func le32(_ i: Int) -> UInt32 { UInt32(b[i + 3]) << 24 | UInt32(b[i + 2]) << 16 | UInt32(b[i + 1]) << 8 | UInt32(b[i]) }
|
||||||
|
|
||||||
|
var cpuTypes: [(type: UInt32, subtype: UInt32)] = []
|
||||||
|
switch be32(0) {
|
||||||
|
case 0xCAFEBABE, 0xCAFEBABF: // universal (fat32 / fat64)
|
||||||
|
let count = Int(be32(4))
|
||||||
|
let entrySize = be32(0) == 0xCAFEBABF ? 32 : 20
|
||||||
|
guard count > 0, count < 32 else { return .unrecognized } // Java class files share the magic
|
||||||
|
for i in 0 ..< count {
|
||||||
|
let offset = 8 + i * entrySize
|
||||||
|
guard offset + 8 <= b.count else { break }
|
||||||
|
cpuTypes.append((be32(offset), be32(offset + 4) & 0x00FF_FFFF))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
switch le32(0) {
|
||||||
|
case 0xFEEDFACF, 0xFEEDFACE: // thin Mach-O, little-endian
|
||||||
|
cpuTypes.append((le32(4), le32(8) & 0x00FF_FFFF))
|
||||||
|
default:
|
||||||
|
return .unrecognized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let names = cpuTypes.map { architectureName(type: $0.type, subtype: $0.subtype) }
|
||||||
|
if cpuTypes.contains(where: { $0.type == 0x0100_000C }) { return .native(architectures: names) }
|
||||||
|
if cpuTypes.contains(where: { $0.type == 0x0100_0007 || $0.type == 7 }) { return .intelOnly(architectures: names) }
|
||||||
|
return .unrecognized
|
||||||
|
}
|
||||||
|
|
||||||
|
static func architectureName(type: UInt32, subtype: UInt32) -> String {
|
||||||
|
switch type {
|
||||||
|
case 0x0100_0007: return "x86_64"
|
||||||
|
case 7: return "i386"
|
||||||
|
case 0x0100_000C: return subtype == 2 ? "arm64e" : "arm64"
|
||||||
|
case 0x0200_000C: return "arm64_32"
|
||||||
|
case 12: return "arm"
|
||||||
|
default: return "cpu\(type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True on Apple silicon (also when this process itself runs under Rosetta).
|
||||||
|
public static var hostIsAppleSilicon: Bool {
|
||||||
|
var value: Int32 = 0
|
||||||
|
var size = MemoryLayout<Int32>.size
|
||||||
|
return sysctlbyname("hw.optional.arm64", &value, &size, nil, 0) == 0 && value == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public static var rosettaInstalled: Bool {
|
||||||
|
FileManager.default.fileExists(atPath: "/Library/Apple/usr/libexec/oah/libRosettaRuntime")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ public struct PPD {
|
|||||||
public private(set) var groups: [String] = []
|
public private(set) var groups: [String] = []
|
||||||
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
|
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
|
||||||
public private(set) var attributes: [String: String] = [:]
|
public private(set) var attributes: [String: String] = [:]
|
||||||
|
/// Every `*cupsFilter` / `*cupsFilter2` line, in order, with the quotes removed.
|
||||||
|
public private(set) var filterLines: [(keyword: String, value: String)] = []
|
||||||
|
|
||||||
public init(text: String) {
|
public init(text: String) {
|
||||||
var defaults: [String: String] = [:]
|
var defaults: [String: String] = [:]
|
||||||
@@ -122,6 +124,9 @@ public struct PPD {
|
|||||||
let (choice, label) = Self.splitText(String(parts[1]))
|
let (choice, label) = Self.splitText(String(parts[1]))
|
||||||
current!.choices.append(choice)
|
current!.choices.append(choice)
|
||||||
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
|
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
|
||||||
|
} else if parts.count == 1, mainKey == "cupsFilter" || mainKey == "cupsFilter2" {
|
||||||
|
filterLines.append((mainKey, value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))))
|
||||||
|
if attributes[mainKey] == nil { attributes[mainKey] = filterLines.last!.value }
|
||||||
} else if parts.count == 1, attributes[mainKey] == nil {
|
} else if parts.count == 1, attributes[mainKey] == nil {
|
||||||
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,22 @@ enum PPDReportCommand {
|
|||||||
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
|
action.isAvailable(in: context) ? action.id : "\(action.id) (unavailable)"
|
||||||
}
|
}
|
||||||
print("Quick: \(quick.joined(separator: ", "))")
|
print("Quick: \(quick.joined(separator: ", "))")
|
||||||
|
let filters = DriverFilterCheck.check(ppd)
|
||||||
|
if !filters.filters.isEmpty {
|
||||||
|
print("Filters: " + filters.filters.map { filter -> String in
|
||||||
|
switch filter.status {
|
||||||
|
case .native(let archs): return "\(filter.name) (\(archs.joined(separator: " ")))"
|
||||||
|
case .intelOnly(let archs): return "\(filter.name) (\(archs.joined(separator: " ")) only)"
|
||||||
|
case .script: return "\(filter.name) (script)"
|
||||||
|
case .passthrough: return "- (pass-through)"
|
||||||
|
case .missing: return "\(filter.name) (missing)"
|
||||||
|
case .unrecognized: return "\(filter.name) (unrecognized)"
|
||||||
|
}
|
||||||
|
}.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
if let warning = filters.warning {
|
||||||
|
print("WARNING: \(warning)")
|
||||||
|
}
|
||||||
|
|
||||||
for group in ppd.groups {
|
for group in ppd.groups {
|
||||||
print("\n\(group)")
|
print("\n\(group)")
|
||||||
|
|||||||
@@ -100,8 +100,51 @@ enum PrinterCommand {
|
|||||||
: label + ":" + String(repeating: " ", count: width - label.count)
|
: label + ":" + String(repeating: " ", count: width - label.count)
|
||||||
print("\(heading) \(value)")
|
print("\(heading) \(value)")
|
||||||
}
|
}
|
||||||
|
if let ppdText = try? await client.getPPD(queue: queue),
|
||||||
|
let warning = DriverFilterCheck.check(PPD(text: ppdText)).warning {
|
||||||
|
print("\nWARNING: \(warning)")
|
||||||
|
}
|
||||||
print("\n(use --all for every attribute)")
|
print("\n(use --all for every attribute)")
|
||||||
|
|
||||||
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
|
return "OK: \(queue) \(Format.printerState(p.int("printer-state"))), reasons \(Format.reasons(p.strings("printer-state-reasons")))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `printers --rosetta`: queues whose PPD filters have no arm64 slice or don't exist.
|
||||||
|
enum RosettaCommand {
|
||||||
|
static func run(client: CupsClient) async throws -> String {
|
||||||
|
let printers = try await client.getPrinters(requested: ["printer-name"])
|
||||||
|
let names = printers.compactMap { $0.string("printer-name") }
|
||||||
|
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||||
|
|
||||||
|
var rows: [[String]] = []
|
||||||
|
var affected = 0
|
||||||
|
for name in names {
|
||||||
|
guard let text = try await client.getPPD(queue: name) else { continue }
|
||||||
|
let report = DriverFilterCheck.check(PPD(text: text))
|
||||||
|
guard report.isFlagged else { continue }
|
||||||
|
affected += 1
|
||||||
|
let flagged = report.missing + report.intelOnly
|
||||||
|
for (i, filter) in flagged.enumerated() {
|
||||||
|
let what: String
|
||||||
|
switch filter.status {
|
||||||
|
case .intelOnly(let archs): what = archs.joined(separator: " ")
|
||||||
|
case .missing: what = "missing"
|
||||||
|
default: what = ""
|
||||||
|
}
|
||||||
|
rows.append([i == 0 ? name : "", i == 0 ? (report.headline ?? "") : "", what, filter.path ?? filter.program])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = DriverFilterCheck.hostIsAppleSilicon
|
||||||
|
? "Apple silicon, Rosetta \(DriverFilterCheck.rosettaInstalled ? "installed" : "NOT installed")"
|
||||||
|
: "Intel Mac (Intel-only filters run natively here)"
|
||||||
|
if rows.isEmpty {
|
||||||
|
print("No queues need Rosetta or have missing driver filters. This Mac: \(host).")
|
||||||
|
return "OK: none of \(names.count) queues need Rosetta or have missing filters"
|
||||||
|
}
|
||||||
|
printTable(headers: ["QUEUE", "PROBLEM", "ARCH", "FILTER"], rows: rows)
|
||||||
|
print("\nThis Mac: \(host).")
|
||||||
|
return "OK: \(affected) of \(names.count) queues need Rosetta or have missing filters"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ let usage = """
|
|||||||
usage: cupsadmin <command> [args]
|
usage: cupsadmin <command> [args]
|
||||||
|
|
||||||
printers all queues: state, reasons, accepting, shared, device URI
|
printers all queues: state, reasons, accepting, shared, device URI
|
||||||
|
printers --rosetta only queues whose driver filters are Intel-only or missing
|
||||||
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
|
printer <queue> [--all] one queue in detail (--all dumps every IPP attribute)
|
||||||
jobs [queue] [--completed | --all-jobs]
|
jobs [queue] [--completed | --all-jobs]
|
||||||
active jobs (all queues unless one is given)
|
active jobs (all queues unless one is given)
|
||||||
@@ -80,9 +81,10 @@ func runCommand(_ args: [String]) async throws -> String {
|
|||||||
return "OK: help"
|
return "OK: help"
|
||||||
|
|
||||||
case "printers":
|
case "printers":
|
||||||
|
let rosetta = takeFlag("--rosetta")
|
||||||
try rejectUnknownFlags()
|
try rejectUnknownFlags()
|
||||||
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
|
guard rest.isEmpty else { throw CupsAdminError.usage("printers takes no arguments") }
|
||||||
return try await PrintersCommand.run(client: client)
|
return rosetta ? try await RosettaCommand.run(client: client) : try await PrintersCommand.run(client: client)
|
||||||
|
|
||||||
case "printer":
|
case "printer":
|
||||||
let dumpAll = takeFlag("--all")
|
let dumpAll = takeFlag("--all")
|
||||||
|
|||||||
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal file
103
Tests/CupsKitTests/DriverFilterTests.swift
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
@testable import CupsKit
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
private func le32(_ v: UInt32) -> [UInt8] { [UInt8(v & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v >> 16 & 0xFF), UInt8(v >> 24)] }
|
||||||
|
private func be32(_ v: UInt32) -> [UInt8] { [UInt8(v >> 24), UInt8(v >> 16 & 0xFF), UInt8(v >> 8 & 0xFF), UInt8(v & 0xFF)] }
|
||||||
|
private let x86_64: UInt32 = 0x0100_0007, arm64: UInt32 = 0x0100_000C
|
||||||
|
|
||||||
|
/// Thin 64-bit Mach-O header (little-endian).
|
||||||
|
private func thin(_ cpu: UInt32, subtype: UInt32 = 0) -> [UInt8] {
|
||||||
|
var bytes: [UInt8] = le32(0xFEEDFACF)
|
||||||
|
bytes += le32(cpu)
|
||||||
|
bytes += le32(subtype)
|
||||||
|
bytes += [UInt8](repeating: 0, count: 20)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Universal (fat32) header with the given slices.
|
||||||
|
private func fat(_ slices: [(UInt32, UInt32)]) -> [UInt8] {
|
||||||
|
var bytes: [UInt8] = be32(0xCAFEBABE)
|
||||||
|
bytes += be32(UInt32(slices.count))
|
||||||
|
for (cpu, subtype) in slices {
|
||||||
|
bytes += be32(cpu)
|
||||||
|
bytes += be32(subtype)
|
||||||
|
bytes += be32(0x4000) // offset
|
||||||
|
bytes += be32(0x1000) // size
|
||||||
|
bytes += be32(14) // align
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct MachOArchitectures {
|
||||||
|
@Test func thinAndUniversalBinaries() {
|
||||||
|
#expect(DriverFilterCheck.status(header: thin(arm64)) == .native(architectures: ["arm64"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: thin(x86_64)) == .intelOnly(architectures: ["x86_64"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 2)])) == .native(architectures: ["x86_64", "arm64e"]))
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (7, 3)])) == .intelOnly(architectures: ["x86_64", "i386"]))
|
||||||
|
// A newer arm64 subtype lipo can't name (seen on macOS 27's rastertohp) still counts as arm64.
|
||||||
|
#expect(DriverFilterCheck.status(header: fat([(x86_64, 3), (arm64, 12)])) == .native(architectures: ["x86_64", "arm64"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func scriptsAndOtherFiles() {
|
||||||
|
#expect(DriverFilterCheck.status(header: Array("#!/bin/sh\nexec foo\n".utf8)) == .script(interpreter: "/bin/sh"))
|
||||||
|
#expect(DriverFilterCheck.status(header: Array("%PDF-1.4 not a filter".utf8)) == .unrecognized)
|
||||||
|
#expect(DriverFilterCheck.status(header: [0xCA, 0xFE, 0xBA, 0xBE, 0, 0, 0, 52]) == .unrecognized) // Java class file
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: FileManager.default.fileExists(atPath: "/usr/libexec/cups/filter/rastertohp")))
|
||||||
|
func appleFilterIsNative() {
|
||||||
|
guard case .native = DriverFilterCheck.status(atPath: "/usr/libexec/cups/filter/rastertohp") else {
|
||||||
|
Issue.record("rastertohp should have an arm64 slice"); return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct FilterLines {
|
||||||
|
@Test func resolvesAndClassifiesEveryFilter() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cupsadmin-filters-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
try Data(thin(arm64)).write(to: dir.appendingPathComponent("nativefilter"))
|
||||||
|
let intel = dir.appendingPathComponent("Vendor Filters/pstopsvendor")
|
||||||
|
try FileManager.default.createDirectory(at: intel.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data(fat([(x86_64, 3)])).write(to: intel)
|
||||||
|
|
||||||
|
let ppd = PPD(text: """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*cupsFilter: "application/vnd.cups-raster 50 nativefilter"
|
||||||
|
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
|
||||||
|
*cupsFilter2: "application/vnd.cups-pdf application/pdf 0 maxsize(0) -"
|
||||||
|
*cupsFilter: "application/vnd.cups-command 0 /Library/Printers/Nobody/Filters/gone"
|
||||||
|
*cupsFilter: "application/vnd.cups-postscript 0 \(intel.path)"
|
||||||
|
""")
|
||||||
|
#expect(ppd.filterLines.count == 5)
|
||||||
|
|
||||||
|
let report = DriverFilterCheck.check(ppd, filterDirectory: dir.path)
|
||||||
|
#expect(report.filters.count == 4) // duplicate pstopsvendor line counted once
|
||||||
|
#expect(report.filters[0].status == .native(architectures: ["arm64"]))
|
||||||
|
#expect(report.filters[0].path == dir.appendingPathComponent("nativefilter").path)
|
||||||
|
#expect(report.filters[1].status == .intelOnly(architectures: ["x86_64"]))
|
||||||
|
#expect(report.filters[2].status == .passthrough)
|
||||||
|
#expect(report.filters[3].status == .missing)
|
||||||
|
|
||||||
|
#expect(report.needsRosetta && report.hasMissingFilter)
|
||||||
|
#expect(report.headline == "Driver filter missing") // missing outranks Rosetta
|
||||||
|
#expect(report.detail == "/Library/Printers/Nobody/Filters/gone not found; pstopsvendor is Intel-only (x86_64)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rosettaOnlyAndClean() {
|
||||||
|
let passthrough = PPD(text: "*cupsFilter2: \"application/vnd.cups-pdf application/pdf 0 maxsize(0) -\"\n*cupsFilter: \"image/urf 10 -\"\n")
|
||||||
|
let clean = DriverFilterCheck.check(passthrough)
|
||||||
|
#expect(!clean.isFlagged && clean.headline == nil && clean.warning == nil)
|
||||||
|
|
||||||
|
let none = DriverFilterCheck.check(PPD(text: "*NickName: \"No filters\"\n"))
|
||||||
|
#expect(none.filters.isEmpty && !none.isFlagged)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
|
||||||
|
func ricohVendorPPDNamesItsFilters() throws {
|
||||||
|
let report = DriverFilterCheck.check(try #require(vendorPPD("RICOH IM C4500")))
|
||||||
|
#expect(report.filters.map(\.name) == ["commandfilterRV1", "pstopsRV2"] || report.filters.map(\.name) == ["pstopsRV2", "commandfilterRV1"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user