From 4281d0775498f4878b0158c91e17756da3c8b3cc Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:14:11 +0100 Subject: [PATCH] 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> --- .../ICCeryCore/Print/CupsParsers.swift | 10 ++ Sources/ICCery/AppEnvironment.swift | 52 +++++- Sources/ICCery/Print/PrintPanelService.swift | 148 ++++++++++++++++++ Tests/ICCeryCoreTests/PrintPanelTests.swift | 71 +++++++++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 Sources/ICCery/Print/PrintPanelService.swift create mode 100644 Tests/ICCeryCoreTests/PrintPanelTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 2faf6d4..eafddf4 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -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`; diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index 00b796a..5d7c0e9 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -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) diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift new file mode 100644 index 0000000..ee9af07 --- /dev/null +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -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, ¤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 + } +} diff --git a/Tests/ICCeryCoreTests/PrintPanelTests.swift b/Tests/ICCeryCoreTests/PrintPanelTests.swift new file mode 100644 index 0000000..3bdc0f2 --- /dev/null +++ b/Tests/ICCeryCoreTests/PrintPanelTests.swift @@ -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) + } + } +} -- 2.39.5