feat(print): capture native print ticket on panel OK (#201 Phase 2) #204
@@ -36,7 +36,10 @@ enum PMTicketError: LocalizedError, Equatable {
|
|||||||
/// so Swift hands back an unbalanced `Unmanaged`).
|
/// so Swift hands back an unbalanced `Unmanaged`).
|
||||||
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
|
||||||
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
|
/// `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
|
/// * `printInfo.pmPrintSession()/pmPrintSettings()/pmPageFormat()` are
|
||||||
/// borrowed from the `NSPrintInfo` → never released.
|
/// borrowed from the `NSPrintInfo` → never released.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -102,12 +105,168 @@ enum PMTicketBridge {
|
|||||||
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
guard PMSessionGetCurrentPrinter(session, ¤t) == 0,
|
||||||
let printer = current
|
let printer = current
|
||||||
else { return fallback }
|
else { return fallback }
|
||||||
defer { PMRelease(object(printer)) }
|
// Borrowed from the session — never released (see header doc).
|
||||||
guard let id = PMPrinterGetID(printer)
|
guard let id = PMPrinterGetID(printer)
|
||||||
else { return fallback }
|
else { return fallback }
|
||||||
return id.takeUnretainedValue() as String
|
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<CFData>?
|
||||||
|
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<CFData>?
|
||||||
|
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
|
// MARK: Values
|
||||||
|
|
||||||
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ struct PrintPanelService {
|
|||||||
cupsService: CupsService,
|
cupsService: CupsService,
|
||||||
initialSelections: PrintPanelInitialSelections =
|
initialSelections: PrintPanelInitialSelections =
|
||||||
PrintPanelInitialSelections()
|
PrintPanelInitialSelections()
|
||||||
) async throws -> PrintPropertiesResult? {
|
) async throws -> PanelCaptureResult? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if UITestHooks.printPanelStubbed {
|
if UITestHooks.printPanelStubbed {
|
||||||
return UITestHooks.printPanelResult(forQueue: queue)
|
return UITestHooks.printPanelResult(forQueue: queue).map {
|
||||||
|
PanelCaptureResult(properties: $0, ticket: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// `??` rhs is a non-async @autoclosure — fetch first.
|
// `??` rhs is a non-async @autoclosure — fetch first.
|
||||||
@@ -84,7 +86,7 @@ struct PrintPanelService {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
optionKeys: Set<String>,
|
optionKeys: Set<String>,
|
||||||
initialSelections: PrintPanelInitialSelections
|
initialSelections: PrintPanelInitialSelections
|
||||||
) throws -> PrintPropertiesResult? {
|
) throws -> PanelCaptureResult? {
|
||||||
let printInfo = NSPrintInfo()
|
let printInfo = NSPrintInfo()
|
||||||
var pmPrinter: PMPrinter?
|
var pmPrinter: PMPrinter?
|
||||||
var boundViaPM = false
|
var boundViaPM = false
|
||||||
@@ -177,22 +179,31 @@ struct PrintPanelService {
|
|||||||
mediaType = captured.mediaType
|
mediaType = captured.mediaType
|
||||||
}
|
}
|
||||||
let capturedOptions = cupsOptions ?? ""
|
let capturedOptions = cupsOptions ?? ""
|
||||||
return PrintPropertiesResult(
|
let resolvedQueue = boundViaPM
|
||||||
selectedPrinter: boundViaPM
|
? PMTicketBridge.currentPrinterID(
|
||||||
? PMTicketBridge.currentPrinterID(
|
session: PMTicketBridge.session(printInfo),
|
||||||
session: PMTicketBridge.session(printInfo),
|
fallback: queue)
|
||||||
fallback: queue)
|
: queue
|
||||||
: nil,
|
// ⑦ Serialise the native ticket — the payload `lp -o` could
|
||||||
options: PrintOptions(
|
// never carry (#201). Warn-only via `try?`: a serialise
|
||||||
orientation: CupsParsers.extractOrientation(
|
// failure must not lose the Stage 2 mirror above.
|
||||||
fromOptionsString: capturedOptions),
|
let ticket = try? PMTicketBridge.serialise(
|
||||||
paperSize: CupsParsers.extractOption(
|
printInfo, queue: resolvedQueue)
|
||||||
named: "PageSize", fromOptionsString: capturedOptions),
|
return PanelCaptureResult(
|
||||||
mediaType: mediaType,
|
properties: PrintPropertiesResult(
|
||||||
quality: CupsParsers.extractQuality(
|
selectedPrinter: boundViaPM ? resolvedQueue : nil,
|
||||||
fromOptionsString: capturedOptions),
|
options: PrintOptions(
|
||||||
ppdUncorrectedPassthrough: true,
|
orientation: CupsParsers.extractOrientation(
|
||||||
cupsOptions: cupsOptions))
|
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,
|
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
@Published var selectedQuality: String?
|
@Published var selectedQuality: String?
|
||||||
@Published var printOrientation = "portrait"
|
@Published var printOrientation = "portrait"
|
||||||
@Published var capturedCupsOptions: [String: String] = [:]
|
@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 printNotice: Notice?
|
||||||
@Published var isPrinting = false
|
@Published var isPrinting = false
|
||||||
private var printTask: Task<Void, Never>?
|
private var printTask: Task<Void, Never>?
|
||||||
@@ -164,31 +167,35 @@ final class PrintSessionViewModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let selected = result.selectedPrinter,
|
if let selected = result.properties.selectedPrinter,
|
||||||
printers.contains(where: { $0.name == selected }),
|
printers.contains(where: { $0.name == selected }),
|
||||||
selected != queue {
|
selected != queue {
|
||||||
selectedPrinter = selected
|
selectedPrinter = selected
|
||||||
await reloadSelectedCapabilities()
|
await reloadSelectedCapabilities()
|
||||||
}
|
}
|
||||||
if let captured = result.options.cupsOptions {
|
if let captured = result.properties.options.cupsOptions {
|
||||||
capturedCupsOptions[selectedPrinter] = captured
|
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
|
selectedMediaType = media
|
||||||
}
|
}
|
||||||
// Capture-return (#183/#186): a dialog paper/quality/
|
// Capture-return (#183/#186): a dialog paper/quality/
|
||||||
// orientation change updates the Stage 2 selections —
|
// orientation change updates the Stage 2 selections —
|
||||||
// never `workflow.pageSize` (printtarg layout is
|
// never `workflow.pageSize` (printtarg layout is
|
||||||
// sacred).
|
// sacred).
|
||||||
if let paper = result.options.paperSize,
|
if let paper = result.properties.options.paperSize,
|
||||||
let match = printerCaps.paperSizes
|
let match = printerCaps.paperSizes
|
||||||
.first(where: { $0.name == paper }) {
|
.first(where: { $0.name == paper }) {
|
||||||
selectedPaperSize = match.id
|
selectedPaperSize = match.id
|
||||||
}
|
}
|
||||||
if let quality = result.options.quality {
|
if let quality = result.properties.options.quality {
|
||||||
selectedQuality = quality
|
selectedQuality = quality
|
||||||
}
|
}
|
||||||
if let orientation = result.options.orientation {
|
if let orientation = result.properties.options.orientation {
|
||||||
printOrientation = orientation
|
printOrientation = orientation
|
||||||
}
|
}
|
||||||
printNotice = Notice(
|
printNotice = Notice(
|
||||||
|
|||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
"<?xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCrossQueueReplayIsRefused() throws {
|
||||||
|
let target = NSPrintInfo()
|
||||||
|
let bound = boundQueue(of: target)
|
||||||
|
let foreign = "DefinitelyNot_\(bound)"
|
||||||
|
try XCTSkipIf(
|
||||||
|
bound == foreign,
|
||||||
|
"destination resolved to the foreign queue")
|
||||||
|
|
||||||
|
// Sentinel on the destination — a refused restore must leave
|
||||||
|
// the target's settings untouched.
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"999", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(target),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
XCTAssertTrue(
|
||||||
|
PMTicketBridge.setValue(
|
||||||
|
"305", forKey: "EPIJ_Qual", locked: false,
|
||||||
|
in: PMTicketBridge.settings(source),
|
||||||
|
context: "PrintTicketTests"))
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: foreign)
|
||||||
|
|
||||||
|
try PMTicketBridge.restore(ticket, into: target)
|
||||||
|
XCTAssertEqual(
|
||||||
|
PMTicketBridge.stringValue(
|
||||||
|
forKey: "EPIJ_Qual",
|
||||||
|
in: PMTicketBridge.settings(target)),
|
||||||
|
"999")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPrintInfoPlistFallbackRoundTrips() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
// The `com.apple.print.PrintSettings` entry only exists once
|
||||||
|
// something writes into the attribute dictionary — the
|
||||||
|
// ColorSync mirror does this on the real panel path.
|
||||||
|
source.dictionary()[
|
||||||
|
NSPrintInfo.AttributeKey("com.apple.print.PrintSettings")] =
|
||||||
|
["PMColorMatchingMode": "APCustomColorMatching"]
|
||||||
|
as NSDictionary
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
let data = try XCTUnwrap(ticket.printInfoPlist)
|
||||||
|
let object = try PropertyListSerialization.propertyList(
|
||||||
|
from: data, options: [], format: nil)
|
||||||
|
let dictionary = try XCTUnwrap(object as? [String: Any])
|
||||||
|
XCTAssertNotNil(
|
||||||
|
dictionary["com.apple.print.PrintSettings"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 200× serialise loop — a `takeRetainedValue` over/under-release
|
||||||
|
/// canary. Byte counts must stay stable; ASan + Malloc Scribble
|
||||||
|
/// catches an actual imbalance.
|
||||||
|
func testSerialiseIsStableAcrossIterations() throws {
|
||||||
|
let source = NSPrintInfo()
|
||||||
|
var firstCount: Int?
|
||||||
|
for _ in 0..<200 {
|
||||||
|
let ticket = try PMTicketBridge.serialise(
|
||||||
|
source, queue: "AnyQueue")
|
||||||
|
if let first = firstCount {
|
||||||
|
XCTAssertEqual(ticket.printSettings.count, first)
|
||||||
|
} else {
|
||||||
|
firstCount = ticket.printSettings.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertNotNil(firstCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user