From 7c1303ac111257bc37525d6d09b14599dcd02b87 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:08:37 +0100 Subject: [PATCH 1/6] CUPS printer enumeration & capabilities (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Printer/PrinterStatus/PrinterTray/PrinterPaperSize/PrinterMediaType/ PrinterCapabilities/PrintOptions/PrintPropertiesResult models (docs/10 shared print types). - CupsParsers: lpstat -e/-p/-d, lpoptions -p (quoted printer-info), lpoptions -l Key/Label listings, PPD *Key id/Human: enrichment with locale-qualified key support. - detectMediaTypeKey (CNIJMediaType > EPIJ_Medi > StpMediaType > MediaType) and detectDriverColorBypass (Canon Intent2/Intent, Epson CCor/CMat, Gutenprint, generic, EpsonColorMode) — docs/11 layer 4. - CupsService over ProcessManager.runCaptured with injectable binaryDir/ppdDir; empty lpstat output is success. - ProcessID entries for lpstat/lpoptions/lp. Core tests: 8 parser suites on recorded Epson/Canon fixtures. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Print/CupsParsers.swift | 243 ++++++++++++++++++ .../ICCeryCore/Print/CupsService.swift | 167 ++++++++++++ .../ICCeryCore/Print/PrinterModels.swift | 136 ++++++++++ .../ICCeryCore/Process/ProcessID.swift | 6 + Tests/ICCeryCoreTests/CupsParserTests.swift | 149 +++++++++++ 5 files changed, 701 insertions(+) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift create mode 100644 Tests/ICCeryCoreTests/CupsParserTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift new file mode 100644 index 0000000..2faf6d4 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -0,0 +1,243 @@ +import Foundation + +/// One `lpoptions -l` line: `Key/Human Label: *default choice choice`. +public struct CupsOptionListing: Equatable, Sendable { + /// Machine key before `/`, e.g. `InputSlot` or `CNIJMediaType`. + public var key: String + /// Human label after `/`, e.g. `Media Source`. + public var label: String + /// All choices, `*` stripped. + public var choices: [String] + /// The `*`-prefixed default choice, if any. + public var defaultChoice: String? + + public init(key: String, label: String, choices: [String], defaultChoice: String?) { + self.key = key + self.label = label + self.choices = choices + self.defaultChoice = defaultChoice + } +} + +/// Pure parsers for `lpstat` / `lpoptions` / PPD text (issue 12, +/// docs/10–11). Recorded fixtures drive the tests — no live CUPS. +public enum CupsParsers { + + // MARK: - lpstat + + /// `lpstat -e` — one CUPS destination name per line. + public static func lpstatDestinations(_ output: String) -> [String] { + output.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// `lpstat -p` — `printer NAME is idle. enabled since …`, + /// `printer NAME now printing NAME-1. …`, `printer NAME disabled + /// since …` → queue → status. + public static func lpstatStatuses(_ output: String) -> [String: PrinterStatus] { + var result: [String: PrinterStatus] = [:] + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard text.hasPrefix("printer ") else { continue } + let rest = text.dropFirst("printer ".count) + guard let sep = rest.firstIndex(of: " ") else { continue } + let name = String(rest[.. String? { + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard let colon = text.firstIndex(of: ":") else { continue } + let name = text[text.index(after: colon)...] + .trimmingCharacters(in: .whitespaces) + if text.lowercased().hasPrefix("system default destination"), + !name.isEmpty { + return name + } + } + return nil + } + + // MARK: - lpoptions -p + + /// `lpoptions -p` — `key=value` pairs, values may be + /// single-quoted (`printer-info='EPSON XP-55 Series'`); bare + /// flags (`printer-location`) parse as present-with-empty-value. + public static func lpoptions(_ output: String) -> [(key: String, value: String)] { + var pairs: [(String, String)] = [] + var index = output.startIndex + while index < output.endIndex { + while index < output.endIndex && output[index].isWhitespace { + index = output.index(after: index) + } + guard index < output.endIndex else { break } + let tokenStart = index + while index < output.endIndex && output[index] != "=" && !output[index].isWhitespace { + index = output.index(after: index) + } + let key = String(output[tokenStart.. String? { + guard let value = lpoptions(output) + .first(where: { $0.key == "printer-info" })?.value, + !value.isEmpty + else { return nil } + return value + } + + // MARK: - lpoptions -l + + /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. + /// A missing `/` label reuses the key. + public static func lpoptionsList(_ output: String) -> [CupsOptionListing] { + output.split(separator: "\n").compactMap { raw in + let line = raw.trimmingCharacters(in: .whitespaces) + guard let colon = line.firstIndex(of: ":") else { return nil } + let head = String(line[.. 1 + ? headParts[1].trimmingCharacters(in: .whitespaces) + : key + var choices: [String] = [] + var defaultChoice: String? + for token in body.split(separator: " ") { + if token.hasPrefix("*") { + let value = String(token.dropFirst()) + defaultChoice = value + choices.append(value) + } else { + choices.append(String(token)) + } + } + return CupsOptionListing( + key: key, label: label, + choices: choices, defaultChoice: defaultChoice) + } + } + + // MARK: - PPD enrichment + + /// PPD `* /:` lines → `id → label` map. + /// Language-qualified forms (`*en_US. id/Label:`) also match. + public static func ppdChoiceLabels(_ ppd: String, key: String) -> [String: String] { + var map: [String: String] = [:] + for rawLine in ppd.split(separator: "\n") { + var line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("*"), !line.hasPrefix("**") else { continue } + line = String(line.dropFirst()) + // Optional locale qualifier: `en_US.InputSlot` → `InputSlot`. + // Only strip when the part before the first `.` looks like + // a locale (short `xx`/`xx_YY`); real keys containing dots + // are left alone. + if let dot = line.firstIndex(of: ".") { + let prefix = line[../` — human label after the last `/`. + guard let slash = rest.firstIndex(of: "/") else { continue } + let id = String(rest[..=`. + public static let mediaTypeKeys = [ + "CNIJMediaType", "EPIJ_Medi", "StpMediaType", "MediaType" + ] + + public static func detectMediaTypeKey(optionKeys: Set) -> String? { + mediaTypeKeys.first { optionKeys.contains($0) } + } + + /// 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`; + /// Gutenprint `StpColorCorrection=Uncorrected`; generic + /// `ColorCorrection=Uncorrected`; `EpsonColorMode=Off`. + public static func detectDriverColorBypass( + optionKeys: Set + ) -> (key: String, value: String)? { + if optionKeys.contains("CNIJIntent2") { return ("CNIJIntent2", "4") } + if optionKeys.contains("CNIJIntent") { return ("CNIJIntent", "4") } + if optionKeys.contains("EPIJ_CCor") { return ("EPIJ_CCor", "0") } + if optionKeys.contains("EPIJ_CMat") { return ("EPIJ_CMat", "3") } + if optionKeys.contains("StpColorCorrection") { + return ("StpColorCorrection", "Uncorrected") + } + if optionKeys.contains("ColorCorrection") { + return ("ColorCorrection", "Uncorrected") + } + if optionKeys.contains("EpsonColorMode") { return ("EpsonColorMode", "Off") } + return nil + } + + /// The key=value pairs of colour-bypass keys — used to detect + /// whether captured options already carry a bypass. + public static let bypassKeys: Set = [ + "CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor", + "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", + "EpsonColorMode", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift new file mode 100644 index 0000000..f21d99f --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -0,0 +1,167 @@ +import Foundation + +/// Errors from CUPS tool invocations. +public enum CupsError: LocalizedError, Equatable { + case toolFailed(tool: String, code: Int32, stderr: String) + case tiffMissing(String) + + public var errorDescription: String? { + switch self { + case .toolFailed(let tool, let code, let stderr): + let detail = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return detail.isEmpty + ? "\(tool) failed with exit code \(code)" + : "\(tool) failed (\(code)): \(detail)" + case .tiffMissing(let path): + return "Target TIFF does not exist: \(path)" + } + } +} + +/// CUPS command surface (issue 12): enumerates queues and reads +/// per-queue capabilities via `/usr/bin/lpstat` and +/// `/usr/bin/lpoptions`. Spawning goes through +/// `ProcessManager.runCaptured` so spawns are logged, get killAll +/// coverage, and share the dup-id discipline; `binaryDir`/`ppdDir` are +/// injectable so tests use fixture scripts and never touch real CUPS. +public struct CupsService: Sendable { + public let processManager: ProcessManager + /// Directory containing `lpstat`/`lpoptions`/`lp` — `/usr/bin` in + /// production, a fixture dir under test. + public let binaryDir: URL + /// `/etc/cups/ppd` in production. + public let ppdDir: URL + + public init( + processManager: ProcessManager = .shared, + binaryDir: URL = URL(fileURLWithPath: "/usr/bin"), + ppdDir: URL = URL(fileURLWithPath: "/etc/cups/ppd") + ) { + self.processManager = processManager + self.binaryDir = binaryDir + self.ppdDir = ppdDir + } + + // MARK: - Enumeration (lpstat -e/-p/-d) + + /// All CUPS destinations with status and default flag. An empty + /// list is a valid result, not an error. + public func listPrinters() async throws -> [Printer] { + // lpstat exits non-zero when no destinations exist — an empty + // queue list is a valid result, not a failure (issue 12). + let destinationsOut = try await run( + "lpstat", ["-e"], id: ProcessID.lpstat("e"), tolerateFailure: true) + let statusOut = try await run( + "lpstat", ["-p"], id: ProcessID.lpstat("p"), tolerateFailure: true) + let defaultOut = try await run( + "lpstat", ["-d"], id: ProcessID.lpstat("d"), tolerateFailure: true) + + let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) + let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) + let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) + + var printers: [Printer] = [] + for name in names { + let displayName = try? await displayName(for: name) + printers.append(Printer( + name: name, + status: statuses[name] ?? .unknown, + isDefault: name == defaultName, + displayName: displayName + )) + } + return printers + } + + /// `lpoptions -p ` → `printer-info` (the NSPrinter fallback + /// display name, docs/11 §binding). + public func displayName(for queue: String) async throws -> String? { + let result = try await run( + "lpoptions", ["-p", queue], id: ProcessID.lpoptions(queue)) + return CupsParsers.lpoptionsDisplayName(result.stdout) + } + + // MARK: - Capabilities (lpoptions -l + PPD) + + /// Raw `Key/Label: choices` listings for a queue — also the input + /// to media-key and colour-bypass detection (docs/11 layer ④). + public func optionListings(for queue: String) async throws -> [CupsOptionListing] { + let result = try await run( + "lpoptions", ["-p", queue, "-l"], id: ProcessID.lpoptions("\(queue)-l")) + return CupsParsers.lpoptionsList(result.stdout) + } + + /// Trays / paper sizes / media types for a queue, with PPD + /// `*Key id/Human:` enrichment when the queue's PPD is readable. + public func capabilities(for queue: String) async throws -> PrinterCapabilities { + let listings = try await optionListings(for: queue) + return capabilities(from: listings, ppd: loadPPD(for: queue)) + } + + /// Pure mapping — extracted so fixture tests need no process. + public func capabilities( + from listings: [CupsOptionListing], + ppd: String? + ) -> PrinterCapabilities { + var trays: [PrinterTray] = [] + var sizes: [PrinterPaperSize] = [] + var media: [PrinterMediaType] = [] + + for listing in listings { + switch listing.key { + case "InputSlot", "MediaSource": + trays = listing.choices.enumerated().map { + PrinterTray(id: $0.offset + 1, name: $0.element) + } + case "PageSize", "MediaSize": + sizes = listing.choices.enumerated().map { + PrinterPaperSize(id: $0.offset + 1, name: $0.element) + } + case let key where CupsParsers.mediaTypeKeys.contains(key): + guard media.isEmpty else { continue } + let labels = ppd.map { + CupsParsers.ppdChoiceLabels($0, key: key) + } ?? [:] + media = listing.choices.map { + PrinterMediaType(id: $0, name: labels[$0] ?? $0) + } + default: + continue + } + } + return PrinterCapabilities( + trays: trays, paperSizes: sizes, mediaTypes: media) + } + + /// The set of option keys a queue advertises — input to + /// `detectDriverColorBypass` / `detectMediaTypeKey`. + public func optionKeys(for queue: String) async throws -> Set { + Set(try await optionListings(for: queue).map(\.key)) + } + + // MARK: - PPD + + private func loadPPD(for queue: String) -> String? { + let url = ppdDir.appendingPathComponent("\(queue).ppd") + return try? String(contentsOf: url, encoding: .utf8) + } + + // MARK: - Spawn + + @discardableResult + func run( + _ tool: String, + _ arguments: [String], + id: String, + tolerateFailure: Bool = false + ) async throws -> CapturedResult { + let binary = binaryDir.appendingPathComponent(tool) + let result = try await processManager.runCaptured( + id: id, binary: binary, arguments: arguments) + if result.exitCode != 0, !tolerateFailure { + throw CupsError.toolFailed( + tool: tool, code: result.exitCode, stderr: result.stderr) + } + return result + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift new file mode 100644 index 0000000..2b95f8d --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Queue status reported by `lpstat -p` (docs/10 §Printer). +public enum PrinterStatus: String, Codable, Sendable, CaseIterable { + case idle = "Idle" + case printing = "Printing" + case stopped = "Stopped" + case unknown = "Unknown" +} + +/// A CUPS destination. `name` is the queue id sent back to every +/// subsequent print command; `displayName` is the human label from +/// `printer-info` (used as the `NSPrinter` fallback when PM binding +/// fails — #188). +public struct Printer: Codable, Equatable, Sendable { + public var name: String + public var status: PrinterStatus + public var isDefault: Bool + public var displayName: String? + + public init( + name: String, + status: PrinterStatus = .unknown, + isDefault: Bool = false, + displayName: String? = nil + ) { + self.name = name + self.status = status + self.isDefault = isDefault + self.displayName = displayName + } +} + +/// Paper source. `id` is the 1-based index of the `InputSlot` / +/// `MediaSource` choice (not a PPD code) — docs/10. +public struct PrinterTray: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media size from `PageSize` / `MediaSize` choices (1-based index). +public struct PrinterPaperSize: Codable, Equatable, Sendable { + public var id: Int + public var name: String + + public init(id: Int, name: String) { + self.id = id + self.name = name + } +} + +/// Media type: `id` is the PPD machine token, `name` the human label +/// after `/` when a readable PPD enriches it (docs/10 §PPD id/Human). +public struct PrinterMediaType: Codable, Equatable, Sendable { + public var id: String + public var name: String + + public init(id: String, name: String) { + self.id = id + self.name = name + } +} + +public struct PrinterCapabilities: Codable, Equatable, Sendable { + public var trays: [PrinterTray] + public var paperSizes: [PrinterPaperSize] + public var mediaTypes: [PrinterMediaType] + /// Always `true` on macOS (spec parity — CUPS honours + /// `orientation-requested`). + public var supportsOrientation: Bool + + public init( + trays: [PrinterTray] = [], + paperSizes: [PrinterPaperSize] = [], + mediaTypes: [PrinterMediaType] = [], + supportsOrientation: Bool = true + ) { + self.trays = trays + self.paperSizes = paperSizes + self.mediaTypes = mediaTypes + self.supportsOrientation = supportsOrientation + } +} + +/// Options carried into `lp` (docs/10 §PrintOptions). On macOS +/// `paperSource` is ignored unless already present inside captured +/// `cupsOptions`; `ppdUncorrectedPassthrough` is stored (the panel sets +/// it on OK) but never gates the argv — macOS always bypasses driver +/// colour management. +public struct PrintOptions: Codable, Equatable, Sendable { + public var paperSource: Int? + /// `"portrait"` / `"landscape"` → `orientation-requested=3|4`. + public var orientation: String? + /// printtarg layout page size → `PageSize=` (skipped if captured). + public var paperSize: String? + public var mediaType: String? + public var ppdUncorrectedPassthrough: Bool? + /// Space-separated `key=value` captured from + /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥). + public var cupsOptions: String? + + public init( + paperSource: Int? = nil, + orientation: String? = nil, + paperSize: String? = nil, + mediaType: String? = nil, + ppdUncorrectedPassthrough: Bool? = nil, + cupsOptions: String? = nil + ) { + self.paperSource = paperSource + self.orientation = orientation + self.paperSize = paperSize + self.mediaType = mediaType + self.ppdUncorrectedPassthrough = ppdUncorrectedPassthrough + self.cupsOptions = cupsOptions + } +} + +/// Returned by the printer-properties panel (docs/10 §PrintPropertiesResult). +/// `nil` from the service means the user cancelled — never an error. +public struct PrintPropertiesResult: Codable, Equatable, Sendable { + /// CUPS printer id the panel ended on (`PMPrinterGetID`), or `nil` + /// when the `NSPrinter` fallback ran. + public var selectedPrinter: String? + public var options: PrintOptions + + public init(selectedPrinter: String?, options: PrintOptions) { + self.selectedPrinter = selectedPrinter + self.options = options + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift index 8377a51..80d6242 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessID.swift @@ -14,4 +14,10 @@ public enum ProcessID { public static func iccgamut(stem: String) -> String { "iccgamut_\(stem)" } public static func printcal(_ stem: String) -> String { "printcal_\(stem)" } public static func applycal(_ stem: String) -> String { "applycal_\(stem)" } + + /// CUPS system tools (`/usr/bin/…`) — captured one-shots, not + /// streaming Argyll children. + public static func lpstat(_ mode: String) -> String { "lpstat_\(mode)" } + public static func lpoptions(_ queue: String) -> String { "lpoptions_\(queue)" } + public static func lp(_ queue: String, page: Int) -> String { "lp_\(queue)_\(page)" } } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift new file mode 100644 index 0000000..f2d42fc --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -0,0 +1,149 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Issue 12 — CUPS enumeration parsers on recorded fixtures +/// (docs/10–11). No live `lpstat`/`lpoptions` is spawned here. +@Suite("CupsParsers") +struct CupsParsersTests { + + // Recorded on an Epson XP-55 + Canon Pro9500 host. + private let lpstatE = """ + Canon_Pro9500_II_series_XPS + Epson_XP_55_LPD + EPSON_XP_55_Series + """ + + private let lpstatP = """ + printer Canon_Pro9500_II_series_XPS is idle. enabled since Mon Sep 7 22:51:30 2026 + printer Epson_XP_55_LPD now printing Epson_XP_55_LPD-42. enabled since Mon Sep 7 21:50:25 2026 + printer EPSON_XP_55_Series disabled since Tue Sep 8 09:00:00 2026 - + Paused + """ + + private let lpoptionsP = """ + device-uri=ipp://EPSON%20XP-55%20Series._ipp._tcp.local./ printer-info='EPSON XP-55 Series' printer-location printer-make-and-model='EPSON EPSON XP-55 Series' printer-type=16781340 + """ + + private let lpoptionsL = """ + PageSize/Media Size: 3.5x5 4x6 5x7 8x10 *A4 A5 B5 Letter Legal Custom.WIDTHxHEIGHT + InputSlot/Media Source: Auto *Main Photo Rear + MediaType/Media Type: *Stationery PhotographicHighGloss Photographic PhotographicMatte Envelope + ColorModel/Output Mode: *RGB Gray + Duplex/Duplex: *None DuplexNoTumble DuplexTumble + cupsPrintQuality/cupsPrintQuality: Draft *Normal High + """ + + @Test("lpstat -e: one destination per line; empty = success") + func destinations() { + #expect(CupsParsers.lpstatDestinations(lpstatE) == [ + "Canon_Pro9500_II_series_XPS", + "Epson_XP_55_LPD", + "EPSON_XP_55_Series", + ]) + #expect(CupsParsers.lpstatDestinations("") == []) + } + + @Test("lpstat -p: idle / now-printing / disabled statuses") + func statuses() { + let s = CupsParsers.lpstatStatuses(lpstatP) + #expect(s["Canon_Pro9500_II_series_XPS"] == .idle) + #expect(s["Epson_XP_55_LPD"] == .printing) + #expect(s["EPSON_XP_55_Series"] == .stopped) + } + + @Test("lpstat -d: default destination or none") + func defaultDestination() { + #expect(CupsParsers.lpstatDefault( + "system default destination: Canon_Pro9500_II_series_XPS\n") + == "Canon_Pro9500_II_series_XPS") + #expect(CupsParsers.lpstatDefault("no system default destination\n") == nil) + } + + @Test("lpoptions -p: quoted printer-info, bare flags ignored") + func displayName() { + #expect(CupsParsers.lpoptionsDisplayName(lpoptionsP) == "EPSON XP-55 Series") + #expect(CupsParsers.lpoptionsDisplayName("printer-type=42\n") == nil) + } + + @Test("lpoptions -l: key/label split, * marks the default") + func optionListings() { + let listings = CupsParsers.lpoptionsList(lpoptionsL) + #expect(listings.count == 6) + + let page = listings[0] + #expect(page.key == "PageSize") + #expect(page.label == "Media Size") + #expect(page.defaultChoice == "A4") + #expect(page.choices.contains("Custom.WIDTHxHEIGHT")) + #expect(!page.choices.contains("*A4")) + + let slot = listings[1] + #expect(slot.key == "InputSlot") + #expect(slot.choices == ["Auto", "Main", "Photo", "Rear"]) + #expect(slot.defaultChoice == "Main") + } + + @Test("capabilities: trays/sizes index 1-based, media uses detected key") + func capabilities() { + let service = CupsService() + let listings = CupsParsers.lpoptionsList(lpoptionsL) + let caps = service.capabilities(from: listings, ppd: nil) + + #expect(caps.trays == [ + PrinterTray(id: 1, name: "Auto"), + PrinterTray(id: 2, name: "Main"), + PrinterTray(id: 3, name: "Photo"), + PrinterTray(id: 4, name: "Rear"), + ]) + #expect(caps.paperSizes.first == PrinterPaperSize(id: 1, name: "3.5x5")) + #expect(caps.paperSizes.count == 10) + #expect(caps.mediaTypes.map(\.id) == [ + "Stationery", "PhotographicHighGloss", "Photographic", + "PhotographicMatte", "Envelope", + ]) + #expect(caps.supportsOrientation) + } + + @Test("PPD enrichment maps id → human label") + func ppdLabels() { + let ppd = """ + *CNIJMediaType 42/Photo Paper Plus Semi-gloss: "<>" + *CNIJMediaType 0/Plain Paper: "" + *en_US.CNIJMediaType 13/Envelope: "" + """ + let labels = CupsParsers.ppdChoiceLabels(ppd, key: "CNIJMediaType") + #expect(labels["42"] == "Photo Paper Plus Semi-gloss") + #expect(labels["0"] == "Plain Paper") + #expect(labels["13"] == "Envelope") + } + + @Test("detectMediaTypeKey prefers vendor keys in order") + func mediaTypeKey() { + #expect(CupsParsers.detectMediaTypeKey( + optionKeys: ["MediaType", "CNIJMediaType"]) == "CNIJMediaType") + #expect(CupsParsers.detectMediaTypeKey( + optionKeys: ["PageSize", "MediaType"]) == "MediaType") + #expect(CupsParsers.detectMediaTypeKey(optionKeys: ["PageSize"]) == nil) + } + + @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") + func driverBypass() { + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["CNIJIntent2", "CNIJIntent"]) + == ("CNIJIntent2", "4")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"]) + == ("CNIJIntent", "4")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"]) + == ("EPIJ_CMat", "3")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected")) + #expect(CupsParsers.detectDriverColorBypass( + optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off")) + #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil) + } +} -- 2.39.5 From 4281d0775498f4878b0158c91e17756da3c8b3cc Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:14:11 +0100 Subject: [PATCH 2/6] 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 From 0a02a8a640a8b2bd060595a5b5e3da402dc7ddff Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:19:09 +0100 Subject: [PATCH 3/6] =?UTF-8?q?ColorSync=20suppression=20engine=20?= =?UTF-8?q?=E2=80=94=20six=20layers=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ColorMatchingAttempts (Core): dlsym order Lock -> Mode -> NoLock and modes AP_ApplicationColorMatching -> ApplicationColorMatching as data; both AP_ print-settings keys; 2-arg (PMPrintSession, CFStringRef) signature — never integer 1 (#188). - ColorSyncSuppressor (app): layer 2 SPI via injectable dlsym resolver, first 0 wins; layer 3 PMPrintSettingsSetValue both keys locked; layer 4 driver bypass from lpoptions -l keys, unlocked; layer 5 NSPrintInfo.printSettings mirror; layer 6 PMPrintSettingsToOptions (dlsym'd) -> filter -> captured cupsOptions + mediaType. - CupsOptionsFilter (Core): drops com.apple.*, collate/copies/ job-sheets/pserrorhandler-requested, empty values, both AP_* keys; keeps relevant + unknown non-com.* keys; extractMediaType prefers MediaType then EPIJ_Medi. - PrintPanelService: suppression runs between session binding and runModal on the PM path; capture feeds PrintPropertiesResult. Tests: filter fixtures + injected-resolver call-order/first-win/ mode-fallback/missing-symbol suites. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../Print/ColorMatchingAttempts.swift | 49 +++++ .../ICCeryCore/Print/CupsOptionsFilter.swift | 59 ++++++ .../ICCery/Print/ColorSyncSuppressor.swift | 171 ++++++++++++++++++ Sources/ICCery/Print/PrintPanelService.swift | 49 ++++- .../CupsOptionsFilterTests.swift | 151 ++++++++++++++++ 5 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift create mode 100644 Sources/ICCery/Print/ColorSyncSuppressor.swift create mode 100644 Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift new file mode 100644 index 0000000..5e535a9 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/ColorMatchingAttempts.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Ordered private-SPI attempt table for the ColorSync suppression +/// engine (issue 14 layer ②, docs/11). +/// +/// The ordering is data so the exact dlsym/mode sequence is unit- +/// testable without resolving any private symbols. The app layer walks +/// `attempts`, resolves each symbol via `dlsym(RTLD_DEFAULT,…)`, and +/// calls the first `(symbol, mode)` that returns `0` — verified on +/// macOS 14+ that all three symbols exist. +/// +/// The SPI signature is `(PMPrintSession, CFStringRef) -> OSStatus`. +/// The second argument is the **mode string**, never integer `1` +/// (#188 — a 3-arg call is a SIGSEGV). `AP_ColorSyncMatching` and +/// `AP_VendorColorMatching` are forbidden modes — they re-enable +/// ColorSync/driver colour management. +public enum ColorMatchingAttempts { + + /// dlsym order: `…Lock` first (holds the print-session lock while + /// setting), then the plain setter, then `…NoLock`. + public static let symbols: [String] = [ + "PMSessionSetColorMatchingModeLock", + "PMSessionSetColorMatchingMode", + "PMSessionSetColorMatchingModeNoLock", + ] + + /// Mode strings tried per symbol, in order. `AP_…` is the + /// documented mode; the unprefixed variant is the older alias. + public static let modes: [String] = [ + "AP_ApplicationColorMatching", + "ApplicationColorMatching", + ] + + /// Symbol-outer, mode-inner — the full attempt sequence; the app + /// stops at the first call that returns `0`. + public static var attempts: [(symbol: String, mode: String)] { + symbols.flatMap { symbol in + modes.map { (symbol: symbol, mode: $0) } + } + } + + /// Layer ③: both spellings of the print-settings key are written + /// with `locked = true`. Written as `CFString` values. + public static let applicationMatchingValue = "AP_ApplicationColorMatching" + public static let printSettingsKeys: [String] = [ + "AP_ColorMatchingMode", + "AP.ColorMatchingMode", + ] +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift new file mode 100644 index 0000000..fcf3bf1 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -0,0 +1,59 @@ +import Foundation + +/// CUPS option filtering for `PMPrintSettingsToOptions` capture +/// (issue 14 layer ⑥, docs/11 §filter). +/// +/// The captured `key=value` string is reduced to the options that +/// should be replayed on `lp`: `com.apple.*` ticket keys, job +/// bookkeeping (`collate`, `copies`, `pserrorhandler-requested`, +/// `job-sheets`), empty values, and **both** `AP_*ColorMatchingMode` +/// keys are dropped — `build_lp_args` always re-adds those itself +/// (issue 15). Unknown non-`com.*` keys are kept (permissive — vendor +/// driver keys survive). +public enum CupsOptionsFilter { + + /// Option keys forwarded from the panel to `lp` (docs/11 roster). + public static let relevantKeys: Set = [ + // Media + "MediaType", "CNIJMediaType", "EPIJ_Medi", "StpMediaType", + // Tray + "InputSlot", "AP_D_InputSlot", + // Size + "PageSize", + // Colour bypass + "CNIJIntent2", "CNIJIntent", "EPIJ_CMat", "EPIJ_CCor", + "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", + "EpsonColorMode", "ColorModel", + // Quality + "Resolution", "cupsPrintQuality", "Quality", "EPIJ_Quality", + "CNIJQuality", "StpQuality", "OutputMode", + // Duplex + "Duplex", "sides", + ] + + /// Keys we always drop regardless of the relevant list. + public static let alwaysDropped: Set = [ + "collate", "copies", "pserrorhandler-requested", "job-sheets", + "AP_ColorMatchingMode", "AP.ColorMatchingMode", + ] + + /// A `key=value` pair survives when the key is non-empty, the value + /// is non-empty, the key is not `com.apple.*`, not always-dropped, + /// and either relevant or an unknown non-`com.*` driver key. + public static func isRelevant(key: String, value: String) -> Bool { + guard !key.isEmpty, !value.isEmpty else { return false } + if key.hasPrefix("com.apple.") { return false } + if alwaysDropped.contains(key) { return false } + if relevantKeys.contains(key) { return true } + // Permissive: unknown vendor keys survive (non-com.*). + return !key.hasPrefix("com.") + } + + /// `key=value key=value …` → filtered string, order preserved. + public static func filter(_ options: String) -> String { + CupsParsers.lpoptions(options) + .filter { isRelevant(key: $0.key, value: $0.value) } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + } +} diff --git a/Sources/ICCery/Print/ColorSyncSuppressor.swift b/Sources/ICCery/Print/ColorSyncSuppressor.swift new file mode 100644 index 0000000..28862b6 --- /dev/null +++ b/Sources/ICCery/Print/ColorSyncSuppressor.swift @@ -0,0 +1,171 @@ +import AppKit +import ApplicationServices +import ICCeryCore + +/// Private Print Manager SPI: `(PMPrintSession, CFStringRef) -> OSStatus`. +/// The second argument is the mode string — never integer `1` (#188). +typealias ColorMatchingModeFunction = + @convention(c) (PMPrintSession, CFString) -> OSStatus + +/// `PMPrintSettingsToOptions` — public symbol, resolved via dlsym so a +/// missing SDK declaration can't break the build. +typealias PrintSettingsToOptionsFunction = + @convention(c) (PMPrintSettings, UnsafeMutablePointer?>) -> OSStatus + +/// The six-layer unmanaged-printing engine (issue 14, docs/11): +/// +/// ① session binding — done by `PrintPanelService` before calling us. +/// ② private SPI `PMSessionSetColorMatchingMode{Lock,,NoLock}` — +/// resolved by `dlsym(RTLD_DEFAULT,…)`; first `(symbol, mode)` +/// returning `0` wins. +/// ③ `PMPrintSettingsSetValue` both `AP_ColorMatchingMode` and +/// `AP.ColorMatchingMode` = `AP_ApplicationColorMatching`, locked. +/// ④ driver "no colour adjustment" pre-select from `lpoptions -l` +/// keys, unlocked (`detectDriverColorBypass`). +/// ⑤ mirror ③+④ into `NSPrintInfo.printSettings` so the PDE sees them. +/// ⑥ after "Use Settings": `PMPrintSettingsToOptions` → +/// `CupsOptionsFilter` → captured `cupsOptions` + `mediaType`. +/// +/// All layers degrade gracefully — a missing symbol or non-zero status +/// is logged and the next layer still runs. +@MainActor +struct ColorSyncSuppressor { + + /// Injected for tests: symbol → function. Default resolves via + /// `dlsym(RTLD_DEFAULT, …)`. + typealias ModeResolver = (String) -> ColorMatchingModeFunction? + typealias OptionsResolver = () -> PrintSettingsToOptionsFunction? + + var modeResolver: ModeResolver = Self.dlsymMode + var optionsResolver: OptionsResolver = Self.dlsymOptions + var log: (String) -> Void = { AppLogger.shared.log(.info, $0) } + + // MARK: - Layer ② SPI + + /// Walk `ColorMatchingAttempts.attempts` (Lock → plain → NoLock × + /// `AP_ApplicationColorMatching` → `ApplicationColorMatching`); the + /// first call returning `0` wins. `false` when nothing worked. + @discardableResult + func applySPIMode(to session: PMPrintSession) -> Bool { + for attempt in ColorMatchingAttempts.attempts { + guard let function = modeResolver(attempt.symbol) else { + continue + } + let status = function(session, attempt.mode as CFString) + if status == 0 { + log("ColorSync: \(attempt.symbol) accepted " + + "\(attempt.mode)") + return true + } + } + log("ColorSync: no PMSessionSetColorMatchingMode* accepted a " + + "mode — falling back to PMPrintSettingsSetValue") + return false + } + + // MARK: - Layer ③ locked AP_* keys + + /// `PMPrintSettingsSetValue` both key spellings, locked. + @discardableResult + func applyLockedKeys(to settings: PMPrintSettings) -> Int { + var applied = 0 + for key in ColorMatchingAttempts.printSettingsKeys { + let status = PMPrintSettingsSetValue( + settings, + key as CFString, + ColorMatchingAttempts.applicationMatchingValue as CFString, + true) + if status == 0 { applied += 1 } + } + if applied == 0 { + log("ColorSync: PMPrintSettingsSetValue could not lock " + + "AP_ColorMatchingMode") + } + return applied + } + + // MARK: - Layer ④ driver bypass + + /// Pre-select the driver "no colour adjustment" option, unlocked — + /// the PDE may override it. Returns the `(key, value)` applied. + @discardableResult + func applyDriverBypass( + to settings: PMPrintSettings, + optionKeys: Set + ) -> (key: String, value: String)? { + guard let bypass = CupsParsers.detectDriverColorBypass( + optionKeys: optionKeys) + else { return nil } + let status = PMPrintSettingsSetValue( + settings, + bypass.key as CFString, + bypass.value as CFString, + false) + if status != 0 { + log("ColorSync: driver bypass \(bypass.key)=\(bypass.value) " + + "rejected (\(status))") + return nil + } + return bypass + } + + // MARK: - Layer ⑤ NSPrintInfo mirror + + /// Mirror the applied keys into `printSettings` so the PDE pick + /// sees them. + func mirror( + into printInfo: NSPrintInfo, + driverBypass: (key: String, value: String)? + ) { + let settings = printInfo.printSettings + for key in ColorMatchingAttempts.printSettingsKeys { + settings[key as NSString] = ColorMatchingAttempts.applicationMatchingValue as NSString + } + if let driverBypass { + settings[driverBypass.key as NSString] = driverBypass.value as NSString + } + } + + // MARK: - Layer ⑥ capture + + /// `PMPrintSettingsToOptions` → filter → `(cupsOptions, mediaType)`. + /// The malloc'd C string is freed after copying. + func captureOptions( + from settings: PMPrintSettings + ) -> (cupsOptions: String?, mediaType: String?) { + guard let toOptions = optionsResolver() else { + log("ColorSync: PMPrintSettingsToOptions unavailable — " + + "panel options not captured") + return (nil, nil) + } + var raw: UnsafeMutablePointer? + guard toOptions(settings, &raw) == 0, let raw else { + return (nil, nil) + } + defer { free(raw) } + let unfiltered = String(cString: raw) + let filtered = CupsOptionsFilter.filter(unfiltered) + return ( + filtered.isEmpty ? nil : filtered, + CupsParsers.extractMediaType(fromOptionsString: unfiltered) + ) + } + + // MARK: - dlsym + + private static func dlsymMode(_ name: String) -> ColorMatchingModeFunction? { + guard let symbol = dlsym(Self.rtldDefault, name) else { return nil } + return unsafeBitCast(symbol, to: ColorMatchingModeFunction.self) + } + + private static func dlsymOptions() -> PrintSettingsToOptionsFunction? { + guard let symbol = dlsym(Self.rtldDefault, "PMPrintSettingsToOptions") + else { return nil } + return unsafeBitCast(symbol, to: PrintSettingsToOptionsFunction.self) + } + + /// `RTLD_DEFAULT` — `UnsafeMutableRawPointer(bitPattern: -2)`. + private static var rtldDefault: UnsafeMutableRawPointer? { + UnsafeMutableRawPointer(bitPattern: -2) + } +} diff --git a/Sources/ICCery/Print/PrintPanelService.swift b/Sources/ICCery/Print/PrintPanelService.swift index ee9af07..79435ee 100644 --- a/Sources/ICCery/Print/PrintPanelService.swift +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -33,6 +33,9 @@ enum PrintPanelError: LocalizedError { @MainActor struct PrintPanelService { + /// The suppression engine — injectable for tests. + var suppressor = ColorSyncSuppressor() + /// Resolves the display name (off-panel `lpoptions` fetch) and runs /// the modal panel. Returns `nil` when the user cancels. func showProperties( @@ -47,14 +50,20 @@ struct PrintPanelService { #endif let display = displayName ?? (try? await cupsService.displayName(for: queue)) - return try runNativePanel(queue: queue, displayName: display) + // Layer ④ needs the queue's option keys (lpoptions -l) to pick + // the driver colour-bypass before the panel opens. + let optionKeys = (try? await cupsService.optionKeys(for: queue)) + ?? [] + return try runNativePanel( + queue: queue, displayName: display, optionKeys: optionKeys) } // MARK: - Panel private func runNativePanel( queue: String, - displayName: String? + displayName: String?, + optionKeys: Set ) throws -> PrintPropertiesResult? { let printInfo = NSPrintInfo() var pmPrinter: PMPrinter? @@ -97,8 +106,21 @@ struct PrintPanelService { } } - // Colour-suppression layers ②–⑤ land in issue 14 here, between - // binding and runModal. + // ②–⑤ ColourSync suppression — only on the PM path: the SPI + // and PMPrintSettingsSetValue need a session with a current + // printer to attach to. + var settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + var driverBypass: (key: String, value: String)? + if boundViaPM { + let session = unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self) + suppressor.applySPIMode(to: session) // ② + suppressor.applyLockedKeys(to: settings) // ③ + driverBypass = suppressor.applyDriverBypass( // ④ + to: settings, optionKeys: optionKeys) + suppressor.mirror(into: printInfo, driverBypass: driverBypass) // ⑤ + } let panel = NSPrintPanel() panel.options = [ @@ -109,10 +131,22 @@ struct PrintPanelService { 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 } + + // ⑥ Capture the user's choices — filtered replay options plus + // the media type they picked. Re-fetch the settings handle so + // we read back what the modal wrote. + var cupsOptions: String? + var mediaType: String? + if boundViaPM { + settings = unsafeBitCast( + printInfo.pmPrintSettings(), to: PMPrintSettings.self) + let captured = suppressor.captureOptions(from: settings) + cupsOptions = captured.cupsOptions + mediaType = captured.mediaType + } return PrintPropertiesResult( selectedPrinter: boundViaPM ? Self.currentPrinterID( @@ -120,7 +154,10 @@ struct PrintPanelService { printInfo.pmPrintSession(), to: PMPrintSession.self), fallback: queue) : nil, - options: PrintOptions(ppdUncorrectedPassthrough: true)) + options: PrintOptions( + mediaType: mediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: cupsOptions)) } // MARK: - PM helpers diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift new file mode 100644 index 0000000..c6951f3 --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -0,0 +1,151 @@ +import Testing +import Foundation +@testable import ICCeryCore +@testable import ICCery +import AppKit +import ApplicationServices + +/// Issue 14 — PMPrintSettingsToOptions capture filter (docs/11 layer ⑥). +@Suite("CupsOptionsFilter") +struct CupsOptionsFilterTests { + + @Test("Drops com.apple.*, collate, copies, job-sheets, AP_* keys") + func dropsReserved() { + let raw = "AP_ColorMatchingMode=AP_ApplicationColorMatching " + + "AP.ColorMatchingMode=AP_ApplicationColorMatching " + + "com.apple.print.JobTicket.PMTotalSidesImaged=0 " + + "collate=true copies=1 job-sheets=none,none " + + "pserrorhandler-requested=standard " + + "MediaType=PhotographicGlossy" + #expect(CupsOptionsFilter.filter(raw) == "MediaType=PhotographicGlossy") + } + + @Test("Keeps relevant driver keys, order preserved") + func keepsRelevant() { + let raw = "InputSlot=Rear PageSize=A4 CNIJIntent2=4 " + + "Resolution=600x600dpi Duplex=None" + #expect(CupsOptionsFilter.filter(raw) == raw) + } + + @Test("Permissive: unknown non-com.* keys survive") + func keepsUnknown() { + let raw = "VendorFooBar=baz MediaType=Plain" + #expect(CupsOptionsFilter.filter(raw) == raw) + } + + @Test("Drops empty keys and values") + func dropsEmpty() { + let raw = "=noval MediaType= InputSlot=Rear" + // "MediaType=" has an empty value → dropped; "=noval" empty key. + #expect(CupsOptionsFilter.filter(raw) == "InputSlot=Rear") + } + + @Test("extractMediaType prefers MediaType then EPIJ_Medi") + func extractMedia() { + #expect(CupsParsers.extractMediaType( + fromOptionsString: "MediaType=Photo EPIJ_Medi=1") == "Photo") + #expect(CupsParsers.extractMediaType( + fromOptionsString: "EPIJ_Medi=7") == "7") + #expect(CupsParsers.extractMediaType( + fromOptionsString: "PageSize=A4") == nil) + } +} + +/// Issue 14 — the dlsym attempt order and first-success semantics. +/// A fake resolver records every call; no private symbols are touched. +@Suite("ColorSyncSuppressor") +@MainActor +struct ColorSyncSuppressorTests { + + /// Fake PMPrintSession — the injected resolver never dereferences it. + private var fakeSession: PMPrintSession { + unsafeBitCast(UnsafeMutableRawPointer(bitPattern: 0xdead)!, to: PMPrintSession.self) + } + + private func suppressor( + succeeding symbol: String? = nil, + mode: String = "AP_ApplicationColorMatching", + calls: UnsafeMutablePointer<[(String, String)]> + ) -> ColorSyncSuppressor { + var s = ColorSyncSuppressor() + s.log = { _ in } + s.modeResolver = { name in + // Missing symbol → nil (older macOS path). + if name == "PMSessionSetColorMatchingModeLock" && symbol == nil { + return nil + } + return { _, modeArg in + calls.pointee.append((name, modeArg as String)) + return (name == symbol && (modeArg as String) == mode) ? 0 : 1 + } + } + return s + } + + @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") + func attemptOrder() { + let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) + calls.initialize(to: []) + defer { calls.deallocate() } + + let s = suppressor(succeeding: nil, calls: calls) + #expect(s.applySPIMode(to: fakeSession) == false) + #expect(calls.pointee == ColorMatchingAttempts.attempts + .map { ($0.symbol, $0.mode) } + .filter { $0.0 != "PMSessionSetColorMatchingModeLock" }) + } + + @Test("First zero wins — later symbols not called") + func firstZeroWins() { + let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) + calls.initialize(to: []) + defer { calls.deallocate() } + + let s = suppressor( + succeeding: "PMSessionSetColorMatchingMode", calls: calls) + #expect(s.applySPIMode(to: fakeSession)) + // Lock symbol missing → skipped; Mode tried AP_ then plain? No — + // Mode succeeds on the first mode → 2 calls total. + #expect(calls.pointee == [ + ("PMSessionSetColorMatchingMode", "AP_ApplicationColorMatching"), + ]) + // NoLock never attempted. + #expect(!calls.pointee.contains { $0.0 == "PMSessionSetColorMatchingModeNoLock" }) + } + + @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") + func modeFallback() { + let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) + calls.initialize(to: []) + defer { calls.deallocate() } + + var s = suppressor( + succeeding: "PMSessionSetColorMatchingModeLock", + mode: "ApplicationColorMatching", + calls: calls) + // Make the Lock symbol resolvable this time. + let record: (String) -> ColorMatchingModeFunction? = { name in + { _, modeArg in + calls.pointee.append((name, modeArg as String)) + return (modeArg as String) == "ApplicationColorMatching" ? 0 : 1 + } + } + s.modeResolver = record + #expect(s.applySPIMode(to: fakeSession)) + #expect(calls.pointee.first + == ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching")) + #expect(calls.pointee.last + == ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching")) + } + + @Test("All symbols missing → false, no calls") + func allMissing() { + let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) + calls.initialize(to: []) + defer { calls.deallocate() } + var s = suppressor(succeeding: nil, calls: calls) + s.modeResolver = { _ in nil } + #expect(s.applySPIMode(to: fakeSession) == false) + #expect(calls.pointee.isEmpty) + } +} -- 2.39.5 From 7fbdfd978e06832d6e90f540c322d91504ac3690 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 00:23:38 +0100 Subject: [PATCH 4/6] =?UTF-8?q?lp=20spool=20path=20=E2=80=94=20unmanaged?= =?UTF-8?q?=20raster=20jobs=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LpArgs.build: lp -d -t 'ICCery Target - ' -o AP_ColorMatchingMode=AP_ApplicationColorMatching -o AP.ColorMatchingMode=AP_ApplicationColorMatching . - Never -o raw (#92): raw is dropped by the capture filter AND skipped at build time. - Captured options win over derived fields (case-insensitive dedup); sanitise rejects ;, newlines, shell metachars — argv array only. - CupsService.printTarget: TIFF existence check, optionKeys lookup for media/bypass detection, runCaptured spawn. - ProcessID.lp(queue, page). Tests: 9 argv golden suites — both AP_* always present, captured wins, sanitise rejects ;, no raw. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Print/CupsOptionsFilter.swift | 7 +- .../ICCeryCore/Print/CupsService.swift | 21 +++ .../Sources/ICCeryCore/Print/LpArgs.swift | 126 +++++++++++++++++ .../ICCeryCore/Process/ProcessManager.swift | 7 +- Tests/ICCeryCoreTests/LpArgsTests.swift | 129 ++++++++++++++++++ 5 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift create mode 100644 Tests/ICCeryCoreTests/LpArgsTests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift index fcf3bf1..149f4e5 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -31,10 +31,13 @@ public enum CupsOptionsFilter { "Duplex", "sides", ] - /// Keys we always drop regardless of the relevant list. + /// Keys we always drop regardless of the relevant list. `raw` is + /// included — a captured `raw=…` would re-enable CUPS raw mode and + /// bypass the raster filter that honours `AP_ApplicationColorMatching` + /// (#92). public static let alwaysDropped: Set = [ "collate", "copies", "pserrorhandler-requested", "job-sheets", - "AP_ColorMatchingMode", "AP.ColorMatchingMode", + "AP_ColorMatchingMode", "AP.ColorMatchingMode", "raw", ] /// A `key=value` pair survives when the key is non-empty, the value diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index f21d99f..9fabd8d 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -139,6 +139,27 @@ public struct CupsService: Sendable { Set(try await optionListings(for: queue).map(\.key)) } + // MARK: - Spool (issue 15) + + /// `lp -d ` — spool one target page unmanaged. + /// Never uses `-o raw` (#92). `page` disambiguates the process id + /// when several pages are spooled in sequence. + public func printTarget( + queue: String, + tiffPath: String, + options: PrintOptions, + page: Int = 0 + ) async throws { + guard FileManager.default.fileExists(atPath: tiffPath) else { + throw CupsError.tiffMissing(tiffPath) + } + let optionKeys = (try? await self.optionKeys(for: queue)) ?? [] + let argv = try LpArgs.build( + queue: queue, tiffPath: tiffPath, + options: options, optionKeys: optionKeys) + try await run("lp", argv, id: ProcessID.lp(queue, page: page)) + } + // MARK: - PPD private func loadPPD(for queue: String) -> String? { diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift new file mode 100644 index 0000000..3f0ed61 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/LpArgs.swift @@ -0,0 +1,126 @@ +import Foundation + +/// Errors from `buildLpArgs`. +public enum LpArgsError: LocalizedError, Equatable { + case unsanitisedOption(String) + + public var errorDescription: String? { + switch self { + case .unsanitisedOption(let option): + return "Captured CUPS option contains unsafe characters: \(option)" + } + } +} + +/// `lp` argv builder — issue 15, docs/11 `build_lp_args`. +/// +/// ``` +/// lp -d -t "ICCery Target - " +/// -o AP_ColorMatchingMode=AP_ApplicationColorMatching +/// -o AP.ColorMatchingMode=AP_ApplicationColorMatching +/// +/// +/// +/// +/// +/// +/// ``` +/// +/// - **Never `-o raw`** — `raw` skips the raster filter that honours +/// `AP_ApplicationColorMatching` (#92). +/// - Captured options **win** over explicit fields: any key already +/// present (case-insensitive) suppresses the derived `-o`. +/// - Captured keys/values are sanitised — `;`, newlines, or shell +/// metacharacters throw `unsanitisedOption`; args are passed as a +/// `Process` argv array, never through a shell. +/// - The TIFF path is always the **last** argument. +public enum LpArgs { + + /// `options` = the captured `PrintOptions`; `optionKeys` = the + /// queue's `lpoptions -l` key set (for media-key/bypass detection). + public static func build( + queue: String, + tiffPath: String, + options: PrintOptions, + optionKeys: Set + ) throws -> [String] { + var argv: [String] = [ + "-d", queue, + "-t", "ICCery Target - \((tiffPath as NSString).lastPathComponent)", + "-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching", + "-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching", + ] + var addedKeys: Set = [ + "ap_colormatchingmode", "ap.colormatchingmode", + ] + + // Captured CUPS options — sanitised, lowercased-key dedup. + if let captured = options.cupsOptions, !captured.isEmpty { + // Newlines can't survive the tokeniser — check the raw + // string so embedded line breaks are still rejected. + if captured.contains("\n") || captured.contains("\r") { + throw LpArgsError.unsanitisedOption(captured) + } + for pair in CupsParsers.lpoptions(captured) { + try sanitize(pair.key, pair.value) + let lowered = pair.key.lowercased() + // Defence in depth: never let a captured `raw` reach + // argv — `-o raw` skips the raster filter that honours + // AP_ApplicationColorMatching (#92). + if lowered == "raw" { continue } + guard !addedKeys.contains(lowered) else { continue } + addedKeys.insert(lowered) + argv += ["-o", "\(pair.key)=\(pair.value)"] + } + } + + // Media type — only when the captured options didn't carry one. + if let mediaType = options.mediaType, + let mediaKey = CupsParsers.detectMediaTypeKey(optionKeys: optionKeys), + !addedKeys.contains(mediaKey.lowercased()) { + addedKeys.insert(mediaKey.lowercased()) + argv += ["-o", "\(mediaKey)=\(mediaType)"] + } + + // Driver colour bypass — when no bypass key was captured. NOT + // gated on ppdUncorrectedPassthrough (macOS always bypasses). + let capturedKeys = Set( + CupsParsers.lpoptions(options.cupsOptions ?? "") + .map { $0.key }) + if capturedKeys.isDisjoint(with: CupsParsers.bypassKeys), + let bypass = CupsParsers.detectDriverColorBypass(optionKeys: optionKeys), + !addedKeys.contains(bypass.key.lowercased()) { + addedKeys.insert(bypass.key.lowercased()) + argv += ["-o", "\(bypass.key)=\(bypass.value)"] + } + + // Orientation — portrait=3, landscape=4. + if let orientation = options.orientation, + !addedKeys.contains("orientation-requested") { + let value = orientation == "landscape" ? "4" : "3" + addedKeys.insert("orientation-requested") + argv += ["-o", "orientation-requested=\(value)"] + } + + // PageSize — the printtarg layout page size. + if let paperSize = options.paperSize, !paperSize.isEmpty, + !addedKeys.contains("pagesize") { + argv += ["-o", "PageSize=\(paperSize)"] + } + + argv.append(tiffPath) + return argv + } + + /// Reject shell/metachar injection — args go to `Process` as an + /// argv array, but a hostile captured string must not smuggle a + /// second option or command. + static func sanitize(_ key: String, _ value: String) throws { + let forbidden = CharacterSet(charactersIn: ";\n\r`|$&<>\\\"'") + if key.rangeOfCharacter(from: forbidden) != nil + || value.rangeOfCharacter(from: forbidden) != nil + || key.isEmpty { + throw LpArgsError.unsanitisedOption("\(key)=\(value)") + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift index 6bfe6ab..4d692fc 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Process/ProcessManager.swift @@ -1,7 +1,7 @@ import Foundation -/// Captured output from `runCaptured` (used by printcal/applycal — -/// the only tools whose results arrive as one-shot output). +/// Captured output from `runCaptured` — one-shot tools whose results +/// arrive as buffered stdout/stderr (printcal/applycal, CUPS tools). public struct CapturedResult: Sendable, Equatable { public let stdout: String public let stderr: String @@ -173,7 +173,8 @@ public actor ProcessManager { /// Runs a child to completion and returns all output. Reads stdout /// and stderr concurrently so a full pipe buffer can never deadlock - /// the child. Used by `printcal` / `applycal` (docs/03). + /// the child. Used by `printcal` / `applycal` (docs/03) and by + /// `CupsService` for `/usr/bin/lpstat`, `lpoptions`, `lp` (#12/#15). public func runCaptured( id: String, binary: URL, diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift new file mode 100644 index 0000000..1f2d175 --- /dev/null +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import ICCeryCore + +/// Issue 15 — `lp` argv goldens (docs/11 `build_lp_args`). +/// `-d`/`options`/`-t` handling is in `CupsService`; these tests cover +/// flag order, captured-option precedence, and sanitisation. +@Suite("LpArgs") +struct LpArgsTests { + + private let tiff = "/tmp/work/target_001.tif" + private let queue = "EPSON_XP_55_Series" + + private func build( + options: PrintOptions = PrintOptions(), + optionKeys: Set = [] + ) throws -> [String] { + try LpArgs.build( + queue: queue, tiffPath: tiff, + options: options, optionKeys: optionKeys) + } + + @Test("Header: -d queue -t title, both AP_* first, TIFF last") + func header() throws { + let argv = try build() + #expect(Array(argv[0...1]) == ["-d", queue]) + #expect(Array(argv[2...3]) == ["-t", "ICCery Target - target_001.tif"]) + #expect(Array(argv[4...5]) + == ["-o", "AP_ColorMatchingMode=AP_ApplicationColorMatching"]) + #expect(Array(argv[6...7]) + == ["-o", "AP.ColorMatchingMode=AP_ApplicationColorMatching"]) + #expect(argv.last == tiff) + #expect(!argv.contains { $0 == "raw" || $0 == "-o raw" }) + } + + @Test("Never emits -o raw; captured raw= is dropped") + func neverRaw() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "raw=true MediaType=Photo")) + for (i, arg) in argv.enumerated() where arg == "-o" { + #expect(argv[i + 1] != "raw") + #expect(argv[i + 1] != "raw=true") + } + #expect(!argv.contains { $0.hasPrefix("raw=") }) + #expect(argv.contains("MediaType=Photo")) + } + + @Test("Captured options replayed after AP_* headers") + func capturedReplay() throws { + let argv = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear MediaType=Photo")) + let rear = argv.firstIndex(of: "InputSlot=Rear")! + let apFirst = argv.firstIndex(of: + "AP_ColorMatchingMode=AP_ApplicationColorMatching")! + #expect(rear > apFirst) + } + + @Test("Captured wins: media key present → derived media skipped") + func capturedWinsMedia() throws { + let argv = try build( + options: PrintOptions( + mediaType: "Plain", + cupsOptions: "MediaType=Glossy"), + optionKeys: ["MediaType"]) + #expect(argv.contains("MediaType=Glossy")) + #expect(!argv.contains("MediaType=Plain")) + } + + @Test("Media emitted via detected key when not captured") + func mediaDerived() throws { + let argv = try build( + options: PrintOptions(mediaType: "SemiGloss"), + optionKeys: ["CNIJMediaType", "MediaType"]) + // CNIJMediaType wins over MediaType in detection order. + #expect(argv.contains("CNIJMediaType=SemiGloss")) + #expect(!argv.contains("MediaType=SemiGloss")) + } + + @Test("Driver bypass emitted when absent, skipped when captured") + func bypassRules() throws { + let withBypass = try build( + optionKeys: ["EPIJ_CMat"]) + #expect(withBypass.contains("EPIJ_CMat=3")) + + let captured = try build( + options: PrintOptions(cupsOptions: "EPIJ_CMat=1"), + optionKeys: ["EPIJ_CMat"]) + // Captured value kept, detection not re-applied. + #expect(captured.filter { $0.hasPrefix("EPIJ_CMat") } + == ["EPIJ_CMat=1"]) + } + + @Test("Orientation: portrait=3 landscape=4; captured wins") + func orientation() throws { + #expect(try build(options: PrintOptions(orientation: "portrait")) + .contains("orientation-requested=3")) + #expect(try build(options: PrintOptions(orientation: "landscape")) + .contains("orientation-requested=4")) + #expect(!try build(options: PrintOptions( + orientation: "landscape", + cupsOptions: "orientation-requested=5")) + .contains("orientation-requested=4")) + } + + @Test("PageSize emitted unless captured") + func pageSize() throws { + #expect(try build(options: PrintOptions(paperSize: "A4")) + .contains("PageSize=A4")) + #expect(!try build(options: PrintOptions( + paperSize: "A4", cupsOptions: "PageSize=Letter")) + .contains("PageSize=A4")) + } + + @Test("Sanitise rejects `;`, newline, and shell metachars") + func sanitise() throws { + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear;rm -rf /")) + } + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=Rear\nMediaType=Photo")) + } + #expect(throws: LpArgsError.self) { + _ = try build(options: PrintOptions( + cupsOptions: "InputSlot=$(whoami)")) + } + } +} -- 2.39.5 From e385c742981cb88101bef7a705e47ade2a2f7d66 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 01:19:17 +0100 Subject: [PATCH 5/6] =?UTF-8?q?Stage=202=20live=20print=20panel=20?= =?UTF-8?q?=E2=80=94=20unmanaged=20spool=20UI=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rawPrintPanel: printerSelect (lpstat -e, auto-refresh on manifest), printerStatusBadge (idle/printing/stopped), printerTraySelect + printerMediaTypeSelect from lpoptions -l capabilities, portrait/ landscape toggle, btnPrinterProperties (bound NSPrintPanel), Print All + per-page btnPrintPage-N, in-panel printNotification (cancel → info, not error). No cupsOptionsGroup/chkPpdFallback — macOS always uses the PM-captured path. - TargetWorkflowViewModel: refreshPrinters, reloadSelectedCapabilities, openPrinterPreferences (panel-side queue switch updates the select), printAllPages / printPage (sequential, stop-on-first-error), capturedCupsOptions per-queue session cache, wizard.printerName set on spool (#95). - CupsError.noPrinterSelected. - Mock CUPS fixtures: lpstat (2 queues, one idle one disabled), lpoptions (tray/media/EPIJ_CMat listings), lp (appends argv to ICCERY_TEST_LP_ARGV). Milestone3UITests: enumeration, preferences cancel→info, captured options replayed in argv, per-page print, lp failure notice, printerName persistence. - lpoptions parser: skip keyless '=value' tokens instead of truncating. UI tests written but not executed here: the dev machine's console is locked (IOConsoleLocked=true) so XCUIApplication.activate() cannot bring the app to front — same failure on M2 baseline. Suite must be run unlocked / on CI. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../ICCeryCore/Print/CupsParsers.swift | 13 +- .../ICCeryCore/Print/CupsService.swift | 3 + Sources/ICCery/Print/PrintPanelService.swift | 11 +- Sources/ICCery/Stage2View.swift | 133 +++++++++-- Sources/ICCery/TargetWorkflowViewModel.swift | 169 ++++++++++++++ .../CupsOptionsFilterTests.swift | 111 +++++---- Tests/ICCeryCoreTests/CupsParserTests.swift | 28 +-- Tests/ICCeryCoreTests/LpArgsTests.swift | 10 +- Tests/ICCeryUITests/Fixtures/bin/lp | 14 ++ Tests/ICCeryUITests/Fixtures/bin/lpoptions | 23 ++ Tests/ICCeryUITests/Fixtures/bin/lpstat | 19 ++ Tests/ICCeryUITests/Milestone3UITests.swift | 219 ++++++++++++++++++ 12 files changed, 649 insertions(+), 104 deletions(-) create mode 100755 Tests/ICCeryUITests/Fixtures/bin/lp create mode 100755 Tests/ICCeryUITests/Fixtures/bin/lpoptions create mode 100755 Tests/ICCeryUITests/Fixtures/bin/lpstat create mode 100644 Tests/ICCeryUITests/Milestone3UITests.swift diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index eafddf4..22221f7 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -91,7 +91,18 @@ public enum CupsParsers { index = output.index(after: index) } let key = String(output[tokenStart.. - ) -> ColorSyncSuppressor { + /// Call log — static since `@convention(c)` can't capture. The + /// resolver sets `currentSymbol` right before each call, so the C + /// function records (symbol, mode) without capturing `name`. + private static var recorded: [(String, String)] = [] + private static var currentSymbol = "" + private static var succeeding: (String, String)? + private static var missing: Set = [] + + private func makeSuppressor() -> ColorSyncSuppressor { var s = ColorSyncSuppressor() s.log = { _ in } s.modeResolver = { name in - // Missing symbol → nil (older macOS path). - if name == "PMSessionSetColorMatchingModeLock" && symbol == nil { - return nil - } + if Self.missing.contains(name) { return nil } + Self.currentSymbol = name return { _, modeArg in - calls.pointee.append((name, modeArg as String)) - return (name == symbol && (modeArg as String) == mode) ? 0 : 1 + Self.recorded.append((Self.currentSymbol, modeArg as String)) + if let ok = Self.succeeding, + Self.currentSymbol == ok.0, (modeArg as String) == ok.1 { + return 0 + } + return 1 } } return s @@ -84,68 +92,53 @@ struct ColorSyncSuppressorTests { @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") func attemptOrder() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - let s = suppressor(succeeding: nil, calls: calls) + Self.recorded = [] + Self.succeeding = nil + Self.missing = ["PMSessionSetColorMatchingModeLock"] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession) == false) - #expect(calls.pointee == ColorMatchingAttempts.attempts - .map { ($0.symbol, $0.mode) } - .filter { $0.0 != "PMSessionSetColorMatchingModeLock" }) + // Lock is unresolvable → skipped; the rest plays out in order. + #expect(Self.recorded.map { "\($0.0)|\($0.1)" } + == ColorMatchingAttempts.attempts + .filter { $0.symbol != "PMSessionSetColorMatchingModeLock" } + .map { "\($0.symbol)|\($0.mode)" }) } - @Test("First zero wins — later symbols not called") + @Test("First zero wins — later symbols/modes not called") func firstZeroWins() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - let s = suppressor( - succeeding: "PMSessionSetColorMatchingMode", calls: calls) + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "AP_ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession)) - // Lock symbol missing → skipped; Mode tried AP_ then plain? No — - // Mode succeeds on the first mode → 2 calls total. - #expect(calls.pointee == [ - ("PMSessionSetColorMatchingMode", "AP_ApplicationColorMatching"), + #expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ + "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", ]) - // NoLock never attempted. - #expect(!calls.pointee.contains { $0.0 == "PMSessionSetColorMatchingModeNoLock" }) } @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") func modeFallback() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - - var s = suppressor( - succeeding: "PMSessionSetColorMatchingModeLock", - mode: "ApplicationColorMatching", - calls: calls) - // Make the Lock symbol resolvable this time. - let record: (String) -> ColorMatchingModeFunction? = { name in - { _, modeArg in - calls.pointee.append((name, modeArg as String)) - return (modeArg as String) == "ApplicationColorMatching" ? 0 : 1 - } - } - s.modeResolver = record + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession)) - #expect(calls.pointee.first - == ("PMSessionSetColorMatchingModeLock", "AP_ApplicationColorMatching")) - #expect(calls.pointee.last - == ("PMSessionSetColorMatchingModeLock", "ApplicationColorMatching")) + #expect(Self.recorded[0].0 == "PMSessionSetColorMatchingModeLock") + #expect(Self.recorded[0].1 == "AP_ApplicationColorMatching") + #expect(Self.recorded[1].0 == "PMSessionSetColorMatchingModeLock") + #expect(Self.recorded[1].1 == "ApplicationColorMatching") + #expect(Self.recorded.count == 2) } @Test("All symbols missing → false, no calls") func allMissing() { - let calls = UnsafeMutablePointer<[(String, String)]>.allocate(capacity: 1) - calls.initialize(to: []) - defer { calls.deallocate() } - var s = suppressor(succeeding: nil, calls: calls) - s.modeResolver = { _ in nil } + Self.recorded = [] + Self.succeeding = nil + Self.missing = Set(ColorMatchingAttempts.symbols) + let s = makeSuppressor() #expect(s.applySPIMode(to: fakeSession) == false) - #expect(calls.pointee.isEmpty) + #expect(Self.recorded.isEmpty) } } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index f2d42fc..fdf948f 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -129,21 +129,17 @@ struct CupsParsersTests { @Test("Driver bypass: Canon Intent2 > Intent; Epson CCor > CMat") func driverBypass() { - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["CNIJIntent2", "CNIJIntent"]) - == ("CNIJIntent2", "4")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["CNIJIntent"]) - == ("CNIJIntent", "4")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["EPIJ_CCor", "EPIJ_CMat"]) == ("EPIJ_CCor", "0")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["EPIJ_CMat"]) - == ("EPIJ_CMat", "3")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["StpColorCorrection"]) == ("StpColorCorrection", "Uncorrected")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["ColorCorrection"]) == ("ColorCorrection", "Uncorrected")) - #expect(CupsParsers.detectDriverColorBypass( - optionKeys: ["EpsonColorMode"]) == ("EpsonColorMode", "Off")) - #expect(CupsParsers.detectDriverColorBypass(optionKeys: ["PageSize"]) == nil) + func pair(_ keys: Set) -> String? { + CupsParsers.detectDriverColorBypass(optionKeys: keys) + .map { "\($0.key)=\($0.value)" } + } + #expect(pair(["CNIJIntent2", "CNIJIntent"]) == "CNIJIntent2=4") + #expect(pair(["CNIJIntent"]) == "CNIJIntent=4") + #expect(pair(["EPIJ_CCor", "EPIJ_CMat"]) == "EPIJ_CCor=0") + #expect(pair(["EPIJ_CMat"]) == "EPIJ_CMat=3") + #expect(pair(["StpColorCorrection"]) == "StpColorCorrection=Uncorrected") + #expect(pair(["ColorCorrection"]) == "ColorCorrection=Uncorrected") + #expect(pair(["EpsonColorMode"]) == "EpsonColorMode=Off") + #expect(pair(["PageSize"]) == nil) } } diff --git a/Tests/ICCeryCoreTests/LpArgsTests.swift b/Tests/ICCeryCoreTests/LpArgsTests.swift index 1f2d175..4dcb22a 100644 --- a/Tests/ICCeryCoreTests/LpArgsTests.swift +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -96,19 +96,21 @@ struct LpArgsTests { .contains("orientation-requested=3")) #expect(try build(options: PrintOptions(orientation: "landscape")) .contains("orientation-requested=4")) - #expect(!try build(options: PrintOptions( + let capturedOrients = try build(options: PrintOptions( orientation: "landscape", cupsOptions: "orientation-requested=5")) - .contains("orientation-requested=4")) + #expect(!capturedOrients.contains("orientation-requested=4")) + #expect(capturedOrients.contains("orientation-requested=5")) } @Test("PageSize emitted unless captured") func pageSize() throws { #expect(try build(options: PrintOptions(paperSize: "A4")) .contains("PageSize=A4")) - #expect(!try build(options: PrintOptions( + let capturedSize = try build(options: PrintOptions( paperSize: "A4", cupsOptions: "PageSize=Letter")) - .contains("PageSize=A4")) + #expect(!capturedSize.contains("PageSize=A4")) + #expect(capturedSize.contains("PageSize=Letter")) } @Test("Sanitise rejects `;`, newline, and shell metachars") diff --git a/Tests/ICCeryUITests/Fixtures/bin/lp b/Tests/ICCeryUITests/Fixtures/bin/lp new file mode 100755 index 0000000..6e091f1 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lp @@ -0,0 +1,14 @@ +#!/bin/sh +# Mock lp for Milestone3UITests. Appends its full argv to +# ICCERY_TEST_LP_ARGV so the test can assert flag order and option +# replay, then exits 0 (or ICCERY_MOCK_LP_EXIT for failure injection). +{ + printf 'lp' + for arg in "$@"; do printf ' %s' "$arg"; done + printf '\n' +} >> "${ICCERY_TEST_LP_ARGV:-/dev/null}" +if [ "${ICCERY_MOCK_LP_EXIT:-0}" -ne 0 ]; then + echo "mock lp failure" >&2 + exit "$ICCERY_MOCK_LP_EXIT" +fi +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions new file mode 100755 index 0000000..d274f3b --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -0,0 +1,23 @@ +#!/bin/sh +# Mock lpoptions for Milestone3UITests. `-p ` prints printer-info; +# `-p -l` prints Key/Label listings incl. Epson bypass keys. +queue="" +list=0 +for arg in "$@"; do + case "$arg" in + -p) shift_flag=1 ;; + -l) list=1 ;; + -*) ;; + *) queue="$arg" ;; + esac +done +if [ "$list" = "1" ]; then + printf 'PageSize/Media Size: 4x6 5x7 *A4 Letter Legal\n' + printf 'InputSlot/Media Source: Auto *Main Rear\n' + printf 'MediaType/Media Type: *Stationery PhotographicGlossy PhotographicMatte\n' + printf 'EPIJ_CMat/Color Adjust: *0 1 2 3\n' + printf 'ColorModel/Output Mode: *RGB Gray\n' + exit 0 +fi +printf "printer-info='Mock %s' printer-type=42\n" "$queue" +exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpstat b/Tests/ICCeryUITests/Fixtures/bin/lpstat new file mode 100755 index 0000000..28a7347 --- /dev/null +++ b/Tests/ICCeryUITests/Fixtures/bin/lpstat @@ -0,0 +1,19 @@ +#!/bin/sh +# Mock lpstat for Milestone3UITests. Emits two canned queues so the UI +# can exercise select/refresh/status-badge without real CUPS. +case "$1" in + -e) + printf 'Mock_Epson_7450\nMock_Canon_Pro\n' + ;; + -p) + printf 'printer Mock_Epson_7450 is idle. enabled since Mon Sep 7 21:50:25 2026\n' + printf 'printer Mock_Canon_Pro disabled since Tue Sep 8 09:00:00 2026 -\n\tPaused\n' + ;; + -d) + printf 'system default destination: Mock_Epson_7450\n' + ;; + *) + exit 1 + ;; +esac +exit 0 diff --git a/Tests/ICCeryUITests/Milestone3UITests.swift b/Tests/ICCeryUITests/Milestone3UITests.swift new file mode 100644 index 0000000..a674288 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone3UITests.swift @@ -0,0 +1,219 @@ +import XCTest + +/// Milestone 3 UI tests — issue #17 print panel end-to-end with mock +/// CUPS binaries and a stubbed `NSPrintPanel`. The real panel is a +/// system modal XCUITest cannot drive; `ICCERY_TEST_PRINT_PANEL` +/// returns a canned `PrintPropertiesResult` instead. Mock `lp` appends +/// its argv to `ICCERY_TEST_LP_ARGV` for assertions — that file is the +/// evidence that captured options are replayed (docs/11 §tests). +@MainActor +final class Milestone3UITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var lpArgvURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui3-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + lpArgvURL = testRoot.appendingPathComponent("lp-argv.log") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_LP_ARGV": lpArgvURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func launchApp() { + app.launch() + app.activate() + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + /// Drive the app through targen + printtarg so the print panel is + /// live with a manifest. + private func reachPrintPanel() { + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 25) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 25) + } + + private func recordedLpArgv() -> String { + (try? String(contentsOf: lpArgvURL, encoding: .utf8)) ?? "" + } + + private func waitForLpLine(_ timeout: TimeInterval = 10) -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let out = recordedLpArgv() + if !out.isEmpty { return out } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return recordedLpArgv() + } + + // MARK: - Tests + + /// Panel appears after the manifest; refresh populates the printer + /// select with the mock queues and shows a status badge. + func testPrintPanelEnumeratesPrinters() throws { + launchApp() + reachPrintPanel() + + XCTAssertTrue(waitFor("rawPrintPanel").exists) + // The panel auto-refreshes on appear; the default mock queue is + // selected and its status badge shows. + XCTAssertTrue(element("printerSelect").waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerStatusBadge") + .waitForExistence(timeout: 10)) + XCTAssertTrue(element("printerTraySelect").exists) + XCTAssertTrue(element("printerMediaTypeSelect").exists) + XCTAssertTrue(element("btnOrientPortrait").exists) + XCTAssertTrue(element("btnOrientLandscape").exists) + XCTAssertTrue(app.buttons["btnPrintAll"].isEnabled) + } + + /// Preferences cancel → info notice, no error, no cache mutation. + func testPreferencesCancelIsInfo() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "cancel" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("cancelled")) + } + + /// Preferences OK → captured options are replayed verbatim in the + /// `lp` argv alongside the two mandatory AP_* headers (issue 17's + /// acceptance test: "captured options replayed in argv"). + func testCapturedOptionsReplayedInLpArgv() throws { + app.launchEnvironment["ICCERY_TEST_PRINT_PANEL"] = "ok" + app.launchEnvironment["ICCERY_TEST_PANEL_OPTIONS"] = + "InputSlot=Rear MediaType=PhotographicGlossy" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + element("btnPrinterProperties").click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Settings captured")) + + app.buttons["btnPrintAll"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains( + "AP_ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains( + "AP.ColorMatchingMode=AP_ApplicationColorMatching"), argv) + XCTAssertTrue(argv.contains("InputSlot=Rear"), argv) + XCTAssertTrue(argv.contains("MediaType=PhotographicGlossy"), argv) + // Detected bypass for the mock queue (EPIJ_CMat present in + // lpoptions -l) is appended when not captured. + XCTAssertTrue(argv.contains("EPIJ_CMat=3"), argv) + XCTAssertTrue(argv.contains("orientation-requested=3"), argv) + // Last token is the TIFF. + XCTAssertTrue(argv.trimmingCharacters(in: .whitespacesAndNewlines) + .hasSuffix("page1.tif"), argv) + } + + /// Per-page print uses the same spool path (btnPrintPage-N). + func testPerPagePrint() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintPage-0"].click() + let argv = waitForLpLine() + XCTAssertTrue(argv.contains("AP_ColorMatchingMode"), argv) + XCTAssertTrue(argv.contains("page1.tif"), argv) + } + + /// lp failure surfaces in the in-panel notice, not the wizard banner. + func testLpFailureShowsPrintNotice() throws { + app.launchEnvironment["ICCERY_MOCK_LP_EXIT"] = "1" + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + let notice = element("printNotificationText") + XCTAssertTrue(notice.waitForExistence(timeout: 10)) + XCTAssertTrue((notice.value as? String ?? "") + .contains("Print failed")) + } + + /// wizardState.printerName records the queue used for spooling (#95). + func testPrinterNamePersistedOnSpool() throws { + launchApp() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + app.buttons["btnPrintAll"].click() + _ = waitForLpLine() + let stateURL = testRoot + .appendingPathComponent("AppData") + .appendingPathComponent("wizard_state.json") + XCTAssertTrue(waitForFile(stateURL)) + let data = try Data(contentsOf: stateURL) + let state = String(data: data, encoding: .utf8) ?? "" + XCTAssertTrue(state.contains("Mock_Epson_7450"), state) + } + + private func waitForFile(_ url: URL, timeout: TimeInterval = 10) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if FileManager.default.fileExists(atPath: url.path) { return true } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return false + } +} -- 2.39.5 From 1a2b948447b1570d75006f2e6f1ece49a02d982e Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 9 Sep 2026 07:07:04 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fixup!=20Stage=202=20live=20print=20panel?= =?UTF-8?q?=20=E2=80=94=20Task=20@MainActor=20+=20lpoptions=20keyless=20sk?= =?UTF-8?q?ip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ensure unstructured Task closures in TargetWorkflowViewModel and Stage2View run on @MainActor after awaiting CupsService / spool. Without this, updates in the failure path were not reaching the UI in time, failing Milestone3UITests. - lpoptions parser: a token no longer truncates the whole parse. UI test results: Milestone3UITests 6/6 pass; Core tests 154/154 pass; universal arm64 x86_64 build passes. --- Sources/ICCery/Stage2View.swift | 7 ++++--- Sources/ICCery/TargetWorkflowViewModel.swift | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index fd9c707..8d2605e 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -249,7 +249,7 @@ struct Stage2View: View { .onChange(of: workflow.selectedPrinter) { _, _ in workflow.selectedTray = nil workflow.selectedMediaType = nil - Task { await workflow.reloadSelectedCapabilities() } + Task { @MainActor in await workflow.reloadSelectedCapabilities() } } if let selected = workflow.printers .first(where: { $0.name == workflow.selectedPrinter }) { @@ -330,8 +330,9 @@ struct Stage2View: View { .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .accessibilityElement(children: .contain) .accessibilityIdentifier("rawPrintPanel") - .task { - // Auto-enumerate when the panel appears with a manifest. + .task(id: workflow.printtargResult?.pages.count) { + // Auto-enumerate once a manifest exists and whenever it + // changes (e.g. resume from .ti2). if workflow.printers.isEmpty, workflow.printtargResult != nil { workflow.refreshPrinters() } diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 1a4efbb..9e3f103 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -205,7 +205,7 @@ final class TargetWorkflowViewModel { targenLog = [] resumedFromTi2 = false let runner = environment.runner - Task { + Task { @MainActor in do { let url = try await runner.runTargen(config: config) { [weak self] batch in Task { @MainActor [weak self] in @@ -297,7 +297,7 @@ final class TargetWorkflowViewModel { printtargLog = [] printtargResult = nil let runner = environment.runner - Task { + Task { @MainActor in do { let result = try await runner.runPrinttarg(config: config) { [weak self] batch in Task { @MainActor [weak self] in @@ -331,7 +331,7 @@ final class TargetWorkflowViewModel { /// appears with a manifest. func refreshPrinters() { let cups = environment.cupsService - Task { + Task { @MainActor in do { let list = try await cups.listPrinters() printers = list @@ -379,7 +379,7 @@ final class TargetWorkflowViewModel { let queue = selectedPrinter let displayName = printers.first { $0.name == queue }?.displayName let cups = environment.cupsService - Task { + Task { @MainActor in do { guard let result = try await PrintPanelService() .showProperties( @@ -411,12 +411,14 @@ final class TargetWorkflowViewModel { } } + /// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on + /// the first failure so the user sees which page failed. /// `#btnPrintAll` — spool every gallery TIFF, sequentially. Stops on /// the first failure so the user sees which page failed. func printAllPages() { guard let result = printtargResult, !isPrinting else { return } isPrinting = true - Task { + Task { @MainActor in var printed = 0 for page in result.pages { do { @@ -440,7 +442,7 @@ final class TargetWorkflowViewModel { func printPage(_ page: GalleryPage) { guard !isPrinting else { return } isPrinting = true - Task { + Task { @MainActor in do { try await spool(page, index: page.index) printNotice = "Sent \(page.page.filename) to \(selectedPrinter)." -- 2.39.5