- rawPrintPanel: printerSelect (lpstat -e, auto-refresh on manifest), printerStatusBadge (idle/printing/stopped), printerTraySelect + printerMediaTypeSelect from lpoptions -l capabilities, portrait/ landscape toggle, btnPrinterProperties (bound NSPrintPanel), Print All + per-page btnPrintPage-N, in-panel printNotification (cancel → info, not error). No cupsOptionsGroup/chkPpdFallback — macOS always uses the PM-captured path. - TargetWorkflowViewModel: refreshPrinters, reloadSelectedCapabilities, openPrinterPreferences (panel-side queue switch updates the select), printAllPages / printPage (sequential, stop-on-first-error), capturedCupsOptions per-queue session cache, wizard.printerName set on spool (#95). - CupsError.noPrinterSelected. - Mock CUPS fixtures: lpstat (2 queues, one idle one disabled), lpoptions (tray/media/EPIJ_CMat listings), lp (appends argv to ICCERY_TEST_LP_ARGV). Milestone3UITests: enumeration, preferences cancel→info, captured options replayed in argv, per-page print, lp failure notice, printerName persistence. - lpoptions parser: skip keyless '=value' tokens instead of truncating. UI tests written but not executed here: the dev machine's console is locked (IOConsoleLocked=true) so XCUIApplication.activate() cannot bring the app to front — same failure on M2 baseline. Suite must be run unlocked / on CI. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
187 lines
7.2 KiB
Swift
187 lines
7.2 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import ICCeryCore
|
|
|
|
/// Errors raised while preparing the bound print panel.
|
|
enum PrintPanelError: LocalizedError {
|
|
case sessionBindingFailed(OSStatus)
|
|
case noPrinterFound(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .sessionBindingFailed(let status):
|
|
return "Could not bind the print session to the queue (OSStatus \(status))."
|
|
case .noPrinterFound(let name):
|
|
return "No printer found for '\(name)'."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Preferences → native `NSPrintPanel` bound to the selected CUPS
|
|
/// queue (issue 13, docs/11).
|
|
///
|
|
/// This is a **settings-capture** dialog — the default button is
|
|
/// "Use Settings", never "Print". It is never System Settings, the
|
|
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
|
|
/// and is not an error.
|
|
///
|
|
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)` →
|
|
/// `PMSessionSetCurrentPMPrinter` → session default settings/page
|
|
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
|
|
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
|
|
/// label) → `printInfo.printer`.
|
|
@MainActor
|
|
struct PrintPanelService {
|
|
|
|
/// The suppression engine — injectable for tests.
|
|
var suppressor = ColorSyncSuppressor()
|
|
|
|
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
|
|
/// the modal panel. Returns `nil` when the user cancels.
|
|
func showProperties(
|
|
queue: String,
|
|
displayName: String?,
|
|
cupsService: CupsService
|
|
) async throws -> PrintPropertiesResult? {
|
|
#if DEBUG
|
|
if UITestHooks.printPanelStubbed {
|
|
return UITestHooks.printPanelResult(forQueue: queue)
|
|
}
|
|
#endif
|
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
|
let fetched = try? await cupsService.displayName(for: queue)
|
|
let display = displayName ?? fetched
|
|
// Layer ④ needs the queue's option keys (lpoptions -l) to pick
|
|
// the driver colour-bypass before the panel opens.
|
|
let optionKeys = (try? await cupsService.optionKeys(for: queue))
|
|
?? []
|
|
return try runNativePanel(
|
|
queue: queue, displayName: display, optionKeys: optionKeys)
|
|
}
|
|
|
|
// MARK: - Panel
|
|
|
|
private func runNativePanel(
|
|
queue: String,
|
|
displayName: String?,
|
|
optionKeys: Set<String>
|
|
) throws -> PrintPropertiesResult? {
|
|
let printInfo = NSPrintInfo()
|
|
var pmPrinter: PMPrinter?
|
|
var boundViaPM = false
|
|
|
|
// ① Bind the session to the selected CUPS queue (docs/11).
|
|
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
|
|
pmPrinter = printer
|
|
let session = unsafeBitCast(
|
|
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
|
let settings = unsafeBitCast(
|
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
let pageFormat = unsafeBitCast(
|
|
printInfo.pmPageFormat(), to: PMPageFormat.self)
|
|
|
|
let status = PMSessionSetCurrentPMPrinter(session, printer)
|
|
if status != 0 {
|
|
PMRelease(Self.pmObject(printer))
|
|
throw PrintPanelError.sessionBindingFailed(status)
|
|
}
|
|
// Warn-only: defaults keep the panel consistent with the
|
|
// queue but are not fatal when they fail.
|
|
_ = PMSessionDefaultPrintSettings(session, settings)
|
|
_ = PMSessionDefaultPageFormat(session, pageFormat)
|
|
boundViaPM = true
|
|
} else {
|
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
|
guard let displayName,
|
|
let nsPrinter = NSPrinter(name: displayName)
|
|
else {
|
|
throw PrintPanelError.noPrinterFound(
|
|
displayName ?? queue)
|
|
}
|
|
printInfo.printer = nsPrinter
|
|
printInfo.setUpPrintOperationDefaultValues()
|
|
}
|
|
defer {
|
|
if let printer = pmPrinter {
|
|
PMRelease(Self.pmObject(printer))
|
|
}
|
|
}
|
|
|
|
// ②–⑤ ColourSync suppression — only on the PM path: the SPI
|
|
// and PMPrintSettingsSetValue need a session with a current
|
|
// printer to attach to.
|
|
var settings = unsafeBitCast(
|
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
var driverBypass: (key: String, value: String)?
|
|
if boundViaPM {
|
|
let session = unsafeBitCast(
|
|
printInfo.pmPrintSession(), to: PMPrintSession.self)
|
|
suppressor.applySPIMode(to: session) // ②
|
|
suppressor.applyLockedKeys(to: settings) // ③
|
|
driverBypass = suppressor.applyDriverBypass( // ④
|
|
to: settings, optionKeys: optionKeys)
|
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
|
}
|
|
|
|
let panel = NSPrintPanel()
|
|
panel.options = [
|
|
.showsCopies, .showsPageRange, .showsPaperSize,
|
|
.showsOrientation, .showsScaling, .showsPrintSelection,
|
|
.showsPageSetupAccessory, .showsPreview,
|
|
]
|
|
panel.setDefaultButtonTitle("Use Settings")
|
|
|
|
let response = panel.runModal(with: printInfo)
|
|
guard response == NSApplication.ModalResponse.OK.rawValue else {
|
|
return nil
|
|
}
|
|
|
|
// ⑥ Capture the user's choices — filtered replay options plus
|
|
// the media type they picked. Re-fetch the settings handle so
|
|
// we read back what the modal wrote.
|
|
var cupsOptions: String?
|
|
var mediaType: String?
|
|
if boundViaPM {
|
|
settings = unsafeBitCast(
|
|
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
|
|
let captured = suppressor.captureOptions(from: settings)
|
|
cupsOptions = captured.cupsOptions
|
|
mediaType = captured.mediaType
|
|
}
|
|
return PrintPropertiesResult(
|
|
selectedPrinter: boundViaPM
|
|
? Self.currentPrinterID(
|
|
session: unsafeBitCast(
|
|
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
|
fallback: queue)
|
|
: nil,
|
|
options: PrintOptions(
|
|
mediaType: mediaType,
|
|
ppdUncorrectedPassthrough: true,
|
|
cupsOptions: cupsOptions))
|
|
}
|
|
|
|
// MARK: - PM helpers
|
|
|
|
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
|
|
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
|
|
static func pmObject(_ printer: PMPrinter) -> PMObject {
|
|
unsafeBitCast(printer, to: PMObject.self)
|
|
}
|
|
|
|
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
|
|
private static func currentPrinterID(
|
|
session: PMPrintSession,
|
|
fallback: String
|
|
) -> String {
|
|
var current: PMPrinter?
|
|
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
|
let printer = current
|
|
else { return fallback }
|
|
defer { PMRelease(pmObject(printer)) }
|
|
guard let id = PMPrinterGetID(printer)
|
|
else { return fallback }
|
|
return id.takeUnretainedValue() as String
|
|
}
|
|
}
|