feat(print): AirPrint queue detection + Stage 2 warning badge (#202 Phase 5) #207
@@ -73,6 +73,26 @@ public enum CupsParsers {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
|
||||||
|
/// Entries with no URI (e.g. `network <name>` remote stubs) are
|
||||||
|
/// skipped (#202 AirPrint detection).
|
||||||
|
public static func lpstatDeviceURIs(output: String) -> [String: String] {
|
||||||
|
var result: [String: String] = [:]
|
||||||
|
for line in output.split(separator: "\n") {
|
||||||
|
let text = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard text.hasPrefix("device for ") else { continue }
|
||||||
|
let rest = text.dropFirst("device for ".count)
|
||||||
|
guard let colon = rest.firstIndex(of: ":") else { continue }
|
||||||
|
let name = String(rest[..<colon])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
let uri = String(rest[rest.index(after: colon)...])
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty, !uri.isEmpty else { continue }
|
||||||
|
result[name] = uri
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -p <queue>
|
// MARK: - lpoptions -p <queue>
|
||||||
|
|
||||||
/// `lpoptions -p` — `key=value` pairs, values may be
|
/// `lpoptions -p` — `key=value` pairs, values may be
|
||||||
@@ -137,6 +157,16 @@ public enum CupsParsers {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Make-and-model from `printer-make-and-model` in `lpoptions -p`
|
||||||
|
/// output — an AirPrint detection input (#202).
|
||||||
|
public static func lpoptionsMakeAndModel(output: String) -> String? {
|
||||||
|
guard let value = lpoptions(output)
|
||||||
|
.first(where: { $0.key == "printer-make-and-model" })?.value,
|
||||||
|
!value.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - lpoptions -l
|
// MARK: - lpoptions -l
|
||||||
|
|
||||||
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
/// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`.
|
||||||
@@ -352,4 +382,62 @@ public enum CupsParsers {
|
|||||||
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
"EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection",
|
||||||
"EpsonColorMode",
|
"EpsonColorMode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// MARK: - AirPrint detection (#202, docs/14 §10.2)
|
||||||
|
|
||||||
|
/// AirPrint queues always colour-manage the URF raster path, so
|
||||||
|
/// unmanaged device colour cannot be guaranteed — Stage 2 warns
|
||||||
|
/// via `airPrintWarningBadge`. `true` when ANY rule matches:
|
||||||
|
///
|
||||||
|
/// 1. device URI contains `apple-airprint://`
|
||||||
|
/// 2. PPD declares `*APAirPrint: True`
|
||||||
|
/// 3. make-and-model contains "Apple" **and** "AirPrint"
|
||||||
|
/// 4. `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// (case-insensitive)
|
||||||
|
/// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS
|
||||||
|
/// subtype (`_universal._sub._ipp._tcp`)
|
||||||
|
/// 6. PPD carries a `*cupsFilter2` rule whose destination MIME
|
||||||
|
/// type is `image/urf` — the raster only AirPrint consumes
|
||||||
|
public static func detectAirPrint(
|
||||||
|
deviceURI: String?, makeAndModel: String?, ppd: String
|
||||||
|
) -> Bool {
|
||||||
|
if let uri = deviceURI {
|
||||||
|
let lower = uri.lowercased()
|
||||||
|
if lower.contains("apple-airprint://") { return true }
|
||||||
|
if lower.hasPrefix("ipps://"),
|
||||||
|
ppd.range(of: "airprint", options: .caseInsensitive) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if lower.hasPrefix("ipp://"),
|
||||||
|
lower.contains("_universal._sub._ipp._tcp") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ppd.contains("*APAirPrint: True") { return true }
|
||||||
|
if let model = makeAndModel,
|
||||||
|
model.contains("Apple"), model.contains("AirPrint") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ppdCarriesURFFilter(ppd)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes
|
||||||
|
/// optional); `image/urf` as the destination token means the queue
|
||||||
|
/// consumes the AirPrint raster.
|
||||||
|
private static func ppdCarriesURFFilter(_ ppd: String) -> Bool {
|
||||||
|
for rawLine in ppd.split(separator: "\n") {
|
||||||
|
let line = rawLine.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard line.lowercased().hasPrefix("*cupsfilter2"),
|
||||||
|
let colon = line.firstIndex(of: ":")
|
||||||
|
else { continue }
|
||||||
|
let tokens = line[line.index(after: colon)...]
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
.split(separator: " ")
|
||||||
|
if tokens.count >= 2,
|
||||||
|
tokens[1].lowercased() == "image/urf" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,19 +57,29 @@ public struct CupsService: Sendable {
|
|||||||
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
"lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true)
|
||||||
let defaultOut = try await run(
|
let defaultOut = try await run(
|
||||||
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
"lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true)
|
||||||
|
// One-shot device-URI fetch for AirPrint detection (#202). A
|
||||||
|
// failed `lpstat -v` is tolerated — enumeration proceeds with
|
||||||
|
// no URIs and no fallback respawn.
|
||||||
|
let deviceOut = try await run(
|
||||||
|
"lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true)
|
||||||
|
|
||||||
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
let names = CupsParsers.lpstatDestinations(destinationsOut.stdout)
|
||||||
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
let statuses = CupsParsers.lpstatStatuses(statusOut.stdout)
|
||||||
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout)
|
||||||
|
let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout)
|
||||||
|
|
||||||
var printers: [Printer] = []
|
var printers: [Printer] = []
|
||||||
for name in names {
|
for name in names {
|
||||||
let displayName = try? await displayName(for: name)
|
let identity = try? await queueIdentity(for: name)
|
||||||
printers.append(Printer(
|
printers.append(Printer(
|
||||||
name: name,
|
name: name,
|
||||||
status: statuses[name] ?? .unknown,
|
status: statuses[name] ?? .unknown,
|
||||||
isDefault: name == defaultName,
|
isDefault: name == defaultName,
|
||||||
displayName: displayName
|
displayName: identity?.displayName,
|
||||||
|
isAirPrint: CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: deviceURIs[name],
|
||||||
|
makeAndModel: identity?.makeAndModel,
|
||||||
|
ppd: loadPPD(for: name) ?? "")
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return printers
|
return printers
|
||||||
@@ -78,9 +88,19 @@ public struct CupsService: Sendable {
|
|||||||
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
/// `lpoptions -p <queue>` → `printer-info` (the NSPrinter fallback
|
||||||
/// display name, docs/11 §binding).
|
/// display name, docs/11 §binding).
|
||||||
public func displayName(for queue: String) async throws -> String? {
|
public func displayName(for queue: String) async throws -> String? {
|
||||||
|
try await queueIdentity(for: queue).displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One `lpoptions -p <queue>` spawn yields both identity fields —
|
||||||
|
/// `printer-info` (display name) and `printer-make-and-model`
|
||||||
|
/// (AirPrint rule 3, #202).
|
||||||
|
private func queueIdentity(
|
||||||
|
for queue: String
|
||||||
|
) async throws -> (displayName: String?, makeAndModel: String?) {
|
||||||
let result = try await run(
|
let result = try await run(
|
||||||
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
"lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue))
|
||||||
return CupsParsers.lpoptionsDisplayName(result.stdout)
|
return (CupsParsers.lpoptionsDisplayName(result.stdout),
|
||||||
|
CupsParsers.lpoptionsMakeAndModel(output: result.stdout))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Capabilities (lpoptions -l + PPD)
|
// MARK: - Capabilities (lpoptions -l + PPD)
|
||||||
|
|||||||
@@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable {
|
|||||||
public var status: PrinterStatus
|
public var status: PrinterStatus
|
||||||
public var isDefault: Bool
|
public var isDefault: Bool
|
||||||
public var displayName: String?
|
public var displayName: String?
|
||||||
|
/// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always
|
||||||
|
/// colour-managed, so Stage 2 shows a persistent warning badge.
|
||||||
|
public var isAirPrint: Bool = false
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
name: String,
|
name: String,
|
||||||
status: PrinterStatus = .unknown,
|
status: PrinterStatus = .unknown,
|
||||||
isDefault: Bool = false,
|
isDefault: Bool = false,
|
||||||
displayName: String? = nil
|
displayName: String? = nil,
|
||||||
|
isAirPrint: Bool = false
|
||||||
) {
|
) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.status = status
|
self.status = status
|
||||||
self.isDefault = isDefault
|
self.isDefault = isDefault
|
||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
|
self.isAirPrint = isAirPrint
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `isAirPrint` predates #202 payloads — a missing key decodes as
|
||||||
|
/// `false` instead of failing the whole decode.
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
status = try c.decode(PrinterStatus.self, forKey: .status)
|
||||||
|
isDefault = try c.decode(Bool.self, forKey: .isDefault)
|
||||||
|
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||||
|
isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable {
|
|||||||
public var ppdUncorrectedPassthrough: Bool?
|
public var ppdUncorrectedPassthrough: Bool?
|
||||||
/// Space-separated `key=value` captured from
|
/// Space-separated `key=value` captured from
|
||||||
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
/// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) —
|
||||||
/// the Stage 2 mirror only, never a spool payload.
|
/// the Stage 2 mirror only, never an `lp` payload.
|
||||||
public var cupsOptions: String?
|
public var cupsOptions: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
|
|||||||
@@ -266,12 +266,19 @@ struct Stage2View: View {
|
|||||||
.background(Theme.background)
|
.background(Theme.background)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
.accessibilityIdentifier("printerStatusBadge")
|
.accessibilityIdentifier("printerStatusBadge")
|
||||||
}
|
// AirPrint queues colour-manage the URF path —
|
||||||
// Placeholder — Phase 5 (AirPrint detection, M12)
|
// unmanaged colour cannot be guaranteed (#202).
|
||||||
// replaces this with the live unmanaged-colour
|
if selected.isAirPrint {
|
||||||
// warning badge for AirPrint queues.
|
Text("AirPrint queue — unmanaged colour "
|
||||||
EmptyView()
|
+ "cannot be guaranteed.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
|
.background(Color.orange.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
.accessibilityIdentifier("airPrintWarningBadge")
|
.accessibilityIdentifier("airPrintWarningBadge")
|
||||||
|
}
|
||||||
|
}
|
||||||
Button(action: workflow.print.refreshPrinters) {
|
Button(action: workflow.print.refreshPrinters) {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase {
|
|||||||
processManager: ProcessManager(),
|
processManager: ProcessManager(),
|
||||||
binaryDir: bin, ppdDir: ppdDir), root)
|
binaryDir: bin, ppdDir: ppdDir), root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - #202 AirPrint detection
|
||||||
|
|
||||||
|
/// `lpstat -v` — `device for <name>: <uri>` lines; a `network`
|
||||||
|
/// remote stub carries no URI and is skipped.
|
||||||
|
private let lpstatV = """
|
||||||
|
device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB
|
||||||
|
device for Epson_XP_55_LPD: lpd://192.168.1.50/queue
|
||||||
|
device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./
|
||||||
|
device for Office_IPPS: ipps://print.example.com/ipp/print
|
||||||
|
network Remote_Queue
|
||||||
|
"""
|
||||||
|
|
||||||
|
func testLpstatDeviceURIs() {
|
||||||
|
let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV)
|
||||||
|
XCTAssertEqual(uris.count, 4)
|
||||||
|
XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"],
|
||||||
|
"usb://Canon/PRO-9500%20II%20series?serial=1234AB")
|
||||||
|
XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue")
|
||||||
|
XCTAssertEqual(uris["EPSON_XP_55_Series"],
|
||||||
|
"ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./")
|
||||||
|
XCTAssertEqual(uris["Office_IPPS"],
|
||||||
|
"ipps://print.example.com/ipp/print")
|
||||||
|
XCTAssertNil(uris["Remote_Queue"])
|
||||||
|
XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLpoptionsMakeAndModel() {
|
||||||
|
XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: lpoptionsP), "EPSON EPSON XP-55 Series")
|
||||||
|
XCTAssertNil(CupsParsers.lpoptionsMakeAndModel(
|
||||||
|
output: "printer-type=42\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 1 — an `apple-airprint://` device URI is AirPrint on its
|
||||||
|
/// own; PPD and make-and-model are irrelevant.
|
||||||
|
func testAirPrintRuleAppleAirPrintScheme() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 2 — the PPD declares `*APAirPrint: True`.
|
||||||
|
func testAirPrintRulePPDFlag() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*APAirPrint: True
|
||||||
|
*OpenUI *PageSize/Media Size: PickOne
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 3 — make-and-model contains "Apple" and "AirPrint".
|
||||||
|
func testAirPrintRuleMakeAndModel() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple AirPrint", ppd: ""))
|
||||||
|
// Both tokens are required.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "Apple LaserWriter", ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "socket://10.0.0.9/",
|
||||||
|
makeAndModel: "HP AirPrint-Ready", ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint"
|
||||||
|
/// case-insensitively. `ipps://` alone is not enough.
|
||||||
|
func testAirPrintRuleIPPSWithPPDMention() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"AIRPRINT enabled\"\n"))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipps://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n"))
|
||||||
|
// An unencrypted ipp:// URI does not satisfy rule 4.
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://print.example.com/ipp/print",
|
||||||
|
makeAndModel: nil,
|
||||||
|
ppd: "*Foo: \"airprint enabled\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS
|
||||||
|
/// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name
|
||||||
|
/// without the subtype is not AirPrint.
|
||||||
|
func testAirPrintRuleMDNSSubtype() {
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./",
|
||||||
|
makeAndModel: nil, ppd: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rule 6 — a `*cupsFilter2` rule whose destination MIME is
|
||||||
|
/// `image/urf` (the AirPrint-only raster). A PWG-raster filter is
|
||||||
|
/// not AirPrint.
|
||||||
|
func testAirPrintRuleCupsFilter2URF() {
|
||||||
|
let ppd = """
|
||||||
|
*cupsFilter2: "application/pdf image/urf 0 -"
|
||||||
|
*cupsFilter2: "image/urf image/urf 100 -"
|
||||||
|
"""
|
||||||
|
XCTAssertTrue(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil, ppd: ppd))
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: nil, makeAndModel: nil,
|
||||||
|
ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Negative — a standard USB raster-driver queue (Epson XP-55)
|
||||||
|
/// matches none of the six rules.
|
||||||
|
func testAirPrintNegativeUSBRaster() {
|
||||||
|
let ppd = """
|
||||||
|
*PPD-Adobe: "4.3"
|
||||||
|
*EPIJ_Qual 303/Normal: ""
|
||||||
|
*cupsFilter: "application/vnd.cups-raster 0 rastertoepson"
|
||||||
|
"""
|
||||||
|
XCTAssertFalse(CupsParsers.detectAirPrint(
|
||||||
|
deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123",
|
||||||
|
makeAndModel: "EPSON XP-55 Series", ppd: ppd))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `listPrinters` survives a failing `lpstat -v` — the failure is
|
||||||
|
/// tolerated, enumeration proceeds, every queue reports
|
||||||
|
/// `isAirPrint == false`, and there is **no** fallback respawn
|
||||||
|
/// (exactly one `-v` invocation, #202).
|
||||||
|
func testListPrintersToleratesLpstatVFailure() async throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-airprint-\(UUID().uuidString)")
|
||||||
|
let bin = root.appendingPathComponent("bin")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: bin, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let log = root.appendingPathComponent("lpstat.log")
|
||||||
|
|
||||||
|
let lpstat = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf '%s\\n' "$1" >> '\(log.path)'
|
||||||
|
case "$1" in
|
||||||
|
-e) printf 'Mock_Epson\\n' ;;
|
||||||
|
-p) printf 'printer Mock_Epson is idle.\\n' ;;
|
||||||
|
-d) printf 'no system default destination\\n' ;;
|
||||||
|
-v) exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"""
|
||||||
|
let lpoptions = """
|
||||||
|
#!/bin/sh
|
||||||
|
printf "printer-info='Mock'\\n"
|
||||||
|
"""
|
||||||
|
for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] {
|
||||||
|
let url = bin.appendingPathComponent(name)
|
||||||
|
try body.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes(
|
||||||
|
[.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = CupsService(
|
||||||
|
processManager: ProcessManager(), binaryDir: bin,
|
||||||
|
ppdDir: root.appendingPathComponent("ppd"))
|
||||||
|
let printers = try await service.listPrinters()
|
||||||
|
XCTAssertEqual(printers.map(\.name), ["Mock_Epson"])
|
||||||
|
XCTAssertFalse(printers[0].isAirPrint)
|
||||||
|
|
||||||
|
let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "")
|
||||||
|
.split(separator: "\n")
|
||||||
|
XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1,
|
||||||
|
"lpstat -v must be spawned exactly once: \(calls)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then
|
|||||||
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
printf 'ColorModel/Output Mode: *RGB Gray\n'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf "printer-info='Mock %s' printer-type=42\n" "$queue"
|
printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ case "$1" in
|
|||||||
-d)
|
-d)
|
||||||
printf 'system default destination: Mock_Epson_7450\n'
|
printf 'system default destination: Mock_Epson_7450\n'
|
||||||
;;
|
;;
|
||||||
|
-v)
|
||||||
|
# Mock_Canon_Pro is the AirPrint fixture queue (#202): local
|
||||||
|
# unencrypted ipp:// resolved via the _universal mDNS subtype.
|
||||||
|
printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n'
|
||||||
|
printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n'
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock
|
||||||
|
/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted
|
||||||
|
/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS
|
||||||
|
/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster
|
||||||
|
/// queue — so the badge must track the picker selection.
|
||||||
|
@MainActor
|
||||||
|
final class Milestone12AirPrintUITests: XCTestCase {
|
||||||
|
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private var testRoot: URL!
|
||||||
|
private var binDir: URL!
|
||||||
|
private var workDir: URL!
|
||||||
|
private var spoolLogURL: URL!
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
testRoot = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("iccery-ui12-\(UUID().uuidString)")
|
||||||
|
binDir = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appendingPathComponent("Fixtures/bin")
|
||||||
|
workDir = testRoot.appendingPathComponent("work")
|
||||||
|
spoolLogURL = testRoot.appendingPathComponent("spool.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_SPOOL_LOG": spoolLogURL.path,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
app?.terminate()
|
||||||
|
app = nil
|
||||||
|
if let testRoot {
|
||||||
|
try? FileManager.default.removeItem(at: testRoot)
|
||||||
|
}
|
||||||
|
testRoot = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll until `id` no longer resolves — the badge is conditionally
|
||||||
|
/// rendered, so absence is only meaningful after a settle window.
|
||||||
|
private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if !element(id).exists { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(element(id).exists, "Element \(id) should be absent")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the printer-picker menu item whose title contains
|
||||||
|
/// `needle` (display names come from the `lpoptions` fixture).
|
||||||
|
private func selectPrinter(containing needle: String) {
|
||||||
|
let picker = app.popUpButtons["printerSelect"]
|
||||||
|
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||||
|
picker.click()
|
||||||
|
let item = app.menuItems
|
||||||
|
.matching(NSPredicate(format: "title CONTAINS %@", needle))
|
||||||
|
.firstMatch
|
||||||
|
XCTAssertTrue(item.waitForExistence(timeout: 5),
|
||||||
|
"No printer menu item containing \(needle)")
|
||||||
|
item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
/// The badge appears while the AirPrint fixture queue is selected
|
||||||
|
/// and is absent for the USB Epson — in both directions.
|
||||||
|
func testAirPrintBadgeTracksSelectedQueue() throws {
|
||||||
|
app.launch()
|
||||||
|
app.activate()
|
||||||
|
reachPrintPanel()
|
||||||
|
_ = waitFor("printerStatusBadge")
|
||||||
|
|
||||||
|
// Default queue is the USB Epson — no badge.
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Canon")
|
||||||
|
let badge = element("airPrintWarningBadge")
|
||||||
|
XCTAssertTrue(badge.waitForExistence(timeout: 10))
|
||||||
|
// StaticText exposes its content via AXValue, not the label.
|
||||||
|
let badgeText = [badge.value as? String, badge.label, badge.title]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.first { !$0.isEmpty } ?? ""
|
||||||
|
XCTAssertTrue(
|
||||||
|
badgeText.contains(
|
||||||
|
"AirPrint queue — unmanaged colour cannot be guaranteed."),
|
||||||
|
"Unexpected badge text: \(badgeText)")
|
||||||
|
|
||||||
|
selectPrinter(containing: "Epson")
|
||||||
|
waitForAbsence("airPrintWarningBadge")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user