@obsrviq/react-native 0.3.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.
@@ -0,0 +1,703 @@
1
+ import Foundation
2
+ import UIKit
3
+ import UIKit.UIGestureRecognizerSubclass // touchesBegan/Moved/Ended overrides
4
+
5
+ /**
6
+ * Lumera native screenshot-replay capture engine (iOS).
7
+ *
8
+ * THREADING CONTRACT (the whole point):
9
+ * • UI thread → TWO things only: the `view.drawHierarchy(in:afterScreenUpdates:false)`
10
+ * raster into a raw CGContext at native scale, and the cheap mask-rect
11
+ * view-tree walk (geometry needs the main thread). Nothing else.
12
+ * • bg queue → mask compositing, JPEG encode, JSON assembly, and the HTTP UPLOAD.
13
+ * • change-driven, throttled to `fps`, overlapping captures DROPPED.
14
+ *
15
+ * No pixels ever cross the RN bridge — this engine uploads frames itself to
16
+ * `${endpoint}/v1/batch` as `type:"screen"` events, with its OWN monotonic `seq`
17
+ * (independent of the JS structured-event stream's seq; the worker keys frames by
18
+ * event id, not seq, so the two streams never collide).
19
+ *
20
+ * Reached from the ObjC++ TurboModule bridge (LumeraReplayModule.mm) via the `@objc`
21
+ * wrappers at the bottom of this file.
22
+ */
23
+ @objc(LumeraReplay)
24
+ final class LumeraReplay: NSObject {
25
+
26
+ struct Options {
27
+ var endpoint: String
28
+ var siteKey: String
29
+ var sessionId: String
30
+ var startedAtMs: Double
31
+ var fps: Double
32
+ var jpegQuality: Double
33
+ var maxCaptureDim: Int
34
+ var maskAllText: Bool
35
+ var maskAllImages: Bool
36
+ var captureTouches: Bool
37
+ var flushIntervalMs: Double
38
+ }
39
+
40
+ /// One process-wide instance — the bridge forwards every call here so the JS
41
+ /// surface (start/stop/configure/mask/diagnostics) talks to a single engine.
42
+ @objc static let shared = LumeraReplay()
43
+
44
+ // Serial background queue: ALL post-capture work (mask/encode/JSON/upload) lands here.
45
+ private let work = DispatchQueue(label: "app.lumera.replay.work", qos: .utility)
46
+ // URLSession on the background queue — uploads never touch the JS bridge or main thread.
47
+ private let session: URLSession = {
48
+ let cfg = URLSessionConfiguration.default
49
+ cfg.timeoutIntervalForRequest = 20
50
+ cfg.waitsForConnectivity = true
51
+ cfg.httpShouldUsePipelining = true
52
+ return URLSession(configuration: cfg)
53
+ }()
54
+
55
+ // `state` guards everything touched from more than one thread (options, the masked-tag
56
+ // sets, inFlight, the change token, and the diagnostics counters). Held only for cheap
57
+ // reads/writes — never across a raster, encode, or upload.
58
+ private let lock = NSLock()
59
+ private var options: Options?
60
+ private var displayLink: CADisplayLink?
61
+ private var touchRecognizer: UIGestureRecognizer? // passive (non-consuming) touch observer
62
+ private var inFlight = false // drop overlapping captures
63
+ private var lastCaptureHost: Int = 0 // change-detection token
64
+ private var maskedTags = Set<Int>() // explicit per-view mask opt-in
65
+ private var unmaskedTags = Set<Int>() // explicit per-view unmask (wins over maskAll*)
66
+
67
+ // `pending` and `seq` are confined to the `work` queue — only ever touched there.
68
+ private var pending: [ScreenEvent] = [] // buffered frames awaiting flush
69
+ private var pendingTouch: [TouchEvent] = [] // buffered taps/swipes awaiting flush (work-confined)
70
+ private var lastTouchMoveTs: Int = 0 // move-event throttle (work-confined)
71
+ private var seq: Int = 0 // our OWN monotonic batch counter (starts at 0)
72
+ private var flushScheduled = false
73
+ // Backpressure: a prolonged network outage must not grow memory without bound. When the
74
+ // buffer exceeds this, the OLDEST frames are dropped (and counted) — the same drop-rather-
75
+ // than-snowball philosophy as the in-flight guard. ~5 min of headroom at 1 fps.
76
+ private let maxPending = 300
77
+
78
+ // Diagnostics (read via getDiagnostics; mutated under `lock`).
79
+ private var framesCaptured = 0
80
+ private var framesSent = 0
81
+ private var framesDropped = 0
82
+ private var bytesSent = 0
83
+
84
+ override init() { super.init() }
85
+
86
+ // MARK: - Lifecycle (called from the TurboModule bridge)
87
+
88
+ func start(_ opts: Options) {
89
+ lock.lock()
90
+ options = opts
91
+ lock.unlock()
92
+
93
+ DispatchQueue.main.async { [weak self] in
94
+ guard let self else { return }
95
+ self.displayLink?.invalidate()
96
+ // A CADisplayLink gated to ~`fps` drives change-driven capture cheaply.
97
+ let link = CADisplayLink(target: self, selector: #selector(self.tick))
98
+ link.preferredFramesPerSecond = max(1, Int(opts.fps.rounded()))
99
+ link.add(to: .main, forMode: .common)
100
+ self.displayLink = link
101
+
102
+ // Passive touch observer: a non-consuming gesture recognizer on the key window
103
+ // records taps + swipes without interfering with the app's own gestures.
104
+ if opts.captureTouches, self.touchRecognizer == nil, let window = self.keyWindow() {
105
+ let r = LumeraTouchRecognizer(target: self, action: #selector(self.touchNoop))
106
+ r.sink = self
107
+ window.addGestureRecognizer(r)
108
+ self.touchRecognizer = r
109
+ }
110
+ }
111
+ // Periodic flush of buffered frames, off-main.
112
+ work.async { [weak self] in self?.scheduleFlush() }
113
+ }
114
+
115
+ func stop() {
116
+ DispatchQueue.main.async { [weak self] in
117
+ self?.displayLink?.invalidate()
118
+ self?.displayLink = nil
119
+ if let r = self?.touchRecognizer {
120
+ r.view?.removeGestureRecognizer(r)
121
+ self?.touchRecognizer = nil
122
+ }
123
+ }
124
+ lock.lock()
125
+ options = nil // stops the flush loop + makes tick() a no-op
126
+ lock.unlock()
127
+ // Final flush of anything still buffered.
128
+ work.async { [weak self] in self?.flush() }
129
+ }
130
+
131
+ func configure(fps: Double?, jpegQuality: Double?, maskAllText: Bool?, maskAllImages: Bool?) {
132
+ lock.lock()
133
+ guard var o = options else { lock.unlock(); return }
134
+ if let v = fps { o.fps = v }
135
+ if let v = jpegQuality { o.jpegQuality = v }
136
+ if let v = maskAllText { o.maskAllText = v }
137
+ if let v = maskAllImages { o.maskAllImages = v }
138
+ options = o
139
+ lock.unlock()
140
+ if let v = fps {
141
+ DispatchQueue.main.async { [weak self] in
142
+ self?.displayLink?.preferredFramesPerSecond = max(1, Int(v.rounded()))
143
+ }
144
+ }
145
+ }
146
+
147
+ /// Explicitly mask (true) or unmask (false) a view by its RN reactTag. An explicit
148
+ /// unmask wins over maskAllText/maskAllImages; an explicit mask forces redaction even
149
+ /// when the global maskAll* flags are off.
150
+ func setViewMasked(_ reactTag: Int, _ masked: Bool) {
151
+ lock.lock()
152
+ if masked { maskedTags.insert(reactTag); unmaskedTags.remove(reactTag) }
153
+ else { unmaskedTags.insert(reactTag); maskedTags.remove(reactTag) }
154
+ lock.unlock()
155
+ }
156
+
157
+ /// Snapshot the diagnostics counters (cheap; called off the capture path).
158
+ func diagnostics() -> [String: Any] {
159
+ lock.lock(); defer { lock.unlock() }
160
+ return [
161
+ "framesCaptured": framesCaptured,
162
+ "framesSent": framesSent,
163
+ "framesDropped": framesDropped,
164
+ "bytesSent": bytesSent,
165
+ ]
166
+ }
167
+
168
+ // MARK: - Capture (UI thread — keep this minimal)
169
+
170
+ @objc private func tick() {
171
+ // Read shared state once, under the lock.
172
+ lock.lock()
173
+ guard let opts = options, !inFlight else { lock.unlock(); return }
174
+ lock.unlock()
175
+
176
+ guard let window = keyWindow() else { return }
177
+
178
+ // Cheap change-detection: skip if nothing visibly changed since last frame.
179
+ let host = changeToken(window)
180
+ lock.lock()
181
+ if host == lastCaptureHost { lock.unlock(); return }
182
+ // Re-check inFlight (a previous tick this run-loop may have claimed it) then claim.
183
+ if inFlight { lock.unlock(); return }
184
+ lastCaptureHost = host
185
+ inFlight = true
186
+ framesCaptured += 1
187
+ let snapMasked = maskedTags
188
+ let snapUnmasked = unmaskedTags
189
+ lock.unlock()
190
+
191
+ // Collect redaction rects during a cheap traversal (still on the UI thread —
192
+ // we need view geometry — but no rasterization here).
193
+ let masks = collectMaskRects(window, opts: opts, masked: snapMasked, unmasked: snapUnmasked)
194
+
195
+ let scale = window.screen.scale
196
+ let bounds = window.bounds
197
+ let pxW = Int((bounds.width * scale).rounded())
198
+ let pxH = Int((bounds.height * scale).rounded())
199
+
200
+ // THE ONE EXPENSIVE UI-THREAD CALL — raw CGContext, native scale, afterScreenUpdates:false.
201
+ // (Not UIGraphicsImageRenderer; not afterScreenUpdates:true; not CALayer.render.)
202
+ guard pxW > 0, pxH > 0, let ctx = CGContext(
203
+ data: nil,
204
+ width: pxW, height: pxH,
205
+ bitsPerComponent: 8, bytesPerRow: 0,
206
+ space: CGColorSpaceCreateDeviceRGB(),
207
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
208
+ ) else {
209
+ releaseInFlight()
210
+ return
211
+ }
212
+
213
+ // Flip + scale so UIKit draws right-side-up at native pixel density.
214
+ ctx.translateBy(x: 0, y: CGFloat(pxH))
215
+ ctx.scaleBy(x: scale, y: -scale)
216
+ UIGraphicsPushContext(ctx)
217
+ window.drawHierarchy(in: bounds, afterScreenUpdates: false)
218
+ UIGraphicsPopContext()
219
+
220
+ guard let cgImage = ctx.makeImage() else { releaseInFlight(); return }
221
+
222
+ let ts = Int(Date().timeIntervalSince1970 * 1000)
223
+ let tRel = max(0, ts - Int(opts.startedAtMs))
224
+ let logical = bounds.size
225
+
226
+ // Hand off to the background queue — UI thread is done.
227
+ work.async { [weak self] in
228
+ self?.process(cgImage, masks: masks, scale: scale, logical: logical, tRel: tRel, ts: ts, opts: opts)
229
+ }
230
+ }
231
+
232
+ private func releaseInFlight() {
233
+ lock.lock(); inFlight = false; lock.unlock()
234
+ }
235
+
236
+ // MARK: - Post-capture pipeline (background queue)
237
+
238
+ private func process(_ cgImage: CGImage, masks: [CGRect], scale: CGFloat, logical: CGSize, tRel: Int, ts: Int, opts: Options) {
239
+ defer { releaseInFlight() }
240
+
241
+ // ① Mask compositing onto the already-captured bitmap (NEVER a second screenshot).
242
+ let masked = composite(cgImage, rects: masks)
243
+
244
+ // ② Downscale (THE storage/bandwidth lever) — cap the longer edge at maxCaptureDim.
245
+ // Full native res is ~4× the bytes for no replay benefit; masks were composited at
246
+ // full res above so they scale cleanly. No-op when already within the cap.
247
+ let out = Self.downscale(masked, maxDim: opts.maxCaptureDim)
248
+
249
+ // ③ JPEG encode (hardware-accelerated; off-main is safe for CGImage/Core Graphics).
250
+ guard let jpeg = UIImage(cgImage: out).jpegData(compressionQuality: CGFloat(opts.jpegQuality)) else { return }
251
+
252
+ // ④ Wrap into a fully-formed `screen` event + buffer for the next flush.
253
+ let effScale = logical.width > 0 ? Double(out.width) / Double(logical.width) : Double(scale)
254
+ let event = ScreenEvent(
255
+ id: Self.uuidv4(),
256
+ sessionId: opts.sessionId,
257
+ type: "screen",
258
+ t: tRel,
259
+ ts: ts,
260
+ img: jpeg.base64EncodedString(),
261
+ format: "jpeg",
262
+ w: out.width, // encoded (possibly downscaled) pixel size
263
+ h: out.height,
264
+ screenW: Int(logical.width.rounded()), // logical pt — unchanged by downscale
265
+ screenH: Int(logical.height.rounded()),
266
+ scale: effScale,
267
+ full: true
268
+ )
269
+ pending.append(event) // `work`-confined — no lock needed
270
+ trimPending()
271
+ }
272
+
273
+ /// Cap the (work-confined) buffer; drop the OLDEST overflow and count it. Cheap — runs
274
+ /// only when the buffer is already over the cap (i.e. a sustained upload failure).
275
+ private func trimPending() {
276
+ guard pending.count > maxPending else { return }
277
+ let overflow = pending.count - maxPending
278
+ pending.removeFirst(overflow)
279
+ lock.lock(); framesDropped += overflow; lock.unlock()
280
+ }
281
+
282
+ // MARK: - Helpers
283
+
284
+ private func keyWindow() -> UIWindow? {
285
+ // Prefer foreground-active scenes so we never raster a backgrounded scene's window.
286
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
287
+ let active = scenes.filter { $0.activationState == .foregroundActive }
288
+ let ordered = active.isEmpty ? scenes : active
289
+ if let key = ordered.flatMap({ $0.windows }).first(where: { $0.isKeyWindow }) { return key }
290
+ if #available(iOS 15.0, *), let key = ordered.compactMap({ $0.keyWindow }).first { return key }
291
+ return ordered.flatMap { $0.windows }.first { !$0.isHidden }
292
+ }
293
+
294
+ /// Cheap "did anything change" token. A layout/scroll/text edit shifts at least one
295
+ /// subview frame, which moves this hash; an idle screen keeps it stable (→ no capture).
296
+ private func changeToken(_ window: UIWindow) -> Int {
297
+ var h = Hasher()
298
+ h.combine(window.bounds.size.width)
299
+ h.combine(window.bounds.size.height)
300
+ var stack: [UIView] = [window]
301
+ var seen = 0
302
+ while let v = stack.popLast(), seen < 400 { // bounded so deep trees stay cheap
303
+ if v.isHidden || v.alpha == 0 { continue }
304
+ let f = v.frame
305
+ h.combine(Int(f.origin.x)); h.combine(Int(f.origin.y))
306
+ h.combine(Int(f.size.width)); h.combine(Int(f.size.height))
307
+ // Text changes that don't move a frame still need to redraw a frame.
308
+ if let lbl = v as? UILabel { h.combine(lbl.text?.hashValue ?? 0) }
309
+ else if let tf = v as? UITextField { h.combine(tf.text?.hashValue ?? 0) }
310
+ seen += 1
311
+ stack.append(contentsOf: v.subviews)
312
+ }
313
+ return h.finalize()
314
+ }
315
+
316
+ /// Walk the view tree on the MAIN thread, emitting redaction rects (in captured-pixel
317
+ /// space) for masked nodes. Honors maskAllText/maskAllImages and the per-view
318
+ /// mask/unmask opt-in/out (matched by RN reactTag where available). Geometry-only —
319
+ /// no rasterization here.
320
+ private func collectMaskRects(_ window: UIWindow, opts: Options, masked: Set<Int>, unmasked: Set<Int>) -> [CGRect] {
321
+ var rects: [CGRect] = []
322
+ let scale = window.screen.scale
323
+ var stack: [UIView] = Array(window.subviews.reversed())
324
+
325
+ while let view = stack.popLast() {
326
+ if view.isHidden || view.alpha == 0 { continue }
327
+
328
+ let tag = reactTag(of: view)
329
+ let explicitlyUnmasked = tag != nil && unmasked.contains(tag!)
330
+ let explicitlyMasked = tag != nil && masked.contains(tag!)
331
+
332
+ // Class heuristics for the global maskAll* flags.
333
+ let isText = view is UILabel || view is UITextField || view is UITextView
334
+ let isImage = view is UIImageView
335
+ let shouldMaskByClass = (isText && opts.maskAllText) || (isImage && opts.maskAllImages)
336
+
337
+ if !explicitlyUnmasked && (explicitlyMasked || shouldMaskByClass) {
338
+ // Convert the view's bounds → window coords → captured-pixel space.
339
+ let inWindow = view.convert(view.bounds, to: window)
340
+ let pixelRect = inWindow.applying(CGAffineTransform(scaleX: scale, y: scale)).integral
341
+ if pixelRect.width >= 1, pixelRect.height >= 1 { rects.append(pixelRect) }
342
+ // An explicit-mask subtree is fully covered by this rect; no need to descend.
343
+ if explicitlyMasked { continue }
344
+ }
345
+
346
+ stack.append(contentsOf: view.subviews)
347
+ }
348
+ return rects
349
+ }
350
+
351
+ /// Best-effort RN reactTag for a view. RN host views expose a `reactTag` property
352
+ /// (Paper directly; Fabric via the RCTComponentViewProtocol category). We MUST gate on
353
+ /// `responds(to:)` first: a plain UIKit view has no such key and `value(forKey:)` would
354
+ /// raise an uncatchable NSUnknownKeyException. Returns nil for non-RN / untagged views.
355
+ private static let reactTagSelector = Selector(("reactTag"))
356
+ private func reactTag(of view: UIView) -> Int? {
357
+ guard view.responds(to: Self.reactTagSelector),
358
+ let n = view.value(forKey: "reactTag") as? NSNumber else { return nil }
359
+ let tag = n.intValue
360
+ return tag != 0 ? tag : nil // 0 == unset
361
+ }
362
+
363
+ /// Fill redaction rects with a neutral rounded block. Core Graphics is background-safe,
364
+ /// so this runs on `work`. We draw onto a fresh context seeded with the captured image
365
+ /// (never a second screenshot) and return the masked CGImage.
366
+ private func composite(_ image: CGImage, rects: [CGRect]) -> CGImage {
367
+ guard !rects.isEmpty else { return image }
368
+ let w = image.width, h = image.height
369
+ guard let ctx = CGContext(
370
+ data: nil, width: w, height: h,
371
+ bitsPerComponent: 8, bytesPerRow: 0,
372
+ space: CGColorSpaceCreateDeviceRGB(),
373
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
374
+ ) else { return image }
375
+
376
+ // Flip the CTM to a UIKit-style top-left / y-down space ONCE, so both the captured
377
+ // image and the redaction rects share one coordinate system. `image` was produced
378
+ // top-origin (the capture context was flipped before drawHierarchy); flipping here
379
+ // too makes `draw` reproduce it unrotated, and lets the rects (already in top-left
380
+ // pixel space) be filled directly — no per-rect Y math, no double-flip.
381
+ ctx.translateBy(x: 0, y: CGFloat(h))
382
+ ctx.scaleBy(x: 1, y: -1)
383
+
384
+ let full = CGRect(x: 0, y: 0, width: w, height: h)
385
+ ctx.draw(image, in: full)
386
+ ctx.setFillColor(UIColor.systemGray.cgColor)
387
+ for rect in rects {
388
+ let clamped = rect.intersection(full)
389
+ guard !clamped.isNull, clamped.width >= 1, clamped.height >= 1 else { continue }
390
+ let radius = min(8, min(clamped.width, clamped.height) / 4)
391
+ let path = CGPath(roundedRect: clamped, cornerWidth: radius, cornerHeight: radius, transform: nil)
392
+ ctx.addPath(path)
393
+ ctx.fillPath()
394
+ }
395
+ return ctx.makeImage() ?? image
396
+ }
397
+
398
+ /// Downscale so the longer edge is ≤ maxDim (storage/bandwidth lever). No-op when the
399
+ /// image is already within the cap or maxDim ≤ 0. Core Graphics → background-safe.
400
+ private static func downscale(_ image: CGImage, maxDim: Int) -> CGImage {
401
+ guard maxDim > 0 else { return image }
402
+ let longEdge = max(image.width, image.height)
403
+ guard longEdge > maxDim else { return image }
404
+ let s = Double(maxDim) / Double(longEdge)
405
+ let tw = max(1, Int((Double(image.width) * s).rounded()))
406
+ let th = max(1, Int((Double(image.height) * s).rounded()))
407
+ guard let ctx = CGContext(
408
+ data: nil, width: tw, height: th,
409
+ bitsPerComponent: 8, bytesPerRow: 0,
410
+ space: CGColorSpaceCreateDeviceRGB(),
411
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
412
+ ) else { return image }
413
+ ctx.interpolationQuality = .medium
414
+ ctx.draw(image, in: CGRect(x: 0, y: 0, width: tw, height: th))
415
+ return ctx.makeImage() ?? image
416
+ }
417
+
418
+ // MARK: - Flush / upload (background queue)
419
+
420
+ /// Kick off the periodic-flush loop. Idempotent: a second start() while a loop is
421
+ /// already armed is a no-op (no double-flushing). Runs on `work`, so `flushScheduled`
422
+ /// is work-confined and needs no lock.
423
+ private func scheduleFlush() {
424
+ guard !flushScheduled else { return }
425
+ flushScheduled = true
426
+ armNextFlush()
427
+ }
428
+
429
+ private func armNextFlush() {
430
+ lock.lock()
431
+ let ms = options?.flushIntervalMs
432
+ lock.unlock()
433
+ // Recording stopped → let the loop die (a final flush already ran in stop()).
434
+ guard let ms else { flushScheduled = false; return }
435
+ work.asyncAfter(deadline: .now() + .milliseconds(Int(ms))) { [weak self] in
436
+ guard let self else { return }
437
+ self.flush()
438
+ self.armNextFlush()
439
+ }
440
+ }
441
+
442
+ /// Assemble ONE IngestBatch from ALL buffered frames, serialize to plain JSON, and POST
443
+ /// it to `${endpoint}/v1/batch` on `work` via URLSession. On HTTP 2xx bump
444
+ /// framesSent/bytesSent and increment our OWN seq; on failure re-queue the frames (their
445
+ /// ids are stable, so a retry is idempotent server-side).
446
+ private func flush() {
447
+ guard !pending.isEmpty || !pendingTouch.isEmpty else { return }
448
+ lock.lock()
449
+ let opts = options
450
+ let currentSeq = seq
451
+ lock.unlock()
452
+ guard let opts else { return }
453
+
454
+ // Drain both buffers; build the batch from frames + touches together.
455
+ let frames = pending
456
+ let touches = pendingTouch
457
+ pending.removeAll(keepingCapacity: true)
458
+ pendingTouch.removeAll(keepingCapacity: true)
459
+ let events: [WireEvent] = frames.map { .screen($0) } + touches.map { .touch($0) }
460
+
461
+ let logical = frames.first.map { CGSize(width: $0.screenW, height: $0.screenH) } ?? .zero
462
+ let batch = IngestBatch(
463
+ siteKey: opts.siteKey,
464
+ sessionId: opts.sessionId,
465
+ seq: currentSeq,
466
+ meta: BatchMeta(
467
+ startedAt: Int(opts.startedAtMs),
468
+ entryUrl: "",
469
+ device: DeviceMeta(
470
+ type: "mobile",
471
+ os: "iOS",
472
+ viewport: Viewport(w: Int(logical.width), h: Int(logical.height))
473
+ )
474
+ ),
475
+ events: events
476
+ )
477
+
478
+ guard let body = try? JSONEncoder().encode(batch),
479
+ let url = URL(string: batchURL(opts.endpoint)) else {
480
+ // Couldn't even serialize — re-queue so we don't silently drop anything.
481
+ pending.insert(contentsOf: frames, at: 0)
482
+ pendingTouch.insert(contentsOf: touches, at: 0)
483
+ return
484
+ }
485
+
486
+ var req = URLRequest(url: url)
487
+ req.httpMethod = "POST"
488
+ req.setValue(opts.siteKey, forHTTPHeaderField: "x-lumera-key")
489
+ // octet-stream, NOT application/json — the ingest reads the raw body buffer and
490
+ // sniffs gzip itself; application/json hits Fastify's JSON parser → 400 empty_body.
491
+ req.setValue("application/octet-stream", forHTTPHeaderField: "content-type")
492
+ req.httpBody = body
493
+
494
+ // Block this serial-queue slot on the upload so flushes can't interleave or stampede.
495
+ let sem = DispatchSemaphore(value: 0)
496
+ var ok = false
497
+ let task = session.dataTask(with: req) { _, response, _ in
498
+ if let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) { ok = true }
499
+ sem.signal()
500
+ }
501
+ task.resume()
502
+ sem.wait()
503
+
504
+ if ok {
505
+ lock.lock()
506
+ framesSent += frames.count
507
+ bytesSent += body.count
508
+ seq = currentSeq + 1 // bump our OWN monotonic counter per successful flush
509
+ lock.unlock()
510
+ } else {
511
+ // Re-queue at the FRONT so order is preserved + the next flush retries them.
512
+ // Not counted as dropped — the events are kept; their stable ids make the retry
513
+ // idempotent server-side (mobile_frames is ON CONFLICT (session_id, id) DO NOTHING).
514
+ // Trim from the FRONT (oldest) if a long outage pushed us past the cap.
515
+ pending.insert(contentsOf: frames, at: 0)
516
+ pendingTouch.insert(contentsOf: touches, at: 0)
517
+ trimPending()
518
+ }
519
+ }
520
+
521
+ private func batchURL(_ endpoint: String) -> String {
522
+ var base = endpoint
523
+ while base.hasSuffix("/") { base.removeLast() }
524
+ return base + "/v1/batch"
525
+ }
526
+
527
+ // MARK: - JSON wire model (matches @obsrviq/types IngestBatch / MobileScreenEvent exactly)
528
+
529
+ /// One `type:"screen"` event. Field order/names are the wire contract the worker parses.
530
+ private struct ScreenEvent: Encodable {
531
+ let id: String
532
+ let sessionId: String
533
+ let type: String // "screen"
534
+ let t: Int // ms since startedAtMs
535
+ let ts: Int // epoch ms
536
+ let img: String // base64 JPEG
537
+ let format: String // "jpeg"
538
+ let w: Int // encoded px
539
+ let h: Int
540
+ let screenW: Int // logical points
541
+ let screenH: Int
542
+ let scale: Double // effective px/pt ratio after downscale
543
+ let full: Bool // keyframe marker
544
+ }
545
+
546
+ /// One `type:"touch"` event — a tap or a (throttled) swipe sample, in LOGICAL
547
+ /// screen points so the player maps it by screenW/screenH (matches @obsrviq/types
548
+ /// MobileTouchEvent). Rides the same batch upload as screen frames.
549
+ struct TouchEvent: Encodable {
550
+ let id: String
551
+ let sessionId: String
552
+ let type: String // "touch"
553
+ let t: Int // ms since startedAtMs
554
+ let ts: Int // epoch ms
555
+ let phase: String // "start" | "move" | "end" | "cancel"
556
+ let points: [Point]
557
+ }
558
+ struct Point: Encodable { let x: Int; let y: Int }
559
+
560
+ /// A batch carries a mix of screen + touch events; this type-erases the two so
561
+ /// they serialize into one heterogeneous `events` array.
562
+ private enum WireEvent: Encodable {
563
+ case screen(ScreenEvent)
564
+ case touch(TouchEvent)
565
+ func encode(to encoder: Encoder) throws {
566
+ switch self {
567
+ case .screen(let s): try s.encode(to: encoder)
568
+ case .touch(let t): try t.encode(to: encoder)
569
+ }
570
+ }
571
+ }
572
+
573
+ private struct Viewport: Encodable { let w: Int; let h: Int }
574
+ private struct DeviceMeta: Encodable { let type: String; let os: String; let viewport: Viewport }
575
+ private struct BatchMeta: Encodable { let startedAt: Int; let entryUrl: String; let device: DeviceMeta }
576
+ private struct IngestBatch: Encodable {
577
+ let siteKey: String
578
+ let sessionId: String
579
+ let seq: Int
580
+ let meta: BatchMeta
581
+ let events: [WireEvent]
582
+ }
583
+
584
+ // MARK: - Touch capture (called from the passive recognizer on the main thread)
585
+
586
+ /// No-op action target the recognizer is constructed with (recording happens in
587
+ /// its touchesBegan/Moved/Ended overrides, not via the action).
588
+ @objc func touchNoop() {}
589
+
590
+ /// Buffer a tap/swipe sample. Called on the MAIN thread from the recognizer; reads
591
+ /// options under the lock then hands off to the `work` queue for buffering. Moves
592
+ /// are throttled to ~50ms so a swipe doesn't flood the buffer.
593
+ func recordTouch(_ phase: String, _ pts: [CGPoint]) {
594
+ guard !pts.isEmpty else { return }
595
+ lock.lock(); let opts = options; lock.unlock()
596
+ guard let opts, opts.captureTouches else { return }
597
+ let ts = Int(Date().timeIntervalSince1970 * 1000)
598
+ let tRel = max(0, ts - Int(opts.startedAtMs))
599
+ let points = pts.map { Point(x: Int($0.x.rounded()), y: Int($0.y.rounded())) }
600
+ work.async { [weak self] in
601
+ guard let self else { return }
602
+ if phase == "move" {
603
+ if ts - self.lastTouchMoveTs < 50 { return }
604
+ self.lastTouchMoveTs = ts
605
+ }
606
+ self.pendingTouch.append(TouchEvent(
607
+ id: Self.uuidv4(), sessionId: opts.sessionId, type: "touch",
608
+ t: tRel, ts: ts, phase: phase, points: points))
609
+ if self.pendingTouch.count > self.maxPending {
610
+ self.pendingTouch.removeFirst(self.pendingTouch.count - self.maxPending)
611
+ }
612
+ }
613
+ }
614
+
615
+ /// RFC 4122 v4 UUID for frame ids (the worker keys frame blobs by this — must be unique).
616
+ private static func uuidv4() -> String { UUID().uuidString.lowercased() }
617
+
618
+ // MARK: - @objc bridge surface (called from LumeraReplayModule.mm)
619
+ //
620
+ // The ObjC++ TurboModule passes NSDictionary option maps straight through; these
621
+ // wrappers unbox them into the Swift `Options` struct and forward to the real methods.
622
+
623
+ @objc(startWithOptions:)
624
+ func startObjc(_ options: NSDictionary) {
625
+ start(Self.options(from: options))
626
+ }
627
+
628
+ @objc func stopObjc() { stop() }
629
+
630
+ @objc(configureWithOptions:)
631
+ func configureObjc(_ options: NSDictionary) {
632
+ configure(
633
+ fps: (options["fps"] as? NSNumber)?.doubleValue,
634
+ jpegQuality: (options["jpegQuality"] as? NSNumber)?.doubleValue,
635
+ maskAllText: (options["maskAllText"] as? NSNumber)?.boolValue,
636
+ maskAllImages: (options["maskAllImages"] as? NSNumber)?.boolValue
637
+ )
638
+ }
639
+
640
+ @objc(setViewMasked:masked:)
641
+ func setViewMaskedObjc(_ reactTag: NSNumber, masked: NSNumber) {
642
+ setViewMasked(reactTag.intValue, masked.boolValue)
643
+ }
644
+
645
+ @objc func getDiagnosticsObjc() -> NSDictionary {
646
+ diagnostics() as NSDictionary
647
+ }
648
+
649
+ /// Unbox the JS StartOptions map into Options, applying the same defaults the JS config
650
+ /// layer uses (so a partial map never produces a 0-fps / quality-0 capture).
651
+ private static func options(from map: NSDictionary) -> Options {
652
+ func num(_ key: String, _ fallback: Double) -> Double { (map[key] as? NSNumber)?.doubleValue ?? fallback }
653
+ func str(_ key: String) -> String { (map[key] as? String) ?? "" }
654
+ func bool(_ key: String, _ fallback: Bool) -> Bool { (map[key] as? NSNumber)?.boolValue ?? fallback }
655
+ return Options(
656
+ endpoint: str("endpoint"),
657
+ siteKey: str("siteKey"),
658
+ sessionId: str("sessionId"),
659
+ startedAtMs: num("startedAtMs", Date().timeIntervalSince1970 * 1000),
660
+ fps: num("fps", 1),
661
+ jpegQuality: num("jpegQuality", 0.4),
662
+ maxCaptureDim: Int(num("maxCaptureDim", 1200)),
663
+ maskAllText: bool("maskAllText", true),
664
+ maskAllImages: bool("maskAllImages", true),
665
+ captureTouches: bool("captureTouches", true),
666
+ flushIntervalMs: num("flushIntervalMs", 5000)
667
+ )
668
+ }
669
+ }
670
+
671
+ /// A passive (non-consuming) touch observer added to the key window. Records taps +
672
+ /// swipe samples and forwards them to the engine without interfering with the app's
673
+ /// own gesture handling (cancelsTouchesInView = false; it never claims a gesture).
674
+ final class LumeraTouchRecognizer: UIGestureRecognizer {
675
+ weak var sink: LumeraReplay?
676
+
677
+ override init(target: Any?, action: Selector?) {
678
+ super.init(target: target, action: action)
679
+ cancelsTouchesInView = false
680
+ delaysTouchesBegan = false
681
+ delaysTouchesEnded = false
682
+ }
683
+
684
+ private func pts(_ touches: Set<UITouch>) -> [CGPoint] {
685
+ guard let v = view else { return [] }
686
+ return touches.map { $0.location(in: v) } // logical points in window space
687
+ }
688
+
689
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
690
+ sink?.recordTouch("start", pts(touches))
691
+ }
692
+ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
693
+ sink?.recordTouch("move", pts(touches))
694
+ }
695
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
696
+ sink?.recordTouch("end", pts(touches))
697
+ state = .failed // reset for the next gesture; never consume the touch
698
+ }
699
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
700
+ sink?.recordTouch("cancel", pts(touches))
701
+ state = .failed
702
+ }
703
+ }