Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1530ab2e | ||
|
|
ffbe56f43a | ||
|
|
ee5da4a637 | ||
|
|
0513b272cb | ||
|
|
555bf8fda4 | ||
|
|
f3c7b19694 | ||
|
|
5e696ff3e9 |
@@ -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
@@ -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.**
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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, ¤t) == 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
|
||||||
printInfo.pmPrintSession(), to: PMPrintSession.self),
|
|
||||||
fallback: queue)
|
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(
|
options: PrintOptions(
|
||||||
orientation: CupsParsers.extractOrientation(
|
orientation: CupsParsers.extractOrientation(
|
||||||
fromOptionsString: capturedOptions),
|
fromOptionsString: capturedOptions),
|
||||||
paperSize: CupsParsers.extractOption(
|
paperSize: CupsParsers.extractOption(
|
||||||
named: "PageSize", fromOptionsString: capturedOptions),
|
named: "PageSize",
|
||||||
|
fromOptionsString: capturedOptions),
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
quality: CupsParsers.extractQuality(
|
quality: CupsParsers.extractQuality(
|
||||||
fromOptionsString: capturedOptions),
|
fromOptionsString: capturedOptions),
|
||||||
ppdUncorrectedPassthrough: true,
|
ppdUncorrectedPassthrough: true,
|
||||||
cupsOptions: cupsOptions))
|
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, ¤t) == 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(
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user