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.
- package/README.md +227 -53
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +449 -0
- package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
- package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
- package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +117 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +89 -0
- package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
- package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
- package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
- package/native/simframed/Sources/SimframeCore/Element.swift +120 -0
- package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
- package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
- package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
- package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
- package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
- package/native/simframed/Sources/simframed/main.swift +357 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +240 -0
- package/package.json +10 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +91 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/src/actions.js +82 -5
- package/src/cli.js +347 -31
- package/src/control.js +76 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +150 -0
- package/src/graph.js +409 -0
- package/src/index.js +292 -23
- package/src/input.js +80 -2
- package/src/matching.js +194 -0
- package/src/mcp.js +45 -1
- package/src/navigate.js +120 -0
- package/src/regions.js +90 -0
- package/src/screenmap.js +77 -21
- package/src/simctl.js +20 -4
- 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, ¤t) == 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
|
+
}
|