Compare commits

..
Author SHA1 Message Date
gronod fc1530ab2e feat(print): capture native print ticket on panel OK (#201 Phase 2)
macOS CI / build-and-test (push) Skipped
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.
2026-09-17 13:43:43 +01:00
gronod ffbe56f43a Merge pull request 'refactor(print): extract PMTicketBridge from PrintPanelService (#201 Phase 1)' (#203) from feat/201-pm-ticket-bridge into milestone/m12-native-spool
macOS CI / build-and-test (push) Skipped
2026-09-17 12:16:26 +01:00
gronod ee5da4a637 refactor(print): extract PMTicketBridge from PrintPanelService
macOS CI / build-and-test (push) Skipped
Centralise every Core Printing (PM*) call in one @MainActor bridge so
the upcoming ticket serialise/restore path and native spooler share a
single documented ownership policy. Pure move — no behaviour change.
2026-09-17 12:15:27 +01:00
gronod 0513b272cb docs(agents): add issue ticket style and dependency tracking guidelines
macOS CI / package (push) Skipped
macOS CI / build-and-test (push) Skipped
2026-09-17 10:34:50 +01:00
gronod 555bf8fda4 chore(release): bump MARKETING_VERSION to 2.0.1
macOS CI / build-and-test (push) Successful in 7s
macOS CI / package (push) Failing after 44s
2026-09-16 06:40:31 +01:00
gronod f3c7b19694 docs(m11): mark milestone shipped on develop
macOS CI / build-and-test (push) Failing after 45m36s
macOS CI / package (push) Skipped
Refs #183

Generated with [Devin](https://devin.ai)
2026-09-16 01:49:12 +01:00
gronod 5e696ff3e9 Merge pull request 'Milestone 11 — Printer settings completeness & dialog binding (#183, #180, #181, #186)' (#199) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 43m58s
macOS CI / package (push) Successful in 3m17s
2026-09-16 00:11:59 +01:00
gronod 19f619ad98 Merge branch 'develop' into chore/m11-sync-develop
macOS CI / build-and-test (push) Skipped
macOS CI / build-and-test (pull_request) Successful in 42m51s
macOS CI / package (pull_request) Skipped
2026-09-15 21:52:15 +01:00
gronod 09f466ae46 Merge pull request 'docs(ui): M11 stage-2 a11y id roster update' (#197) from chore/m11-docs-hygiene into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 21:52:06 +01:00
gronod 62a45d9c7d Merge pull request 'Milestone/m11 print settings' (#192) from milestone/m11-print-settings into develop
macOS CI / build-and-test (push) Successful in 37m41s
macOS CI / package (push) Successful in 4m9s
Reviewed-on: #192
2026-09-15 18:11:39 +01:00
9 changed files with 637 additions and 185 deletions
+21
View File
@@ -50,6 +50,27 @@ bump `project.yml` on `develop` before tagging. CI needs `fetch-depth: 0`.
`develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`. `develop` ← `milestone/mN-<name>` ← `feat/<issue#>-<slug>`.
PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`. PRs via Gitea MCP. Every issue/PR: `Project/ICCery-v2` + `Feature/*` or `Bug/*` + `Priority/*`.
## Issue ticket style
- Title: `[Kind/Priority] short description` — e.g. `[Bug/Critical] …`, `[Feature/Medium] …`.
- Labels: `Kind/Bug` or `Kind/Feature` (also `Kind/Testing` for test work),
one `Bug/<area>` or `Feature/<area>` (Architecture/Backend/UI/DevOps),
one `Priority/*`, plus `Project/ICCery-v2`. Set the milestone when the work
belongs to an active `mN` milestone.
- Bug bodies: `## Summary` → `## Root Cause Analysis` (file:line evidence;
note checked-and-dismissed hypotheses) → `## Proposed Fix` (options or
deterministic plan) → `## Acceptance Criteria` (checkbox list) →
`## Dependencies` → `## References`.
- Feature bodies: same skeleton minus Root Cause; lead with Summary and a
concrete implementation plan.
- Dependencies/blockers must **always** be recorded via the gitea MCP
`issue_write` methods (`add_dependency`, `block_issue`; reads via
`issue_read` `list_dependencies` / `list_blocks` — see "Gitea issue
dependencies"), not just mentioned in the body. This is
mandatory when issues share a milestone with an implementation order:
wire up `add_dependency` (blocked-by) and `block_issue` (blocks) links so
the order is machine-readable. The `## Dependencies` body section may
still summarise them for readability, but the MCP links are authoritative.
## Verify ## Verify
``` ```
xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)" xcodebuild test -scheme ICCery -destination 'platform=macOS' ARCHS="$(uname -m)"
+1 -1
View File
@@ -23,7 +23,7 @@ Hardware gates block *release of that sprint*, not filing, and not starting codi
| M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv | | M11 | Printer settings completeness & dialog binding | 183, 180, 181, 186 | Quality/media/paper caps parse + `LpArgs` emit + picker UI smoke + panel capture-return | Real Epson: 7 `EPIJ_Qual` options; real Canon Pro9500: English media labels; dialog bound to non-default queue with paper/quality/media/orientation preselected; captured settings reach `lp` argv |
| Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF | | Later | Quartz / TargetPrint | 16 | `ICCeryPrintKit` standalone + seam test | 1:1 on paper vs TIFF |
M8–M10 are merged to `develop`; M11 assembles on `milestone/m11-print-settings` from `736fd08`. M8–M11 are merged to `develop`; M11 shipped via PR #199 (hardware gate passed on real Epson XP-55 / Canon Pro9500 II).
Issue **16 is not an M3, M6, M10, or M11 exit gate.** Issue **16 is not an M3, M6, M10, or M11 exit gate.**
+4 -4
View File
@@ -26,7 +26,7 @@ as AGPLv3 child processes. The GUI never `dlopen`s or links Argyll.
| M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped | | M8 | Deduplication contracts & UAT-ready hardening (#79–#86) — shipped |
| M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped | | M9 | macOS 12 / Xcode 14.2 retarget (PR #145) — shipped |
| M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` | | M10 | Studio workflow: media library (#146), gamut compare (#147), Spot Read (#148), project files (#149) — shipped on `develop` |
| M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — in flight on `milestone/m11-print-settings` | | M11 | Printer settings completeness & dialog binding (#183, #180, #181, #186) — shipped on `develop` (PR #199) |
| Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 | | Licence | Proprietary source in [`LICENCE.md`](LICENCE.md); bundled Argyll sidecars remain AGPLv3 |
## What it does ## What it does
@@ -299,9 +299,9 @@ fix/<issue>-<slug>
``` ```
Open feature/fix PRs against **`develop`**. A `milestone/m…` integration Open feature/fix PRs against **`develop`**. A `milestone/m…` integration
branch is used only while that milestone is assembling — currently branch is used only while that milestone is assembling; `milestone/m10-studio`
`milestone/m11-print-settings` (`milestone/m10-studio` was merged and and `milestone/m11-print-settings` have been merged (branches retained). Do
deleted). Do not open umbrella “bugfix” branches that mix tickets. not open umbrella “bugfix” branches that mix tickets.
`main` is push-protected and requires status check `main` is push-protected and requires status check
`macOS CI / build-and-test (push)`. Protected **file** patterns on `main` `macOS CI / build-and-test (push)`. Protected **file** patterns on `main`
+389
View File
@@ -0,0 +1,389 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// Errors raised by Core Printing bridge operations.
enum PMTicketError: LocalizedError, Equatable {
case printerUnknown(queue: String)
case sessionBindingFailed(OSStatus)
case serialiseFailed(stage: String, status: OSStatus)
case restoreFailed(stage: String, status: OSStatus)
var errorDescription: String? {
switch self {
case .printerUnknown(let queue):
return "No printer found for queue '\(queue)'."
case .sessionBindingFailed(let status):
return "Could not bind the print session to the queue (OSStatus \(status))."
case .serialiseFailed(let stage, let status):
return "Print ticket capture failed at \(stage) (OSStatus \(status))."
case .restoreFailed(let stage, let status):
return "Print ticket restore failed at \(stage) (OSStatus \(status))."
}
}
}
/// Every Core Printing (`PM*`) call in ICCery lives here (#201 D12).
///
/// Ownership — the ONLY rules, do not improvise:
/// * `PMPrinterCreateFromPrinterID`, `PMCreatePageFormatWithPMPaper`,
/// `PMPrintSettingsCreateWithDataRepresentation`,
/// `PMPageFormatCreateWithDataRepresentation` return **+1** →
/// `PMRelease(object(_:))` on every path (`defer`).
/// * `PM*CreateDataRepresentation` writes a **+1 CFData** →
/// `takeRetainedValue()` (the headers say "the caller is responsible
/// for releasing"; the out-param is not `CF_RETURNS_RETAINED`-annotated,
/// so Swift hands back an unbalanced `Unmanaged`).
/// * `PMPrinterGetPaperList`, `PMPaperGetID`, `PMPrinterGetID`,
/// `PMPrintSettingsGetValue` are **borrowed** → `takeUnretainedValue()`,
/// 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
enum PMTicketBridge {
// MARK: Handles
/// Borrowed `PMPrintSession` from the `NSPrintInfo` — never released.
static func session(_ printInfo: NSPrintInfo) -> PMPrintSession {
unsafeBitCast(printInfo.pmPrintSession(), to: PMPrintSession.self)
}
/// Borrowed `PMPrintSettings` from the `NSPrintInfo` — never released.
static func settings(_ printInfo: NSPrintInfo) -> PMPrintSettings {
unsafeBitCast(printInfo.pmPrintSettings(), to: PMPrintSettings.self)
}
/// Borrowed `PMPageFormat` from the `NSPrintInfo` — never released.
static func pageFormat(_ printInfo: NSPrintInfo) -> PMPageFormat {
unsafeBitCast(printInfo.pmPageFormat(), to: PMPageFormat.self)
}
/// Any `PM*` handle → `PMObject` for `PMRelease` — the Carbon API
/// wants `UnsafeRawPointer`, Swift imports handles as `OpaquePointer`.
static func object<Handle>(_ handle: Handle) -> PMObject {
unsafeBitCast(handle, to: PMObject.self)
}
// MARK: Binding (layer ①)
/// `PMPrinterCreateFromPrinterID` — **+1**, caller must
/// `PMTicketBridge.release` on every path.
static func makePrinter(queue: String) -> PMPrinter? {
PMPrinterCreateFromPrinterID(queue as CFString)
}
/// Balances `makePrinter(queue:)` / any +1 PM handle.
static func release(_ printer: PMPrinter) {
PMRelease(object(printer))
}
/// `PMSessionSetCurrentPMPrinter` + session defaults (warn-only).
/// Throws `PMTicketError.sessionBindingFailed` when the bind itself
/// fails — the caller still owns `printer` on every path.
static func bind(printer: PMPrinter, to printInfo: NSPrintInfo) throws {
let session = session(printInfo)
let status = PMSessionSetCurrentPMPrinter(session, printer)
guard status == 0 else {
throw PMTicketError.sessionBindingFailed(status)
}
// Warn-only: defaults keep the panel consistent with the
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings(printInfo))
_ = PMSessionDefaultPageFormat(session, pageFormat(printInfo))
}
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
static func currentPrinterID(
session: PMPrintSession,
fallback: String
) -> String {
var current: PMPrinter?
guard PMSessionGetCurrentPrinter(session, &current) == 0,
let printer = current
else { return fallback }
// 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
/// must not abort the caller's flow. Returns `true` on success.
@discardableResult
static func setValue(
_ value: String,
forKey key: String,
locked: Bool,
in settings: PMPrintSettings,
context: String
) -> Bool {
let status = PMPrintSettingsSetValue(
settings, key as CFString, value as CFString, locked)
if status != 0 {
AppLogger.shared.warn(
"\(context): PMPrintSettingsSetValue(\(key)) "
+ "rejected (\(status))")
return false
}
return true
}
/// `PMPrintSettingsGetValue` — borrowed value, never released.
/// `nil` for absent keys, non-string values, or lookup errors.
static func stringValue(
forKey key: String,
in settings: PMPrintSettings
) -> String? {
var value: Unmanaged<CFTypeRef>?
guard PMPrintSettingsGetValue(
settings, key as CFString, &value) == 0,
let ref = value?.takeUnretainedValue()
else { return nil }
return ref as? String
}
// MARK: Paper
/// The `PMPageFormat` half of paper preselect (#186 E1): the
/// panel's paper dropdown reflects the page format's `PMPaper`,
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
/// paper from `PMPrinterGetPaperList`, rebuild the page format
/// around it, and copy it into the printInfo's format (TN2248:
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
/// missed match must not keep the panel from opening.
static func applyPaper(
token: String,
printer: PMPrinter,
session: PMPrintSession,
printInfo: NSPrintInfo
) {
if let custom = customPaperDimensions(from: token) {
printInfo.paperSize = NSSize(
width: custom.width, height: custom.height)
return
}
var paperList: Unmanaged<CFArray>?
guard PMPrinterGetPaperList(printer, &paperList) == 0,
let papers = paperList?.takeUnretainedValue()
else {
AppLogger.shared.warn(
"Print panel: PMPrinterGetPaperList failed — "
+ "paper preselect skipped")
return
}
// The list (and its elements) is owned by the printer —
// borrowed, never released.
var match: PMPaper?
for index in 0..<CFArrayGetCount(papers) {
let paper = unsafeBitCast(
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
var idRef: Unmanaged<CFString>?
guard PMPaperGetID(paper, &idRef) == 0,
let paperID = idRef?.takeUnretainedValue() as String?
else { continue }
if paperID == token {
match = paper
break
}
}
guard let paper = match else {
AppLogger.shared.warn(
"Print panel: no PMPaper id matches '\(token)'")
return
}
var created: PMPageFormat?
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
let newFormat = created
else {
AppLogger.shared.warn(
"Print panel: PMCreatePageFormatWithPMPaper failed "
+ "for '\(token)'")
return
}
defer { PMRelease(object(newFormat)) }
_ = PMSessionValidatePageFormat(session, newFormat, nil)
_ = PMCopyPageFormat(newFormat, pageFormat(printInfo))
printInfo.updateFromPMPageFormat()
}
/// `Custom.<w>x<h>` → dimensions in points (the token builder
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
/// malformed tokens — a malformed `Custom.*` then misses the
/// `PMPaper` match and logs instead of guessing a size.
static func customPaperDimensions(
from token: String
) -> (width: Double, height: Double)? {
guard token.hasPrefix("Custom.") else { return nil }
let dims = token.dropFirst("Custom.".count).split(separator: "x")
guard dims.count == 2,
let width = Double(dims[0]), let height = Double(dims[1]),
width > 0, height > 0
else { return nil }
return (width, height)
}
}
+57 -173
View File
@@ -4,13 +4,10 @@ import ICCeryCore
/// Errors raised while preparing the bound print panel. /// Errors raised while preparing the bound print panel.
enum PrintPanelError: LocalizedError { enum PrintPanelError: LocalizedError {
case sessionBindingFailed(OSStatus)
case noPrinterFound(String) case noPrinterFound(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .sessionBindingFailed(let status):
return "Could not bind the print session to the queue (OSStatus \(status))."
case .noPrinterFound(let name): case .noPrinterFound(let name):
return "No printer found for '\(name)'." return "No printer found for '\(name)'."
} }
@@ -62,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.
@@ -87,30 +86,23 @@ 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
// ① Bind the session to the selected CUPS queue (docs/11). // ① Bind the session to the selected CUPS queue (docs/11).
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) { if let printer = PMTicketBridge.makePrinter(queue: queue) {
pmPrinter = printer pmPrinter = printer
let session = unsafeBitCast( let session = PMTicketBridge.session(printInfo)
printInfo.pmPrintSession(), to: PMPrintSession.self) let settings = PMTicketBridge.settings(printInfo)
let settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let pageFormat = unsafeBitCast(
printInfo.pmPageFormat(), to: PMPageFormat.self)
let status = PMSessionSetCurrentPMPrinter(session, printer) do {
if status != 0 { try PMTicketBridge.bind(printer: printer, to: printInfo)
PMRelease(Self.pmObject(printer)) } catch {
throw PrintPanelError.sessionBindingFailed(status) PMTicketBridge.release(printer)
throw error
} }
// Warn-only: defaults keep the panel consistent with the
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings)
_ = PMSessionDefaultPageFormat(session, pageFormat)
// Initial selections — after `PMSessionDefault*`, before // Initial selections — after `PMSessionDefault*`, before
// ColorSync suppression ②–⑤ (locked write order, // ColorSync suppression ②–⑤ (locked write order,
// #183/#186). Paper is TWO writes (E1): the `PageSize` // #183/#186). Paper is TWO writes (E1): the `PageSize`
@@ -119,9 +111,9 @@ struct PrintPanelService {
applyInitialSelections( applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys) initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize { if let paperToken = initialSelections.paperSize {
applyPaperPageFormat( PMTicketBridge.applyPaper(
paperToken, printer: printer, session: session, token: paperToken, printer: printer,
printInfo: printInfo) session: session, printInfo: printInfo)
} }
boundViaPM = true boundViaPM = true
} else { } else {
@@ -142,19 +134,17 @@ struct PrintPanelService {
} }
defer { defer {
if let printer = pmPrinter { if let printer = pmPrinter {
PMRelease(Self.pmObject(printer)) PMTicketBridge.release(printer)
} }
} }
// ②–⑤ ColourSync suppression — only on the PM path: the SPI // ②–⑤ ColourSync suppression — only on the PM path: the SPI
// and PMPrintSettingsSetValue need a session with a current // and PMPrintSettingsSetValue need a session with a current
// printer to attach to. // printer to attach to.
var settings = unsafeBitCast( var settings = PMTicketBridge.settings(printInfo)
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
var driverBypass: (key: String, value: String)? var driverBypass: (key: String, value: String)?
if boundViaPM { if boundViaPM {
let session = unsafeBitCast( let session = PMTicketBridge.session(printInfo)
printInfo.pmPrintSession(), to: PMPrintSession.self)
suppressor.applySPIMode(to: session) // ② suppressor.applySPIMode(to: session) // ②
suppressor.applyLockedKeys(to: settings) // ③ suppressor.applyLockedKeys(to: settings) // ③
driverBypass = suppressor.applyDriverBypass( // ④ driverBypass = suppressor.applyDriverBypass( // ④
@@ -183,30 +173,37 @@ struct PrintPanelService {
var cupsOptions: String? var cupsOptions: String?
var mediaType: String? var mediaType: String?
if boundViaPM { if boundViaPM {
settings = unsafeBitCast( settings = PMTicketBridge.settings(printInfo)
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let captured = suppressor.captureOptions(from: settings) let captured = suppressor.captureOptions(from: settings)
cupsOptions = captured.cupsOptions cupsOptions = captured.cupsOptions
mediaType = captured.mediaType mediaType = captured.mediaType
} }
let capturedOptions = cupsOptions ?? "" let capturedOptions = cupsOptions ?? ""
return PrintPropertiesResult( let resolvedQueue = boundViaPM
selectedPrinter: boundViaPM ? PMTicketBridge.currentPrinterID(
? Self.currentPrinterID( session: PMTicketBridge.session(printInfo),
session: unsafeBitCast( fallback: queue)
printInfo.pmPrintSession(), to: PMPrintSession.self), : queue
fallback: queue) // ⑦ Serialise the native ticket — the payload `lp -o` could
: nil, // never carry (#201). Warn-only via `try?`: a serialise
options: PrintOptions( // failure must not lose the Stage 2 mirror above.
orientation: CupsParsers.extractOrientation( let ticket = try? PMTicketBridge.serialise(
fromOptionsString: capturedOptions), printInfo, queue: resolvedQueue)
paperSize: CupsParsers.extractOption( return PanelCaptureResult(
named: "PageSize", fromOptionsString: capturedOptions), properties: PrintPropertiesResult(
mediaType: mediaType, selectedPrinter: boundViaPM ? resolvedQueue : nil,
quality: CupsParsers.extractQuality( options: PrintOptions(
fromOptionsString: capturedOptions), orientation: CupsParsers.extractOrientation(
ppdUncorrectedPassthrough: true, fromOptionsString: capturedOptions),
cupsOptions: cupsOptions)) 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,
@@ -219,143 +216,30 @@ struct PrintPanelService {
optionKeys: Set<String> optionKeys: Set<String>
) { ) {
if let paperSize = selections.paperSize { if let paperSize = selections.paperSize {
warnOnFailure(PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, "PageSize" as CFString, paperSize, forKey: "PageSize", locked: false,
paperSize as CFString, false), key: "PageSize") in: settings, context: "Print panel")
} }
if let key = selections.qualityKey, let value = selections.quality { if let key = selections.qualityKey, let value = selections.quality {
warnOnFailure(PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, key as CFString, value, forKey: key, locked: false,
value as CFString, false), key: key) in: settings, context: "Print panel")
} }
// Media type via the queue's detected vendor key (#186). // Media type via the queue's detected vendor key (#186).
if let mediaType = selections.mediaType, if let mediaType = selections.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey( let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) { optionKeys: optionKeys) {
warnOnFailure(PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, mediaKey as CFString, mediaType, forKey: mediaKey, locked: false,
mediaType as CFString, false), key: mediaKey) in: settings, context: "Print panel")
} }
// Orientation — portrait=3, landscape=4 (CUPS IPP codes). // Orientation — portrait=3, landscape=4 (CUPS IPP codes).
if let orientation = selections.orientation { if let orientation = selections.orientation {
let code = orientation == "landscape" ? "4" : "3" let code = orientation == "landscape" ? "4" : "3"
warnOnFailure(PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, "orientation-requested" as CFString, code, forKey: "orientation-requested", locked: false,
code as CFString, false), key: "orientation-requested") in: settings, context: "Print panel")
} }
} }
/// The `PMPageFormat` half of paper preselect (#186 E1): the
/// panel's paper dropdown reflects the page format's `PMPaper`,
/// not `PMPrintSettings`. Match the Stage 2 `PageSize` token to a
/// paper from `PMPrinterGetPaperList`, rebuild the page format
/// around it, and copy it into the printInfo's format (TN2248:
/// `PMCreatePageFormatWithPMPaper` → `PMSessionValidatePageFormat`
/// → `PMCopyPageFormat` → `updateFromPMPageFormat`).
/// `Custom.<w>x<h>` tokens (already points) have no `PMPaper` —
/// set the Cocoa `paperSize` directly. Warn-only throughout: a
/// missed match must not keep the panel from opening.
private func applyPaperPageFormat(
_ token: String,
printer: PMPrinter,
session: PMPrintSession,
printInfo: NSPrintInfo
) {
if let custom = Self.customPaperDimensions(from: token) {
printInfo.paperSize = NSSize(
width: custom.width, height: custom.height)
return
}
var paperList: Unmanaged<CFArray>?
guard PMPrinterGetPaperList(printer, &paperList) == 0,
let papers = paperList?.takeUnretainedValue()
else {
AppLogger.shared.warn(
"Print panel: PMPrinterGetPaperList failed — "
+ "paper preselect skipped")
return
}
// The list (and its elements) is owned by the printer —
// borrowed, never released.
var match: PMPaper?
for index in 0..<CFArrayGetCount(papers) {
let paper = unsafeBitCast(
CFArrayGetValueAtIndex(papers, index), to: PMPaper.self)
var idRef: Unmanaged<CFString>?
guard PMPaperGetID(paper, &idRef) == 0,
let paperID = idRef?.takeUnretainedValue() as String?
else { continue }
if paperID == token {
match = paper
break
}
}
guard let paper = match else {
AppLogger.shared.warn(
"Print panel: no PMPaper id matches '\(token)'")
return
}
var created: PMPageFormat?
guard PMCreatePageFormatWithPMPaper(&created, paper) == 0,
let newFormat = created
else {
AppLogger.shared.warn(
"Print panel: PMCreatePageFormatWithPMPaper failed "
+ "for '\(token)'")
return
}
defer { PMRelease(unsafeBitCast(newFormat, to: PMObject.self)) }
_ = PMSessionValidatePageFormat(session, newFormat, nil)
let destination = unsafeBitCast(
printInfo.pmPageFormat(), to: PMPageFormat.self)
_ = PMCopyPageFormat(newFormat, destination)
printInfo.updateFromPMPageFormat()
}
/// `Custom.<w>x<h>` → dimensions in points (the token builder
/// emits integer points, mm × 72/25.4). `nil` for non-custom or
/// malformed tokens — a malformed `Custom.*` then misses the
/// `PMPaper` match and logs instead of guessing a size.
static func customPaperDimensions(
from token: String
) -> (width: Double, height: Double)? {
guard token.hasPrefix("Custom.") else { return nil }
let dims = token.dropFirst("Custom.".count).split(separator: "x")
guard dims.count == 2,
let width = Double(dims[0]), let height = Double(dims[1]),
width > 0, height > 0
else { return nil }
return (width, height)
}
private func warnOnFailure(_ status: OSStatus, key: String) {
if status != 0 {
AppLogger.shared.warn(
"Print panel: PMPrintSettingsSetValue(\(key)) "
+ "rejected (\(status))")
}
}
// MARK: - PM helpers
/// `PMPrinter` → `PMObject` for `PMRelease` — the Carbon API wants
/// `UnsafeRawPointer`, Swift imports `PMPrinter` as `OpaquePointer`.
static func pmObject(_ printer: PMPrinter) -> PMObject {
unsafeBitCast(printer, to: PMObject.self)
}
/// `PMSessionGetCurrentPrinter` → `PMPrinterGetID` → String.
private static func currentPrinterID(
session: PMPrintSession,
fallback: String
) -> String {
var current: PMPrinter?
guard PMSessionGetCurrentPrinter(session, &current) == 0,
let printer = current
else { return fallback }
defer { PMRelease(pmObject(printer)) }
guard let id = PMPrinterGetID(printer)
else { return fallback }
return id.takeUnretainedValue() as String
}
} }
@@ -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(
+30
View File
@@ -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)
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ targets:
INFOPLIST_KEY_LSMinimumSystemVersion: "12.0" INFOPLIST_KEY_LSMinimumSystemVersion: "12.0"
INFOPLIST_KEY_NSPrincipalClass: NSApplication INFOPLIST_KEY_NSPrincipalClass: NSApplication
INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3." INFOPLIST_KEY_NSHumanReadableCopyright: "Copyright © 2026 Gronod. AGPLv3."
MARKETING_VERSION: "2.0.0" MARKETING_VERSION: "2.0.1"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "1"
# Stamped by scripts/version.sh at packaging/CI time (release tag or # Stamped by scripts/version.sh at packaging/CI time (release tag or
# `git describe` string); About shows it next to the marketing version. # `git describe` string); About shows it next to the marketing version.