simframe 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +227 -53
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
  4. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  5. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  6. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
  7. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
  8. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  9. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  10. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  11. package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
  12. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  13. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  14. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  15. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  16. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  17. package/native/simframed/Sources/simframed/main.swift +357 -0
  18. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
  19. package/package.json +10 -4
  20. package/scripts/bench-flow.mjs +54 -0
  21. package/scripts/bench.sh +98 -0
  22. package/scripts/check-package.mjs +91 -0
  23. package/scripts/smoke.mjs +76 -0
  24. package/scripts/verify-baseline.mjs +65 -0
  25. package/src/actions.js +82 -5
  26. package/src/cli.js +347 -31
  27. package/src/control.js +76 -0
  28. package/src/daemon.js +8 -1
  29. package/src/engine.js +99 -0
  30. package/src/fingerprint.js +150 -0
  31. package/src/graph.js +409 -0
  32. package/src/index.js +292 -23
  33. package/src/input.js +80 -2
  34. package/src/matching.js +194 -0
  35. package/src/mcp.js +45 -1
  36. package/src/navigate.js +120 -0
  37. package/src/regions.js +90 -0
  38. package/src/screenmap.js +77 -21
  39. package/src/simctl.js +20 -4
  40. package/src/store.js +8 -0
@@ -0,0 +1,117 @@
1
+ import Foundation
2
+
3
+ /// A frame as the simulator hands it to us: tightly-packed BGRA is not
4
+ /// guaranteed, so `bytesPerRow` must always be honoured rather than assumed
5
+ /// to equal `width * 4`.
6
+ public struct RawFrame {
7
+ public let width: Int
8
+ public let height: Int
9
+ public let bytesPerRow: Int
10
+ public let pixels: UnsafePointer<UInt8>
11
+
12
+ public init(width: Int, height: Int, bytesPerRow: Int, pixels: UnsafePointer<UInt8>) {
13
+ self.width = width
14
+ self.height = height
15
+ self.bytesPerRow = bytesPerRow
16
+ self.pixels = pixels
17
+ }
18
+ }
19
+
20
+ public struct DeviceInfo: Sendable {
21
+ public let udid: String
22
+ public let name: String
23
+ public let runtime: String
24
+ /// Native pixels, as the framebuffer reports them.
25
+ public let pixelWidth: Int
26
+ public let pixelHeight: Int
27
+ /// Points per pixel. Input coordinates are in points, frames are in pixels.
28
+ public let scale: Double
29
+
30
+ public var pointWidth: Int { scale > 0 ? Int((Double(pixelWidth) / scale).rounded()) : pixelWidth }
31
+ public var pointHeight: Int { scale > 0 ? Int((Double(pixelHeight) / scale).rounded()) : pixelHeight }
32
+
33
+ public init(udid: String, name: String, runtime: String,
34
+ pixelWidth: Int = 0, pixelHeight: Int = 0, scale: Double = 1) {
35
+ self.udid = udid
36
+ self.name = name
37
+ self.runtime = runtime
38
+ self.pixelWidth = pixelWidth
39
+ self.pixelHeight = pixelHeight
40
+ self.scale = scale
41
+ }
42
+ }
43
+
44
+ /// Hardware buttons, named rather than numbered at call sites.
45
+ public enum HardwareButton: String, Sendable, CaseIterable {
46
+ case home, lock, siri, volumeUp, volumeDown
47
+ }
48
+
49
+ public enum PrivateAPIError: Error, CustomStringConvertible {
50
+ case frameworksUnavailable(String)
51
+ case noBootedDevice
52
+ case deviceNotFound(String)
53
+ case noDisplayPort
54
+ case surfaceUnavailable
55
+ case hidUnavailable(String)
56
+ case simctlFailed(String)
57
+
58
+ public var description: String {
59
+ switch self {
60
+ case .frameworksUnavailable(let d): return "simulator frameworks unavailable: \(d)"
61
+ case .noBootedDevice: return "no booted simulator"
62
+ case .deviceNotFound(let u): return "no simulator matching \(u)"
63
+ case .noDisplayPort: return "the device exposes no active display port"
64
+ case .surfaceUnavailable: return "the display surface could not be read"
65
+ case .hidUnavailable(let d): return "input is unavailable: \(d)"
66
+ case .simctlFailed(let d): return "simctl \(d)"
67
+ }
68
+ }
69
+ }
70
+
71
+ /// The whole private surface simframe depends on. Anything that talks to
72
+ /// CoreSimulator or SimulatorKit goes through this.
73
+ public protocol SimulatorPlatform: AnyObject {
74
+ func bootedDevices() throws -> [DeviceInfo]
75
+ /// Bind to a device's display. Must be called before `withFrame`.
76
+ func attach(udid: String?) throws -> DeviceInfo
77
+ /// Borrow the current framebuffer. The pointer is only valid inside `body`.
78
+ func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T
79
+ /// Called whenever the display reports damage — the per-redraw signal, so a
80
+ /// capture loop can be driven by the screen rather than by a timer.
81
+ func observeChanges(_ handler: @escaping () -> Void) throws
82
+ func detach()
83
+
84
+ // MARK: Input. Optional: a platform may observe without being able to touch.
85
+
86
+ /// Whether input is available, and why not when it is not.
87
+ func inputStatus() -> (available: Bool, detail: String)
88
+ /// A press and release at one point.
89
+ func tap(at point: CGPoint, durationMs: Double) throws
90
+ /// A deliberate hold. Named separately from `tap` because callers mean
91
+ /// something different by it, even though the mechanism is a longer hold.
92
+ func longPress(at point: CGPoint, durationMs: Double) throws
93
+ /// Press, hold in place, then move and release. The hold is what separates
94
+ /// a drag from a swipe: reorderable lists and drag-and-drop need the UI to
95
+ /// register a pick-up before movement starts.
96
+ func drag(from: CGPoint, to: CGPoint, holdMs: Double, durationMs: Double) throws
97
+ /// A real down, interpolated moves, then up — never a teleporting jump.
98
+ func swipe(from: CGPoint, to: CGPoint, durationMs: Double) throws
99
+ func type(_ text: String) throws
100
+ /// Put text on the device pasteboard and paste it.
101
+ ///
102
+ /// Key events are mapped by whatever keyboard layout iOS has active, so
103
+ /// typing "Fryer 3" against a Persian layout yields Persian text with no
104
+ /// error. The pasteboard carries characters rather than key positions, so
105
+ /// it is the only reliable route for content that must be exact.
106
+ func paste(_ text: String) throws
107
+ func press(_ button: HardwareButton) throws
108
+
109
+ // MARK: App lifecycle. These are simctl, not private API — no HID needed.
110
+
111
+ @discardableResult
112
+ func launch(bundleId: String, arguments: [String], environment: [String: String]) throws -> Int32
113
+ func terminate(bundleId: String) throws
114
+ func openURL(_ url: String) throws
115
+ /// `action` is grant/revoke/reset, `service` one of simctl's privacy services.
116
+ func permission(action: String, service: String, bundleId: String?) throws
117
+ }
@@ -0,0 +1,89 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// A deterministic platform for tests and for machines without Xcode.
5
+ /// Renders a synthetic screen so hashing and frame-store logic can be tested
6
+ /// with no simulator present.
7
+ public final class StubPlatform: SimulatorPlatform {
8
+ public var width: Int
9
+ public var height: Int
10
+ public var tint: UInt8
11
+ private var buffer: [UInt8] = []
12
+ /// What was asked of it, so tests can assert on gestures without a device.
13
+ public var recorded: [String] = []
14
+
15
+ public init(width: Int = 1206, height: Int = 2622, tint: UInt8 = 0) {
16
+ self.width = width
17
+ self.height = height
18
+ self.tint = tint
19
+ }
20
+
21
+ public func bootedDevices() throws -> [DeviceInfo] {
22
+ [DeviceInfo(udid: "STUB-0000", name: "Stub Device", runtime: "iOS 26.0")]
23
+ }
24
+
25
+ public func attach(udid: String?) throws -> DeviceInfo {
26
+ DeviceInfo(udid: udid ?? "STUB-0000", name: "Stub Device", runtime: "iOS 26.0")
27
+ }
28
+
29
+ public func withFrame<T>(_ body: (RawFrame) throws -> T) throws -> T {
30
+ let bytesPerRow = width * 4 + 40 // deliberate padding: mirrors real surfaces
31
+ if buffer.count != bytesPerRow * height {
32
+ buffer = [UInt8](repeating: 0, count: bytesPerRow * height)
33
+ }
34
+ for y in 0..<height {
35
+ for x in 0..<width {
36
+ let i = y * bytesPerRow + x * 4
37
+ let band: UInt8 = x < width / 2 ? 40 : 200
38
+ buffer[i] = band &+ tint // B
39
+ buffer[i + 1] = band // G
40
+ buffer[i + 2] = band // R
41
+ buffer[i + 3] = 255 // A
42
+ }
43
+ }
44
+ return try buffer.withUnsafeBufferPointer { ptr in
45
+ try body(RawFrame(width: width, height: height, bytesPerRow: bytesPerRow, pixels: ptr.baseAddress!))
46
+ }
47
+ }
48
+
49
+ public func observeChanges(_ handler: @escaping () -> Void) throws {
50
+ // The stub never changes on its own; tests drive `tint` and call this.
51
+ stubHandler = handler
52
+ }
53
+
54
+ /// Test hook: mutate the synthetic screen and announce it.
55
+ public func simulateChange() {
56
+ tint = tint &+ 40
57
+ stubHandler?()
58
+ }
59
+
60
+ private var stubHandler: (() -> Void)?
61
+
62
+ public func detach() { stubHandler = nil }
63
+ }
64
+
65
+ extension StubPlatform {
66
+ public func inputStatus() -> (available: Bool, detail: String) { (true, "stub") }
67
+ public func tap(at point: CGPoint, durationMs: Double) throws { recorded.append("tap(\(Int(point.x)),\(Int(point.y)))") }
68
+ public func swipe(from: CGPoint, to: CGPoint, durationMs: Double) throws {
69
+ recorded.append("swipe(\(Int(from.x)),\(Int(from.y))->\(Int(to.x)),\(Int(to.y)))")
70
+ }
71
+ public func type(_ text: String) throws { recorded.append("type(\(text))") }
72
+ public func paste(_ text: String) throws { recorded.append("paste(\(text))") }
73
+ public func press(_ button: HardwareButton) throws { recorded.append("press(\(button.rawValue))") }
74
+ public func longPress(at point: CGPoint, durationMs: Double) throws {
75
+ recorded.append("longPress(\(Int(point.x)),\(Int(point.y)),\(Int(durationMs)))")
76
+ }
77
+ public func drag(from: CGPoint, to: CGPoint, holdMs: Double, durationMs: Double) throws {
78
+ recorded.append("drag(\(Int(from.x)),\(Int(from.y))->\(Int(to.x)),\(Int(to.y)),hold=\(Int(holdMs)))")
79
+ }
80
+ @discardableResult
81
+ public func launch(bundleId: String, arguments: [String], environment: [String: String]) throws -> Int32 {
82
+ recorded.append("launch(\(bundleId))"); return 1234
83
+ }
84
+ public func terminate(bundleId: String) throws { recorded.append("terminate(\(bundleId))") }
85
+ public func openURL(_ url: String) throws { recorded.append("openURL(\(url))") }
86
+ public func permission(action: String, service: String, bundleId: String?) throws {
87
+ recorded.append("permission(\(action),\(service),\(bundleId ?? "-"))")
88
+ }
89
+ }
@@ -0,0 +1,61 @@
1
+ import Foundation
2
+ import PrivateAPI
3
+
4
+ /// A tightly-packed RGBA bitmap, which is what every algorithm below expects.
5
+ /// The simulator hands us padded BGRA, so conversion happens exactly once.
6
+ public struct Bitmap {
7
+ public let width: Int
8
+ public let height: Int
9
+ public var data: [UInt8] // RGBA, 4 bytes per pixel, no row padding
10
+
11
+ public init(width: Int, height: Int, data: [UInt8]) {
12
+ self.width = width
13
+ self.height = height
14
+ self.data = data
15
+ }
16
+
17
+ /// Convert a padded BGRA surface into packed RGBA, downscaling by box
18
+ /// average in the same pass. Box averaging is what `grayGrid` does anyway,
19
+ /// so doing it here keeps the scaled image consistent with the hashes.
20
+ public static func from(_ frame: RawFrame, targetLongEdge: Int) -> Bitmap {
21
+ let (tw, th) = Self.fit(width: frame.width, height: frame.height, longEdge: targetLongEdge)
22
+ var out = [UInt8](repeating: 0, count: tw * th * 4)
23
+ let src = frame.pixels
24
+
25
+ for ty in 0..<th {
26
+ let y0 = (ty * frame.height) / th
27
+ let y1 = max(y0 + 1, ((ty + 1) * frame.height) / th)
28
+ for tx in 0..<tw {
29
+ let x0 = (tx * frame.width) / tw
30
+ let x1 = max(x0 + 1, ((tx + 1) * frame.width) / tw)
31
+ var r = 0, g = 0, b = 0, n = 0
32
+ for y in y0..<y1 {
33
+ let row = y * frame.bytesPerRow
34
+ for x in x0..<x1 {
35
+ let i = row + x * 4
36
+ b += Int(src[i]) // surfaces are BGRA
37
+ g += Int(src[i + 1])
38
+ r += Int(src[i + 2])
39
+ n += 1
40
+ }
41
+ }
42
+ let o = (ty * tw + tx) * 4
43
+ out[o] = UInt8(r / n)
44
+ out[o + 1] = UInt8(g / n)
45
+ out[o + 2] = UInt8(b / n)
46
+ out[o + 3] = 255
47
+ }
48
+ }
49
+ return Bitmap(width: tw, height: th, data: out)
50
+ }
51
+
52
+ /// Match `sips -Z`: the longer edge becomes `longEdge`, aspect preserved.
53
+ public static func fit(width: Int, height: Int, longEdge: Int) -> (Int, Int) {
54
+ guard width > 0, height > 0 else { return (1, 1) }
55
+ if max(width, height) <= longEdge { return (width, height) }
56
+ if height >= width {
57
+ return (max(1, Int((Double(width) * Double(longEdge) / Double(height)).rounded())), longEdge)
58
+ }
59
+ return (longEdge, max(1, Int((Double(height) * Double(longEdge) / Double(width)).rounded())))
60
+ }
61
+ }
@@ -0,0 +1,122 @@
1
+ import Foundation
2
+ import PrivateAPI
3
+
4
+ /// A per-device Unix domain socket carrying one JSON object per line.
5
+ ///
6
+ /// Unix sockets rather than TCP because the permission model is the file
7
+ /// system's: mode 0600 means only this user can drive the simulator, with no
8
+ /// port to collide and nothing reachable from the network.
9
+ public final class ControlSocket {
10
+ public typealias Handler = ([String: Any]) -> [String: Any]
11
+
12
+ private let path: String
13
+ private let handler: Handler
14
+ private var listenFD: Int32 = -1
15
+ private var acceptSource: DispatchSourceRead?
16
+ // Requests run off the capture loop: a swipe sleeps for its whole duration
17
+ // and must not stall frame capture.
18
+ private let workQueue = DispatchQueue(label: "simframe.control", qos: .userInitiated)
19
+ /// Identity of the socket file this instance created, so shutdown can tell
20
+ /// its own socket from one a successor has since bound at the same path.
21
+ private var boundIno: ino_t = 0
22
+ private var boundDev: dev_t = 0
23
+
24
+ public init(path: String, handler: @escaping Handler) {
25
+ self.path = path
26
+ self.handler = handler
27
+ }
28
+
29
+ public func start() throws {
30
+ unlink(path)
31
+ listenFD = socket(AF_UNIX, SOCK_STREAM, 0)
32
+ guard listenFD >= 0 else { throw SocketError.failed("socket(): \(errno)") }
33
+
34
+ var addr = sockaddr_un()
35
+ addr.sun_family = sa_family_t(AF_UNIX)
36
+ let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
37
+ guard path.utf8.count < maxLen else { throw SocketError.failed("socket path too long") }
38
+ withUnsafeMutablePointer(to: &addr.sun_path) { p in
39
+ p.withMemoryRebound(to: CChar.self, capacity: maxLen) { dst in
40
+ _ = strcpy(dst, path)
41
+ }
42
+ }
43
+ let size = socklen_t(MemoryLayout<sockaddr_un>.size)
44
+ let bound = withUnsafePointer(to: &addr) {
45
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(listenFD, $0, size) }
46
+ }
47
+ guard bound == 0 else { close(listenFD); throw SocketError.failed("bind(): \(errno)") }
48
+ // Only this user may drive the device.
49
+ chmod(path, 0o600)
50
+ var bornStat = stat()
51
+ if stat(path, &bornStat) == 0 {
52
+ boundIno = bornStat.st_ino
53
+ boundDev = bornStat.st_dev
54
+ }
55
+ guard listen(listenFD, 8) == 0 else { close(listenFD); throw SocketError.failed("listen(): \(errno)") }
56
+
57
+ let source = DispatchSource.makeReadSource(fileDescriptor: listenFD, queue: workQueue)
58
+ source.setEventHandler { [weak self] in self?.acceptOne() }
59
+ source.resume()
60
+ acceptSource = source
61
+ }
62
+
63
+ private func acceptOne() {
64
+ let fd = accept(listenFD, nil, nil)
65
+ guard fd >= 0 else { return }
66
+ defer { close(fd) }
67
+ guard let request = readLine(fd) else { return }
68
+
69
+ var response: [String: Any]
70
+ if let data = request.data(using: .utf8),
71
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
72
+ response = handler(object)
73
+ if let id = object["id"] { response["id"] = id }
74
+ } else {
75
+ response = ["ok": false, "error": "malformed request"]
76
+ }
77
+ if let out = try? JSONSerialization.data(withJSONObject: response) {
78
+ var line = out
79
+ line.append(0x0A)
80
+ _ = line.withUnsafeBytes { write(fd, $0.baseAddress, $0.count) }
81
+ }
82
+ }
83
+
84
+ private func readLine(_ fd: Int32) -> String? {
85
+ var buffer = [UInt8]()
86
+ var byte: UInt8 = 0
87
+ while read(fd, &byte, 1) == 1 {
88
+ if byte == 0x0A { break }
89
+ buffer.append(byte)
90
+ if buffer.count > 1 << 20 { return nil }
91
+ }
92
+ return buffer.isEmpty ? nil : String(decoding: buffer, as: UTF8.self)
93
+ }
94
+
95
+ public func stop() {
96
+ acceptSource?.cancel()
97
+ acceptSource = nil
98
+ if listenFD >= 0 { close(listenFD) }
99
+ listenFD = -1
100
+ // Only remove the socket if it is still the one we created.
101
+ //
102
+ // A restart binds a fresh socket at the same path, and a dying daemon
103
+ // that unlinks blindly deletes its *successor's* socket. Capture keeps
104
+ // working — that is file-based — so the only symptom is input quietly
105
+ // dropping to idb, which is slower and has different semantics. That is
106
+ // exactly the kind of invisible downgrade this tool is supposed to
107
+ // refuse to have.
108
+ var current = stat()
109
+ if stat(path, &current) == 0, current.st_ino == boundIno, current.st_dev == boundDev {
110
+ unlink(path)
111
+ }
112
+ boundIno = 0
113
+ boundDev = 0
114
+ }
115
+
116
+ public enum SocketError: Error, CustomStringConvertible {
117
+ case failed(String)
118
+ public var description: String {
119
+ switch self { case .failed(let d): return "control socket: \(d)" }
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,70 @@
1
+ import CoreGraphics
2
+ import ImageIO
3
+ import Foundation
4
+ import PrivateAPI
5
+
6
+ /// Downscale through CoreGraphics rather than by box average.
7
+ ///
8
+ /// `sips` — which the JavaScript capture loop uses — is a thin wrapper over
9
+ /// ImageIO/CoreGraphics, so resampling the same way is the closest we can get
10
+ /// to producing byte-identical hashes without reimplementing its filter.
11
+ public enum CoreGraphicsScaler {
12
+ public static func bitmap(from frame: RawFrame, targetLongEdge: Int) -> Bitmap? {
13
+ let (tw, th) = Bitmap.fit(width: frame.width, height: frame.height, longEdge: targetLongEdge)
14
+ let srcBytes = frame.bytesPerRow * frame.height
15
+ guard let provider = CGDataProvider(dataInfo: nil, data: frame.pixels, size: srcBytes, releaseData: { _, _, _ in }) else {
16
+ return nil
17
+ }
18
+ // Surfaces are BGRA; this bitmapInfo is how CoreGraphics is told so.
19
+ let srcInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue).union(.byteOrder32Little)
20
+ guard let cgImage = CGImage(
21
+ width: frame.width, height: frame.height,
22
+ bitsPerComponent: 8, bitsPerPixel: 32,
23
+ bytesPerRow: frame.bytesPerRow,
24
+ space: CGColorSpaceCreateDeviceRGB(),
25
+ bitmapInfo: srcInfo,
26
+ provider: provider, decode: nil,
27
+ shouldInterpolate: false, intent: .defaultIntent
28
+ ) else { return nil }
29
+
30
+ var out = [UInt8](repeating: 0, count: tw * th * 4)
31
+ let ok = out.withUnsafeMutableBytes { buf -> Bool in
32
+ guard let ctx = CGContext(
33
+ data: buf.baseAddress, width: tw, height: th,
34
+ bitsPerComponent: 8, bytesPerRow: tw * 4,
35
+ space: CGColorSpaceCreateDeviceRGB(),
36
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
37
+ ) else { return false }
38
+ ctx.interpolationQuality = .high
39
+ ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: tw, height: th))
40
+ return true
41
+ }
42
+ return ok ? Bitmap(width: tw, height: th, data: out) : nil
43
+ }
44
+ }
45
+
46
+ extension CoreGraphicsScaler {
47
+ /// Re-encode an existing PNG at a fraction of its size. Frames that have
48
+ /// aged out of the fine window stay legible enough to tell which screen was
49
+ /// showing, at roughly a quarter of the bytes.
50
+ public static func downscalePNG(at url: URL, factor: Double) -> Bitmap? {
51
+ guard factor > 0, factor < 1,
52
+ let src = CGImageSourceCreateWithURL(url as CFURL, nil),
53
+ let image = CGImageSourceCreateImageAtIndex(src, 0, nil) else { return nil }
54
+ let tw = max(1, Int(Double(image.width) * factor))
55
+ let th = max(1, Int(Double(image.height) * factor))
56
+ var out = [UInt8](repeating: 0, count: tw * th * 4)
57
+ let ok = out.withUnsafeMutableBytes { buf -> Bool in
58
+ guard let ctx = CGContext(
59
+ data: buf.baseAddress, width: tw, height: th,
60
+ bitsPerComponent: 8, bytesPerRow: tw * 4,
61
+ space: CGColorSpaceCreateDeviceRGB(),
62
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
63
+ ) else { return false }
64
+ ctx.interpolationQuality = .high
65
+ ctx.draw(image, in: CGRect(x: 0, y: 0, width: tw, height: th))
66
+ return true
67
+ }
68
+ return ok ? Bitmap(width: tw, height: th, data: out) : nil
69
+ }
70
+ }
@@ -0,0 +1,120 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// Where an element's information came from. A single element can be seen by
5
+ /// more than one source, which is why this is a set rather than a tag.
6
+ public struct ElementSource: OptionSet, Sendable, Hashable {
7
+ public let rawValue: Int
8
+ public init(rawValue: Int) { self.rawValue = rawValue }
9
+
10
+ public static let accessibility = ElementSource(rawValue: 1 << 0)
11
+ public static let ocr = ElementSource(rawValue: 1 << 1)
12
+ public static let contour = ElementSource(rawValue: 1 << 2)
13
+ public static let template = ElementSource(rawValue: 1 << 3)
14
+
15
+ public var names: [String] {
16
+ var out: [String] = []
17
+ if contains(.accessibility) { out.append("ax") }
18
+ if contains(.ocr) { out.append("ocr") }
19
+ if contains(.contour) { out.append("cv") }
20
+ if contains(.template) { out.append("template") }
21
+ return out
22
+ }
23
+ }
24
+
25
+ public struct ElementState: Sendable, Equatable {
26
+ public var enabled: Bool?
27
+ public var selected: Bool?
28
+ public var checked: Bool?
29
+ public var focused: Bool?
30
+
31
+ public init(enabled: Bool? = nil, selected: Bool? = nil, checked: Bool? = nil, focused: Bool? = nil) {
32
+ self.enabled = enabled
33
+ self.selected = selected
34
+ self.checked = checked
35
+ self.focused = focused
36
+ }
37
+
38
+ public var json: [String: Any] {
39
+ var out: [String: Any] = [:]
40
+ if let enabled { out["enabled"] = enabled }
41
+ if let selected { out["selected"] = selected }
42
+ if let checked { out["checked"] = checked }
43
+ if let focused { out["focused"] = focused }
44
+ return out
45
+ }
46
+ }
47
+
48
+ /// One thing on screen. Frames are in **points**, because that is what input
49
+ /// speaks; the capture side works in pixels and converts once, here.
50
+ public struct Element: Sendable {
51
+ public var id: Int
52
+ public var frame: CGRect
53
+ public var role: String
54
+ public var label: String?
55
+ public var value: String?
56
+ public var state: ElementState
57
+ public var source: ElementSource
58
+ public var confidence: Double
59
+
60
+ public init(id: Int, frame: CGRect, role: String, label: String? = nil, value: String? = nil,
61
+ state: ElementState = ElementState(), source: ElementSource, confidence: Double = 1) {
62
+ self.id = id
63
+ self.frame = frame
64
+ self.role = role
65
+ self.label = label
66
+ self.value = value
67
+ self.state = state
68
+ self.source = source
69
+ self.confidence = confidence
70
+ }
71
+
72
+ public var center: CGPoint { CGPoint(x: frame.midX, y: frame.midY) }
73
+
74
+ public var json: [String: Any] {
75
+ var out: [String: Any] = [
76
+ "id": id,
77
+ "role": role,
78
+ "frame": ["x": frame.origin.x, "y": frame.origin.y, "width": frame.width, "height": frame.height],
79
+ "center": ["x": Int(center.x.rounded()), "y": Int(center.y.rounded())],
80
+ "source": source.names,
81
+ "confidence": (confidence * 1000).rounded() / 1000,
82
+ ]
83
+ if let label { out["label"] = label }
84
+ if let value { out["value"] = value }
85
+ let state = state.json
86
+ if !state.isEmpty { out["state"] = state }
87
+ return out
88
+ }
89
+ }
90
+
91
+ /// Everything known about one screen.
92
+ public struct ScreenMap: Sendable {
93
+ public var fingerprint: String // the layout hash
94
+ public var hash: String // the content hash
95
+ public var size: CGSize // points
96
+ public var elements: [Element]
97
+ public var sources: [String]
98
+ public var capturedAt: Double
99
+
100
+ public init(fingerprint: String, hash: String, size: CGSize, elements: [Element],
101
+ sources: [String], capturedAt: Double) {
102
+ self.fingerprint = fingerprint
103
+ self.hash = hash
104
+ self.size = size
105
+ self.elements = elements
106
+ self.sources = sources
107
+ self.capturedAt = capturedAt
108
+ }
109
+
110
+ public var json: [String: Any] {
111
+ [
112
+ "fingerprint": fingerprint,
113
+ "hash": hash,
114
+ "size": ["width": Int(size.width), "height": Int(size.height)],
115
+ "sources": sources,
116
+ "capturedAt": capturedAt,
117
+ "elements": elements.map(\.json),
118
+ ]
119
+ }
120
+ }