From fc1530ab2ed134f7bcbebcc7766827cd8f2fd3f8 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 17 Sep 2026 13:43:43 +0100 Subject: [PATCH] feat(print): capture native print ticket on panel OK (#201 Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialise PMPrintSettings/PMPageFormat to XML data plus a plist-safe NSPrintInfo dictionary snapshot (PrintTicket), restore via PM*CreateWithDataRepresentation -> PMCopy* -> PMSessionValidate* -> updateFromPM*, and refuse cross-queue replay (R3). PrintPanelService.showProperties now returns PanelCaptureResult; the view model stores tickets in capturedTickets keyed by queue. Also fixes a latent over-release: PMSessionGetCurrentPrinter hands back the session's own printer (borrowed) — the extracted currentPrinterID was PMRelease-ing it, which dangled the session and crashed AppKit's _printerInPrintSession path and session teardown. --- Sources/ICCery/Print/PMTicketBridge.swift | 163 +++++++++++++++++- Sources/ICCery/Print/PrintPanelService.swift | 49 ++++-- .../ICCery/Print/PrintSessionViewModel.swift | 19 +- Sources/ICCery/Print/PrintTicket.swift | 30 ++++ Tests/ICCeryCoreTests/PrintTicketTests.swift | 121 +++++++++++++ 5 files changed, 355 insertions(+), 27 deletions(-) create mode 100644 Sources/ICCery/Print/PrintTicket.swift create mode 100644 Tests/ICCeryCoreTests/PrintTicketTests.swift diff --git a/Sources/ICCery/Print/PMTicketBridge.swift b/Sources/ICCery/Print/PMTicketBridge.swift index ed36022..522a2f4 100644 --- a/Sources/ICCery/Print/PMTicketBridge.swift +++ b/Sources/ICCery/Print/PMTicketBridge.swift @@ -36,7 +36,10 @@ enum PMTicketError: LocalizedError, Equatable { /// so Swift hands back an unbalanced `Unmanaged`). /// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`, /// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`, -/// never released. +/// never released. `PMSessionGetCurrentPrinter` hands back the +/// session's own printer — also borrowed, never `PMRelease`d (an +/// over-release here dangles the session and crashes AppKit's +/// `_printerInPrintSession` / session teardown). /// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are /// borrowed from the `NSPrintInfo` → never released. @MainActor @@ -102,12 +105,168 @@ enum PMTicketBridge { guard PMSessionGetCurrentPrinter(session, ¤t) == 0, let printer = current else { return fallback } - defer { PMRelease(object(printer)) } + // Borrowed from the session — never released (see header doc). guard let id = PMPrinterGetID(printer) else { return fallback } return id.takeUnretainedValue() as String } + // MARK: Ticket (#201 D3) + + /// Capture the live `PMPrintSettings` + `PMPageFormat` as XML + /// `Data` plus a plist-safe `NSPrintInfo.dictionary()` fallback. + /// The CFData out-params are **+1** by header contract + /// ("the caller is responsible for releasing") → + /// `takeRetainedValue()`. + static func serialise( + _ printInfo: NSPrintInfo, + queue: String + ) throws -> PrintTicket { + var settingsRef: Unmanaged? + let settingsStatus = PMPrintSettingsCreateDataRepresentation( + settings(printInfo), &settingsRef, kPMDataFormatXMLDefault) + guard settingsStatus == noErr, let settingsRef else { + throw PMTicketError.serialiseFailed( + stage: "printSettings", status: settingsStatus) + } + let settingsData = settingsRef.takeRetainedValue() as Data + + var pageFormatRef: Unmanaged? + let pageFormatStatus = PMPageFormatCreateDataRepresentation( + pageFormat(printInfo), &pageFormatRef, kPMDataFormatXMLDefault) + guard pageFormatStatus == noErr, let pageFormatRef else { + throw PMTicketError.serialiseFailed( + stage: "pageFormat", status: pageFormatStatus) + } + let pageFormatData = pageFormatRef.takeRetainedValue() as Data + + // Cocoa-level fallback — warn-only, never fatal: filter the + // dictionary to plist-safe values so one exotic attribute + // cannot fail the whole snapshot. + let plist = try? PropertyListSerialization.data( + fromPropertyList: plistSafe(printInfo.dictionary()) ?? [:], + format: .binary, options: 0) + + return PrintTicket( + queue: queue, + printSettings: settingsData, + pageFormat: pageFormatData, + printInfoPlist: plist, + capturedAt: Date()) + } + + /// Rehydrate a ticket into `printInfo`'s live PM objects. Order is + /// load-bearing: create → copy → session-validate → Cocoa update. + /// Cross-queue replay is refused (R3) — the destination's bound + /// queue is the session's current printer; a destination with no + /// bound printer accepts the ticket (the spooler always binds + /// first, so production replay is always guarded). + /// A page-format failure is warn-only — paper is re-derived + /// upstream by the spooler's S6/S7. + static func restore( + _ ticket: PrintTicket, + into printInfo: NSPrintInfo + ) throws { + let bound = currentPrinterID( + session: session(printInfo), + fallback: ticket.queue) + guard ticket.queue == bound else { + AppLogger.shared.warn( + "PrintTicket: refusing to replay a ticket captured " + + "for '\(ticket.queue)' onto '\(bound)'") + return + } + + var srcSettings: PMPrintSettings? + let createStatus = PMPrintSettingsCreateWithDataRepresentation( + ticket.printSettings as CFData, &srcSettings) + defer { + if let srcSettings { PMRelease(object(srcSettings)) } + } + guard createStatus == noErr, let srcSettings else { + throw PMTicketError.restoreFailed( + stage: "printSettings", status: createStatus) + } + let copyStatus = PMCopyPrintSettings( + srcSettings, settings(printInfo)) + guard copyStatus == noErr else { + throw PMTicketError.restoreFailed( + stage: "printSettings", status: copyStatus) + } + var changed = DarwinBoolean(false) + _ = PMSessionValidatePrintSettings( + session(printInfo), settings(printInfo), &changed) + if changed.boolValue { + AppLogger.shared.info( + "PrintTicket: driver adjusted the restored ticket") + } + printInfo.updateFromPMPrintSettings() + + // Page format — warn-only. + var srcFormat: PMPageFormat? + let formatStatus = PMPageFormatCreateWithDataRepresentation( + ticket.pageFormat as CFData, &srcFormat) + defer { + if let srcFormat { PMRelease(object(srcFormat)) } + } + guard formatStatus == noErr, let srcFormat else { + AppLogger.shared.warn( + "PrintTicket: page format restore failed " + + "(\(formatStatus)) — paper re-derived upstream") + return + } + guard PMCopyPageFormat(srcFormat, pageFormat(printInfo)) == noErr + else { + AppLogger.shared.warn( + "PrintTicket: page format copy failed — " + + "paper re-derived upstream") + return + } + var formatChanged = DarwinBoolean(false) + _ = PMSessionValidatePageFormat( + session(printInfo), pageFormat(printInfo), &formatChanged) + printInfo.updateFromPMPageFormat() + } + + /// Recursive plist-safety filter for `NSPrintInfo.dictionary()`: + /// keeps String / NSNumber / Bool / Date / Data / URL (→ + /// absoluteString) / Array / Dictionary, drops everything else, so + /// one non-plist attribute cannot fail the whole snapshot. + private static func plistSafe(_ value: Any) -> Any? { + switch value { + case let string as String: + return string + case let number as NSNumber: + return number + case let date as Date: + return date + case let data as Data: + return data + case let url as URL: + return url.absoluteString + case let array as [Any]: + return array.compactMap(plistSafe) + case let dictionary as [String: Any]: + var safe: [String: Any] = [:] + for (key, element) in dictionary { + if let filtered = plistSafe(element) { + safe[key] = filtered + } + } + return safe + case let dictionary as [NSPrintInfo.AttributeKey: Any]: + var safe: [String: Any] = [:] + for (key, element) in dictionary { + if let filtered = plistSafe(element) { + safe[key.rawValue] = filtered + } + } + return safe + default: + return nil + } + } + // MARK: Values /// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 67adfac..22ed51c 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -59,10 +59,12 @@ struct PrintPanelService { cupsService: CupsService, initialSelections: PrintPanelInitialSelections = PrintPanelInitialSelections() - ) async throws -> PrintPropertiesResult? { + ) async throws -> PanelCaptureResult? { #if DEBUG if UITestHooks.printPanelStubbed { - return UITestHooks.printPanelResult(forQueue: queue) + return UITestHooks.printPanelResult(forQueue: queue).map { + PanelCaptureResult(properties: $0, ticket: nil) + } } #endif // `??` rhs is a non-async @autoclosure — fetch first. @@ -84,7 +86,7 @@ struct PrintPanelService { displayName: String?, optionKeys: Set, initialSelections: PrintPanelInitialSelections - ) throws -> PrintPropertiesResult? { + ) throws -> PanelCaptureResult? { let printInfo = NSPrintInfo() var pmPrinter: PMPrinter? var boundViaPM = false @@ -177,22 +179,31 @@ struct PrintPanelService { mediaType = captured.mediaType } let capturedOptions = cupsOptions ?? "" - return PrintPropertiesResult( - selectedPrinter: boundViaPM - ? PMTicketBridge.currentPrinterID( - session: PMTicketBridge.session(printInfo), - fallback: queue) - : 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)) + let resolvedQueue = boundViaPM + ? PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: queue) + : 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, diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 7f684af..1c992d2 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -23,6 +23,9 @@ final class PrintSessionViewModel: ObservableObject { @Published var selectedQuality: String? @Published var printOrientation = "portrait" @Published var capturedCupsOptions: [String: String] = [:] + /// Native print tickets per queue — the spool payload (#201). + /// Session-only; cleared whenever the queue's mirror is. + @Published var capturedTickets: [String: PrintTicket] = [:] @Published var printNotice: Notice? @Published var isPrinting = false private var printTask: Task? @@ -164,31 +167,35 @@ final class PrintSessionViewModel: ObservableObject { ) return } - if let selected = result.selectedPrinter, + if let selected = result.properties.selectedPrinter, printers.contains(where: { $0.name == selected }), selected != queue { selectedPrinter = selected await reloadSelectedCapabilities() } - if let captured = result.options.cupsOptions { + if let captured = result.properties.options.cupsOptions { capturedCupsOptions[selectedPrinter] = captured } - if let media = result.options.mediaType { + // The native ticket rides alongside the mirror (#201). + if let ticket = result.ticket { + capturedTickets[ticket.queue] = ticket + } + if let media = result.properties.options.mediaType { selectedMediaType = media } // Capture-return (#183/#186): a dialog paper/quality/ // orientation change updates the Stage 2 selections — // never `workflow.pageSize` (printtarg layout is // sacred). - if let paper = result.options.paperSize, + if let paper = result.properties.options.paperSize, let match = printerCaps.paperSizes .first(where: { $0.name == paper }) { selectedPaperSize = match.id } - if let quality = result.options.quality { + if let quality = result.properties.options.quality { selectedQuality = quality } - if let orientation = result.options.orientation { + if let orientation = result.properties.options.orientation { printOrientation = orientation } printNotice = Notice( diff --git a/Sources/ICCery/Print/PrintTicket.swift b/Sources/ICCery/Print/PrintTicket.swift new file mode 100644 index 0000000..2568271 --- /dev/null +++ b/Sources/ICCery/Print/PrintTicket.swift @@ -0,0 +1,30 @@ +import Foundation +import ICCeryCore + +/// A captured native print ticket (#201) — the payload `lp -o` could +/// never carry. Vendor PDE state, including opaque binary blobs, is +/// preserved verbatim inside `printSettings`. +/// +/// In-memory, session-only. Never written to `settings.json` or an +/// `.icceryproj` (a ticket is queue- and driver-version-specific). +struct PrintTicket: Equatable, Sendable { + /// CUPS queue id this ticket was captured for. Replay onto any other + /// queue is refused (R3). + let queue: String + /// `PMPrintSettingsCreateDataRepresentation(…, kPMDataFormatXMLDefault)`. + let printSettings: Data + /// `PMPageFormatCreateDataRepresentation(…, kPMDataFormatXMLDefault)`. + let pageFormat: Data + /// Binary-plist snapshot of `NSPrintInfo.dictionary()`, plist-filtered. + /// Cocoa-level fallback only — never the primary restore path. + let printInfoPlist: Data? + let capturedAt: Date +} + +/// App-level panel outcome: the ICCeryCore `PrintPropertiesResult` +/// (Stage 2 mirror values, #183/#186) **plus** the native ticket the +/// spooler replays (#201). +struct PanelCaptureResult { + var properties: PrintPropertiesResult + var ticket: PrintTicket? +} diff --git a/Tests/ICCeryCoreTests/PrintTicketTests.swift b/Tests/ICCeryCoreTests/PrintTicketTests.swift new file mode 100644 index 0000000..fc5b064 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintTicketTests.swift @@ -0,0 +1,121 @@ +import AppKit +import Foundation +import XCTest +@testable import ICCery + +/// Issue #201 Phase 2 — `PMTicketBridge.serialise`/`restore` byte +/// round-trip, cross-queue refusal (R3), plist fallback, and the +/// `takeRetainedValue` ownership canary. +@MainActor +final class PrintTicketTests: XCTestCase { + + /// The queue a destination `NSPrintInfo` will report as bound — + /// the session's current printer, else the Cocoa printer name, + /// else a synthetic token for a queue-less environment. + private func boundQueue(of printInfo: NSPrintInfo) -> String { + PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: "UnboundQueue") + } + + func testRoundTripRetainsVendorValue() throws { + let source = NSPrintInfo() + XCTAssertTrue( + PMTicketBridge.setValue( + "305", forKey: "EPIJ_Qual", locked: false, + in: PMTicketBridge.settings(source), + context: "PrintTicketTests")) + + let target = NSPrintInfo() + let ticket = try PMTicketBridge.serialise( + source, queue: boundQueue(of: target)) + try PMTicketBridge.restore(ticket, into: target) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(target)), + "305") + } + + func testPrintSettingsDataIsXmlPlist() throws { + let ticket = try PMTicketBridge.serialise( + NSPrintInfo(), queue: "AnyQueue") + XCTAssertFalse(ticket.printSettings.isEmpty) + XCTAssertFalse(ticket.pageFormat.isEmpty) + XCTAssertEqual( + String(decoding: ticket.printSettings.prefix(5), + as: UTF8.self), + "