Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4281d07754 | ||
|
|
73db1c8c25 |
@@ -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`;
|
||||
|
||||
@@ -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, ¤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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user