@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.
- package/README.md +108 -0
- package/android/build.gradle +31 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/app/lumera/replay/LumeraMaskViewManager.kt +39 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayModule.kt +530 -0
- package/android/src/main/java/app/lumera/replay/LumeraReplayPackage.kt +50 -0
- package/ios/LumeraMaskView.h +19 -0
- package/ios/LumeraMaskView.m +79 -0
- package/ios/LumeraMaskViewManager.m +29 -0
- package/ios/LumeraReplay.swift +703 -0
- package/ios/LumeraReplayModule.h +20 -0
- package/ios/LumeraReplayModule.mm +93 -0
- package/lumera-react-native.podspec +19 -0
- package/package.json +46 -0
- package/src/LumeraMask.tsx +52 -0
- package/src/config.ts +96 -0
- package/src/emit.ts +5 -0
- package/src/index.ts +292 -0
- package/src/instrument/console.ts +46 -0
- package/src/instrument/errors.ts +28 -0
- package/src/instrument/network.ts +219 -0
- package/src/session.ts +108 -0
- package/src/spec/NativeLumeraReplay.ts +70 -0
- package/src/transport.ts +40 -0
- package/src/util.ts +38 -0
|
@@ -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
|
+
}
|