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.
- package/README.md +334 -85
- package/native/simframed/Package.swift +16 -0
- package/native/simframed/Sources/PrivateAPI/AccessibilityBridge.swift +379 -0
- package/native/simframed/Sources/PrivateAPI/CoreSimulatorPlatform.swift +523 -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 +149 -0
- package/native/simframed/Sources/PrivateAPI/StubPlatform.swift +112 -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 +148 -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 +485 -0
- package/native/simframed/Tests/SimframeCoreTests/HashingTests.swift +270 -0
- package/package.json +12 -4
- package/scripts/bench-flow.mjs +54 -0
- package/scripts/bench.sh +98 -0
- package/scripts/check-package.mjs +99 -0
- package/scripts/ci-memory.mjs +416 -0
- package/scripts/eval-fingerprint.mjs +192 -0
- package/scripts/smoke.mjs +76 -0
- package/scripts/sync-server-version.mjs +39 -0
- package/scripts/verify-baseline.mjs +65 -0
- package/skills/simframe/SKILL.md +173 -0
- package/src/actions.js +264 -18
- package/src/cli.js +561 -89
- package/src/control.js +77 -0
- package/src/daemon.js +8 -1
- package/src/engine.js +99 -0
- package/src/fingerprint.js +183 -0
- package/src/graph.js +411 -0
- package/src/index.js +351 -24
- package/src/input.js +179 -2
- package/src/matching.js +265 -0
- package/src/mcp.js +425 -112
- package/src/navigate.js +120 -0
- package/src/refs.js +141 -0
- package/src/regions.js +267 -0
- package/src/screenmap.js +119 -22
- package/src/simctl.js +74 -5
- package/src/store.js +8 -0
- 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
|
+
}
|