Files
iccery-v2-mac/Sources/ICCery/Print/PrintPanelService.swift
gronod 12061a03ac
macOS CI / build-and-test (push) Skipped
fix(print): restore captured ticket when reopening properties dialog (#217)
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
)
2026-09-22 17:47:09 +01:00

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
}
}