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,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.4.1",
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"