Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1530ab2e | ||
|
|
ffbe56f43a |
@@ -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<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
|
||||
|
||||
/// Warn-only `PMPrintSettingsSetValue`: a driver that rejects a key
|
||||
|
||||
@@ -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<String>,
|
||||
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
|
||||
let resolvedQueue = boundViaPM
|
||||
? PMTicketBridge.currentPrinterID(
|
||||
session: PMTicketBridge.session(printInfo),
|
||||
fallback: queue)
|
||||
: nil,
|
||||
: 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),
|
||||
named: "PageSize",
|
||||
fromOptionsString: capturedOptions),
|
||||
mediaType: mediaType,
|
||||
quality: CupsParsers.extractQuality(
|
||||
fromOptionsString: capturedOptions),
|
||||
ppdUncorrectedPassthrough: true,
|
||||
cupsOptions: cupsOptions))
|
||||
cupsOptions: cupsOptions)),
|
||||
ticket: ticket)
|
||||
}
|
||||
|
||||
/// Initial-selection `PMPrintSettings` writes — paper, quality,
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
@@ -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(
|
||||
|
||||
@@ -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