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,357 @@
|
|
|
1
|
+
import CoreGraphics
|
|
2
|
+
import Foundation
|
|
3
|
+
import PrivateAPI
|
|
4
|
+
import SimframeCore
|
|
5
|
+
|
|
6
|
+
let args = Array(CommandLine.arguments.dropFirst())
|
|
7
|
+
func flag(_ name: String) -> String? {
|
|
8
|
+
guard let a = args.first(where: { $0.hasPrefix("--\(name)=") }) else { return nil }
|
|
9
|
+
return String(a.dropFirst(name.count + 3))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let command = args.first(where: { !$0.hasPrefix("--") }) ?? "run"
|
|
13
|
+
let platform: SimulatorPlatform = args.contains("--stub") ? StubPlatform() : CoreSimulatorPlatform()
|
|
14
|
+
let longEdge = Int(flag("max-dim") ?? "") ?? 700
|
|
15
|
+
|
|
16
|
+
func fail(_ message: String) -> Never {
|
|
17
|
+
FileHandle.standardError.write("simframed: \(message)\n".data(using: .utf8)!)
|
|
18
|
+
exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch command {
|
|
22
|
+
case "devices":
|
|
23
|
+
do {
|
|
24
|
+
for d in try platform.bootedDevices() { print("\(d.udid) \(d.name) \(d.runtime)") }
|
|
25
|
+
} catch { fail("\(error)") }
|
|
26
|
+
|
|
27
|
+
case "hash":
|
|
28
|
+
// One-shot: used to verify the Swift hashes match the JavaScript ones.
|
|
29
|
+
do {
|
|
30
|
+
let device = try platform.attach(udid: flag("udid"))
|
|
31
|
+
let t0 = DispatchTime.now().uptimeNanoseconds
|
|
32
|
+
let useCG = !args.contains("--box")
|
|
33
|
+
let (bmp, native) = try platform.withFrame { frame -> (Bitmap, (Int, Int)) in
|
|
34
|
+
let scaled = useCG
|
|
35
|
+
? (CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: longEdge) ?? Bitmap.from(frame, targetLongEdge: longEdge))
|
|
36
|
+
: Bitmap.from(frame, targetLongEdge: longEdge)
|
|
37
|
+
return (scaled, (frame.width, frame.height))
|
|
38
|
+
}
|
|
39
|
+
let ms = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6
|
|
40
|
+
let json: [String: Any] = [
|
|
41
|
+
"device": ["udid": device.udid, "name": device.name, "runtime": device.runtime],
|
|
42
|
+
"native": ["width": native.0, "height": native.1],
|
|
43
|
+
"scaled": ["width": bmp.width, "height": bmp.height],
|
|
44
|
+
"hash": Hashing.frameHash(bmp),
|
|
45
|
+
"layoutHash": Hashing.layoutHash(bmp),
|
|
46
|
+
"regions": Hashing.signatureToHex(Hashing.regionSignature(bmp)),
|
|
47
|
+
"captureMs": (ms * 100).rounded() / 100,
|
|
48
|
+
]
|
|
49
|
+
let data = try JSONSerialization.data(withJSONObject: json, options: [.sortedKeys])
|
|
50
|
+
print(String(data: data, encoding: .utf8)!)
|
|
51
|
+
} catch { fail("\(error)") }
|
|
52
|
+
|
|
53
|
+
case "bench":
|
|
54
|
+
do {
|
|
55
|
+
_ = try platform.attach(udid: flag("udid"))
|
|
56
|
+
let n = Int(flag("n") ?? "") ?? 200
|
|
57
|
+
var grab: [Double] = [], full: [Double] = []
|
|
58
|
+
for _ in 0..<n {
|
|
59
|
+
let t0 = DispatchTime.now().uptimeNanoseconds
|
|
60
|
+
let bmp = try platform.withFrame { Bitmap.from($0, targetLongEdge: longEdge) }
|
|
61
|
+
let t1 = DispatchTime.now().uptimeNanoseconds
|
|
62
|
+
_ = Hashing.frameHash(bmp)
|
|
63
|
+
_ = Hashing.layoutHash(bmp)
|
|
64
|
+
let t2 = DispatchTime.now().uptimeNanoseconds
|
|
65
|
+
grab.append(Double(t1 - t0) / 1e6)
|
|
66
|
+
full.append(Double(t2 - t0) / 1e6)
|
|
67
|
+
}
|
|
68
|
+
func pct(_ xs: [Double], _ p: Double) -> Double {
|
|
69
|
+
let s = xs.sorted(); return (s[min(s.count - 1, Int(Double(s.count) * p))] * 100).rounded() / 100
|
|
70
|
+
}
|
|
71
|
+
print("n=\(n) grab+scale median \(pct(grab, 0.5))ms p95 \(pct(grab, 0.95))ms | +hash median \(pct(full, 0.5))ms p95 \(pct(full, 0.95))ms")
|
|
72
|
+
} catch { fail("\(error)") }
|
|
73
|
+
|
|
74
|
+
case "snap":
|
|
75
|
+
// Write one frame to disk so it can be looked at. Correct hashes prove
|
|
76
|
+
// nothing about channel order or row padding; a picture does.
|
|
77
|
+
do {
|
|
78
|
+
_ = try platform.attach(udid: flag("udid"))
|
|
79
|
+
let out = flag("out") ?? "simframed-snap.png"
|
|
80
|
+
let full = args.contains("--native")
|
|
81
|
+
let bmp = try platform.withFrame { frame -> Bitmap in
|
|
82
|
+
let edge = full ? max(frame.width, frame.height) : longEdge
|
|
83
|
+
return CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: edge)
|
|
84
|
+
?? Bitmap.from(frame, targetLongEdge: edge)
|
|
85
|
+
}
|
|
86
|
+
try PNGWriter.write(bmp, to: URL(fileURLWithPath: out))
|
|
87
|
+
print("\(out) — \(bmp.width)x\(bmp.height)")
|
|
88
|
+
} catch { fail("\(error)") }
|
|
89
|
+
|
|
90
|
+
case "input":
|
|
91
|
+
do {
|
|
92
|
+
let device = try platform.attach(udid: flag("udid"))
|
|
93
|
+
let status = platform.inputStatus()
|
|
94
|
+
print("\(status.available ? "ok " : "FAIL") \(status.detail)")
|
|
95
|
+
print(" \(device.name): \(device.pixelWidth)x\(device.pixelHeight)px @\(device.scale)x = \(device.pointWidth)x\(device.pointHeight)pt")
|
|
96
|
+
} catch { fail("\(error)") }
|
|
97
|
+
|
|
98
|
+
case "tap", "swipe", "type", "paste", "press":
|
|
99
|
+
do {
|
|
100
|
+
_ = try platform.attach(udid: flag("udid"))
|
|
101
|
+
let positional = args.filter { !$0.hasPrefix("--") }.dropFirst()
|
|
102
|
+
let t0 = DispatchTime.now().uptimeNanoseconds
|
|
103
|
+
switch command {
|
|
104
|
+
case "tap":
|
|
105
|
+
guard positional.count >= 2, let x = Double(positional.first!), let y = Double(positional.dropFirst().first!) else {
|
|
106
|
+
fail("usage: simframed tap <x> <y> [--duration-ms=70]")
|
|
107
|
+
}
|
|
108
|
+
try platform.tap(at: CGPoint(x: x, y: y), durationMs: Double(flag("duration-ms") ?? "") ?? 70)
|
|
109
|
+
case "swipe":
|
|
110
|
+
let v = positional.compactMap(Double.init)
|
|
111
|
+
guard v.count >= 4 else { fail("usage: simframed swipe <x1> <y1> <x2> <y2> [--duration-ms=300]") }
|
|
112
|
+
try platform.swipe(from: CGPoint(x: v[0], y: v[1]), to: CGPoint(x: v[2], y: v[3]),
|
|
113
|
+
durationMs: Double(flag("duration-ms") ?? "") ?? 300)
|
|
114
|
+
case "type":
|
|
115
|
+
guard !positional.isEmpty else { fail("usage: simframed type <text>") }
|
|
116
|
+
try platform.type(positional.joined(separator: " "))
|
|
117
|
+
case "paste":
|
|
118
|
+
guard !positional.isEmpty else { fail("usage: simframed paste <text>") }
|
|
119
|
+
try platform.paste(positional.joined(separator: " "))
|
|
120
|
+
default:
|
|
121
|
+
guard let name = positional.first, let button = HardwareButton(rawValue: name) else {
|
|
122
|
+
fail("usage: simframed press <\(HardwareButton.allCases.map(\.rawValue).joined(separator: "|"))>")
|
|
123
|
+
}
|
|
124
|
+
try platform.press(button)
|
|
125
|
+
}
|
|
126
|
+
print(String(format: "%@ in %.0fms", command, Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6))
|
|
127
|
+
} catch { fail("\(error)") }
|
|
128
|
+
|
|
129
|
+
case "run":
|
|
130
|
+
// The capture loop. Driven by the display's damage callback rather than a
|
|
131
|
+
// timer, so an idle screen costs nothing and a moving one is picked up at
|
|
132
|
+
// once. A slow floor keeps state fresh enough for liveness checks.
|
|
133
|
+
do {
|
|
134
|
+
let device = try platform.attach(udid: flag("udid"))
|
|
135
|
+
var opts = FrameStore.Options()
|
|
136
|
+
opts.maxDim = longEdge
|
|
137
|
+
let store = try FrameStore(device: device, options: opts)
|
|
138
|
+
try store.claim()
|
|
139
|
+
defer { store.release() }
|
|
140
|
+
|
|
141
|
+
// `defer` does not run when a signal terminates the process, so a
|
|
142
|
+
// plain Ctrl-C would leave meta.json naming a dead pid. Node recovers
|
|
143
|
+
// from that on its own, but a daemon should tidy up after itself.
|
|
144
|
+
var stopping = false
|
|
145
|
+
let signalQueue = DispatchQueue(label: "simframe.signals")
|
|
146
|
+
var signalSources: [DispatchSourceSignal] = []
|
|
147
|
+
for sig in [SIGINT, SIGTERM] {
|
|
148
|
+
signal(sig, SIG_IGN) // let the dispatch source own it
|
|
149
|
+
let source = DispatchSource.makeSignalSource(signal: sig, queue: signalQueue)
|
|
150
|
+
source.setEventHandler { stopping = true }
|
|
151
|
+
source.resume()
|
|
152
|
+
signalSources.append(source)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let minInterval = Double(flag("min-interval-ms") ?? "") ?? 80 // coalesce bursts
|
|
156
|
+
let idleInterval = Double(flag("idle-interval-ms") ?? "") ?? 2000 // keep state fresh
|
|
157
|
+
let idleExitMs = Double(flag("idle-exit-ms") ?? "") ?? 15 * 60_000
|
|
158
|
+
|
|
159
|
+
let lock = NSLock()
|
|
160
|
+
var dirty = true
|
|
161
|
+
var lastCapture = 0.0
|
|
162
|
+
var frames = 0
|
|
163
|
+
var lastReport = Date().timeIntervalSince1970
|
|
164
|
+
var latencies: [Double] = []
|
|
165
|
+
|
|
166
|
+
try platform.observeChanges {
|
|
167
|
+
lock.lock(); dirty = true; lock.unlock()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Control socket: requests are served off the capture loop, because a
|
|
171
|
+
// swipe sleeps for its whole duration and must not stall frames.
|
|
172
|
+
let socketPath = store.controlSocketPath
|
|
173
|
+
let control = ControlSocket(path: socketPath) { request in
|
|
174
|
+
let action = request["action"] as? String ?? ""
|
|
175
|
+
let started = DispatchTime.now().uptimeNanoseconds
|
|
176
|
+
func done(_ extra: [String: Any] = [:]) -> [String: Any] {
|
|
177
|
+
var r: [String: Any] = ["ok": true]
|
|
178
|
+
r["ms"] = (Double(DispatchTime.now().uptimeNanoseconds - started) / 1e6 * 100).rounded() / 100
|
|
179
|
+
for (k, v) in extra { r[k] = v }
|
|
180
|
+
return r
|
|
181
|
+
}
|
|
182
|
+
func point(_ xKey: String, _ yKey: String) -> CGPoint? {
|
|
183
|
+
guard let x = request[xKey] as? Double ?? (request[xKey] as? Int).map(Double.init),
|
|
184
|
+
let y = request[yKey] as? Double ?? (request[yKey] as? Int).map(Double.init) else { return nil }
|
|
185
|
+
return CGPoint(x: x, y: y)
|
|
186
|
+
}
|
|
187
|
+
do {
|
|
188
|
+
switch action {
|
|
189
|
+
case "ping":
|
|
190
|
+
return done(["device": device.name, "udid": device.udid])
|
|
191
|
+
case "status":
|
|
192
|
+
let input = platform.inputStatus()
|
|
193
|
+
return done([
|
|
194
|
+
"input": ["available": input.available, "detail": input.detail],
|
|
195
|
+
"device": ["name": device.name, "udid": device.udid,
|
|
196
|
+
"pointWidth": device.pointWidth, "pointHeight": device.pointHeight,
|
|
197
|
+
"scale": device.scale],
|
|
198
|
+
"engine": "simframed",
|
|
199
|
+
])
|
|
200
|
+
case "ui":
|
|
201
|
+
// OCR straight off the framebuffer: no PNG encode, no file,
|
|
202
|
+
// no process spawn. Runs on the socket queue, so a slow
|
|
203
|
+
// recognition pass cannot stall frame capture.
|
|
204
|
+
let started = DispatchTime.now().uptimeNanoseconds
|
|
205
|
+
let elements = try platform.withFrame { frame in
|
|
206
|
+
try VisionOCR.recognise(frame: frame, scale: device.scale)
|
|
207
|
+
}
|
|
208
|
+
let latest = store.latestState()
|
|
209
|
+
let map = ScreenMap(
|
|
210
|
+
fingerprint: latest?["layoutHash"] as? String ?? "",
|
|
211
|
+
hash: latest?["hash"] as? String ?? "",
|
|
212
|
+
size: CGSize(width: device.pointWidth, height: device.pointHeight),
|
|
213
|
+
elements: elements,
|
|
214
|
+
sources: ["ocr"],
|
|
215
|
+
capturedAt: FrameStore.nowMs()
|
|
216
|
+
)
|
|
217
|
+
var payload = map.json
|
|
218
|
+
payload["ocrMs"] = (Double(DispatchTime.now().uptimeNanoseconds - started) / 1e6 * 100).rounded() / 100
|
|
219
|
+
return done(["screen": payload])
|
|
220
|
+
case "tap":
|
|
221
|
+
guard let p = point("x", "y") else { return ["ok": false, "error": "tap needs x and y"] }
|
|
222
|
+
try platform.tap(at: p, durationMs: request["durationMs"] as? Double ?? 70)
|
|
223
|
+
return done()
|
|
224
|
+
case "swipe":
|
|
225
|
+
guard let from = point("x1", "y1"), let to = point("x2", "y2") else {
|
|
226
|
+
return ["ok": false, "error": "swipe needs x1, y1, x2, y2"]
|
|
227
|
+
}
|
|
228
|
+
try platform.swipe(from: from, to: to, durationMs: request["durationMs"] as? Double ?? 300)
|
|
229
|
+
return done()
|
|
230
|
+
case "type":
|
|
231
|
+
guard let text = request["text"] as? String else { return ["ok": false, "error": "type needs text"] }
|
|
232
|
+
try platform.type(text)
|
|
233
|
+
return done()
|
|
234
|
+
case "paste":
|
|
235
|
+
guard let text = request["text"] as? String else { return ["ok": false, "error": "paste needs text"] }
|
|
236
|
+
try platform.paste(text)
|
|
237
|
+
return done()
|
|
238
|
+
case "longPress":
|
|
239
|
+
guard let p = point("x", "y") else { return ["ok": false, "error": "longPress needs x and y"] }
|
|
240
|
+
try platform.longPress(at: p, durationMs: request["durationMs"] as? Double ?? 600)
|
|
241
|
+
return done()
|
|
242
|
+
case "drag":
|
|
243
|
+
guard let from = point("x1", "y1"), let to = point("x2", "y2") else {
|
|
244
|
+
return ["ok": false, "error": "drag needs x1, y1, x2, y2"]
|
|
245
|
+
}
|
|
246
|
+
try platform.drag(from: from, to: to,
|
|
247
|
+
holdMs: request["holdMs"] as? Double ?? 500,
|
|
248
|
+
durationMs: request["durationMs"] as? Double ?? 400)
|
|
249
|
+
return done()
|
|
250
|
+
case "launch":
|
|
251
|
+
guard let bundleId = request["bundleId"] as? String else { return ["ok": false, "error": "launch needs bundleId"] }
|
|
252
|
+
let pid = try platform.launch(bundleId: bundleId,
|
|
253
|
+
arguments: request["arguments"] as? [String] ?? [],
|
|
254
|
+
environment: request["environment"] as? [String: String] ?? [:])
|
|
255
|
+
return done(["pid": Int(pid)])
|
|
256
|
+
case "terminate":
|
|
257
|
+
guard let bundleId = request["bundleId"] as? String else { return ["ok": false, "error": "terminate needs bundleId"] }
|
|
258
|
+
try platform.terminate(bundleId: bundleId)
|
|
259
|
+
return done()
|
|
260
|
+
case "openUrl":
|
|
261
|
+
guard let url = request["url"] as? String else { return ["ok": false, "error": "openUrl needs url"] }
|
|
262
|
+
try platform.openURL(url)
|
|
263
|
+
return done()
|
|
264
|
+
case "permission":
|
|
265
|
+
guard let action = request["permissionAction"] as? String, let service = request["service"] as? String else {
|
|
266
|
+
return ["ok": false, "error": "permission needs permissionAction and service"]
|
|
267
|
+
}
|
|
268
|
+
try platform.permission(action: action, service: service, bundleId: request["bundleId"] as? String)
|
|
269
|
+
return done()
|
|
270
|
+
case "press":
|
|
271
|
+
guard let name = request["button"] as? String, let button = HardwareButton(rawValue: name) else {
|
|
272
|
+
return ["ok": false, "error": "press needs a known button name"]
|
|
273
|
+
}
|
|
274
|
+
try platform.press(button)
|
|
275
|
+
return done()
|
|
276
|
+
default:
|
|
277
|
+
return ["ok": false, "error": "unknown action '\(action)'"]
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
return ["ok": false, "error": "\(error)"]
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
do {
|
|
284
|
+
try control.start()
|
|
285
|
+
FileHandle.standardError.write("simframed: control socket at \(socketPath)\n".data(using: .utf8)!)
|
|
286
|
+
} catch {
|
|
287
|
+
// Capture is still useful without input; say so rather than dying.
|
|
288
|
+
FileHandle.standardError.write("simframed: control socket unavailable: \(error)\n".data(using: .utf8)!)
|
|
289
|
+
}
|
|
290
|
+
defer { control.stop() }
|
|
291
|
+
|
|
292
|
+
FileHandle.standardError.write("simframed: capturing \(device.name) (\(device.udid))\n".data(using: .utf8)!)
|
|
293
|
+
|
|
294
|
+
while true {
|
|
295
|
+
let now = Date().timeIntervalSince1970 * 1000
|
|
296
|
+
lock.lock()
|
|
297
|
+
let isDirty = dirty
|
|
298
|
+
lock.unlock()
|
|
299
|
+
|
|
300
|
+
let due = (isDirty && now - lastCapture >= minInterval) || (now - lastCapture >= idleInterval)
|
|
301
|
+
if due {
|
|
302
|
+
lock.lock(); dirty = false; lock.unlock()
|
|
303
|
+
let t0 = DispatchTime.now().uptimeNanoseconds
|
|
304
|
+
do {
|
|
305
|
+
// One grab produces both sizes; the surface pointer is only
|
|
306
|
+
// valid inside this call, so nothing may be deferred out of it.
|
|
307
|
+
let wantFull = store.wantsFullFrame()
|
|
308
|
+
let (bmp, full) = try platform.withFrame { frame -> (Bitmap, Bitmap?) in
|
|
309
|
+
let scaled = CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: longEdge)
|
|
310
|
+
?? Bitmap.from(frame, targetLongEdge: longEdge)
|
|
311
|
+
let native = wantFull
|
|
312
|
+
? CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: max(frame.width, frame.height))
|
|
313
|
+
: nil
|
|
314
|
+
return (scaled, native)
|
|
315
|
+
}
|
|
316
|
+
let ms = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6
|
|
317
|
+
try store.record(bmp, fullBitmap: full, captureMs: ms)
|
|
318
|
+
latencies.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6)
|
|
319
|
+
frames += 1
|
|
320
|
+
lastCapture = now
|
|
321
|
+
} catch {
|
|
322
|
+
FileHandle.standardError.write("simframed: capture failed: \(error)\n".data(using: .utf8)!)
|
|
323
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let wall = Date().timeIntervalSince1970
|
|
328
|
+
if wall - lastReport >= 1 {
|
|
329
|
+
let sorted = latencies.sorted()
|
|
330
|
+
let median = sorted.isEmpty ? 0 : sorted[sorted.count / 2]
|
|
331
|
+
FileHandle.standardError.write(
|
|
332
|
+
String(format: "simframed: %.1f fps, median %.2fms\n", Double(frames) / (wall - lastReport), median)
|
|
333
|
+
.data(using: .utf8)!)
|
|
334
|
+
frames = 0; latencies.removeAll(); lastReport = wall
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if stopping {
|
|
338
|
+
FileHandle.standardError.write("simframed: stopping\n".data(using: .utf8)!)
|
|
339
|
+
break
|
|
340
|
+
}
|
|
341
|
+
if store.heartbeatAge() > idleExitMs {
|
|
342
|
+
FileHandle.standardError.write("simframed: exiting, no client heartbeat\n".data(using: .utf8)!)
|
|
343
|
+
break
|
|
344
|
+
}
|
|
345
|
+
if !store.stillOwner() {
|
|
346
|
+
FileHandle.standardError.write("simframed: exiting, superseded by another capture loop\n".data(using: .utf8)!)
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
// The damage callback is delivered on a framework queue, so the
|
|
350
|
+
// main thread only needs to wake often enough to notice.
|
|
351
|
+
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
|
|
352
|
+
}
|
|
353
|
+
} catch { fail("\(error)") }
|
|
354
|
+
|
|
355
|
+
default:
|
|
356
|
+
fail("unknown command '\(command)' (try: devices, hash, bench, snap, run, input, tap, swipe, type, press)")
|
|
357
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import PrivateAPI
|
|
3
|
+
@testable import SimframeCore
|
|
4
|
+
|
|
5
|
+
/// These run without a simulator, against StubPlatform, so the pure logic is
|
|
6
|
+
/// testable on any machine — including CI, which has no booted device.
|
|
7
|
+
final class HashingTests: XCTestCase {
|
|
8
|
+
|
|
9
|
+
private func solid(_ w: Int, _ h: Int, _ v: UInt8) -> Bitmap {
|
|
10
|
+
var data = [UInt8](repeating: 0, count: w * h * 4)
|
|
11
|
+
for i in stride(from: 0, to: data.count, by: 4) {
|
|
12
|
+
data[i] = v; data[i + 1] = v; data[i + 2] = v; data[i + 3] = 255
|
|
13
|
+
}
|
|
14
|
+
return Bitmap(width: w, height: h, data: data)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private func split(_ w: Int, _ h: Int, left: UInt8, right: UInt8) -> Bitmap {
|
|
18
|
+
var data = [UInt8](repeating: 0, count: w * h * 4)
|
|
19
|
+
for y in 0..<h {
|
|
20
|
+
for x in 0..<w {
|
|
21
|
+
let v = x < w / 2 ? left : right
|
|
22
|
+
let i = (y * w + x) * 4
|
|
23
|
+
data[i] = v; data[i + 1] = v; data[i + 2] = v; data[i + 3] = 255
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return Bitmap(width: w, height: h, data: data)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func testFitMatchesSipsLongEdgeBehaviour() {
|
|
30
|
+
// The real device shapes this has to reproduce.
|
|
31
|
+
XCTAssertEqual(Bitmap.fit(width: 1206, height: 2622, longEdge: 700).0, 322)
|
|
32
|
+
XCTAssertEqual(Bitmap.fit(width: 1206, height: 2622, longEdge: 700).1, 700)
|
|
33
|
+
XCTAssertEqual(Bitmap.fit(width: 2064, height: 2752, longEdge: 700).0, 525)
|
|
34
|
+
XCTAssertEqual(Bitmap.fit(width: 2064, height: 2752, longEdge: 700).1, 700)
|
|
35
|
+
// Smaller than the target is left alone rather than upscaled.
|
|
36
|
+
XCTAssertEqual(Bitmap.fit(width: 100, height: 200, longEdge: 700).1, 200)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testGrayGridAveragesToLuminance() {
|
|
40
|
+
XCTAssertEqual(Hashing.grayGrid(solid(20, 20, 255), cols: 2, rows: 2), [255, 255, 255, 255])
|
|
41
|
+
XCTAssertEqual(Hashing.grayGrid(solid(20, 20, 0), cols: 2, rows: 2), [0, 0, 0, 0])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func testIdenticalScreensHashIdentically() {
|
|
45
|
+
let a = split(64, 128, left: 20, right: 220)
|
|
46
|
+
let b = split(64, 128, left: 20, right: 220)
|
|
47
|
+
XCTAssertEqual(Hashing.frameHash(a), Hashing.frameHash(b))
|
|
48
|
+
XCTAssertEqual(Hashing.layoutHash(a), Hashing.layoutHash(b))
|
|
49
|
+
XCTAssertEqual(Hashing.signatureDiff(Hashing.regionSignature(a), Hashing.regionSignature(b)), 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func testMirroredLayoutIsFarAway() {
|
|
53
|
+
let a = split(64, 128, left: 20, right: 220)
|
|
54
|
+
let b = split(64, 128, left: 220, right: 20)
|
|
55
|
+
// Well beyond the tolerance of 12 the screen memory uses.
|
|
56
|
+
XCTAssertGreaterThan(Hashing.hashDistance(Hashing.layoutHash(a), Hashing.layoutHash(b)), 20)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func testHashesAreTheExpectedWidth() {
|
|
60
|
+
let bmp = split(64, 128, left: 20, right: 220)
|
|
61
|
+
XCTAssertEqual(Hashing.frameHash(bmp).count, 32, "128 bits as 32 hex characters")
|
|
62
|
+
XCTAssertEqual(Hashing.layoutHash(bmp).count, 72, "288 bits as 72 hex characters")
|
|
63
|
+
XCTAssertEqual(Hashing.signatureToHex(Hashing.regionSignature(bmp)).count, 64)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func testSignatureDiffTreatsMissingBaselineAsFullChange() {
|
|
67
|
+
XCTAssertEqual(Hashing.signatureDiff(Hashing.regionSignature(solid(8, 8, 100)), nil), 1)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func testHashDistanceCountsBits() {
|
|
71
|
+
XCTAssertEqual(Hashing.hashDistance("abcd", "abcd"), 0)
|
|
72
|
+
XCTAssertEqual(Hashing.hashDistance("0", "f"), 4)
|
|
73
|
+
XCTAssertEqual(Hashing.hashDistance("abc", "abcd"), Int.max, "different widths are incomparable")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func testStubPlatformProducesReadablePaddedFrames() throws {
|
|
77
|
+
let stub = StubPlatform(width: 120, height: 260)
|
|
78
|
+
_ = try stub.attach(udid: nil)
|
|
79
|
+
let bmp = try stub.withFrame { frame -> Bitmap in
|
|
80
|
+
// The stub pads its rows on purpose: honouring bytesPerRow is the
|
|
81
|
+
// single easiest thing to get wrong against a real surface.
|
|
82
|
+
XCTAssertEqual(frame.bytesPerRow, frame.width * 4 + 40)
|
|
83
|
+
return Bitmap.from(frame, targetLongEdge: 130)
|
|
84
|
+
}
|
|
85
|
+
XCTAssertEqual(bmp.height, 130)
|
|
86
|
+
XCTAssertEqual(bmp.width, 60)
|
|
87
|
+
// Left half dark, right half light — a shear from bad padding would blur this.
|
|
88
|
+
let leftPixel = Int(bmp.data[(65 * bmp.width + 5) * 4])
|
|
89
|
+
let rightPixel = Int(bmp.data[(65 * bmp.width + bmp.width - 5) * 4])
|
|
90
|
+
XCTAssertLessThan(leftPixel, 100)
|
|
91
|
+
XCTAssertGreaterThan(rightPixel, 150)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testChangeIsDetectedAcrossStubFrames() throws {
|
|
95
|
+
let stub = StubPlatform(width: 80, height: 160)
|
|
96
|
+
_ = try stub.attach(udid: nil)
|
|
97
|
+
let before = try stub.withFrame { Hashing.regionSignature(Bitmap.from($0, targetLongEdge: 80)) }
|
|
98
|
+
stub.simulateChange()
|
|
99
|
+
let after = try stub.withFrame { Hashing.regionSignature(Bitmap.from($0, targetLongEdge: 80)) }
|
|
100
|
+
XCTAssertGreaterThan(Hashing.signatureDiff(after, before), 0.004)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// The Platform surface, exercised through StubPlatform so gestures and app
|
|
105
|
+
/// lifecycle can be tested with no simulator and no private frameworks.
|
|
106
|
+
final class PlatformSurfaceTests: XCTestCase {
|
|
107
|
+
private func stub() throws -> StubPlatform {
|
|
108
|
+
let s = StubPlatform(width: 120, height: 260)
|
|
109
|
+
_ = try s.attach(udid: nil)
|
|
110
|
+
return s
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func testGesturesReachThePlatform() throws {
|
|
114
|
+
let s = try stub()
|
|
115
|
+
try s.tap(at: CGPoint(x: 10, y: 20), durationMs: 70)
|
|
116
|
+
try s.longPress(at: CGPoint(x: 30, y: 40), durationMs: 600)
|
|
117
|
+
try s.drag(from: CGPoint(x: 1, y: 2), to: CGPoint(x: 3, y: 4), holdMs: 500, durationMs: 400)
|
|
118
|
+
try s.swipe(from: CGPoint(x: 5, y: 6), to: CGPoint(x: 7, y: 8), durationMs: 300)
|
|
119
|
+
XCTAssertEqual(s.recorded, [
|
|
120
|
+
"tap(10,20)",
|
|
121
|
+
"longPress(30,40,600)",
|
|
122
|
+
"drag(1,2->3,4,hold=500)",
|
|
123
|
+
"swipe(5,6->7,8)",
|
|
124
|
+
])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func testAppLifecycleReachesThePlatform() throws {
|
|
128
|
+
let s = try stub()
|
|
129
|
+
_ = try s.launch(bundleId: "com.example.app", arguments: ["-a"], environment: ["K": "V"])
|
|
130
|
+
try s.terminate(bundleId: "com.example.app")
|
|
131
|
+
try s.openURL("myapp://x")
|
|
132
|
+
try s.permission(action: "grant", service: "photos", bundleId: "com.example.app")
|
|
133
|
+
XCTAssertEqual(s.recorded, [
|
|
134
|
+
"launch(com.example.app)",
|
|
135
|
+
"terminate(com.example.app)",
|
|
136
|
+
"openURL(myapp://x)",
|
|
137
|
+
"permission(grant,photos,com.example.app)",
|
|
138
|
+
])
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func testTextRoutesAreDistinct() throws {
|
|
142
|
+
let s = try stub()
|
|
143
|
+
try s.type("abc")
|
|
144
|
+
try s.paste("abc")
|
|
145
|
+
// Two different mechanisms: key events are layout-dependent, paste is not.
|
|
146
|
+
XCTAssertEqual(s.recorded, ["type(abc)", "paste(abc)"])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func testAsciiIsTypeableAndEmojiIsNot() {
|
|
150
|
+
XCTAssertTrue(HIDKeyboard.canType("Fryer 3!"))
|
|
151
|
+
XCTAssertFalse(HIDKeyboard.canType("Fryer 🍟"), "emoji has no usage code and must go via paste")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func testOnlyVerifiedButtonsHaveCodes() {
|
|
155
|
+
XCTAssertEqual(HIDKeyboard.buttonCode(.home), 2)
|
|
156
|
+
// Unverified codes must refuse rather than guess: a wrong Indigo button
|
|
157
|
+
// can crash backboardd or lock the device.
|
|
158
|
+
for button in [HardwareButton.lock, .siri, .volumeUp, .volumeDown] {
|
|
159
|
+
XCTAssertNil(HIDKeyboard.buttonCode(button), "\(button.rawValue) is not verified")
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Motion analysis, on synthetic frames so the expected answer is known.
|
|
165
|
+
final class MotionTests: XCTestCase {
|
|
166
|
+
private func grid(_ fill: (Int, Int) -> UInt8) -> [UInt8] {
|
|
167
|
+
var g = [UInt8](repeating: 0, count: Motion.cols * Motion.rows)
|
|
168
|
+
for r in 0..<Motion.rows { for c in 0..<Motion.cols { g[r * Motion.cols + c] = fill(c, r) } }
|
|
169
|
+
return g
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Content that varies in both directions, so a shift has a unique best
|
|
173
|
+
/// match. Horizontally uniform content makes every horizontal offset score
|
|
174
|
+
/// identically, and the search then reports an arbitrary one.
|
|
175
|
+
private func striped(offsetRows: Int = 0) -> [UInt8] {
|
|
176
|
+
grid { c, r in UInt8((((r + offsetRows) * 13) + c * 7) % 256) }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func testIdenticalFramesAreStill() {
|
|
180
|
+
XCTAssertEqual(Motion.difference(striped(), striped()), 0)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func testSettleNeedsConsecutiveStillFrames() {
|
|
184
|
+
let still = striped()
|
|
185
|
+
var history: [Motion.Frame] = []
|
|
186
|
+
for i in 0..<5 { history.append(Motion.Frame(at: Double(i) * 100, gray: still)) }
|
|
187
|
+
let state = Motion.state(history: history, now: 500)
|
|
188
|
+
XCTAssertTrue(state.settled)
|
|
189
|
+
XCTAssertGreaterThanOrEqual(state.stillFrames, Motion.stillFramesRequired)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func testAChangingScreenIsNotSettled() {
|
|
193
|
+
var history: [Motion.Frame] = []
|
|
194
|
+
for i in 0..<5 { history.append(Motion.Frame(at: Double(i) * 100, gray: striped(offsetRows: i * 6))) }
|
|
195
|
+
XCTAssertFalse(Motion.state(history: history, now: 500).settled)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func testSpinnerIsNeitherStillNorASceneChange() {
|
|
199
|
+
// Everything holds still except a small patch that keeps changing.
|
|
200
|
+
var history: [Motion.Frame] = []
|
|
201
|
+
for i in 0..<5 {
|
|
202
|
+
var g = striped()
|
|
203
|
+
for r in 40..<45 { for c in 20..<25 { g[r * Motion.cols + c] = UInt8((i * 90) % 256) } }
|
|
204
|
+
history.append(Motion.Frame(at: Double(i) * 100, gray: g))
|
|
205
|
+
}
|
|
206
|
+
let state = Motion.state(history: history, now: 500)
|
|
207
|
+
XCTAssertFalse(state.settled, "a spinner must not read as settled")
|
|
208
|
+
XCTAssertNotNil(state.animatingRegion, "the moving patch should be localised")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
func testVerticalShiftIsMeasured() {
|
|
212
|
+
let (dx, dy, score) = Motion.shift(striped(), striped(offsetRows: 8))
|
|
213
|
+
XCTAssertEqual(dy, -8, "content moved up by eight rows")
|
|
214
|
+
XCTAssertEqual(dx, 0)
|
|
215
|
+
XCTAssertLessThan(score, 0.02)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
func testScrollIsClassifiedWithAnOffset() throws {
|
|
219
|
+
let t = Motion.classify(before: striped(), after: striped(offsetRows: 8),
|
|
220
|
+
pointHeight: 874, pointWidth: 402)
|
|
221
|
+
XCTAssertEqual(t.kind, .scroll)
|
|
222
|
+
// XCTUnwrap rather than `!`: a force unwrap here kills the whole test
|
|
223
|
+
// process with signal 5 and hides every other result.
|
|
224
|
+
let offset = try XCTUnwrap(t.offset)
|
|
225
|
+
// Eight of ninety-six rows of an 874pt screen.
|
|
226
|
+
XCTAssertEqual(offset.y, -874 * 8 / 96, accuracy: 1)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func testIdenticalScreensClassifyAsNoChange() {
|
|
230
|
+
let t = Motion.classify(before: striped(), after: striped(), pointHeight: 874, pointWidth: 402)
|
|
231
|
+
XCTAssertEqual(t.kind, .none)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func testDimmingTheWholeScreenReadsAsSomethingPresenting() {
|
|
235
|
+
let base = striped()
|
|
236
|
+
let dimmed = base.map { UInt8(max(0, Int($0) - 60)) }
|
|
237
|
+
let t = Motion.classify(before: base, after: dimmed, pointHeight: 874, pointWidth: 402)
|
|
238
|
+
XCTAssertTrue([.sheetPresent, .alertPresent].contains(t.kind), "got \(t.kind)")
|
|
239
|
+
}
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.lvlrSajjad/simframe",
|
|
5
5
|
"description": "Always-warm iOS Simulator frames: agents read the screen in ~20ms instead of waiting on screenshots. MCP server + CLI.",
|
|
6
6
|
"keywords": [
|
|
@@ -40,14 +40,20 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
43
|
-
"native",
|
|
43
|
+
"native/ocr.swift",
|
|
44
|
+
"native/simframed/Package.swift",
|
|
45
|
+
"native/simframed/Sources",
|
|
46
|
+
"native/simframed/Tests",
|
|
47
|
+
"scripts",
|
|
44
48
|
"README.md",
|
|
45
49
|
"LICENSE"
|
|
46
50
|
],
|
|
47
51
|
"scripts": {
|
|
48
|
-
"test": "node --test test/unit.test.mjs",
|
|
52
|
+
"test": "SIMFRAME_HOME=\"$(mktemp -d)\" node --test test/unit.test.mjs",
|
|
49
53
|
"mcp": "node src/cli.js mcp",
|
|
50
|
-
"smoke": "node scripts/smoke.mjs"
|
|
54
|
+
"smoke": "node scripts/smoke.mjs",
|
|
55
|
+
"bench": "./scripts/bench.sh",
|
|
56
|
+
"check:package": "node scripts/check-package.mjs"
|
|
51
57
|
},
|
|
52
58
|
"dependencies": {
|
|
53
59
|
"@modelcontextprotocol/sdk": "^1.0.0"
|