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