diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift index 2764b43..8378711 100644 --- a/Sources/ICCery/Print/ColorSyncSuppressor.swift +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -186,17 +186,9 @@ struct ColorSyncSuppressor { func captureOptions( from settings: PMPrintSettings ) -> (cupsOptions: String?, mediaType: String?) { - guard let toOptions = optionsResolver() else { - log("ColorSync: PMPrintSettingsToOptions unavailable — " - + "panel options not captured") + guard let unfiltered = rawOptionsDump(from: settings) else { return (nil, nil) } - var raw: UnsafeMutablePointer? - guard toOptions(settings, &raw) == 0, let raw else { - return (nil, nil) - } - defer { free(raw) } - let unfiltered = String(cString: raw) let filtered = CupsOptionsFilter.filter(unfiltered) return ( filtered.isEmpty ? nil : filtered, @@ -204,6 +196,23 @@ struct ColorSyncSuppressor { ) } + /// Unfiltered `PMPrintSettingsToOptions` output — the diagnostics + /// half of the capture path (#217 `ICCERY_DEBUG_PANEL_DUMP`). + /// `nil` when the symbol or the call fails. + func rawOptionsDump(from settings: PMPrintSettings) -> String? { + guard let toOptions = optionsResolver() else { + log("ColorSync: PMPrintSettingsToOptions unavailable — " + + "panel options not captured") + return nil + } + var raw: UnsafeMutablePointer? + guard toOptions(settings, &raw) == 0, let raw else { + return nil + } + defer { free(raw) } + return String(cString: raw) + } + // MARK: - dlsym private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index 613fea0..1109fe9 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -32,6 +32,19 @@ struct PrintPanelInitialSelections { 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 + 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). /// @@ -58,7 +71,8 @@ struct PrintPanelService { displayName: String?, cupsService: CupsService, initialSelections: PrintPanelInitialSelections = - PrintPanelInitialSelections() + PrintPanelInitialSelections(), + ticket: PrintTicket? = nil ) async throws -> PanelCaptureResult? { #if DEBUG if UITestHooks.printPanelStubbed { @@ -75,83 +89,139 @@ struct PrintPanelService { let optionKeys = (try? await cupsService.optionKeys(for: queue)) ?? [] return try runNativePanel( - queue: queue, displayName: display, optionKeys: optionKeys, - initialSelections: initialSelections) + PrintPanelRequest( + queue: queue, displayName: display, + optionKeys: optionKeys, + initialSelections: initialSelections, ticket: ticket)) } // MARK: - Panel - private func runNativePanel( - queue: String, - displayName: String?, - optionKeys: Set, - initialSelections: PrintPanelInitialSelections - ) throws -> PanelCaptureResult? { + /// 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() - var pmPrinter: PMPrinter? - var boundViaPM = false // ① Bind the session to the selected CUPS queue (docs/11). - if let printer = PMTicketBridge.makePrinter(queue: queue) { - pmPrinter = printer - let session = PMTicketBridge.session(printInfo) - let settings = PMTicketBridge.settings(printInfo) - - do { - try PMTicketBridge.bind(printer: printer, to: printInfo) - } catch { - PMTicketBridge.release(printer) - throw error - } - // Initial selections — after `PMSessionDefault*`, before - // ColorSync suppression ②–⑤ (locked write order, - // #183/#186). Paper is TWO writes (E1): the `PageSize` - // print-settings value drivers/capture read AND the - // `PMPageFormat` paper the panel's dropdown reflects. - applyInitialSelections( - initialSelections, to: settings, optionKeys: optionKeys) - if let paperToken = initialSelections.paperSize { - PMTicketBridge.applyPaper( - token: paperToken, printer: printer, - session: session, printInfo: printInfo) - } - boundViaPM = true + 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 '\(queue)' — " + "Print panel: PM binding unavailable for " + + "'\(request.queue)' — " + "falling back to NSPrinter(displayName)") - guard let displayName, + guard let displayName = request.displayName, let nsPrinter = NSPrinter(name: displayName) else { throw PrintPanelError.noPrinterFound( - displayName ?? queue) + 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) } } - // ②–⑤ ColourSync suppression — only on the PM path: the SPI - // and PMPrintSettingsSetValue need a session with a current - // printer to attach to. - var settings = PMTicketBridge.settings(printInfo) - var driverBypass: (key: String, value: String)? - if boundViaPM { - let session = PMTicketBridge.session(printInfo) - suppressor.applySPIMode(to: session) // ② - suppressor.applyLockedKeys(to: settings) // ③ - suppressor.applyQuartzMode(to: settings) // ⑤′-a (D2) - driverBypass = suppressor.applyDriverBypass( // ④ - to: settings, optionKeys: optionKeys) - suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ - } + let (printInfo, boundViaPM) = try makePanelPrintInfo( + for: request, boundTo: pmPrinter) + + debugDump(printInfo, when: "pre-modal") let panel = NSPrintPanel() panel.options = [ @@ -174,8 +244,9 @@ struct PrintPanelService { var cupsOptions: String? var mediaType: String? if boundViaPM { - settings = PMTicketBridge.settings(printInfo) - let captured = suppressor.captureOptions(from: settings) + debugDump(printInfo, when: "post-OK") + let captured = suppressor.captureOptions( + from: PMTicketBridge.settings(printInfo)) cupsOptions = captured.cupsOptions mediaType = captured.mediaType } @@ -183,8 +254,8 @@ struct PrintPanelService { let resolvedQueue = boundViaPM ? PMTicketBridge.currentPrinterID( session: PMTicketBridge.session(printInfo), - fallback: queue) - : queue + 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. @@ -243,4 +314,35 @@ struct PrintPanelService { } } + /// `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)) ?? "" + var settingsSize = -1 + var ref: Unmanaged? + if PMPrintSettingsCreateDataRepresentation( + PMTicketBridge.settings(printInfo), &ref, + kPMDataFormatXMLDefault) == noErr, let ref { + settingsSize = (ref.takeRetainedValue() as Data).count + } + var formatSize = -1 + var formatRef: Unmanaged? + 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 + } + } diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index 29dc0f5..f9855f2 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -231,13 +231,15 @@ final class PrintSessionViewModel: ObservableObject { quality: selectedQuality, mediaType: selectedMediaType, orientation: printOrientation) + let ticket = capturedTickets[queue] Task { @MainActor in do { guard let result = try await PrintPanelService() .showProperties( queue: queue, displayName: displayName, cupsService: cups, - initialSelections: selections) + initialSelections: selections, + ticket: ticket) else { printNotice = Notice( kind: .info, diff --git a/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift new file mode 100644 index 0000000..908eff3 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintPanelTicketTests.swift @@ -0,0 +1,148 @@ +import AppKit +import Foundation +import XCTest +@testable import ICCery + +/// Issue #217 — the panel's pre-modal seam +/// (`PrintPanelService.configureBoundPanel`) must rehydrate the +/// captured `PrintTicket` before the Stage 2 writes, validate the +/// result, and refuse a foreign-queue ticket — all without driving +/// the modal or needing a real queue (`printer: nil` exercises +/// restore → selections → validate on a session-less `NSPrintInfo`). +@MainActor +final class PrintPanelTicketTests: XCTestCase { + + private let service = PrintPanelService() + + /// The queue a destination `NSPrintInfo` will report as bound — + /// the session's current printer, else a synthetic token for a + /// queue-less environment (same convention as PrintTicketTests). + private func boundQueue(of printInfo: NSPrintInfo) -> String { + PMTicketBridge.currentPrinterID( + session: PMTicketBridge.session(printInfo), + fallback: "UnboundQueue") + } + + private func makeTicket( + queue: String, + key: String = "EPIJ_Qual", + value: String = "305" + ) throws -> PrintTicket { + let source = NSPrintInfo() + XCTAssertTrue( + PMTicketBridge.setValue( + value, forKey: key, locked: false, + in: PMTicketBridge.settings(source), + context: "PrintPanelTicketTests")) + return try PMTicketBridge.serialise(source, queue: queue) + } + + private func request( + ticket: PrintTicket?, + selections: PrintPanelInitialSelections = + PrintPanelInitialSelections() + ) -> PrintPanelRequest { + PrintPanelRequest( + queue: "Panel_Q", displayName: nil, optionKeys: [], + initialSelections: selections, ticket: ticket) + } + + /// RC1 — a captured vendor key is present in the configured + /// printInfo before the modal would open. + func testTicketRestoredPreModal() throws { + let printInfo = NSPrintInfo() + let ticket = try makeTicket(queue: boundQueue(of: printInfo)) + + service.configureBoundPanel( + printInfo, request: request(ticket: ticket), printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "305") + } + + /// D6 parity — Stage 2 selections win over the restored ticket. + func testStage2WinsOverTicket() throws { + let printInfo = NSPrintInfo() + let ticket = try makeTicket(queue: boundQueue(of: printInfo)) + var selections = PrintPanelInitialSelections() + selections.qualityKey = "EPIJ_Qual" + selections.quality = "303" + + service.configureBoundPanel( + printInfo, + request: request(ticket: ticket, selections: selections), + printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "303") + } + + /// R3 — a ticket captured for another queue is refused; the + /// destination's settings stay untouched and nothing throws. + func testCrossQueueTicketRefusedAtPanel() throws { + let printInfo = NSPrintInfo() + let bound = boundQueue(of: printInfo) + let foreign = "DefinitelyNot_\(bound)" + try XCTSkipIf( + bound == foreign, + "destination resolved to the foreign queue") + XCTAssertTrue( + PMTicketBridge.setValue( + "999", forKey: "EPIJ_Qual", locked: false, + in: PMTicketBridge.settings(printInfo), + context: "PrintPanelTicketTests")) + let ticket = try makeTicket(queue: foreign) + + service.configureBoundPanel( + printInfo, request: request(ticket: ticket), printer: nil) + + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", + in: PMTicketBridge.settings(printInfo)), + "999") + } + + /// First-ever open — no ticket, selections still land and the + /// post-selections validate keeps them readable. + func testNoTicketKeepsSelections() { + let printInfo = NSPrintInfo() + var selections = PrintPanelInitialSelections() + selections.paperSize = "A4" + selections.qualityKey = "EPIJ_Qual" + selections.quality = "303" + selections.orientation = "landscape" + + service.configureBoundPanel( + printInfo, + request: request(ticket: nil, selections: selections), + printer: nil) + + let settings = PMTicketBridge.settings(printInfo) + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "PageSize", in: settings), "A4") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "EPIJ_Qual", in: settings), "303") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "orientation-requested", in: settings), "4") + // The suppression writes still land on the ticket so the PDE + // sees the locked AP_* pair (③) and Quartz vocabulary (⑤′). + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "AP_ColorMatchingMode", in: settings), + "AP_ApplicationColorMatching") + XCTAssertEqual( + PMTicketBridge.stringValue( + forKey: "PMColorMatchingMode", in: settings), + "APCustomColorMatching") + } +}