simframe 0.4.2 → 0.6.0-rc.1

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 (47) hide show
  1. package/README.md +334 -85
  2. package/native/simframed/Package.swift +16 -0
  3. package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
  4. package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -0
  5. package/native/simframed/Sources/PrivateAPI/HIDKeyboard.swift +70 -0
  6. package/native/simframed/Sources/PrivateAPI/IndigoHID.swift +121 -0
  7. package/native/simframed/Sources/PrivateAPI/PrivateAPI.swift +149 -0
  8. package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -0
  9. package/native/simframed/Sources/SimframeCore/Bitmap.swift +61 -0
  10. package/native/simframed/Sources/SimframeCore/ControlSocket.swift +122 -0
  11. package/native/simframed/Sources/SimframeCore/CoreGraphicsScaler.swift +70 -0
  12. package/native/simframed/Sources/SimframeCore/Element.swift +148 -0
  13. package/native/simframed/Sources/SimframeCore/FrameStore.swift +303 -0
  14. package/native/simframed/Sources/SimframeCore/Hashing.swift +119 -0
  15. package/native/simframed/Sources/SimframeCore/Motion.swift +431 -0
  16. package/native/simframed/Sources/SimframeCore/PNGWriter.swift +40 -0
  17. package/native/simframed/Sources/SimframeCore/VisionOCR.swift +75 -0
  18. package/native/simframed/Sources/simframed/main.swift +485 -0
  19. package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
  20. package/package.json +12 -4
  21. package/scripts/bench-flow.mjs +54 -0
  22. package/scripts/bench.sh +98 -0
  23. package/scripts/check-package.mjs +99 -0
  24. package/scripts/ci-memory.mjs +416 -0
  25. package/scripts/eval-fingerprint.mjs +192 -0
  26. package/scripts/smoke.mjs +76 -0
  27. package/scripts/sync-server-version.mjs +39 -0
  28. package/scripts/verify-baseline.mjs +65 -0
  29. package/skills/simframe/SKILL.md +173 -0
  30. package/src/actions.js +264 -18
  31. package/src/cli.js +561 -89
  32. package/src/control.js +77 -0
  33. package/src/daemon.js +8 -1
  34. package/src/engine.js +99 -0
  35. package/src/fingerprint.js +183 -0
  36. package/src/graph.js +411 -0
  37. package/src/index.js +351 -24
  38. package/src/input.js +179 -2
  39. package/src/matching.js +265 -0
  40. package/src/mcp.js +425 -112
  41. package/src/navigate.js +120 -0
  42. package/src/refs.js +141 -0
  43. package/src/regions.js +267 -0
  44. package/src/screenmap.js +119 -22
  45. package/src/simctl.js +74 -5
  46. package/src/store.js +8 -0
  47. package/src/view.js +342 -0
