Add xcrs-host-inspect host inspection helper

Adds an XCRSHostInspect library and an xcrs-host-inspect executable that list on-screen windows, recognise text with Vision, crop image regions, and report quantised colour runs along a row or column of an image. Output is JSON on stdout: exit code 2 means a usage error and 1 a failure. Coordinates are in image units, so --scale lets a Retina screenshot be described in points. Host inspection needs the macOS window server and Vision, so the command reports an error and exits 1 elsewhere. Co-Authored-By: siGit Code v1.7.0-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 16, 2026 at 20:24 UTC 2aa712e170f56b0f810238cf5ec4b2464316a60b
5 files changed +1134
Package.swift
+18
@@ -15,13 +15,31 @@ let package = Package(
15 .library(
16 name: "XCRSControlKit",
17 targets: ["XCRSControlKit"]
18 + ),
19 + .library(
20 + name: "XCRSHostInspect",
21 + targets: ["XCRSHostInspect"]
22 + ),
23 + .executable(
24 + name: "xcrs-host-inspect",
25 + targets: ["XCRSHostInspectCLI"]
26 )
27 ],
28 targets: [
29 .target(name: "XCRSControlKit"),
30 + .target(name: "XCRSHostInspect"),
31 + .executableTarget(
32 + name: "XCRSHostInspectCLI",
33 + dependencies: ["XCRSHostInspect"]
34 + ),
35 .testTarget(
36 name: "XCRSControlKitTests",
37 dependencies: ["XCRSControlKit"]
38 + ),
39 + .testTarget(
40 + name: "XCRSHostInspectTests",
41 + dependencies: ["XCRSHostInspect"],
42 + resources: [.copy("Fixtures")]
43 )
44 ]
45 )
Sources/XCRSHostInspect/HostInspect.swift new
+807
@@ -0,0 +1,807 @@
1 +import Foundation
2 +
3 +/// The result of one `xcrs-host-inspect` invocation, returned instead of
4 +/// exiting so the command surface stays testable.
5 +public struct HostInspectInvocationResult: Sendable {
6 + public let exitCode: Int32
7 + public let standardOutput: String
8 + public let standardError: String
9 +
10 + public init(exitCode: Int32, standardOutput: String, standardError: String) {
11 + self.exitCode = exitCode
12 + self.standardOutput = standardOutput
13 + self.standardError = standardError
14 + }
15 +}
16 +
17 +#if os(macOS)
18 +
19 +import CoreGraphics
20 +import ImageIO
21 +import Vision
22 +
23 +/// Errors surfaced to the `xcrs-host-inspect` command line.
24 +public enum HostInspectError: Error, CustomStringConvertible {
25 + /// The invocation itself is wrong, so the CLI answers with the usage text.
26 + case usage(String)
27 + /// The request was well formed but could not be carried out.
28 + case failure(String)
29 +
30 + public var description: String {
31 + switch self {
32 + case .usage(let message), .failure(let message):
33 + return message
34 + }
35 + }
36 +}
37 +
38 +/// Converts between the coordinate space a caller reasons about and image
39 +/// pixels. A Retina screenshot is two pixels per point, so `--scale 2` accepts
40 +/// and reports points; the default of `1` accepts and reports pixels.
41 +public struct HostInspectScale: Equatable, Sendable {
42 + public let pixelsPerUnit: Double
43 +
44 + public init(pixelsPerUnit: Double) throws {
45 + guard pixelsPerUnit > 0, pixelsPerUnit.isFinite else {
46 + throw HostInspectError.usage("--scale must be a positive number")
47 + }
48 + self.pixelsPerUnit = pixelsPerUnit
49 + }
50 +
51 + private init(validated pixelsPerUnit: Double) {
52 + self.pixelsPerUnit = pixelsPerUnit
53 + }
54 +
55 + public static let pixels = HostInspectScale(validated: 1)
56 +
57 + public func pixels(fromUnits units: Double) -> Double {
58 + units * pixelsPerUnit
59 + }
60 +
61 + public func units(fromPixels pixels: Double) -> Double {
62 + pixels / pixelsPerUnit
63 + }
64 +
65 + public func units(fromPixels pixels: Int) -> Double {
66 + units(fromPixels: Double(pixels))
67 + }
68 +}
69 +
70 +public struct HostInspectRect: Codable, Equatable, Sendable {
71 + public let x: Double
72 + public let y: Double
73 + public let width: Double
74 + public let height: Double
75 +
76 + public init(x: Double, y: Double, width: Double, height: Double) {
77 + self.x = x
78 + self.y = y
79 + self.width = width
80 + self.height = height
81 + }
82 +}
83 +
84 +/// The image a result was read from, in both pixels and caller units.
85 +public struct HostInspectImage: Codable, Equatable, Sendable {
86 + public let path: String
87 + public let pixelWidth: Int
88 + public let pixelHeight: Int
89 + public let scale: Double
90 + public let width: Double
91 + public let height: Double
92 +}
93 +
94 +public struct HostWindow: Codable, Equatable, Sendable {
95 + public let id: Int
96 + public let owner: String
97 + public let title: String
98 + public let layer: Int
99 + /// Screen points in the global display space, unaffected by `--scale`.
100 + public let bounds: HostInspectRect
101 +}
102 +
103 +public struct HostWindowList: Codable, Equatable, Sendable {
104 + public let windows: [HostWindow]
105 +}
106 +
107 +public struct HostRecognizedText: Codable, Equatable, Sendable {
108 + public let text: String
109 + public let confidence: Double
110 + public let x: Double
111 + public let y: Double
112 + public let width: Double
113 + public let height: Double
114 +}
115 +
116 +public struct HostOcrResult: Codable, Equatable, Sendable {
117 + public let image: HostInspectImage
118 + /// The languages the request was configured with; empty means Vision's
119 + /// own default.
120 + public let languages: [String]
121 + /// Reading order: top to bottom, then left to right.
122 + public let results: [HostRecognizedText]
123 +}
124 +
125 +public struct HostCropResult: Codable, Equatable, Sendable {
126 + public let image: HostInspectImage
127 + /// The requested rect, in caller units.
128 + public let rect: HostInspectRect
129 + public let out: String
130 + public let pixelWidth: Int
131 + public let pixelHeight: Int
132 +}
133 +
134 +public enum HostProbeAxis: String, Codable, Sendable {
135 + case row
136 + case column
137 +}
138 +
139 +public struct HostColorRun: Codable, Equatable, Sendable {
140 + /// Run extent in caller units.
141 + public let start: Double
142 + public let end: Double
143 + public let width: Double
144 + /// Quantised `#rrggbb`, so low-order noise does not split a surface.
145 + public let color: String
146 +}
147 +
148 +public struct HostProbeResult: Codable, Equatable, Sendable {
149 + public let image: HostInspectImage
150 + public let axis: HostProbeAxis
151 + /// The probed row (y) or column (x), in caller units.
152 + public let position: Double
153 + public let from: Double
154 + public let to: Double
155 + public let runs: [HostColorRun]
156 +}
157 +
158 +/// Reads a screenshot or the host window server. Every image coordinate in the
159 +/// output is expressed in caller units; `windows` reports screen points.
160 +public enum HostInspect {
161 + /// Vision reports bounding boxes in normalised space, so lines whose
162 + /// vertical centres differ by less than this are treated as one line.
163 + private static let readingOrderTolerance = 0.01
164 + private static let quantizationShift: UInt8 = 3
165 +
166 + public static func windows(owner: String?) throws -> HostWindowList {
167 + guard
168 + let entries = CGWindowListCopyWindowInfo(
169 + [.optionOnScreenOnly, .excludeDesktopElements],
170 + kCGNullWindowID
171 + ) as? [[String: Any]]
172 + else {
173 + throw HostInspectError.failure("the window server did not return a window list")
174 + }
175 +
176 + var windows: [HostWindow] = []
177 + for entry in entries {
178 + let ownerName = entry[kCGWindowOwnerName as String] as? String ?? ""
179 + if let owner, !ownerName.lowercased().contains(owner.lowercased()) {
180 + continue
181 + }
182 + guard
183 + let bounds = entry[kCGWindowBounds as String] as? [String: Any],
184 + let rect = CGRect(dictionaryRepresentation: bounds as CFDictionary)
185 + else {
186 + continue
187 + }
188 +
189 + windows.append(
190 + HostWindow(
191 + id: entry[kCGWindowNumber as String] as? Int ?? 0,
192 + owner: ownerName,
193 + title: entry[kCGWindowName as String] as? String ?? "",
194 + layer: entry[kCGWindowLayer as String] as? Int ?? 0,
195 + bounds: HostInspectRect(
196 + x: rect.origin.x,
197 + y: rect.origin.y,
198 + width: rect.size.width,
199 + height: rect.size.height
200 + )
201 + )
202 + )
203 + }
204 +
205 + windows.sort { ($0.layer, $0.id) < ($1.layer, $1.id) }
206 + return HostWindowList(windows: windows)
207 + }
208 +
209 + public static func recognizeText(
210 + image path: String,
211 + scale: HostInspectScale,
212 + languages: [String]
213 + ) throws -> HostOcrResult {
214 + let image = try loadImage(at: path)
215 + let request = VNRecognizeTextRequest()
216 + request.recognitionLevel = .accurate
217 + request.usesLanguageCorrection = false
218 + if !languages.isEmpty {
219 + request.recognitionLanguages = languages
220 + }
221 +
222 + do {
223 + try VNImageRequestHandler(cgImage: image, options: [:]).perform([request])
224 + } catch {
225 + throw HostInspectError.failure(
226 + "text recognition failed for \(path): \(error.localizedDescription)"
227 + )
228 + }
229 +
230 + let observations = request.results ?? []
231 + // Vision's origin is the bottom-left corner, so a larger midY is nearer
232 + // the top of the image.
233 + let ordered = observations.sorted { first, second in
234 + let firstMidY = first.boundingBox.midY
235 + let secondMidY = second.boundingBox.midY
236 + if abs(firstMidY - secondMidY) > readingOrderTolerance {
237 + return firstMidY > secondMidY
238 + }
239 + return first.boundingBox.minX < second.boundingBox.minX
240 + }
241 +
242 + let width = Double(image.width)
243 + let height = Double(image.height)
244 + let results = ordered.compactMap { observation -> HostRecognizedText? in
245 + guard let candidate = observation.topCandidates(1).first else { return nil }
246 + let box = observation.boundingBox
247 + return HostRecognizedText(
248 + text: candidate.string,
249 + confidence: Double(candidate.confidence),
250 + x: scale.units(fromPixels: box.minX * width),
251 + y: scale.units(fromPixels: (1 - box.maxY) * height),
252 + width: scale.units(fromPixels: box.width * width),
253 + height: scale.units(fromPixels: box.height * height)
254 + )
255 + }
256 +
257 + return HostOcrResult(
258 + image: describe(image, path: path, scale: scale),
259 + languages: languages,
260 + results: results
261 + )
262 + }
263 +
264 + public static func crop(
265 + image path: String,
266 + rect: HostInspectRect,
267 + scale: HostInspectScale,
268 + out: String
269 + ) throws -> HostCropResult {
270 + let image = try loadImage(at: path)
271 + let pixelRect = CGRect(
272 + x: scale.pixels(fromUnits: rect.x).rounded(),
273 + y: scale.pixels(fromUnits: rect.y).rounded(),
274 + width: scale.pixels(fromUnits: rect.width).rounded(),
275 + height: scale.pixels(fromUnits: rect.height).rounded()
276 + )
277 + guard pixelRect.width >= 1, pixelRect.height >= 1 else {
278 + throw HostInspectError.usage("crop rect is smaller than one pixel: \(describe(rect))")
279 + }
280 + guard
281 + pixelRect.minX >= 0, pixelRect.minY >= 0,
282 + pixelRect.maxX <= CGFloat(image.width), pixelRect.maxY <= CGFloat(image.height)
283 + else {
284 + throw HostInspectError.failure(
285 + "crop rect \(describe(rect)) is outside the "
286 + + "\(image.width)x\(image.height) px image"
287 + )
288 + }
289 + guard let cropped = image.cropping(to: pixelRect) else {
290 + throw HostInspectError.failure("CoreGraphics refused to crop \(describe(pixelRect))")
291 + }
292 +
293 + let url = URL(fileURLWithPath: out)
294 + guard
295 + let destination = CGImageDestinationCreateWithURL(
296 + url as CFURL,
297 + "public.png" as CFString,
298 + 1,
299 + nil
300 + )
301 + else {
302 + throw HostInspectError.failure("cannot write a PNG to \(out)")
303 + }
304 + CGImageDestinationAddImage(destination, cropped, nil)
305 + guard CGImageDestinationFinalize(destination) else {
306 + throw HostInspectError.failure("cannot write a PNG to \(out)")
307 + }
308 +
309 + return HostCropResult(
310 + image: describe(image, path: path, scale: scale),
311 + rect: rect,
312 + out: out,
313 + pixelWidth: cropped.width,
314 + pixelHeight: cropped.height
315 + )
316 + }
317 +
318 + public static func probe(
319 + image path: String,
320 + axis: HostProbeAxis,
321 + position: Double,
322 + from: Double?,
323 + to: Double?,
324 + scale: HostInspectScale
325 + ) throws -> HostProbeResult {
326 + let image = try loadImage(at: path)
327 + let extent = axis == .row ? image.height : image.width
328 +
329 + let positionPixel = Int(scale.pixels(fromUnits: position).rounded())
330 + guard positionPixel >= 0, positionPixel < extent else {
331 + throw HostInspectError.failure(
332 + "probe position \(position) is outside the "
333 + + "\(axis == .row ? "height \(image.height)" : "width \(image.width)") px image"
334 + )
335 + }
336 +
337 + let startPixel = clamp(
338 + Int(scale.pixels(fromUnits: from ?? scale.units(fromPixels: 0)).rounded()),
339 + lower: 0,
340 + upper: extent
341 + )
342 + let endPixel = clamp(
343 + Int(scale.pixels(fromUnits: to ?? scale.units(fromPixels: extent)).rounded()),
344 + lower: 0,
345 + upper: extent
346 + )
347 + guard endPixel > startPixel else {
348 + throw HostInspectError.usage(
349 + "--from must be smaller than --to (got \(startPixel)px ..< \(endPixel)px)"
350 + )
351 + }
352 +
353 + let sampleCount = endPixel - startPixel
354 + let stripRect =
355 + axis == .row
356 + ? CGRect(x: startPixel, y: positionPixel, width: sampleCount, height: 1)
357 + : CGRect(x: positionPixel, y: startPixel, width: 1, height: sampleCount)
358 + // Sampling a one-pixel strip first is what makes the probe read the
359 + // requested line; drawing the whole image into a thin context would
360 + // average every other line into it.
361 + guard let strip = image.cropping(to: stripRect) else {
362 + throw HostInspectError.failure("CoreGraphics refused to crop \(describe(stripRect))")
363 + }
364 +
365 + let buffer = try rgbaPixels(
366 + of: strip,
367 + width: axis == .row ? sampleCount : 1,
368 + height: axis == .row ? 1 : sampleCount
369 + )
370 + let keys = (0..<sampleCount).map { index in
371 + quantize(
372 + red: buffer[index * 4],
373 + green: buffer[index * 4 + 1],
374 + blue: buffer[index * 4 + 2]
375 + )
376 + }
377 +
378 + let runs = groupRuns(keys).map { run in
379 + HostColorRun(
380 + start: scale.units(fromPixels: startPixel + run.start),
381 + end: scale.units(fromPixels: startPixel + run.end),
382 + width: scale.units(fromPixels: run.end - run.start),
383 + color: hexColor(run.key)
384 + )
385 + }
386 +
387 + return HostProbeResult(
388 + image: describe(image, path: path, scale: scale),
389 + axis: axis,
390 + position: position,
391 + from: scale.units(fromPixels: startPixel),
392 + to: scale.units(fromPixels: endPixel),
393 + runs: runs
394 + )
395 + }
396 +
397 + private static func loadImage(at path: String) throws -> CGImage {
398 + let url = URL(fileURLWithPath: path)
399 + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
400 + throw HostInspectError.failure("cannot open an image at \(path)")
401 + }
402 + guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
403 + throw HostInspectError.failure("cannot decode an image at \(path)")
404 + }
405 + return image
406 + }
407 +
408 + private static func describe(
409 + _ image: CGImage,
410 + path: String,
411 + scale: HostInspectScale
412 + ) -> HostInspectImage {
413 + HostInspectImage(
414 + path: path,
415 + pixelWidth: image.width,
416 + pixelHeight: image.height,
417 + scale: scale.pixelsPerUnit,
418 + width: scale.units(fromPixels: image.width),
419 + height: scale.units(fromPixels: image.height)
420 + )
421 + }
422 +
423 + /// Draws the image into a tightly packed RGBA8 buffer at the requested
424 + /// sampling size rather than at native resolution.
425 + private static func rgbaPixels(of image: CGImage, width: Int, height: Int) throws -> [UInt8] {
426 + var buffer = [UInt8](repeating: 0, count: width * height * 4)
427 + let drew = buffer.withUnsafeMutableBytes { raw -> Bool in
428 + guard
429 + let context = CGContext(
430 + data: raw.baseAddress,
431 + width: width,
432 + height: height,
433 + bitsPerComponent: 8,
434 + bytesPerRow: width * 4,
435 + space: CGColorSpaceCreateDeviceRGB(),
436 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
437 + )
438 + else {
439 + return false
440 + }
441 + context.interpolationQuality = .none
442 + // No flip transform here on purpose. A CGBitmapContext maps user
443 + // space from the bottom-left but lays the buffer out from the top
444 + // row down, so drawing an image straight in already yields a
445 + // top-down buffer: buffer row 0 is the image's top row, which is
446 + // the reading order the probe reports.
447 + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
448 + return true
449 + }
450 + guard drew else {
451 + throw HostInspectError.failure("cannot sample a \(width)x\(height) region")
452 + }
453 + return buffer
454 + }
455 +
456 + /// Five bits per channel is enough to separate adjacent UI surfaces
457 + /// without splitting one surface on anti-aliasing.
458 + static func quantize(red: UInt8, green: UInt8, blue: UInt8) -> Int {
459 + Int(red >> quantizationShift) << 10
460 + | Int(green >> quantizationShift) << 5
461 + | Int(blue >> quantizationShift)
462 + }
463 +
464 + static func hexColor(_ key: Int) -> String {
465 + let red = (key >> 10) & 31
466 + let green = (key >> 5) & 31
467 + let blue = key & 31
468 + return String(format: "#%02x%02x%02x", red << 3, green << 3, blue << 3)
469 + }
470 +
471 + /// Collapses equal neighbours into half-open `[start, end)` runs.
472 + static func groupRuns(_ keys: [Int]) -> [(start: Int, end: Int, key: Int)] {
473 + var runs: [(start: Int, end: Int, key: Int)] = []
474 + for (index, key) in keys.enumerated() {
475 + if var last = runs.last, last.key == key {
476 + last.end = index + 1
477 + runs[runs.count - 1] = last
478 + } else {
479 + runs.append((start: index, end: index + 1, key: key))
480 + }
481 + }
482 + return runs
483 + }
484 +
485 + private static func clamp(_ value: Int, lower: Int, upper: Int) -> Int {
486 + min(max(value, lower), upper)
487 + }
488 +
489 + private static func describe(_ rect: CGRect) -> String {
490 + "\(Int(rect.origin.x)),\(Int(rect.origin.y))"
491 + + " \(Int(rect.width))x\(Int(rect.height)) px"
492 + }
493 +
494 + private static func describe(_ rect: HostInspectRect) -> String {
495 + "\(rect.x),\(rect.y) \(rect.width)x\(rect.height)"
496 + }
497 +}
498 +
499 +/// Parses and runs the `xcrs-host-inspect` command line.
500 +public enum HostInspectCommand {
501 + public static let usage = """
502 + xcrs-host-inspect <command> [options]
503 +
504 + Commands:
505 + windows [--owner <substring>]
506 + List on-screen windows with their screen-point bounds.
507 + ocr --image <png> [--scale <n>] [--language <tag>]...
508 + Recognise text and report it with coordinates.
509 + crop --image <png> --rect <x,y,w,h> --out <png> [--scale <n>]
510 + Write a region of an image to a new PNG.
511 + probe --image <png> (--row <y> | --column <x>) [--from <a>] [--to <b>] [--scale <n>]
512 + Report quantised colour runs along one line of an image.
513 + help
514 + Print this text.
515 +
516 + Coordinates are in image units. One unit is one pixel unless --scale is
517 + given, in which case a unit is 1/scale pixels (a Retina screenshot is
518 + --scale 2, so units are points).
519 +
520 + Output is JSON on stdout; exit code 2 means a usage error and 1 a
521 + failed operation.
522 + """
523 +
524 + public static func invoke(arguments: [String]) -> HostInspectInvocationResult {
525 + do {
526 + return HostInspectInvocationResult(
527 + exitCode: 0,
528 + standardOutput: try execute(try parse(arguments)) + "\n",
529 + standardError: ""
530 + )
531 + } catch let error as HostInspectError {
532 + switch error {
533 + case .usage(let message):
534 + return HostInspectInvocationResult(
535 + exitCode: 2,
536 + standardOutput: "",
537 + standardError: "error: \(message)\n\n\(usage)\n"
538 + )
539 + case .failure(let message):
540 + return HostInspectInvocationResult(
541 + exitCode: 1,
542 + standardOutput: "",
543 + standardError: "error: \(message)\n"
544 + )
545 + }
546 + } catch {
547 + return HostInspectInvocationResult(
548 + exitCode: 1,
549 + standardOutput: "",
550 + standardError: "error: \(error)\n"
551 + )
552 + }
553 + }
554 +
555 + enum Command: Equatable {
556 + case help
557 + case windows(owner: String?)
558 + case ocr(image: String, scale: HostInspectScale, languages: [String])
559 + case crop(image: String, rect: HostInspectRect, scale: HostInspectScale, out: String)
560 + case probe(
561 + image: String,
562 + axis: HostProbeAxis,
563 + position: Double,
564 + from: Double?,
565 + to: Double?,
566 + scale: HostInspectScale
567 + )
568 + }
569 +
570 + static func parse(_ arguments: [String]) throws -> Command {
571 + var scanner = ArgumentScanner(arguments: arguments)
572 + guard let name = scanner.next() else {
573 + throw HostInspectError.usage("a command is required")
574 + }
575 +
576 + switch name {
577 + case "help", "-h", "--help":
578 + try scanner.rejectRemaining()
579 + return .help
580 + case "windows":
581 + return .windows(owner: try scanner.options().owner)
582 + case "ocr":
583 + let options = try scanner.options()
584 + return .ocr(
585 + image: try options.requiredImage(),
586 + scale: options.scale,
587 + languages: options.languages
588 + )
589 + case "crop":
590 + let options = try scanner.options()
591 + return .crop(
592 + image: try options.requiredImage(),
593 + rect: try options.requiredRect(),
594 + scale: options.scale,
595 + out: try options.requiredOut()
596 + )
597 + case "probe":
598 + let options = try scanner.options()
599 + let axis: HostProbeAxis
600 + let position: Double
601 + switch (options.row, options.column) {
602 + case (let row?, nil):
603 + axis = .row
604 + position = row
605 + case (nil, let column?):
606 + axis = .column
607 + position = column
608 + case (nil, nil):
609 + throw HostInspectError.usage("probe needs --row or --column")
610 + case (_?, _?):
611 + throw HostInspectError.usage("probe takes either --row or --column, not both")
612 + }
613 + return .probe(
614 + image: try options.requiredImage(),
615 + axis: axis,
616 + position: position,
617 + from: options.from,
618 + to: options.to,
619 + scale: options.scale
620 + )
621 + default:
622 + throw HostInspectError.usage("unknown command \(name)")
623 + }
624 + }
625 +
626 + private static func execute(_ command: Command) throws -> String {
627 + switch command {
628 + case .help:
629 + return usage
630 + case .windows(let owner):
631 + return try encode(HostInspect.windows(owner: owner))
632 + case .ocr(let image, let scale, let languages):
633 + return try encode(
634 + HostInspect.recognizeText(image: image, scale: scale, languages: languages)
635 + )
636 + case .crop(let image, let rect, let scale, let out):
637 + return try encode(
638 + HostInspect.crop(image: image, rect: rect, scale: scale, out: out)
639 + )
640 + case .probe(let image, let axis, let position, let from, let to, let scale):
641 + return try encode(
642 + HostInspect.probe(
643 + image: image,
644 + axis: axis,
645 + position: position,
646 + from: from,
647 + to: to,
648 + scale: scale
649 + )
650 + )
651 + }
652 + }
653 +
654 + private static func encode<T: Encodable>(_ value: T) throws -> String {
655 + let encoder = JSONEncoder()
656 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
657 + guard let text = String(data: try encoder.encode(value), encoding: .utf8) else {
658 + throw HostInspectError.failure("could not render the result as UTF-8 JSON")
659 + }
660 + return text
661 + }
662 +}
663 +
664 +private struct CommandOptions {
665 + var image: String?
666 + var owner: String?
667 + var out: String?
668 + var rect: String?
669 + var scale = HostInspectScale.pixels
670 + var row: Double?
671 + var column: Double?
672 + var from: Double?
673 + var to: Double?
674 + var languages: [String] = []
675 +
676 + func requiredImage() throws -> String {
677 + guard let image else {
678 + throw HostInspectError.usage("--image is required")
679 + }
680 + return image
681 + }
682 +
683 + func requiredOut() throws -> String {
684 + guard let out else {
685 + throw HostInspectError.usage("--out is required")
686 + }
687 + return out
688 + }
689 +
690 + func requiredRect() throws -> HostInspectRect {
691 + guard let rect else {
692 + throw HostInspectError.usage("--rect is required")
693 + }
694 + let parts = rect.split(separator: ",", omittingEmptySubsequences: false)
695 + guard parts.count == 4 else {
696 + throw HostInspectError.usage("--rect expects x,y,w,h, got \(rect)")
697 + }
698 + let values = try parts.map { part -> Double in
699 + let text = String(part).trimmingCharacters(in: .whitespaces)
700 + guard let value = Double(text), value.isFinite else {
701 + throw HostInspectError.usage("--rect values must be numbers, got \(rect)")
702 + }
703 + return value
704 + }
705 + guard values[2] > 0, values[3] > 0 else {
706 + throw HostInspectError.usage("--rect width and height must be positive, got \(rect)")
707 + }
708 + return HostInspectRect(x: values[0], y: values[1], width: values[2], height: values[3])
709 + }
710 +}
711 +
712 +private struct ArgumentScanner {
713 + private let arguments: [String]
714 + private var index = 0
715 +
716 + init(arguments: [String]) {
717 + self.arguments = arguments
718 + }
719 +
720 + mutating func next() -> String? {
721 + guard index < arguments.count else { return nil }
722 + defer { index += 1 }
723 + return arguments[index]
724 + }
725 +
726 + mutating func rejectRemaining() throws {
727 + if let extra = next() {
728 + throw HostInspectError.usage("unexpected argument \(extra)")
729 + }
730 + }
731 +
732 + mutating func options() throws -> CommandOptions {
733 + var options = CommandOptions()
734 + while let argument = next() {
735 + let (name, inlineValue) = ArgumentScanner.split(argument)
736 + switch name {
737 + case "--image":
738 + options.image = try value(name: name, inline: inlineValue)
739 + case "--owner":
740 + options.owner = try value(name: name, inline: inlineValue)
741 + case "--out":
742 + options.out = try value(name: name, inline: inlineValue)
743 + case "--rect":
744 + options.rect = try value(name: name, inline: inlineValue)
745 + case "--scale":
746 + options.scale = try HostInspectScale(
747 + pixelsPerUnit: try number(name: name, inline: inlineValue)
748 + )
749 + case "--row":
750 + options.row = try number(name: name, inline: inlineValue)
751 + case "--column":
752 + options.column = try number(name: name, inline: inlineValue)
753 + case "--from":
754 + options.from = try number(name: name, inline: inlineValue)
755 + case "--to":
756 + options.to = try number(name: name, inline: inlineValue)
757 + case "--language":
758 + options.languages.append(try value(name: name, inline: inlineValue))
759 + case "-h", "--help":
760 + throw HostInspectError.usage("help is its own command")
761 + default:
762 + throw HostInspectError.usage("unknown option \(name)")
763 + }
764 + }
765 + return options
766 + }
767 +
768 + private mutating func value(name: String, inline: String?) throws -> String {
769 + if let inline { return inline }
770 + guard let following = next() else {
771 + throw HostInspectError.usage("\(name) needs a value")
772 + }
773 + return following
774 + }
775 +
776 + private mutating func number(name: String, inline: String?) throws -> Double {
777 + let raw = try value(name: name, inline: inline)
778 + guard let number = Double(raw), number.isFinite else {
779 + throw HostInspectError.usage("\(name) expects a number, got \(raw)")
780 + }
781 + return number
782 + }
783 +
784 + private static func split(_ argument: String) -> (name: String, inlineValue: String?) {
785 + guard let separator = argument.firstIndex(of: "=") else {
786 + return (argument, nil)
787 + }
788 + let name = String(argument[argument.startIndex..<separator])
789 + let value = String(argument[argument.index(after: separator)...])
790 + return (name, value)
791 + }
792 +}
793 +
794 +#else
795 +
796 +/// Host inspection needs the macOS window server and Vision.
797 +public enum HostInspectCommand {
798 + public static func invoke(arguments: [String]) -> HostInspectInvocationResult {
799 + HostInspectInvocationResult(
800 + exitCode: 1,
801 + standardOutput: "",
802 + standardError: "error: xcrs-host-inspect runs on macOS only\n"
803 + )
804 + }
805 +}
806 +
807 +#endif
Sources/XCRSHostInspectCLI/main.swift new
+21
@@ -0,0 +1,21 @@
1 +#if os(macOS)
2 +import Foundation
3 +import XCRSHostInspect
4 +
5 +let result = HostInspectCommand.invoke(arguments: Array(CommandLine.arguments.dropFirst()))
6 +
7 +if !result.standardOutput.isEmpty {
8 + FileHandle.standardOutput.write(Data(result.standardOutput.utf8))
9 +}
10 +if !result.standardError.isEmpty {
11 + FileHandle.standardError.write(Data(result.standardError.utf8))
12 +}
13 +
14 +exit(result.exitCode)
15 +#else
16 +
17 +// The helper drives the macOS window server and Vision, so on any other
18 +// platform it has nothing to do but fail.
19 +exit(1)
20 +
21 +#endif
Tests/XCRSHostInspectTests/Fixtures/top-left-red.png
Binary files /dev/null and b/Tests/XCRSHostInspectTests/Fixtures/top-left-red.png differ
Tests/XCRSHostInspectTests/HostInspectCommandTests.swift new
+288
@@ -0,0 +1,288 @@
1 +#if os(macOS)
2 +import CoreGraphics
3 +import Foundation
4 +import ImageIO
5 +import XCTest
6 +
7 +@testable import XCRSHostInspect
8 +
9 +/// The fixture is an 8x8 PNG whose top-left 4x4 quadrant is red and whose
10 +/// remaining pixels are blue, which is what pins the coordinate conventions:
11 +/// rows count down from the top, columns count right from the left.
12 +final class HostInspectCommandTests: XCTestCase {
13 + private var outputDirectory: URL {
14 + FileManager.default.temporaryDirectory
15 + .appendingPathComponent("xcrs-host-inspect-tests", isDirectory: true)
16 + }
17 +
18 + override func setUpWithError() throws {
19 + try super.setUpWithError()
20 + try FileManager.default.createDirectory(
21 + at: outputDirectory,
22 + withIntermediateDirectories: true
23 + )
24 + }
25 +
26 + func testColumnProbeReadsFromTheTop() throws {
27 + let probe = try decode(
28 + HostProbeResult.self,
29 + from: try invoke(["probe", "--image", try fixture().path, "--column", "1"])
30 + )
31 +
32 + XCTAssertEqual(probe.axis, .column)
33 + XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
34 + XCTAssertEqual(probe.runs.map(\.start), [0, 4])
35 + XCTAssertEqual(probe.runs.map(\.end), [4, 8])
36 + XCTAssertEqual(probe.runs.map(\.width), [4, 4])
37 + }
38 +
39 + func testRowProbeReadsFromTheLeft() throws {
40 + let probe = try decode(
41 + HostProbeResult.self,
42 + from: try invoke(["probe", "--image", try fixture().path, "--row", "1"])
43 + )
44 +
45 + XCTAssertEqual(probe.axis, .row)
46 + XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
47 + XCTAssertEqual(probe.runs.map(\.start), [0, 4])
48 + }
49 +
50 + func testProbeHonoursFromAndTo() throws {
51 + let probe = try decode(
52 + HostProbeResult.self,
53 + from: try invoke(
54 + ["probe", "--image", try fixture().path, "--column", "1", "--from", "4", "--to", "8"]
55 + )
56 + )
57 +
58 + XCTAssertEqual(probe.from, 4)
59 + XCTAssertEqual(probe.to, 8)
60 + XCTAssertEqual(probe.runs.map(\.color), ["#0000f8"])
61 + XCTAssertEqual(probe.runs.map(\.start), [4])
62 + XCTAssertEqual(probe.runs.map(\.end), [8])
63 + }
64 +
65 + func testCropKeepsTheTopLeftOrigin() throws {
66 + let source = try fixture().path
67 +
68 + let topLeft = try temporaryFile(named: "crop-top-left.png")
69 + let result = try decode(
70 + HostCropResult.self,
71 + from: try invoke(
72 + ["crop", "--image", source, "--rect", "0,0,4,4", "--out", topLeft.path]
73 + )
74 + )
75 + XCTAssertEqual(result.pixelWidth, 4)
76 + XCTAssertEqual(result.pixelHeight, 4)
77 + XCTAssertEqual(try Set(pixelColors(of: topLeft)), ["#ff0000"])
78 +
79 + let bottomRight = try temporaryFile(named: "crop-bottom-right.png")
80 + _ = try invoke(
81 + ["crop", "--image", source, "--rect", "4,4,4,4", "--out", bottomRight.path]
82 + )
83 + XCTAssertEqual(try Set(pixelColors(of: bottomRight)), ["#0000ff"])
84 + }
85 +
86 + func testScaleConvertsUnitsToPixels() throws {
87 + // At --scale 4 the 8x8 fixture is 2x2 units, so the quadrant boundary
88 + // falls on the unit boundary at 1.
89 + let probe = try decode(
90 + HostProbeResult.self,
91 + from: try invoke(
92 + ["probe", "--image", try fixture().path, "--column", "0.5", "--scale", "4"]
93 + )
94 + )
95 +
96 + XCTAssertEqual(probe.image.pixelWidth, 8)
97 + XCTAssertEqual(probe.image.width, 2)
98 + XCTAssertEqual(probe.image.height, 2)
99 + XCTAssertEqual(probe.position, 0.5)
100 + XCTAssertEqual(probe.runs.map(\.start), [0, 1])
101 + XCTAssertEqual(probe.runs.map(\.end), [1, 2])
102 + XCTAssertEqual(probe.runs.map(\.color), ["#f80000", "#0000f8"])
103 + }
104 +
105 + func testScaleRejectsZero() {
106 + let result = HostInspectCommand.invoke(
107 + arguments: ["probe", "--image", "x.png", "--column", "1", "--scale", "0"]
108 + )
109 + XCTAssertEqual(result.exitCode, 2)
110 + XCTAssertTrue(result.standardError.contains("--scale"))
111 + }
112 +
113 + func testProbeNeedsAnAxis() {
114 + let result = HostInspectCommand.invoke(arguments: ["probe", "--image", "x.png"])
115 + XCTAssertEqual(result.exitCode, 2)
116 + XCTAssertEqual(result.standardOutput, "")
117 + XCTAssertTrue(result.standardError.contains("--row or --column"))
118 + XCTAssertTrue(result.standardError.contains("xcrs-host-inspect"), "expected the usage text")
119 + }
120 +
121 + func testProbeRejectsBothAxes() {
122 + let result = HostInspectCommand.invoke(
123 + arguments: ["probe", "--image", "x.png", "--row", "1", "--column", "1"]
124 + )
125 + XCTAssertEqual(result.exitCode, 2)
126 + XCTAssertTrue(result.standardError.contains("either --row or --column"))
127 + }
128 +
129 + func testMissingImageIsAUsageError() {
130 + let result = HostInspectCommand.invoke(arguments: ["ocr"])
131 + XCTAssertEqual(result.exitCode, 2)
132 + XCTAssertTrue(result.standardError.contains("--image is required"))
133 + }
134 +
135 + func testBadRectIsAUsageError() {
136 + let result = HostInspectCommand.invoke(
137 + arguments: ["crop", "--image", "x.png", "--rect", "1,2,3", "--out", "o.png"]
138 + )
139 + XCTAssertEqual(result.exitCode, 2)
140 + XCTAssertTrue(result.standardError.contains("--rect expects x,y,w,h"))
141 + }
142 +
143 + func testUnknownOptionIsAUsageError() {
144 + let result = HostInspectCommand.invoke(arguments: ["windows", "--nope"])
145 + XCTAssertEqual(result.exitCode, 2)
146 + XCTAssertTrue(result.standardError.contains("unknown option --nope"))
147 + }
148 +
149 + func testUnknownCommandIsAUsageError() {
150 + let result = HostInspectCommand.invoke(arguments: ["snapshot"])
151 + XCTAssertEqual(result.exitCode, 2)
152 + XCTAssertTrue(result.standardError.contains("unknown command snapshot"))
153 + }
154 +
155 + func testHelpPrintsUsage() {
156 + let result = HostInspectCommand.invoke(arguments: ["help"])
157 + XCTAssertEqual(result.exitCode, 0)
158 + XCTAssertTrue(result.standardOutput.contains("xcrs-host-inspect"))
159 + XCTAssertTrue(result.standardError.isEmpty)
160 + }
161 +
162 + func testUnreadableImageFails() {
163 + let result = HostInspectCommand.invoke(
164 + arguments: [
165 + "crop", "--image", "/nonexistent/missing.png", "--rect", "0,0,1,1", "--out",
166 + temporaryFile(named: "unused.png").path,
167 + ]
168 + )
169 + XCTAssertEqual(result.exitCode, 1)
170 + XCTAssertTrue(result.standardError.contains("cannot open an image"))
171 + }
172 +
173 + func testCropOutsideTheImageFails() throws {
174 + let result = HostInspectCommand.invoke(
175 + arguments: [
176 + "crop", "--image", try fixture().path, "--rect", "6,6,4,4", "--out",
177 + temporaryFile(named: "outside.png").path,
178 + ]
179 + )
180 + XCTAssertEqual(result.exitCode, 1)
181 + XCTAssertTrue(result.standardError.contains("outside the 8x8 px image"))
182 + }
183 +
184 + func testWindowsReturnsAWindowList() throws {
185 + let list = try decode(
186 + HostWindowList.self,
187 + from: try invoke(["windows"])
188 + )
189 + XCTAssertTrue(
190 + list.windows.allSatisfy { $0.bounds.width >= 0 && $0.bounds.height >= 0 },
191 + "window bounds should never be negative"
192 + )
193 +
194 + let filtered = try decode(
195 + HostWindowList.self,
196 + from: try invoke(["windows", "--owner", "no-such-app-2a3f"])
197 + )
198 + XCTAssertTrue(filtered.windows.isEmpty, "the owner filter should exclude everything")
199 + }
200 +
201 + func testQuantizeCollapsesLowOrderBits() {
202 + XCTAssertEqual(
203 + HostInspect.quantize(red: 0xFF, green: 0x00, blue: 0x00),
204 + HostInspect.quantize(red: 0xF8, green: 0x07, blue: 0x07)
205 + )
206 + XCTAssertEqual(
207 + HostInspect.hexColor(HostInspect.quantize(red: 0xFF, green: 0x00, blue: 0x00)),
208 + "#f80000"
209 + )
210 + }
211 +
212 + func testGroupRunsCollapsesNeighbours() {
213 + let runs = HostInspect.groupRuns([1, 1, 2, 2, 2, 1])
214 + XCTAssertEqual(runs.map { $0.start }, [0, 2, 5])
215 + XCTAssertEqual(runs.map { $0.end }, [2, 5, 6])
216 + XCTAssertEqual(runs.map { $0.key }, [1, 2, 1])
217 + }
218 +
219 + func testInlineOptionValuesAreAccepted() throws {
220 + let probe = try decode(
221 + HostProbeResult.self,
222 + from: try invoke(["probe", "--image=\(try fixture().path)", "--column=1"])
223 + )
224 + XCTAssertEqual(probe.runs.count, 2)
225 + }
226 +
227 + private func invoke(_ arguments: [String]) throws -> String {
228 + let result = HostInspectCommand.invoke(arguments: arguments)
229 + XCTAssertEqual(result.exitCode, 0, result.standardError)
230 + return result.standardOutput
231 + }
232 +
233 + private func decode<T: Decodable>(_ type: T.Type, from json: String) throws -> T {
234 + try JSONDecoder().decode(type, from: Data(json.utf8))
235 + }
236 +
237 + private func fixture() throws -> URL {
238 + try XCTUnwrap(
239 + Bundle.module.url(
240 + forResource: "top-left-red.png",
241 + withExtension: nil,
242 + subdirectory: "Fixtures"
243 + ),
244 + "the top-left-red.png fixture is missing from the test bundle"
245 + )
246 + }
247 +
248 + private func temporaryFile(named name: String) -> URL {
249 + outputDirectory.appendingPathComponent(name)
250 + }
251 +
252 + /// Reads every pixel of a PNG as raw hex, without quantisation, so the
253 + /// assertions above only fail for a real orientation or colour mistake.
254 + private func pixelColors(of url: URL) throws -> [String] {
255 + let source = try XCTUnwrap(CGImageSourceCreateWithURL(url as CFURL, nil))
256 + let image = try XCTUnwrap(CGImageSourceCreateImageAtIndex(source, 0, nil))
257 + var buffer = [UInt8](repeating: 0, count: image.width * image.height * 4)
258 + buffer.withUnsafeMutableBytes { raw in
259 + guard
260 + let context = CGContext(
261 + data: raw.baseAddress,
262 + width: image.width,
263 + height: image.height,
264 + bitsPerComponent: 8,
265 + bytesPerRow: image.width * 4,
266 + space: CGColorSpaceCreateDeviceRGB(),
267 + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
268 + )
269 + else {
270 + return
271 + }
272 + context.draw(
273 + image,
274 + in: CGRect(x: 0, y: 0, width: image.width, height: image.height)
275 + )
276 + }
277 + return stride(from: 0, to: buffer.count, by: 4).map { index in
278 + String(
279 + format: "#%02x%02x%02x",
280 + buffer[index],
281 + buffer[index + 1],
282 + buffer[index + 2]
283 + )
284 + }
285 + }
286 +}
287 +
288 +#endif