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,303 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import ImageIO
4
+ import PrivateAPI
5
+
6
+ /// Writes the on-disk layout the Node readers already expect.
7
+ ///
8
+ /// This is a compatibility surface, not a design: every field, filename and
9
+ /// retention rule here exists because `src/daemon.js` produced it and
10
+ /// `simframe state/look/wait/recall` reads it. Changing anything means changing
11
+ /// both sides together.
12
+ public final class FrameStore {
13
+ public struct Options {
14
+ public var ringSize = 400
15
+ public var historySize = 400
16
+ public var historyMs: Double = 90_000
17
+ public var retainMs: Double = 60_000
18
+ public var fineMs: Double = 6_000
19
+ public var keyframeMs: Double = 450
20
+ public var fullKeep = 8
21
+ public var recallScale = 0.5
22
+ public var maxRingBytes = 12 << 20
23
+ public var changeThreshold = 0.004
24
+ public var fullFrameIntervalMs: Double = 1000
25
+ public var housekeepEvery = 20
26
+ public var maxDim = 700
27
+ public init() {}
28
+ }
29
+
30
+ /// State version of the on-disk format. Must match STATE_VERSION in
31
+ /// src/daemon.js, or Node will treat this daemon as stale and replace it.
32
+ public static let stateVersion = 6
33
+
34
+ private let device: DeviceInfo
35
+ private let options: Options
36
+ private let root: URL
37
+ private let fm = FileManager.default
38
+
39
+ private let startedAt = FrameStore.nowMs()
40
+ private var seq = 0
41
+ private var prevSignature: [UInt8]?
42
+ private var lastChangeAt = FrameStore.nowMs()
43
+ private var history: [[String: Any]] = []
44
+ /// Recent frames as small grayscale grids, for settle and transition work.
45
+ private var motionHistory: [Motion.Frame] = []
46
+ private var lastSettledGray: [UInt8]?
47
+ private var lastTransition: Motion.Transition?
48
+ private var wasSettled = false
49
+ private var ringIndex: [(seq: Int, at: Double, small: Bool)] = []
50
+
51
+ public init(device: DeviceInfo, options: Options = Options()) throws {
52
+ self.device = device
53
+ self.options = options
54
+ let home = fm.homeDirectoryForCurrentUser
55
+ let base = ProcessInfo.processInfo.environment["SIMFRAME_HOME"]
56
+ .map { URL(fileURLWithPath: $0) } ?? home.appendingPathComponent(".simframe")
57
+ self.root = base.appendingPathComponent(device.udid)
58
+ for sub in ["ring", "full"] {
59
+ try fm.createDirectory(at: root.appendingPathComponent(sub), withIntermediateDirectories: true)
60
+ }
61
+ }
62
+
63
+ private var deviceJSON: [String: Any] {
64
+ ["udid": device.udid, "name": device.name, "runtime": device.runtime, "state": "Booted"]
65
+ }
66
+
67
+ public static func nowMs() -> Double { (Date().timeIntervalSince1970 * 1000).rounded() }
68
+
69
+ private func writeAtomic(_ data: Data, to url: URL) throws {
70
+ let tmp = url.appendingPathExtension("tmp-\(getpid())")
71
+ try data.write(to: tmp)
72
+ _ = try fm.replaceItemAt(url, withItemAt: tmp)
73
+ }
74
+
75
+ /// Claim the device. Node checks meta.json to decide whether a capture loop
76
+ /// is already running, so writing it is what makes the Node CLI defer to us.
77
+ /// Where the control socket lives for this device. Beside the frames, so
78
+ /// one directory is the whole contract between daemon and clients.
79
+ public var controlSocketPath: String { root.appendingPathComponent("control.sock").path }
80
+
81
+ /// The newest state written, for callers that need the current hashes
82
+ /// without recomputing them.
83
+ public func latestState() -> [String: Any]? {
84
+ guard let data = try? Data(contentsOf: root.appendingPathComponent("state.json")) else { return nil }
85
+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
86
+ }
87
+
88
+ public func claim() throws {
89
+ let meta: [String: Any] = [
90
+ "pid": Int(getpid()),
91
+ "device": deviceJSON,
92
+ "options": ["engine": "simframed", "maxDim": options.maxDim, "control": controlSocketPath],
93
+ "startedAt": Self.nowMs(),
94
+ "version": Self.stateVersion,
95
+ ]
96
+ try writeAtomic(try JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted]),
97
+ to: root.appendingPathComponent("meta.json"))
98
+ }
99
+
100
+ public func release() {
101
+ let url = root.appendingPathComponent("meta.json")
102
+ guard var meta = (try? Data(contentsOf: url)).flatMap({ try? JSONSerialization.jsonObject(with: $0) }) as? [String: Any],
103
+ (meta["pid"] as? Int) == Int(getpid()) else { return }
104
+ meta["pid"] = NSNull()
105
+ meta["stoppedAt"] = Self.nowMs()
106
+ try? writeAtomic(try JSONSerialization.data(withJSONObject: meta, options: [.prettyPrinted]), to: url)
107
+ }
108
+
109
+ /// Milliseconds since a client last asked for anything, so an unattended
110
+ /// daemon can retire itself the way the Node one does.
111
+ ///
112
+ /// Measured from daemon start when no client has ever checked in. Returning
113
+ /// infinity for a missing heartbeat made a freshly started daemon exit on
114
+ /// its first loop, before any client had the chance to connect.
115
+ public func heartbeatAge() -> Double {
116
+ let url = root.appendingPathComponent("heartbeat")
117
+ let since: Double
118
+ if let text = try? String(contentsOf: url, encoding: .utf8),
119
+ let at = Double(text.trimmingCharacters(in: .whitespacesAndNewlines)) {
120
+ since = max(at, startedAt)
121
+ } else {
122
+ since = startedAt
123
+ }
124
+ return Self.nowMs() - since
125
+ }
126
+
127
+ /// Whether meta.json still names this process. A replacement daemon taking
128
+ /// over is how the single-writer rule is enforced.
129
+ public func stillOwner() -> Bool {
130
+ guard let data = try? Data(contentsOf: root.appendingPathComponent("meta.json")),
131
+ let meta = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return false }
132
+ return (meta["pid"] as? Int) == Int(getpid())
133
+ }
134
+
135
+ private let fullWriteQueue = DispatchQueue(label: "simframe.fullframe", qos: .utility)
136
+ private var lastFullAt: Double = 0
137
+
138
+ /// Whether a native-resolution frame is worth taking on this tick. These
139
+ /// exist only as an OCR source for the current screen, so one every so
140
+ /// often is plenty — and encoding one costs ~115ms, far more than the
141
+ /// entire rest of the loop.
142
+ public func wantsFullFrame(now: Double = FrameStore.nowMs()) -> Bool {
143
+ now - lastFullAt >= options.fullFrameIntervalMs
144
+ }
145
+
146
+ @discardableResult
147
+ public func record(_ bmp: Bitmap, fullBitmap: Bitmap?, captureMs: Double) throws -> [String: Any] {
148
+ let now = Self.nowMs()
149
+ let nextSeq = seq + 1
150
+
151
+ let signature = Hashing.regionSignature(bmp)
152
+ let firstFrame = prevSignature == nil
153
+ let diff = Hashing.signatureDiff(signature, prevSignature)
154
+ let deltas = Hashing.regionDeltas(signature, prevSignature)
155
+ let changed = !firstFrame && diff > options.changeThreshold
156
+ if changed || firstFrame { lastChangeAt = now }
157
+
158
+ let ringURL = root.appendingPathComponent("ring/\(nextSeq).png")
159
+ let png = try PNGWriter.encode(bmp)
160
+ try png.write(to: ringURL)
161
+ try writeAtomic(png, to: root.appendingPathComponent("latest.png"))
162
+
163
+ // Encoded off the capture path: a native-resolution PNG costs more
164
+ // than everything else here combined.
165
+ let fullURL = root.appendingPathComponent("full/\(nextSeq).png")
166
+ if let fullBitmap {
167
+ lastFullAt = now
168
+ fullWriteQueue.async { try? PNGWriter.write(fullBitmap, to: fullURL) }
169
+ }
170
+
171
+ seq = nextSeq
172
+ prevSignature = signature
173
+
174
+ // Motion analysis runs on its own grid: coarse enough to be free, fine
175
+ // enough to localise a spinner and measure a scroll offset.
176
+ let motionGray = Motion.grid(from: bmp)
177
+ motionHistory.append(Motion.Frame(at: now, gray: motionGray))
178
+ if motionHistory.count > 30 { motionHistory.removeFirst(motionHistory.count - 30) }
179
+ let motion = Motion.state(history: motionHistory, now: now)
180
+ // Classify only on the edge into settled: comparing two settled screens
181
+ // is the only comparison that describes a completed transition.
182
+ if motion.settled, !wasSettled, let previous = lastSettledGray {
183
+ lastTransition = Motion.classify(
184
+ before: previous, after: motionGray,
185
+ pointHeight: Double(device.pointHeight), pointWidth: Double(device.pointWidth)
186
+ )
187
+ }
188
+ if motion.settled { lastSettledGray = motionGray }
189
+ wasSettled = motion.settled
190
+
191
+ let hash = Hashing.frameHash(bmp)
192
+ history.append([
193
+ "seq": nextSeq, "at": now, "hash": hash,
194
+ "sig": Hashing.signatureToHex(signature),
195
+ "diff": firstFrame ? 0 : round(diff * 100_000) / 100_000,
196
+ ])
197
+ let cutoff = now - options.historyMs
198
+ history = history.filter { ($0["at"] as? Double ?? 0) >= cutoff }.suffix(options.historySize).map { $0 }
199
+
200
+ ringIndex.append((nextSeq, now, false))
201
+ thinRing(now: now)
202
+ // Housekeeping stats every retained frame, so it runs periodically
203
+ // rather than on the capture path of every single tick.
204
+ if nextSeq % options.housekeepEvery == 0 {
205
+ shrinkAgedFrames(now: now)
206
+ enforceByteBudget()
207
+ pruneFull()
208
+ }
209
+
210
+ let state: [String: Any] = [
211
+ "seq": nextSeq,
212
+ "capturedAt": now,
213
+ "captureMs": round(captureMs),
214
+ "width": bmp.width,
215
+ "height": bmp.height,
216
+ "hash": hash,
217
+ "layoutHash": Hashing.layoutHash(bmp),
218
+ "diff": firstFrame ? NSNull() : round(diff * 100_000) / 100_000,
219
+ "changed": changed,
220
+ "firstFrame": firstFrame,
221
+ "stableForMs": now - lastChangeAt,
222
+ "regions": firstFrame ? deltas.map { _ in 0.0 } : deltas.map { round($0 * 10_000) / 10_000 },
223
+ "lastChangeAt": lastChangeAt,
224
+ "history": history,
225
+ "ring": ringIndex.map { r -> [String: Any] in
226
+ r.small ? ["seq": r.seq, "at": r.at, "small": true] : ["seq": r.seq, "at": r.at]
227
+ },
228
+ "settled": motion.settled,
229
+ "motion": motion.json,
230
+ "transition": lastTransition?.json as Any? ?? NSNull(),
231
+ "fullFile": fullURL.path,
232
+ "ringFile": ringURL.path,
233
+ "device": deviceJSON,
234
+ ]
235
+ try writeAtomic(try JSONSerialization.data(withJSONObject: state), to: root.appendingPathComponent("state.json"))
236
+ return state
237
+ }
238
+
239
+ /// Keep every frame inside `fineMs`, then roughly one per `keyframeMs` out
240
+ /// to `retainMs`. Fine detail where transitions live, cheap recall beyond.
241
+ private func thinRing(now: Double) {
242
+ var kept: [(seq: Int, at: Double, small: Bool)] = []
243
+ var lastKeptAt: Double?
244
+ for frame in ringIndex.reversed() {
245
+ let age = now - frame.at
246
+ if age > options.retainMs { drop(frame.seq); continue }
247
+ if age <= options.fineMs || lastKeptAt == nil || (lastKeptAt! - frame.at) >= options.keyframeMs {
248
+ kept.append(frame)
249
+ lastKeptAt = frame.at
250
+ } else {
251
+ drop(frame.seq)
252
+ }
253
+ }
254
+ ringIndex = kept.reversed().suffix(options.ringSize).map { $0 }
255
+ }
256
+
257
+ private func drop(_ seq: Int) {
258
+ try? fm.removeItem(at: root.appendingPathComponent("ring/\(seq).png"))
259
+ }
260
+
261
+ private func shrinkAgedFrames(now: Double) {
262
+ for i in ringIndex.indices where !ringIndex[i].small && (now - ringIndex[i].at) > options.fineMs {
263
+ let url = root.appendingPathComponent("ring/\(ringIndex[i].seq).png")
264
+ if let small = CoreGraphicsScaler.downscalePNG(at: url, factor: options.recallScale) {
265
+ try? PNGWriter.write(small, to: url)
266
+ }
267
+ // Marked regardless: a frame we cannot shrink is still a frame we
268
+ // can serve, and retrying it every tick would be pure waste.
269
+ ringIndex[i].small = true
270
+ }
271
+ }
272
+
273
+ private func enforceByteBudget() {
274
+ var total = 0
275
+ var sizes: [Int] = []
276
+ for f in ringIndex {
277
+ let path = root.appendingPathComponent("ring/\(f.seq).png").path
278
+ let size = ((try? fm.attributesOfItem(atPath: path))?[.size] as? Int) ?? 0
279
+ sizes.append(size)
280
+ total += size
281
+ }
282
+ var i = 0
283
+ while i < ringIndex.count && total > options.maxRingBytes {
284
+ drop(ringIndex[i].seq)
285
+ total -= sizes[i]
286
+ ringIndex.remove(at: i)
287
+ sizes.remove(at: i)
288
+ }
289
+ }
290
+
291
+ private func pruneFull() {
292
+ let dir = root.appendingPathComponent("full")
293
+ guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
294
+ let pngs = names.filter { $0.hasSuffix(".png") }
295
+ .compactMap { name -> (Int, String)? in
296
+ Int(name.replacingOccurrences(of: ".png", with: "")).map { ($0, name) }
297
+ }
298
+ .sorted { $0.0 < $1.0 }
299
+ for (_, name) in pngs.dropLast(options.fullKeep) {
300
+ try? fm.removeItem(at: dir.appendingPathComponent(name))
301
+ }
302
+ }
303
+ }
@@ -0,0 +1,119 @@
1
+ import Foundation
2
+
3
+ /// Ports of the algorithms in src/analyze.js.
4
+ ///
5
+ /// These must stay bit-identical to the JavaScript, because screen-memory keys
6
+ /// and change baselines already on disk were computed by it. Any change here is
7
+ /// a change there, and vice versa.
8
+ public enum Hashing {
9
+ public static let hashCols = 8
10
+ public static let hashRows = 16
11
+ public static let regionCols = 4
12
+ public static let regionRows = 8
13
+ public static let layoutCols = 12
14
+ public static let layoutRows = 24
15
+ public static let layoutTopSkip = 0.07
16
+
17
+ /// Box-average into a small grayscale grid, matching grayGrid() exactly,
18
+ /// including its integer truncation and its round-half-away-from-zero.
19
+ public static func grayGrid(_ bmp: Bitmap, cols: Int, rows: Int) -> [UInt8] {
20
+ var gray = [UInt8](repeating: 0, count: cols * rows)
21
+ for ry in 0..<rows {
22
+ let y0 = (ry * bmp.height) / rows
23
+ let y1 = max(y0 + 1, ((ry + 1) * bmp.height) / rows)
24
+ for rx in 0..<cols {
25
+ let x0 = (rx * bmp.width) / cols
26
+ let x1 = max(x0 + 1, ((rx + 1) * bmp.width) / cols)
27
+ var sum = 0.0
28
+ var n = 0
29
+ for y in y0..<y1 {
30
+ for x in x0..<x1 {
31
+ let i = (y * bmp.width + x) * 4
32
+ sum += (Double(bmp.data[i]) * 299 + Double(bmp.data[i + 1]) * 587 + Double(bmp.data[i + 2]) * 114) / 1000
33
+ n += 1
34
+ }
35
+ }
36
+ gray[ry * cols + rx] = UInt8(clamping: Int((sum / Double(n)).rounded()))
37
+ }
38
+ }
39
+ return gray
40
+ }
41
+
42
+ /// 128-bit mean-threshold hash of the whole frame: identical screens hash
43
+ /// identically even though the encoded bytes never do.
44
+ public static func frameHash(_ bmp: Bitmap) -> String {
45
+ let gray = grayGrid(bmp, cols: hashCols, rows: hashRows)
46
+ let mean = gray.reduce(0.0) { $0 + Double($1) } / Double(gray.count)
47
+ var hex = ""
48
+ var i = 0
49
+ while i < gray.count {
50
+ var nibble = 0
51
+ for b in 0..<4 where Double(gray[i + b]) > mean { nibble |= 1 << b }
52
+ hex += String(nibble, radix: 16)
53
+ i += 4
54
+ }
55
+ return hex
56
+ }
57
+
58
+ /// A hash of layout rather than content: the status bar is cropped and each
59
+ /// bit compares a cell with its right-hand neighbour, so new list rows and a
60
+ /// ticking clock do not move it.
61
+ public static func layoutHash(_ bmp: Bitmap) -> String {
62
+ let top = Int(Double(bmp.height) * layoutTopSkip)
63
+ let cropped = Bitmap(
64
+ width: bmp.width,
65
+ height: bmp.height - top,
66
+ data: Array(bmp.data[(top * bmp.width * 4)...])
67
+ )
68
+ let gray = grayGrid(cropped, cols: layoutCols + 1, rows: layoutRows)
69
+ var bits: [Int] = []
70
+ bits.reserveCapacity(layoutCols * layoutRows)
71
+ for r in 0..<layoutRows {
72
+ for c in 0..<layoutCols {
73
+ let i = r * (layoutCols + 1) + c
74
+ bits.append(gray[i] > gray[i + 1] ? 1 : 0)
75
+ }
76
+ }
77
+ var hex = ""
78
+ var i = 0
79
+ while i < bits.count {
80
+ let nibble = bits[i] | (bits[i + 1] << 1) | (bits[i + 2] << 2) | (bits[i + 3] << 3)
81
+ hex += String(nibble, radix: 16)
82
+ i += 4
83
+ }
84
+ return hex
85
+ }
86
+
87
+ public static func regionSignature(_ bmp: Bitmap) -> [UInt8] {
88
+ grayGrid(bmp, cols: regionCols, rows: regionRows)
89
+ }
90
+
91
+ public static func signatureToHex(_ sig: [UInt8]) -> String {
92
+ sig.map { String(format: "%02x", $0) }.joined()
93
+ }
94
+
95
+ public static func signatureDiff(_ a: [UInt8], _ b: [UInt8]?) -> Double {
96
+ guard let b, a.count == b.count, !a.isEmpty else { return 1 }
97
+ var sum = 0.0
98
+ for i in 0..<a.count { sum += abs(Double(a[i]) - Double(b[i])) }
99
+ return sum / Double(a.count) / 255.0
100
+ }
101
+
102
+ public static func regionDeltas(_ a: [UInt8], _ b: [UInt8]?) -> [Double] {
103
+ guard let b, a.count == b.count else { return a.map { _ in 1 } }
104
+ return (0..<a.count).map { abs(Double(a[$0]) - Double(b[$0])) / 255.0 }
105
+ }
106
+
107
+ private static let bitCount: [Int] = [0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4]
108
+
109
+ public static func hashDistance(_ a: String, _ b: String) -> Int {
110
+ guard a.count == b.count else { return Int.max }
111
+ var d = 0
112
+ for (x, y) in zip(a, b) {
113
+ let xv = Int(String(x), radix: 16) ?? 0
114
+ let yv = Int(String(y), radix: 16) ?? 0
115
+ d += bitCount[(xv ^ yv) & 0xf]
116
+ }
117
+ return d
118
+ }
119
+ }