Compare commits

...
Author SHA1 Message Date
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 4281d07754 Native NSPrintPanel bound to selected queue (#13)
- PrintPanelService: PMPrinterCreateFromPrinterID(CUPS queue id) ->
  PMSessionSetCurrentPMPrinter -> session default settings/page format.
- NSPrinter(name: displayName) fallback when PM binding fails; display
  name resolved via lpoptions printer-info (docs/11 #188).
- Default button 'Use Settings' (capture, not print); cancel -> nil.
- PMPrinter released on every path via defer.
- UITestHooks: printPanelResult stub (ICCERY_TEST_PRINT_PANEL=ok/cancel
  + PANEL_OPTIONS/PANEL_PRINTER), printPanelStubbed gate, cupsBinaryDir
  + lpArgvOutURL env seams; CupsService injected via AppEnvironment.

Never opens System Settings / CUPS web UI / NSWorkspace.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-09 00:14:11 +01:00
gronod 73db1c8c25 Merge pull request 'Printer enumeration & capabilities (#12)' (#42) from feat/12-printer-enum into milestone/m3-printing 2026-09-09 00:08:57 +01:00
4 changed files with 280 additions and 1 deletions
@@ -211,6 +211,16 @@ public enum CupsParsers {
mediaTypeKeys.first { optionKeys.contains($0) }
}
/// Media type from a captured `key=value key=value` options string.
/// Prefers `MediaType`, then `EPIJ_Medi` (docs/11 §tests).
public static func extractMediaType(fromOptionsString options: String) -> String? {
let pairs = lpoptions(options)
if let v = pairs.first(where: { $0.key == "MediaType" })?.value {
return v
}
return pairs.first(where: { $0.key == "EPIJ_Medi" })?.value
}
/// Driver "no colour adjustment" key=value for `lpoptions -l` keys
/// (docs/11 layer ): Canon `CNIJIntent2=4` else `CNIJIntent=4`;
/// Epson `EPIJ_CCor=0` when the key exists else `EPIJ_CMat=3`;
+51 -1
View File
@@ -11,6 +11,7 @@ struct AppEnvironment: Sendable {
let settingsStore: SettingsStore
let presetStore: PresetStore
let runner: ArgyllRunner
let cupsService: CupsService
static func live(
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -18,10 +19,14 @@ struct AppEnvironment: Sendable {
let settingsStore = SettingsStore()
var overrideDir = settingsStore.load().argyllBinaryDir
.map { URL(fileURLWithPath: $0) }
var cupsDir = URL(fileURLWithPath: "/usr/bin")
#if DEBUG
if let dir = environment["ICCERY_ARGYLL_BINARY_DIR"], !dir.isEmpty {
overrideDir = URL(fileURLWithPath: dir)
}
if let dir = environment["ICCERY_CUPS_BIN_DIR"], !dir.isEmpty {
cupsDir = URL(fileURLWithPath: dir)
}
#endif
return AppEnvironment(
stateStore: WizardStateStore(),
@@ -30,7 +35,10 @@ struct AppEnvironment: Sendable {
runner: ArgyllRunner(
processManager: .shared,
binaryResolver: BinaryResolver(overrideDir: overrideDir)
)
),
cupsService: CupsService(
processManager: .shared,
binaryDir: cupsDir)
)
}
}
@@ -63,6 +71,48 @@ enum UITestHooks {
/// Preset export destination.
static var presetExportURL: URL? { url("ICCERY_TEST_PRESET_EXPORT") }
// MARK: - Print panel / CUPS stubs (issue 13/17)
/// Directory of mock `lp`/`lpstat`/`lpoptions` fixture scripts
/// `CupsService.binaryDir` under UI tests.
static var cupsBinaryDir: URL? { url("ICCERY_CUPS_BIN_DIR") }
/// Path the mock `lp` script appends its argv to, for assertions.
static var lpArgvOutURL: URL? { url("ICCERY_TEST_LP_ARGV") }
/// Whether the `NSPrintPanel` should be stubbed under UI testing
/// separate from the stub's *result* so "cancel" (`nil`) does not
/// fall through to the real modal.
static var printPanelStubbed: Bool { isEnabled }
/// Canned `NSPrintPanel` outcome XCUITest cannot drive the
/// system modal. `ICCERY_TEST_PRINT_PANEL`:
/// - `cancel` (or unset while testing) user cancelled `nil`
/// - `ok` `PrintPropertiesResult` with
/// `ICCERY_TEST_PANEL_OPTIONS` (captured `k=v` string) and
/// `ICCERY_TEST_PANEL_PRINTER` (selected queue; default = the
/// queue the panel was opened for).
static func printPanelResult(forQueue queue: String) -> PrintPropertiesResult? {
switch env["ICCERY_TEST_PRINT_PANEL"] {
case "ok":
let options = env["ICCERY_TEST_PANEL_OPTIONS"].flatMap {
$0.isEmpty ? nil : $0
}
return PrintPropertiesResult(
selectedPrinter: env["ICCERY_TEST_PANEL_PRINTER"].flatMap {
$0.isEmpty ? nil : $0
} ?? queue,
options: PrintOptions(
mediaType: options.flatMap {
CupsParsers.extractMediaType(fromOptionsString: $0)
},
ppdUncorrectedPassthrough: true,
cupsOptions: options))
default:
return nil
}
}
private static func url(_ key: String) -> URL? {
guard let raw = env[key], !raw.isEmpty else { return nil }
return URL(fileURLWithPath: raw)
@@ -0,0 +1,148 @@
import AppKit
import ApplicationServices
import ICCeryCore
/// Errors raised while preparing the bound print panel.
enum PrintPanelError: LocalizedError {
case sessionBindingFailed(OSStatus)
case noPrinterFound(String)
var errorDescription: String? {
switch self {
case .sessionBindingFailed(let status):
return "Could not bind the print session to the queue (OSStatus \(status))."
case .noPrinterFound(let name):
return "No printer found for '\(name)'."
}
}
}
/// Preferences native `NSPrintPanel` bound to the selected CUPS
/// queue (issue 13, docs/11).
///
/// This is a **settings-capture** dialog the default button is
/// "Use Settings", never "Print". It is never System Settings, the
/// CUPS web UI, or an `NSWorkspace` open (#188). Cancel returns `nil`
/// and is not an error.
///
/// Binding: `PMPrinterCreateFromPrinterID(CUPS queue id)`
/// `PMSessionSetCurrentPMPrinter` session default settings/page
/// format. `PMPrinter` is `PMRelease`d on every path. Fallback when PM
/// binding fails: `NSPrinter(name: displayName)` (the `printer-info`
/// label) `printInfo.printer`.
@MainActor
struct PrintPanelService {
/// Resolves the display name (off-panel `lpoptions` fetch) and runs
/// the modal panel. Returns `nil` when the user cancels.
func showProperties(
queue: String,
displayName: String?,
cupsService: CupsService
) async throws -> PrintPropertiesResult? {
#if DEBUG
if UITestHooks.printPanelStubbed {
return UITestHooks.printPanelResult(forQueue: queue)
}
#endif
let display = displayName
?? (try? await cupsService.displayName(for: queue))
return try runNativePanel(queue: queue, displayName: display)
}
// MARK: - Panel
private func runNativePanel(
queue: String,
displayName: String?
) throws -> PrintPropertiesResult? {
let printInfo = NSPrintInfo()
var pmPrinter: PMPrinter?
var boundViaPM = false
// Bind the session to the selected CUPS queue (docs/11).
if let printer = PMPrinterCreateFromPrinterID(queue as CFString) {
pmPrinter = printer
let session = unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self)
let settings = unsafeBitCast(
printInfo.pmPrintSettings(), to: PMPrintSettings.self)
let pageFormat = unsafeBitCast(
printInfo.pmPageFormat(), to: PMPageFormat.self)
let status = PMSessionSetCurrentPMPrinter(session, printer)
if status != 0 {
PMRelease(pmObject(printer))
throw PrintPanelError.sessionBindingFailed(status)
}
// Warn-only: defaults keep the panel consistent with the
// queue but are not fatal when they fail.
_ = PMSessionDefaultPrintSettings(session, settings)
_ = PMSessionDefaultPageFormat(session, pageFormat)
boundViaPM = true
} else {
// Fallback: NSPrinter by display name (docs/11 §binding).
guard let displayName,
let nsPrinter = NSPrinter(name: displayName)
else {
throw PrintPanelError.noPrinterFound(
displayName ?? queue)
}
printInfo.printer = nsPrinter
printInfo.setUpPrintOperationDefaultValues()
}
defer {
if let printer = pmPrinter {
PMRelease(pmObject(printer))
}
}
// Colour-suppression layers land in issue 14 here, between
// binding and runModal.
let panel = NSPrintPanel()
panel.options = [
.showsCopies, .showsPageRange, .showsPaperSize,
.showsOrientation, .showsScaling, .showsPrintSelection,
.showsPageSetupAccessory, .showsPreview,
]
panel.defaultButtonTitle = "Use Settings"
let response = panel.runModal(with: printInfo)
// Layer capture (PMPrintSettingsToOptions) lands in issue 14.
guard response == NSApplication.ModalResponse.OK.rawValue else {
return nil
}
return PrintPropertiesResult(
selectedPrinter: boundViaPM
? Self.currentPrinterID(
session: unsafeBitCast(
printInfo.pmPrintSession(), to: PMPrintSession.self),
fallback: queue)
: nil,
options: PrintOptions(ppdUncorrectedPassthrough: true))
}
// 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
}
}
@@ -0,0 +1,71 @@
import Testing
import Foundation
@testable import ICCeryCore
@testable import ICCery
/// Issue 13 panel outcome mapping (cancel nil, ok result).
/// The real `NSPrintPanel` is never run in tests; these exercise the
/// `UITestHooks` seam the UI tests rely on.
@Suite("PrintPanelStub")
struct PrintPanelStubTests {
private func withEnv(
_ vars: [String: String?],
_ body: () throws -> Void
) rethrows {
var saved: [String: String?] = [:]
for key in vars.keys {
saved[key] = ProcessInfo.processInfo.environment[key]
}
for (key, value) in vars {
if let value { setenv(key, value, 1) } else { unsetenv(key) }
}
defer {
for (key, value) in saved {
if let value { setenv(key, value, 1) } else { unsetenv(key) }
}
}
try body()
}
@Test("Cancel returns nil — not an error")
func cancelIsNil() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "cancel",
]) {
#expect(UITestHooks.printPanelStubbed)
#expect(UITestHooks.printPanelResult(forQueue: "q") == nil)
}
}
@Test("OK returns captured options + selected printer")
func okResult() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": "MediaType=Photo InputSlot=Rear",
"ICCERY_TEST_PANEL_PRINTER": "Other_Queue",
]) {
let result = UITestHooks.printPanelResult(forQueue: "q")
#expect(result?.selectedPrinter == "Other_Queue")
#expect(result?.options.cupsOptions == "MediaType=Photo InputSlot=Rear")
#expect(result?.options.mediaType == "Photo")
#expect(result?.options.ppdUncorrectedPassthrough == true)
}
}
@Test("OK defaults selected printer to the opened queue")
func okDefaultsPrinter() throws {
try withEnv([
"ICCERY_UI_TESTING": "1",
"ICCERY_TEST_PRINT_PANEL": "ok",
"ICCERY_TEST_PANEL_OPTIONS": nil,
"ICCERY_TEST_PANEL_PRINTER": nil,
]) {
let result = UITestHooks.printPanelResult(forQueue: "My_Queue")
#expect(result?.selectedPrinter == "My_Queue")
#expect(result?.options.cupsOptions == nil)
}
}
}