[Bug/High] Restore captured PrintTicket when reopening the printer properties dialog #219
@@ -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<CChar>?
|
||||
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<CChar>?
|
||||
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? {
|
||||
|
||||
@@ -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<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).
|
||||
///
|
||||
@@ -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<String>,
|
||||
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)) ?? "<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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user