@@ -0,0 +1,485 @@
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 consecutiveFailures = 0
162
+ /// Roughly three seconds of failed reads at the loop's half-second
163
+ /// back-off. Long enough not to thrash on a momentary hiccup, short
164
+ /// enough that nobody watches a dead capture loop and wonders.
165
+ let reattachAfterFailures = 6
166
+ var lastCapture = 0.0
167
+ var frames = 0
168
+ var lastReport = Date().timeIntervalSince1970
169
+ var latencies: [Double] = []
170
+
171
+ try platform.observeChanges {
172
+ lock.lock(); dirty = true; lock.unlock()
173
+ }
174
+
175
+ // Control socket: requests are served off the capture loop, because a
176
+ // swipe sleeps for its whole duration and must not stall frames.
177
+ let socketPath = store.controlSocketPath
178
+ let control = ControlSocket(path: socketPath) { request in
179
+ let action = request["action"] as? String ?? ""
180
+ let started = DispatchTime.now().uptimeNanoseconds
181
+ func done(_ extra: [String: Any] = [:]) -> [String: Any] {
182
+ var r: [String: Any] = ["ok": true]
183
+ r["ms"] = (Double(DispatchTime.now().uptimeNanoseconds - started) / 1e6 * 100).rounded() / 100
184
+ for (k, v) in extra { r[k] = v }
185
+ return r
186
+ }
187
+ func point(_ xKey: String, _ yKey: String) -> CGPoint? {
188
+ guard let x = request[xKey] as? Double ?? (request[xKey] as? Int).map(Double.init),
189
+ let y = request[yKey] as? Double ?? (request[yKey] as? Int).map(Double.init) else { return nil }
190
+ return CGPoint(x: x, y: y)
191
+ }
192
+ do {
193
+ switch action {
194
+ case "ping":
195
+ return done(["device": device.name, "udid": device.udid])
196
+ case "status":
197
+ let input = platform.inputStatus()
198
+ let ax = platform.accessibilityStatus()
199
+ return done([
200
+ "input": ["available": input.available, "detail": input.detail],
201
+ "accessibility": ["available": ax.available, "detail": ax.detail],
202
+ "device": ["name": device.name, "udid": device.udid,
203
+ "pointWidth": device.pointWidth, "pointHeight": device.pointHeight,
204
+ "scale": device.scale],
205
+ "engine": "simframed",
206
+ ])
207
+ case "ui":
208
+ // The accessibility tree and OCR read the same instant of
209
+ // the screen and neither needs the other, so they run
210
+ // together: in series the tree read is simply added to the
211
+ // OCR pass. Both run off the capture loop.
212
+ let wantAx = request["ax"] as? Bool ?? true
213
+ let wantOcr = request["ocr"] as? Bool ?? true
214
+ // Shared with two background blocks, and a bounded wait
215
+ // means they can still be running when this reads them —
216
+ // so every touch goes through the lock. Timing out and
217
+ // reading racing variables would trade a slow answer for a
218
+ // wrong one.
219
+ final class Reads {
220
+ let lock = NSLock()
221
+ var axTree = AXTree(nodes: [])
222
+ var axError: String?
223
+ var axMs = 0.0
224
+ var ocrElements: [Element] = []
225
+ var ocrError: String?
226
+ var ocrMs = 0.0
227
+ func set(_ body: (Reads) -> Void) { lock.lock(); body(self); lock.unlock() }
228
+ func snapshot() -> (AXTree, String?, Double, [Element], String?, Double) {
229
+ lock.lock(); defer { lock.unlock() }
230
+ return (axTree, axError, axMs, ocrElements, ocrError, ocrMs)
231
+ }
232
+ }
233
+ let reads = Reads()
234
+
235
+ let group = DispatchGroup()
236
+ if wantAx {
237
+ DispatchQueue.global(qos: .userInitiated).async(group: group) {
238
+ let t = DispatchTime.now().uptimeNanoseconds
239
+ var tree = AXTree(nodes: [])
240
+ var failure: String?
241
+ do { tree = try platform.accessibilityTree() }
242
+ catch { failure = "\(error)" }
243
+ let ms = Double(DispatchTime.now().uptimeNanoseconds - t) / 1e6
244
+ reads.set { $0.axTree = tree; $0.axError = failure; $0.axMs = ms }
245
+ }
246
+ }
247
+ if wantOcr {
248
+ DispatchQueue.global(qos: .userInitiated).async(group: group) {
249
+ let t = DispatchTime.now().uptimeNanoseconds
250
+ var found: [Element] = []
251
+ var failure: String?
252
+ do {
253
+ found = try platform.withFrame { frame in
254
+ try VisionOCR.recognise(frame: frame, scale: device.scale)
255
+ }
256
+ } catch { failure = "\(error)" }
257
+ let ms = Double(DispatchTime.now().uptimeNanoseconds - t) / 1e6
258
+ reads.set { $0.ocrElements = found; $0.ocrError = failure; $0.ocrMs = ms }
259
+ }
260
+ }
261
+ // Bounded, because this blocks the control socket and the
262
+ // socket is serial: a read that runs long does not just
263
+ // return late, it holds up every command behind it. A CI
264
+ // runner spent 28 s inside one of these. Whatever has not
265
+ // arrived by the deadline is reported as missing rather
266
+ // than waited for — the layer that did answer is still
267
+ // worth having.
268
+ let timedOut = group.wait(timeout: .now() + .seconds(12)) == .timedOut
269
+ var (axTree, axError, axMs, ocrElements, ocrError, ocrMs) = reads.snapshot()
270
+ if timedOut {
271
+ if wantAx, axTree.nodes.isEmpty, axError == nil {
272
+ axError = "the accessibility read did not finish within 12s"
273
+ }
274
+ if wantOcr, ocrElements.isEmpty, ocrError == nil {
275
+ ocrError = "text recognition did not finish within 12s"
276
+ }
277
+ }
278
+ // OCR failing is fatal to a screen read in a way a missing
279
+ // tree is not: without pixels there is nothing to report.
280
+ if wantOcr, let ocrError, axTree.nodes.isEmpty { return ["ok": false, "error": ocrError] }
281
+
282
+ var elements = axTree.nodes.enumerated().map { Element(id: $0.offset, node: $0.element) }
283
+ for (i, var e) in ocrElements.enumerated() {
284
+ e.id = elements.count + i
285
+ elements.append(e)
286
+ }
287
+ var sources: [String] = []
288
+ // A tree that stopped early is reported as nodes, not as a
289
+ // source. The layer above treats accessibility elements as
290
+ // the real hit targets and writes them into screen memory,
291
+ // and half a screen remembered as a whole one is worse than
292
+ // a screen read again from pixels.
293
+ if wantAx, axError == nil, axTree.truncated == nil { sources.append("ax") }
294
+ if wantOcr, ocrError == nil { sources.append("ocr") }
295
+ let latest = store.latestState()
296
+ let map = ScreenMap(
297
+ fingerprint: latest?["layoutHash"] as? String ?? "",
298
+ hash: latest?["hash"] as? String ?? "",
299
+ size: CGSize(width: device.pointWidth, height: device.pointHeight),
300
+ elements: elements,
301
+ sources: sources,
302
+ capturedAt: FrameStore.nowMs()
303
+ )
304
+ var payload = map.json
305
+ let round = { (v: Double) in (v * 100).rounded() / 100 }
306
+ if wantOcr { payload["ocrMs"] = round(ocrMs) }
307
+ if wantAx {
308
+ payload["axMs"] = round(axMs)
309
+ payload["axCount"] = axTree.nodes.count
310
+ if let truncated = axTree.truncated { payload["axTruncated"] = truncated }
311
+ // Why the tree is missing matters: a launching app and a
312
+ // framework that will not load look identical otherwise.
313
+ if let axError { payload["axError"] = axError }
314
+ }
315
+ if let ocrError { payload["ocrError"] = ocrError }
316
+ return done(["screen": payload])
317
+ case "tap":
318
+ guard let p = point("x", "y") else { return ["ok": false, "error": "tap needs x and y"] }
319
+ try platform.tap(at: p, durationMs: request["durationMs"] as? Double ?? 70)
320
+ return done()
321
+ case "swipe":
322
+ guard let from = point("x1", "y1"), let to = point("x2", "y2") else {
323
+ return ["ok": false, "error": "swipe needs x1, y1, x2, y2"]
324
+ }
325
+ try platform.swipe(from: from, to: to, durationMs: request["durationMs"] as? Double ?? 300)
326
+ return done()
327
+ case "type":
328
+ guard let text = request["text"] as? String else { return ["ok": false, "error": "type needs text"] }
329
+ try platform.type(text)
330
+ return done()
331
+ case "paste":
332
+ guard let text = request["text"] as? String else { return ["ok": false, "error": "paste needs text"] }
333
+ try platform.paste(text)
334
+ return done()
335
+ case "longPress":
336
+ guard let p = point("x", "y") else { return ["ok": false, "error": "longPress needs x and y"] }
337
+ try platform.longPress(at: p, durationMs: request["durationMs"] as? Double ?? 600)
338
+ return done()
339
+ case "drag":
340
+ guard let from = point("x1", "y1"), let to = point("x2", "y2") else {
341
+ return ["ok": false, "error": "drag needs x1, y1, x2, y2"]
342
+ }
343
+ try platform.drag(from: from, to: to,
344
+ holdMs: request["holdMs"] as? Double ?? 500,
345
+ durationMs: request["durationMs"] as? Double ?? 400)
346
+ return done()
347
+ case "launch":
348
+ guard let bundleId = request["bundleId"] as? String else { return ["ok": false, "error": "launch needs bundleId"] }
349
+ let pid = try platform.launch(bundleId: bundleId,
350
+ arguments: request["arguments"] as? [String] ?? [],
351
+ environment: request["environment"] as? [String: String] ?? [:])
352
+ return done(["pid": Int(pid)])
353
+ case "terminate":
354
+ guard let bundleId = request["bundleId"] as? String else { return ["ok": false, "error": "terminate needs bundleId"] }
355
+ try platform.terminate(bundleId: bundleId)
356
+ return done()
357
+ case "openUrl":
358
+ guard let url = request["url"] as? String else { return ["ok": false, "error": "openUrl needs url"] }
359
+ try platform.openURL(url)
360
+ return done()
361
+ case "permission":
362
+ guard let action = request["permissionAction"] as? String, let service = request["service"] as? String else {
363
+ return ["ok": false, "error": "permission needs permissionAction and service"]
364
+ }
365
+ try platform.permission(action: action, service: service, bundleId: request["bundleId"] as? String)
366
+ return done()
367
+ case "press":
368
+ guard let name = request["button"] as? String, let button = HardwareButton(rawValue: name) else {
369
+ return ["ok": false, "error": "press needs a known button name"]
370
+ }
371
+ try platform.press(button)
372
+ return done()
373
+ case "resetInput":
374
+ try platform.resetInput()
375
+ FileHandle.standardError.write("simframed: HID session reset on request\n".data(using: .utf8)!)
376
+ return done()
377
+ default:
378
+ return ["ok": false, "error": "unknown action '\(action)'"]
379
+ }
380
+ } catch {
381
+ return ["ok": false, "error": "\(error)"]
382
+ }
383
+ }
384
+ do {
385
+ try control.start()
386
+ FileHandle.standardError.write("simframed: control socket at \(socketPath)\n".data(using: .utf8)!)
387
+ } catch {
388
+ // Capture is still useful without input; say so rather than dying.
389
+ FileHandle.standardError.write("simframed: control socket unavailable: \(error)\n".data(using: .utf8)!)
390
+ }
391
+ defer { control.stop() }
392
+
393
+ FileHandle.standardError.write("simframed: capturing \(device.name) (\(device.udid))\n".data(using: .utf8)!)
394
+
395
+ while true {
396
+ let now = Date().timeIntervalSince1970 * 1000
397
+ lock.lock()
398
+ let isDirty = dirty
399
+ lock.unlock()
400
+
401
+ let due = (isDirty && now - lastCapture >= minInterval) || (now - lastCapture >= idleInterval)
402
+ if due {
403
+ lock.lock(); dirty = false; lock.unlock()
404
+ let t0 = DispatchTime.now().uptimeNanoseconds
405
+ do {
406
+ // One grab produces both sizes; the surface pointer is only
407
+ // valid inside this call, so nothing may be deferred out of it.
408
+ let wantFull = store.wantsFullFrame()
409
+ let (bmp, full) = try platform.withFrame { frame -> (Bitmap, Bitmap?) in
410
+ let scaled = CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: longEdge)
411
+ ?? Bitmap.from(frame, targetLongEdge: longEdge)
412
+ let native = wantFull
413
+ ? CoreGraphicsScaler.bitmap(from: frame, targetLongEdge: max(frame.width, frame.height))
414
+ : nil
415
+ return (scaled, native)
416
+ }
417
+ let ms = Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6
418
+ try store.record(bmp, fullBitmap: full, captureMs: ms)
419
+ latencies.append(Double(DispatchTime.now().uptimeNanoseconds - t0) / 1e6)
420
+ frames += 1
421
+ lastCapture = now
422
+ consecutiveFailures = 0
423
+ } catch {
424
+ consecutiveFailures += 1
425
+ FileHandle.standardError.write(
426
+ "simframed: capture failed: \(error) (\(consecutiveFailures) in a row)\n".data(using: .utf8)!)
427
+ // The display port can be torn down and rebuilt under a
428
+ // running daemon, and every read on the old descriptor
429
+ // returns nil from then on. Observed on a device that was
430
+ // awake and visible the whole time: six minutes of
431
+ // "the display surface could not be read", cured instantly
432
+ // by restarting the daemon. Reporting a failure loudly is
433
+ // right; never recovering from it is not, so re-resolve the
434
+ // port and re-arm the damage callback.
435
+ if consecutiveFailures >= reattachAfterFailures {
436
+ do {
437
+ _ = try platform.reattachDisplay()
438
+ try platform.observeChanges {
439
+ lock.lock(); dirty = true; lock.unlock()
440
+ }
441
+ lock.lock(); dirty = true; lock.unlock()
442
+ FileHandle.standardError.write(
443
+ "simframed: re-resolved the display port after \(consecutiveFailures) failed reads\n"
444
+ .data(using: .utf8)!)
445
+ consecutiveFailures = 0
446
+ } catch {
447
+ FileHandle.standardError.write(
448
+ "simframed: could not re-resolve the display port: \(error)\n".data(using: .utf8)!)
449
+ }
450
+ }
451
+ Thread.sleep(forTimeInterval: 0.5)
452
+ }
453
+ }
454
+
455
+ let wall = Date().timeIntervalSince1970
456
+ if wall - lastReport >= 1 {
457
+ let sorted = latencies.sorted()
458
+ let median = sorted.isEmpty ? 0 : sorted[sorted.count / 2]
459
+ FileHandle.standardError.write(
460
+ String(format: "simframed: %.1f fps, median %.2fms\n", Double(frames) / (wall - lastReport), median)
461
+ .data(using: .utf8)!)
462
+ frames = 0; latencies.removeAll(); lastReport = wall
463
+ }
464
+
465
+ if stopping {
466
+ FileHandle.standardError.write("simframed: stopping\n".data(using: .utf8)!)
467
+ break
468
+ }
469
+ if store.heartbeatAge() > idleExitMs {
470
+ FileHandle.standardError.write("simframed: exiting, no client heartbeat\n".data(using: .utf8)!)
471
+ break
472
+ }
473
+ if !store.stillOwner() {
474
+ FileHandle.standardError.write("simframed: exiting, superseded by another capture loop\n".data(using: .utf8)!)
475
+ break
476
+ }
477
+ // The damage callback is delivered on a framework queue, so the
478
+ // main thread only needs to wake often enough to notice.
479
+ RunLoop.current.run(until: Date().addingTimeInterval(0.01))
480
+ }
481
+ } catch { fail("\(error)") }
482
+
483
+ default:
484
+ fail("unknown command '\(command)' (try: devices, hash, bench, snap, run, input, tap, swipe, type, press)")
485
+ }