macOS CI / build-and-test (push) Skipped
The bound NSPrintPanel always opened on queue driver defaults — capturedTickets[queue] was stored but never passed back, so every vendor-PDE setting outside the four mirrored fields was discarded on reopen (RC1), and pre-selection writes were never validated into the ticket before runModal (RC2). Extract the pre-modal configuration into a testable configureBoundPanel seam with the spooler's S4→S7 order: ticket restore (warn-only, unlike the spooler's fatal try — a stale ticket must not keep the dialog from opening → Stage 2 selections (D6 parity) → PMSessionValidatePrintSettings + updateFromPMPrintSettings → applyPaper → suppression ②–⑤. ICCERY_DEBUG_PANEL_DUMP=1 logs the raw PMPrintSettingsToOptions output and serialised ticket sizes pre/post modal for the XP-55 RC3 verification. Refs #217 EOF )
349 lines
15 KiB
Swift
349 lines
15 KiB
Swift
import AppKit
|
|
import ApplicationServices
|
|
import ICCeryCore
|
|
|
|
/// Errors raised while preparing the bound print panel.
|
|
enum PrintPanelError: LocalizedError {
|
|
case noPrinterFound(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .noPrinterFound(let name):
|
|
return "No printer found for '\(name)'."
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stage 2 selections pre-applied to the bound print panel before it
|
|
/// opens (#183/#186). Every write is warn-only — the panel still
|
|
/// opens when a driver ignores a key.
|
|
struct PrintPanelInitialSelections {
|
|
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
|
|
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
|
|
var paperSize: String?
|
|
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
|
|
var qualityKey: String?
|
|
/// The selected quality token.
|
|
var quality: String?
|
|
/// The selected media token — written to the queue's detected
|
|
/// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
|
|
var mediaType: String?
|
|
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
|
|
var orientation: String?
|
|
}
|
|
|
|
/// One bound-panel invocation (#217): the queue, its `lpoptions -l`
|
|
/// key roster, the Stage 2 pre-selections, and the session's captured
|
|
/// ticket — everything `makePanelPrintInfo` needs before the modal.
|
|
struct PrintPanelRequest {
|
|
let queue: String
|
|
let displayName: String?
|
|
let optionKeys: Set<String>
|
|
let initialSelections: PrintPanelInitialSelections
|
|
/// The queue's previously captured ticket — rehydrated before the
|
|
/// Stage 2 writes so vendor PDE state survives a reopen (#217).
|
|
let ticket: PrintTicket?
|
|
}
|
|
|
|
/// 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,
|
|
initialSelections: PrintPanelInitialSelections =
|
|
PrintPanelInitialSelections(),
|
|
ticket: PrintTicket? = nil
|
|
) async throws -> PanelCaptureResult? {
|
|
#if DEBUG
|
|
if UITestHooks.printPanelStubbed {
|
|
return UITestHooks.printPanelResult(forQueue: queue).map {
|
|
PanelCaptureResult(properties: $0, ticket: nil)
|
|
}
|
|
}
|
|
#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(
|
|
PrintPanelRequest(
|
|
queue: queue, displayName: display,
|
|
optionKeys: optionKeys,
|
|
initialSelections: initialSelections, ticket: ticket))
|
|
}
|
|
|
|
// MARK: - Panel
|
|
|
|
/// The pre-modal half of `runNativePanel` — a fully configured
|
|
/// `NSPrintInfo` plus the `boundViaPM` flag. Extracted (same
|
|
/// pattern as `NativeTargetSpooler.makePrintInfo`) so unit tests
|
|
/// assert ticket restore / Stage 2 precedence without driving the
|
|
/// modal (#217). The caller owns `printer` — it must stay alive
|
|
/// through the modal.
|
|
func makePanelPrintInfo(
|
|
for request: PrintPanelRequest,
|
|
boundTo printer: PMPrinter?
|
|
) throws -> (printInfo: NSPrintInfo, boundViaPM: Bool) {
|
|
let printInfo = NSPrintInfo()
|
|
|
|
// ① Bind the session to the selected CUPS queue (docs/11).
|
|
if let printer {
|
|
try PMTicketBridge.bind(printer: printer, to: printInfo)
|
|
configureBoundPanel(
|
|
printInfo, request: request, printer: printer)
|
|
} else {
|
|
// Fallback: NSPrinter by display name (docs/11 §binding).
|
|
// Warn — the display name can resolve a *different* queue
|
|
// (#186 E2: diagnosable, not a proven defect).
|
|
AppLogger.shared.warn(
|
|
"Print panel: PM binding unavailable for "
|
|
+ "'\(request.queue)' — "
|
|
+ "falling back to NSPrinter(displayName)")
|
|
guard let displayName = request.displayName,
|
|
let nsPrinter = NSPrinter(name: displayName)
|
|
else {
|
|
throw PrintPanelError.noPrinterFound(
|
|
request.displayName ?? request.queue)
|
|
}
|
|
printInfo.printer = nsPrinter
|
|
printInfo.setUpPrintOperationDefaultValues()
|
|
return (printInfo, false)
|
|
}
|
|
|
|
return (printInfo, true)
|
|
}
|
|
|
|
/// Post-bind configuration in locked order (#217 — mirrors the
|
|
/// spooler's S4→S7): ticket restore → Stage 2 selections →
|
|
/// validate → paper → suppression ②–⑤. Internal so unit tests can
|
|
/// drive it with `printer: nil` — `applyPaper` and the SPI layer
|
|
/// need a bound printer and skip when it is absent, while ticket
|
|
/// restore / selections / validate still run on the session-less
|
|
/// `NSPrintInfo`.
|
|
func configureBoundPanel(
|
|
_ printInfo: NSPrintInfo,
|
|
request: PrintPanelRequest,
|
|
printer: PMPrinter?
|
|
) {
|
|
let session = PMTicketBridge.session(printInfo)
|
|
let settings = PMTicketBridge.settings(printInfo)
|
|
|
|
// Rehydrate the captured ticket *before* the Stage 2 writes
|
|
// (#217 — restore-then-override). Warn-only, unlike the
|
|
// spooler's fatal `try`: a stale/corrupt ticket must not keep
|
|
// the dialog from opening. `restore` itself refuses a
|
|
// cross-queue replay (R3).
|
|
if let ticket = request.ticket {
|
|
do {
|
|
try PMTicketBridge.restore(ticket, into: printInfo)
|
|
} catch {
|
|
AppLogger.shared.warn(
|
|
"Print panel: ticket restore failed — "
|
|
+ "opening on queue defaults "
|
|
+ "(\(error.localizedDescription))")
|
|
}
|
|
}
|
|
|
|
// Initial selections — Stage 2 wins over the restored ticket
|
|
// (D6 parity). Paper is TWO writes (E1): the `PageSize`
|
|
// print-settings value drivers/capture read AND the
|
|
// `PMPageFormat` paper the panel's dropdown reflects.
|
|
applyInitialSelections(
|
|
request.initialSelections, to: settings,
|
|
optionKeys: request.optionKeys)
|
|
|
|
// Reconcile the pre-writes into the ticket before the modal
|
|
// (#217 RC2): the vendor PDE derives pane state from a
|
|
// validated ticket. Warn-only — log when the driver adjusts
|
|
// so a companion-key desync is diagnosable.
|
|
var changed = DarwinBoolean(false)
|
|
_ = PMSessionValidatePrintSettings(session, settings, &changed)
|
|
if changed.boolValue {
|
|
AppLogger.shared.info(
|
|
"Print panel: driver adjusted the pre-selections")
|
|
}
|
|
printInfo.updateFromPMPrintSettings()
|
|
|
|
if let printer, let paperToken = request.initialSelections.paperSize {
|
|
PMTicketBridge.applyPaper(
|
|
token: paperToken, printer: printer,
|
|
session: session, printInfo: printInfo)
|
|
}
|
|
|
|
// ②–⑤ ColourSync suppression. The SPI needs a session with a
|
|
// current printer to attach to; the SetValue/mirror layers
|
|
// are warn-only and run regardless.
|
|
if printer != nil {
|
|
suppressor.applySPIMode(to: session) // ②
|
|
}
|
|
suppressor.applyLockedKeys(to: settings) // ③
|
|
suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2)
|
|
let driverBypass = suppressor.applyDriverBypass( // ④
|
|
to: settings, optionKeys: request.optionKeys)
|
|
suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤
|
|
}
|
|
|
|
private func runNativePanel(
|
|
_ request: PrintPanelRequest
|
|
) throws -> PanelCaptureResult? {
|
|
// The PM printer handle is held through the modal and
|
|
// released on every path.
|
|
let pmPrinter = PMTicketBridge.makePrinter(queue: request.queue)
|
|
defer {
|
|
if let printer = pmPrinter {
|
|
PMTicketBridge.release(printer)
|
|
}
|
|
}
|
|
|
|
let (printInfo, boundViaPM) = try makePanelPrintInfo(
|
|
for: request, boundTo: pmPrinter)
|
|
|
|
debugDump(printInfo, when: "pre-modal")
|
|
|
|
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. Paper size, quality, and
|
|
// orientation ride back parsed from the captured `k=v` string
|
|
// (#183); the PDE may rewrite or drop them (R12).
|
|
var cupsOptions: String?
|
|
var mediaType: String?
|
|
if boundViaPM {
|
|
debugDump(printInfo, when: "post-OK")
|
|
let captured = suppressor.captureOptions(
|
|
from: PMTicketBridge.settings(printInfo))
|
|
cupsOptions = captured.cupsOptions
|
|
mediaType = captured.mediaType
|
|
}
|
|
let capturedOptions = cupsOptions ?? ""
|
|
let resolvedQueue = boundViaPM
|
|
? PMTicketBridge.currentPrinterID(
|
|
session: PMTicketBridge.session(printInfo),
|
|
fallback: request.queue)
|
|
: request.queue
|
|
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
|
// never carry (#201). Warn-only via `try?`: a serialise
|
|
// failure must not lose the Stage 2 mirror above.
|
|
let ticket = try? PMTicketBridge.serialise(
|
|
printInfo, queue: resolvedQueue)
|
|
return PanelCaptureResult(
|
|
properties: PrintPropertiesResult(
|
|
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
|
options: PrintOptions(
|
|
orientation: CupsParsers.extractOrientation(
|
|
fromOptionsString: capturedOptions),
|
|
paperSize: CupsParsers.extractOption(
|
|
named: "PageSize",
|
|
fromOptionsString: capturedOptions),
|
|
mediaType: mediaType,
|
|
quality: CupsParsers.extractQuality(
|
|
fromOptionsString: capturedOptions),
|
|
ppdUncorrectedPassthrough: true,
|
|
cupsOptions: cupsOptions)),
|
|
ticket: ticket)
|
|
}
|
|
|
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
|
/// media type, orientation. All warn-only: a driver that ignores
|
|
/// a key must not keep the panel from opening (R12 surfaces via
|
|
/// the capture echo instead).
|
|
private func applyInitialSelections(
|
|
_ selections: PrintPanelInitialSelections,
|
|
to settings: PMPrintSettings,
|
|
optionKeys: Set<String>
|
|
) {
|
|
if let paperSize = selections.paperSize {
|
|
PMTicketBridge.setValue(
|
|
paperSize, forKey: "PageSize", locked: false,
|
|
in: settings, context: "Print panel")
|
|
}
|
|
if let key = selections.qualityKey, let value = selections.quality {
|
|
PMTicketBridge.setValue(
|
|
value, forKey: key, locked: false,
|
|
in: settings, context: "Print panel")
|
|
}
|
|
// Media type via the queue's detected vendor key (#186).
|
|
if let mediaType = selections.mediaType,
|
|
let mediaKey = CupsParsers.detectMediaTypeKey(
|
|
optionKeys: optionKeys) {
|
|
PMTicketBridge.setValue(
|
|
mediaType, forKey: mediaKey, locked: false,
|
|
in: settings, context: "Print panel")
|
|
}
|
|
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
|
|
if let orientation = selections.orientation {
|
|
let code = orientation == "landscape" ? "4" : "3"
|
|
PMTicketBridge.setValue(
|
|
code, forKey: "orientation-requested", locked: false,
|
|
in: settings, context: "Print panel")
|
|
}
|
|
}
|
|
|
|
/// `ICCERY_DEBUG_PANEL_DUMP=1` — log the unfiltered
|
|
/// `PMPrintSettingsToOptions` output and the serialised
|
|
/// settings/page-format byte counts (#217 diagnostics for the
|
|
/// Epson RC3 question: does the PDE source pane state from the
|
|
/// session ticket or lpoptions?). DEBUG-only, warn-only.
|
|
private func debugDump(_ printInfo: NSPrintInfo, when phase: String) {
|
|
#if DEBUG
|
|
guard ProcessInfo.processInfo.environment[
|
|
"ICCERY_DEBUG_PANEL_DUMP"] == "1" else { return }
|
|
let raw = suppressor.rawOptionsDump(
|
|
from: PMTicketBridge.settings(printInfo)) ?? "<unavailable>"
|
|
var settingsSize = -1
|
|
var ref: Unmanaged<CFData>?
|
|
if PMPrintSettingsCreateDataRepresentation(
|
|
PMTicketBridge.settings(printInfo), &ref,
|
|
kPMDataFormatXMLDefault) == noErr, let ref {
|
|
settingsSize = (ref.takeRetainedValue() as Data).count
|
|
}
|
|
var formatSize = -1
|
|
var formatRef: Unmanaged<CFData>?
|
|
if PMPageFormatCreateDataRepresentation(
|
|
PMTicketBridge.pageFormat(printInfo), &formatRef,
|
|
kPMDataFormatXMLDefault) == noErr, let formatRef {
|
|
formatSize = (formatRef.takeRetainedValue() as Data).count
|
|
}
|
|
AppLogger.shared.info(
|
|
"Print panel dump (\(phase)): settings=\(settingsSize)B "
|
|
+ "pageFormat=\(formatSize)B options: \(raw)")
|
|
#endif
|
|
}
|
|
|
|
}
|