feature/xcodegen-modernize-runner
swift 807 lines 28.3 KB
Raw
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