Compare commits

..
Author SHA1 Message Date
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 d228594bd3 docs(ui): add M11 printer settings a11y ids to stage-2 roster
macOS CI / build-and-test (push) Skipped
Refs #183
2026-09-15 21:51:52 +01:00
gronod 6adbf99bd9 Merge pull request 'fix(print): bind selected printer and settings in NSPrintPanel (#186)' (#196) from feat/186-panel-binding into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 20:41:12 +01:00
gronodandDevin d8c41444b9 fix(print): bind selected printer and settings in NSPrintPanel (#186)
macOS CI / build-and-test (push) Skipped
Refs #186

Co-Authored-By: Devin <devin@cognition.ai>
2026-09-15 20:40:23 +01:00
gronod 1767792f75 Merge pull request 'fix(print): deterministic PPD locale precedence for media labels (#181)' (#195) from feat/181-canon-media-locale into milestone/m11-print-settings
macOS CI / build-and-test (push) Skipped
2026-09-15 19:27:47 +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
13 changed files with 586 additions and 82 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.**
@@ -266,13 +266,20 @@ public enum CupsParsers {
} }
/// Media type from a captured `key=value key=value` options string. /// Media type from a captured `key=value key=value` options string.
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests). /// Prefers `MediaType` (docs/11 §tests), then the remaining roster
/// keys in detection order — `CNIJMediaType`, `EPIJ_Medi`,
/// `StpMediaType` (#186 capture-return).
public static func extractMediaType(fromOptionsString options: String) -> String? { public static func extractMediaType(fromOptionsString options: String) -> String? {
let pairs = lpoptions(options) let pairs = lpoptions(options)
if let v = pairs.first(where: { $0.key == "MediaType" })?.value { if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
return v return v
} }
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value for key in mediaTypeKeys where key != "MediaType" {
if let v = pairs.first(where: { $0.key == key })?.value {
return v
}
}
return nil
} }
/// Print-quality option key in preference order — vendor-first, /// Print-quality option key in preference order — vendor-first,
+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`
+230
View File
@@ -0,0 +1,230 @@
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.
/// * `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 }
defer { PMRelease(object(printer)) }
guard let id = PMPrinterGetID(printer)
else { return fallback }
return id.takeUnretainedValue() as String
}
// 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)
}
}
+66 -67
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)'."
} }
@@ -18,18 +15,21 @@ enum PrintPanelError: LocalizedError {
} }
/// Stage 2 selections pre-applied to the bound print panel before it /// Stage 2 selections pre-applied to the bound print panel before it
/// opens (#183). This phase consumes `paperSize` + `qualityKey`/ /// opens (#183/#186). Every write is warn-only — the panel still
/// `quality` only; `mediaType` and `orientation` preselect — and the /// opens when a driver ignores a key.
/// `PMPageFormat`/`PMPaper` half of paper — are #186's scope.
struct PrintPanelInitialSelections { struct PrintPanelInitialSelections {
/// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. /// CUPS `PageSize` token, e.g. `"A4"` / `"Custom.595x842"`. Written
/// to `PMPrintSettings` **and** `PMPageFormat` (#186 E1).
var paperSize: String? var paperSize: String?
/// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`. /// The queue's detected quality enumeration key, e.g. `EPIJ_Qual`.
var qualityKey: String? var qualityKey: String?
/// The selected quality token. /// The selected quality token.
var quality: String? var quality: String?
var mediaType: String? // #186 consumes /// The selected media token — written to the queue's detected
var orientation: String? // #186 consumes /// vendor key (`CNIJMediaType`/`EPIJ_Medi`/…) (#186).
var mediaType: String?
/// `"portrait"`/`"landscape"` → `orientation-requested` 3|4 (#186).
var orientation: String?
} }
/// Preferences → native `NSPrintPanel` bound to the selected CUPS /// Preferences → native `NSPrintPanel` bound to the selected CUPS
@@ -90,30 +90,37 @@ struct PrintPanelService {
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, #183). // ColorSync suppression ②–⑤ (locked write order,
applyInitialSelections(initialSelections, to: settings) // #183/#186). Paper is TWO writes (E1): the `PageSize`
// print-settings value drivers/capture read AND the
// `PMPageFormat` paper the panel's dropdown reflects.
applyInitialSelections(
initialSelections, to: settings, optionKeys: optionKeys)
if let paperToken = initialSelections.paperSize {
PMTicketBridge.applyPaper(
token: paperToken, printer: printer,
session: session, printInfo: printInfo)
}
boundViaPM = true boundViaPM = true
} else { } else {
// Fallback: NSPrinter by display name (docs/11 §binding). // Fallback: NSPrinter by display name (docs/11 §binding).
// Warn — the display name can resolve a *different* queue
// (#186 E2: diagnosable, not a proven defect).
AppLogger.shared.warn(
"Print panel: PM binding unavailable for '\(queue)' — "
+ "falling back to NSPrinter(displayName)")
guard let displayName, guard let displayName,
let nsPrinter = NSPrinter(name: displayName) let nsPrinter = NSPrinter(name: displayName)
else { else {
@@ -125,19 +132,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( // ④
@@ -166,8 +171,7 @@ 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
@@ -175,9 +179,8 @@ struct PrintPanelService {
let capturedOptions = cupsOptions ?? "" let capturedOptions = cupsOptions ?? ""
return PrintPropertiesResult( return PrintPropertiesResult(
selectedPrinter: boundViaPM selectedPrinter: boundViaPM
? Self.currentPrinterID( ? PMTicketBridge.currentPrinterID(
session: unsafeBitCast( session: PMTicketBridge.session(printInfo),
printInfo.pmPrintSession(), to: PMPrintSession.self),
fallback: queue) fallback: queue)
: nil, : nil,
options: PrintOptions( options: PrintOptions(
@@ -192,44 +195,40 @@ struct PrintPanelService {
cupsOptions: cupsOptions)) cupsOptions: cupsOptions))
} }
/// Initial-selection `PMPrintSettings` writes — paper size and /// Initial-selection `PMPrintSettings` writes — paper, quality,
/// quality only this phase; media type / orientation and the /// media type, orientation. All warn-only: a driver that ignores
/// `PMPageFormat`/`PMPaper` paper half are #186's contract. /// a key must not keep the panel from opening (R12 surfaces via
/// the capture echo instead).
private func applyInitialSelections( private func applyInitialSelections(
_ selections: PrintPanelInitialSelections, _ selections: PrintPanelInitialSelections,
to settings: PMPrintSettings to settings: PMPrintSettings,
optionKeys: Set<String>
) { ) {
if let paperSize = selections.paperSize { if let paperSize = selections.paperSize {
_ = PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, "PageSize" as CFString, paperSize, forKey: "PageSize", locked: false,
paperSize as CFString, false) in: settings, context: "Print panel")
} }
if let key = selections.qualityKey, let value = selections.quality { if let key = selections.qualityKey, let value = selections.quality {
_ = PMPrintSettingsSetValue( PMTicketBridge.setValue(
settings, key as CFString, value as CFString, false) value, forKey: key, locked: false,
in: settings, context: "Print panel")
}
// Media type via the queue's detected vendor key (#186).
if let mediaType = selections.mediaType,
let mediaKey = CupsParsers.detectMediaTypeKey(
optionKeys: optionKeys) {
PMTicketBridge.setValue(
mediaType, forKey: mediaKey, locked: false,
in: settings, context: "Print panel")
}
// Orientation — portrait=3, landscape=4 (CUPS IPP codes).
if let orientation = selections.orientation {
let code = orientation == "landscape" ? "4" : "3"
PMTicketBridge.setValue(
code, forKey: "orientation-requested", locked: false,
in: settings, context: "Print panel")
} }
} }
// 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
}
} }
@@ -147,8 +147,8 @@ final class PrintSessionViewModel: ObservableObject {
paperSize: selectedPaperSizeToken, paperSize: selectedPaperSizeToken,
qualityKey: printerCaps.qualityKey, qualityKey: printerCaps.qualityKey,
quality: selectedQuality, quality: selectedQuality,
mediaType: nil, mediaType: selectedMediaType,
orientation: nil) orientation: printOrientation)
Task { @MainActor in Task { @MainActor in
do { do {
guard let result = try await PrintPanelService() guard let result = try await PrintPanelService()
@@ -176,9 +176,10 @@ final class PrintSessionViewModel: ObservableObject {
if let media = result.options.mediaType { if let media = result.options.mediaType {
selectedMediaType = media selectedMediaType = media
} }
// Capture-return (#183): a dialog paper/quality change // Capture-return (#183/#186): a dialog paper/quality/
// updates the Stage 2 selections — never // orientation change updates the Stage 2 selections —
// `workflow.pageSize` (printtarg layout is sacred). // never `workflow.pageSize` (printtarg layout is
// sacred).
if let paper = result.options.paperSize, if let paper = result.options.paperSize,
let match = printerCaps.paperSizes let match = printerCaps.paperSizes
.first(where: { $0.name == paper }) { .first(where: { $0.name == paper }) {
@@ -187,6 +188,9 @@ final class PrintSessionViewModel: ObservableObject {
if let quality = result.options.quality { if let quality = result.options.quality {
selectedQuality = quality selectedQuality = quality
} }
if let orientation = result.options.orientation {
printOrientation = orientation
}
printNotice = Notice( printNotice = Notice(
kind: .info, kind: .info,
text: "Settings captured for \(selectedPrinter).", text: "Settings captured for \(selectedPrinter).",
@@ -232,6 +232,42 @@ final class CupsParserTests: XCTestCase {
fromOptionsString: "PageSize=A4")) fromOptionsString: "PageSize=A4"))
} }
// MARK: - #186 capture-return
/// A captured `k=v` string maps to all four `PrintOptions`
/// fields — `PageSize`, the detected quality key,
/// `orientation-requested`, and a vendor media key (#186).
func testCapturedStringMapsAllFields() {
let captured =
"PageSize=A4 EPIJ_Qual=305 orientation-requested=4 CNIJMediaType=Photo"
XCTAssertEqual(CupsParsers.extractOption(
named: "PageSize", fromOptionsString: captured), "A4")
XCTAssertEqual(CupsParsers.extractQuality(
fromOptionsString: captured), "305")
XCTAssertEqual(CupsParsers.extractOrientation(
fromOptionsString: captured), "landscape")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: captured), "Photo")
}
/// Vendor media keys beyond `MediaType`/`EPIJ_Medi` extract via
/// the detection roster — `CNIJMediaType`/`StpMediaType` included
/// (#186). `MediaType` still wins when present alongside them.
func testExtractMediaTypeRosterFallback() {
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus"), "PhotoPlus")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "StpMediaType=Glossy"), "Glossy")
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "EPIJ_Medi=Photo"), "Photo")
// `MediaType` keeps first precedence (docs/11 §tests).
XCTAssertEqual(CupsParsers.extractMediaType(
fromOptionsString: "CNIJMediaType=PhotoPlus MediaType=Plain"),
"Plain")
XCTAssertNil(CupsParsers.extractMediaType(
fromOptionsString: "PageSize=A4"))
}
func testDriverBypass() { func testDriverBypass() {
func pair(_ keys: Set<String>) -> String? { func pair(_ keys: Set<String>) -> String? {
CupsParsers.detectDriverColorBypass(optionKeys: keys) CupsParsers.detectDriverColorBypass(optionKeys: keys)
@@ -69,6 +69,24 @@ final class PrintPanelStubTests: XCTestCase {
} }
} }
/// #186 — a vendor media key in the captured string reaches
/// `options.mediaType` through the detection roster
/// (`CNIJMediaType`/`StpMediaType`, not only `MediaType`).
func testOkResultExtractsVendorMediaKey() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS":
"PageSize=A4 orientation-requested=4 CNIJMediaType=Photo",
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
XCTAssertEqual(result?.options.paperSize, "A4")
XCTAssertEqual(result?.options.orientation, "landscape")
XCTAssertEqual(result?.options.mediaType, "Photo")
}
}
func testOkDefaultsPrinter() throws { func testOkDefaultsPrinter() throws {
try withEnv([ try withEnv([
"ICCERY_UI_TESTING": "1", "ICCERY_UI_TESTING": "1",
@@ -244,4 +244,149 @@ final class PrintSessionViewModelTests: XCTestCase {
XCTAssertEqual(workflow.print.selectedPaperSize, 4) XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.selectedQuality, "305") XCTAssertEqual(workflow.print.selectedQuality, "305")
} }
/// #186 — the captured `orientation-requested`/media token apply
/// back to `printOrientation`/`selectedMediaType`, and a dialog
/// result never mutates `workflow.pageSize` (printtarg layout).
func testPanelResultAppliesBackOrientationAndMedia() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS",
"orientation-requested=4 MediaType=Glossy", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.printOrientation, "portrait")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertEqual(workflow.print.selectedMediaType, "Glossy")
XCTAssertEqual(workflow.pageSize, .a4)
}
/// #186 — a captured `PageSize` token with no capability match
/// leaves `selectedPaperSize` unchanged (never a guessed id).
func testPanelResultUnknownPaperLeavesSelection() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Bogus", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "Settings captured")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
}
/// #186 — a stub result pointing at another queue in `printers`
/// switches `selectedPrinter` and reloads its capabilities.
func testPanelResultSwitchesToKnownQueue() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_OPTIONS", "PageSize=Letter", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Other_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_OPTIONS")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [
Printer(name: "Mock_Q", isDefault: true),
Printer(name: "Other_Q"),
]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Other_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Other_Q")
// Caps reloaded for the new queue: paper re-seeded, then the
// captured PageSize applied back onto the new caps.
XCTAssertEqual(workflow.print.selectedPaperSize, 4)
XCTAssertEqual(workflow.print.capturedCupsOptions["Other_Q"],
"PageSize=Letter")
}
/// #186 — a stub result naming a queue absent from `printers`
/// leaves the selection on the opened queue.
func testPanelResultGhostQueueIgnored() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "ok", 1)
setenv("ICCERY_TEST_PANEL_PRINTER", "Ghost_Q", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
unsetenv("ICCERY_TEST_PANEL_PRINTER")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
workflow.print.printers = [Printer(name: "Mock_Q", isDefault: true)]
await loadCaps(workflow.print)
workflow.print.openPrinterPreferences()
await waitForNotice(
workflow.print, containing: "Settings captured for Mock_Q")
XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Q")
}
/// #186 — cancel returns `nil`: info notice, no field changes.
func testPanelCancelLeavesSelections() async throws {
setenv("ICCERY_UI_TESTING", "1", 1)
setenv("ICCERY_TEST_PRINT_PANEL", "cancel", 1)
defer {
unsetenv("ICCERY_UI_TESTING")
unsetenv("ICCERY_TEST_PRINT_PANEL")
}
let workflow = makeWorkflow()
workflow.pageSize = .a4
await loadCaps(workflow.print)
workflow.print.printOrientation = "landscape"
workflow.print.openPrinterPreferences()
await waitForNotice(workflow.print, containing: "cancelled")
XCTAssertEqual(workflow.print.selectedPaperSize, 3)
XCTAssertEqual(workflow.print.selectedQuality, "303")
XCTAssertEqual(workflow.print.selectedMediaType, "Stationery")
XCTAssertEqual(workflow.print.printOrientation, "landscape")
XCTAssertTrue(workflow.print.capturedCupsOptions.isEmpty)
}
/// Poll until the panel task posts a notice whose text contains
/// `fragment` (the Task-completion signal for `nil` results too).
private func waitForNotice(
_ vm: PrintSessionViewModel,
containing fragment: String,
timeout: TimeInterval = 10
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if let text = vm.printNotice?.text, text.contains(fragment) {
return
}
try? await Task.sleep(nanoseconds: 100_000_000)
}
XCTFail("Timed out waiting for notice containing '\(fragment)'")
}
} }
@@ -188,6 +188,50 @@ final class Milestone11PrintSettingsUITests: XCTestCase {
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv) XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
} }
/// #186 — the stubbed panel result's `orientation-requested=` /
/// `MediaType=` apply back to the Stage 2 selections and reach the
/// `lp` argv through the captured `cupsOptions` replay.
func testPanelResultAppliesBackOrientationAndMedia() throws {
app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok"
app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] =
"PageSize=Letter EPIJ_Qual=305 orientation-requested=4 MediaType=PhotographicGlossy"
launchAppWithDefaults()
reachPrintPanel()
_ = waitFor("printerStatusBadge")
element("btnPrinterProperties").click()
let notice = element("printNotificationText")
XCTAssertTrue(notice.waitForExistence(timeout: 10))
XCTAssertTrue((notice.value as? String ?? "")
.contains("Settings captured"))
// `printerMediaTypeSelect` is the group's id — the popup is a
// descendant (stacked identifiers collapse to the container).
// The popup's AX title lags the binding — poll for the
// apply-back value.
let mediaPopup = element("printerMediaTypeSelect")
.descendants(matching: .popUpButton).firstMatch
XCTAssertTrue(mediaPopup.waitForExistence(timeout: 5))
var mediaSelection = ""
let deadline = Date().addingTimeInterval(10)
while Date() < deadline, mediaSelection != "PhotographicGlossy" {
mediaSelection = [
mediaPopup.title, mediaPopup.label,
mediaPopup.value as? String ?? "",
].first { !$0.isEmpty } ?? ""
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
XCTAssertEqual(mediaSelection, "PhotographicGlossy")
XCTAssertEqual(selection(of: "printerPaperSizeSelect"), "Letter")
app.buttons["btnPrintAll"].click()
let argv = waitForLpLine()
XCTAssertTrue(argv.contains("orientation-requested=4"), argv)
XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv)
XCTAssertTrue(argv.contains("PageSize=Letter"), argv)
XCTAssertTrue(argv.contains("EPIJ_Qual=305"), argv)
}
private func launchAppWithDefaults() { private func launchAppWithDefaults() {
app.launch() app.launch()
app.activate() app.activate()
File diff suppressed because one or more lines are too long
+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.