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..149f4e5 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsOptionsFilter.swift @@ -0,0 +1,62 @@ +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. `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", "raw", + ] + + /// 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/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift new file mode 100644 index 0000000..22221f7 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -0,0 +1,264 @@ +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) } + } + + /// 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`; + /// 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..63eeb60 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -0,0 +1,191 @@ +import Foundation + +/// Errors from CUPS tool invocations. +public enum CupsError: LocalizedError, Equatable { + case toolFailed(tool: String, code: Int32, stderr: String) + case tiffMissing(String) + case noPrinterSelected + + 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)" + case .noPrinterSelected: + return "No printer selected." + } + } +} + +/// 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: - 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? { + 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/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/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/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/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/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 new file mode 100644 index 0000000..3436efe --- /dev/null +++ b/Sources/ICCery/Print/PrintPanelService.swift @@ -0,0 +1,186 @@ +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 { + + /// 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( + queue: String, + displayName: String?, + cupsService: CupsService + ) async throws -> PrintPropertiesResult? { + #if DEBUG + if UITestHooks.printPanelStubbed { + return UITestHooks.printPanelResult(forQueue: queue) + } + #endif + // `??` rhs is a non-async @autoclosure — fetch first. + let fetched = try? await cupsService.displayName(for: queue) + let display = displayName ?? fetched + // 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?, + optionKeys: Set + ) 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(Self.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(Self.pmObject(printer)) + } + } + + // ②–⑤ 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 = [ + .showsCopies, .showsPageRange, .showsPaperSize, + .showsOrientation, .showsScaling, .showsPrintSelection, + .showsPageSetupAccessory, .showsPreview, + ] + panel.setDefaultButtonTitle("Use Settings") + + let response = panel.runModal(with: printInfo) + 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( + session: unsafeBitCast( + printInfo.pmPrintSession(), to: PMPrintSession.self), + fallback: queue) + : nil, + options: PrintOptions( + mediaType: mediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: cupsOptions)) + } + + // 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/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 206fbf9..8d2605e 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -202,7 +202,7 @@ struct Stage2View: View { spacing: 12 ) { ForEach(result.pages) { page in - GalleryPageView(page: page) + GalleryPageView(page: page, workflow: workflow) } } .accessibilityElement(children: .contain) @@ -213,24 +213,111 @@ struct Stage2View: View { } } - // MARK: - Raw print panel (#rawPrintPanel) — stubbed until M3 + // MARK: - Raw print panel (#rawPrintPanel) — unmanaged lp path private var printPanel: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Print").font(.headline).foregroundStyle(Theme.text) - Text("Unmanaged printing (lp) lands in Milestone 3.") - .font(.caption).foregroundStyle(.secondary) - .accessibilityIdentifier("printNotification") + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 12) { + Text("Print").font(.headline).foregroundStyle(Theme.text) + if let notice = workflow.printNotice { + Image(systemName: workflow.printNoticeIsError + ? "xmark.circle.fill" : "info.circle.fill") + .foregroundStyle(workflow.printNoticeIsError + ? .red : .blue) + .accessibilityIdentifier("printNotificationIcon") + Text(notice) + .font(.caption) + .foregroundStyle(workflow.printNoticeIsError + ? .red : .secondary) + .accessibilityIdentifier("printNotificationText") + } + Spacer() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("printNotification") + + // Printer row: select + status + refresh + Preferences. + HStack(spacing: 10) { + Picker("Printer", selection: $workflow.selectedPrinter) { + ForEach(workflow.printers, id: \.name) { printer in + Text(printer.displayName ?? printer.name) + .tag(printer.name) + } + } + .frame(maxWidth: 320) + .accessibilityIdentifier("printerSelect") + .onChange(of: workflow.selectedPrinter) { _, _ in + workflow.selectedTray = nil + workflow.selectedMediaType = nil + Task { @MainActor in await workflow.reloadSelectedCapabilities() } + } + if let selected = workflow.printers + .first(where: { $0.name == workflow.selectedPrinter }) { + Text(selected.status.rawValue) + .font(.caption).foregroundStyle(.secondary) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Theme.background) + .clipShape(Capsule()) + .accessibilityIdentifier("printerStatusBadge") + } + Button(action: workflow.refreshPrinters) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh printer list") + .accessibilityIdentifier("btnRefreshPrinters") + Button(action: workflow.openPrinterPreferences) { + Image(systemName: "gearshape") + } + .help("Printer properties — bound NSPrintPanel") + .disabled(workflow.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrinterProperties") + } + + // Tray / media / orientation — from queue capabilities. + HStack(spacing: 14) { + if !workflow.printerCaps.trays.isEmpty { + Picker("Tray", selection: $workflow.selectedTray) { + ForEach(workflow.printerCaps.trays, id: \.id) { + Text($0.name).tag(Optional($0.id)) + } + } + .frame(maxWidth: 200) + .accessibilityIdentifier("printerTraySelect") + } + if !workflow.printerCaps.mediaTypes.isEmpty { + Picker("Media", selection: $workflow.selectedMediaType) { + ForEach(workflow.printerCaps.mediaTypes, id: \.id) { + Text($0.name).tag(Optional($0.id)) + } + } + .frame(maxWidth: 240) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mediaTypeGroup") + .accessibilityIdentifier("printerMediaTypeSelect") + } + HStack(spacing: 0) { + Button("Portrait") { workflow.printOrientation = "portrait" } + .buttonStyle(.bordered) + .tint(workflow.printOrientation == "portrait" ? .accentColor : .gray) + .accessibilityIdentifier("btnOrientPortrait") + Button("Landscape") { workflow.printOrientation = "landscape" } + .buttonStyle(.bordered) + .tint(workflow.printOrientation == "landscape" ? .accentColor : .gray) + .accessibilityIdentifier("btnOrientLandscape") + } + Spacer() + } + HStack(spacing: 8) { - Button("Print All") {} - .accessibilityIdentifier("btnPrintAll") - .disabled(true) - Button("Refresh Printers") {} - .accessibilityIdentifier("btnRefreshPrinters") - .disabled(true) - Button("Printer Properties") {} - .accessibilityIdentifier("btnPrinterProperties") - .disabled(true) + Button(action: workflow.printAllPages) { + Label(workflow.isPrinting ? "Printing…" : "Print All", + systemImage: "printer") + } + .controlSize(.large) + .disabled(workflow.isPrinting + || workflow.printtargResult == nil + || workflow.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnPrintAll") Spacer() Button("Advance to Stage 3") { workflow.advanceToStage3() } .accessibilityIdentifier("btnAdvanceToStage3") @@ -243,12 +330,20 @@ struct Stage2View: View { .clipShape(RoundedRectangle(cornerRadius: Theme.Metrics.cornerMedium)) .accessibilityElement(children: .contain) .accessibilityIdentifier("rawPrintPanel") + .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() + } + } } } -/// One gallery cell: PNG preview + per-page stubbed Print button. +/// One gallery cell: PNG preview + per-page Print button. private struct GalleryPageView: View { let page: GalleryPage + let workflow: TargetWorkflowViewModel var body: some View { VStack(spacing: 6) { @@ -269,8 +364,9 @@ private struct GalleryPageView: View { Text("\(page.page.patches) patches · " + "\(Int(page.page.widthMm))×\(Int(page.page.heightMm)) mm") .font(.caption2).foregroundStyle(.secondary) - Button("Print") {} - .disabled(true) + Button("Print") { workflow.printPage(page) } + .disabled(workflow.isPrinting + || workflow.selectedPrinter.isEmpty) .accessibilityIdentifier("btnPrintPage-\(page.index)") } .padding(8) diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index 80b5707..9e3f103 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -89,6 +89,27 @@ final class TargetWorkflowViewModel { /// Stage 3 (`#stage3LoadedTargetBanner` data). var resumedFromTi2 = false + // MARK: - Print panel (issue 17) + + /// CUPS destinations from `lpstat` (#printerSelect). + var printers: [Printer] = [] + /// Selected queue name. + var selectedPrinter = "" + /// Capabilities of the selected queue (#printerTraySelect / + /// #printerMediaTypeSelect / PageSize source). + var printerCaps = PrinterCapabilities() + var selectedTray: Int? + var selectedMediaType: String? + /// "portrait" | "landscape" (#btnOrientPortrait/#btnOrientLandscape). + var printOrientation = "portrait" + /// Per-queue captured `key=value` strings from Preferences — replayed + /// on `lp` (session-only, docs/11 §capturedCupsOptions). + var capturedCupsOptions: [String: String] = [:] + /// In-panel notice (#printNotification) — cancel → info, not error. + var printNotice: String? + var printNoticeIsError = false + var isPrinting = false + // MARK: - Presets var presets: [ProfilingPreset] = [] @@ -184,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 @@ -276,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 @@ -303,6 +324,156 @@ final class TargetWorkflowViewModel { wizard.go(to: .measure) } + // MARK: - Print panel actions (issue 17) + + /// `#btnRefreshPrinters` — re-enumerate CUPS destinations and load + /// capabilities for the selection. Auto-runs when the panel first + /// appears with a manifest. + func refreshPrinters() { + let cups = environment.cupsService + Task { @MainActor in + do { + let list = try await cups.listPrinters() + printers = list + if !list.contains(where: { $0.name == selectedPrinter }) { + selectedPrinter = list.first { $0.isDefault }?.name + ?? list.first?.name ?? "" + } + await reloadSelectedCapabilities() + } catch { + printNotice = "Could not list printers: \(error.localizedDescription)" + printNoticeIsError = true + } + } + } + + /// Capabilities for `selectedPrinter` — trays / media / sizes feed + /// the selects. + func reloadSelectedCapabilities() async { + guard !selectedPrinter.isEmpty else { + printerCaps = PrinterCapabilities() + return + } + do { + printerCaps = try await environment.cupsService + .capabilities(for: selectedPrinter) + // Default selections only when the captured options didn't + // already pin them (Preferences round-trip wins). + if selectedMediaType == nil { + selectedMediaType = printerCaps.mediaTypes.first?.id + } + if selectedTray == nil { + selectedTray = printerCaps.trays.first?.id + } + } catch { + printerCaps = PrinterCapabilities() + } + } + + /// `#btnPrinterProperties` — bound NSPrintPanel ("Use Settings"). + /// Cancel → info notice, never an error, cache untouched. On OK the + /// captured options are stored per-queue; a panel-side queue switch + /// updates `printerSelect` when the returned CUPS id is in the list. + func openPrinterPreferences() { + guard !selectedPrinter.isEmpty else { return } + let queue = selectedPrinter + let displayName = printers.first { $0.name == queue }?.displayName + let cups = environment.cupsService + Task { @MainActor in + do { + guard let result = try await PrintPanelService() + .showProperties( + queue: queue, displayName: displayName, + cupsService: cups) + else { + printNotice = "Printer properties dialog cancelled." + printNoticeIsError = false + return + } + if let selected = result.selectedPrinter, + printers.contains(where: { $0.name == selected }), + selected != queue { + selectedPrinter = selected + await reloadSelectedCapabilities() + } + if let captured = result.options.cupsOptions { + capturedCupsOptions[selectedPrinter] = captured + } + if let media = result.options.mediaType { + selectedMediaType = media + } + printNotice = "Settings captured for \(selectedPrinter)." + printNoticeIsError = false + } catch { + printNotice = error.localizedDescription + printNoticeIsError = true + } + } + } + + /// `#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 { @MainActor in + var printed = 0 + for page in result.pages { + do { + try await spool(page, index: page.index) + printed += 1 + } catch { + printNotice = "Print failed on \(page.page.filename): " + + error.localizedDescription + printNoticeIsError = true + isPrinting = false + return + } + } + printNotice = "Sent \(printed) page(s) to \(selectedPrinter)." + printNoticeIsError = false + isPrinting = false + } + } + + /// `#btnPrintPage-N` — one TIFF. + func printPage(_ page: GalleryPage) { + guard !isPrinting else { return } + isPrinting = true + Task { @MainActor in + do { + try await spool(page, index: page.index) + printNotice = "Sent \(page.page.filename) to \(selectedPrinter)." + printNoticeIsError = false + } catch { + printNotice = "Print failed: \(error.localizedDescription)" + printNoticeIsError = true + } + isPrinting = false + } + } + + private func spool(_ page: GalleryPage, index: Int) async throws { + guard !selectedPrinter.isEmpty else { + throw CupsError.noPrinterSelected + } + let options = PrintOptions( + orientation: printOrientation, + paperSize: pageSize == .custom ? nil : pageSize.rawValue, + mediaType: selectedMediaType, + ppdUncorrectedPassthrough: true, + cupsOptions: capturedCupsOptions[selectedPrinter]) + try await environment.cupsService.printTarget( + queue: selectedPrinter, + tiffPath: page.fileURL.path, + options: options, + page: index) + // For Stage 5 history (#95): record which queue printed. + wizard.printerName = selectedPrinter + } + // MARK: - Presets func reloadPresets() { diff --git a/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift new file mode 100644 index 0000000..52498d3 --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsOptionsFilterTests.swift @@ -0,0 +1,144 @@ +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. +/// `@convention(c)` closures can't capture, so recording goes through +/// a file-scope recorder keyed by global state; 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) + } + + /// 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 + if Self.missing.contains(name) { return nil } + Self.currentSymbol = name + return { _, modeArg in + 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 + } + + @Test("Attempt order: Lock → Mode → NoLock, AP_ prefix first") + func attemptOrder() { + Self.recorded = [] + Self.succeeding = nil + Self.missing = ["PMSessionSetColorMatchingModeLock"] + let s = makeSuppressor() + #expect(s.applySPIMode(to: fakeSession) == false) + // 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/modes not called") + func firstZeroWins() { + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "AP_ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() + #expect(s.applySPIMode(to: fakeSession)) + #expect(Self.recorded.map { "\($0.0)|\($0.1)" } == [ + "PMSessionSetColorMatchingModeLock|AP_ApplicationColorMatching", + ]) + } + + @Test("Mode fallback: AP_ rejected → ApplicationColorMatching tried") + func modeFallback() { + Self.recorded = [] + Self.succeeding = ("PMSessionSetColorMatchingModeLock", + "ApplicationColorMatching") + Self.missing = [] + let s = makeSuppressor() + #expect(s.applySPIMode(to: fakeSession)) + #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() { + Self.recorded = [] + Self.succeeding = nil + Self.missing = Set(ColorMatchingAttempts.symbols) + let s = makeSuppressor() + #expect(s.applySPIMode(to: fakeSession) == false) + #expect(Self.recorded.isEmpty) + } +} diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift new file mode 100644 index 0000000..fdf948f --- /dev/null +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -0,0 +1,145 @@ +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() { + 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 new file mode 100644 index 0000000..4dcb22a --- /dev/null +++ b/Tests/ICCeryCoreTests/LpArgsTests.swift @@ -0,0 +1,131 @@ +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")) + let capturedOrients = try build(options: PrintOptions( + orientation: "landscape", + cupsOptions: "orientation-requested=5")) + #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")) + let capturedSize = try build(options: PrintOptions( + paperSize: "A4", cupsOptions: "PageSize=Letter")) + #expect(!capturedSize.contains("PageSize=A4")) + #expect(capturedSize.contains("PageSize=Letter")) + } + + @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)")) + } + } +} 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) + } + } +} 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 + } +}