sitepong 0.2.13 → 0.2.14
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/dist/cdn/sitepong.min.js +1 -1
- package/dist/cdn/sitepong.min.js.map +1 -1
- package/dist/entries/rn.d.ts +1 -1
- package/dist/entries/rn.js +12 -4
- package/dist/entries/rn.js.map +1 -1
- package/dist/entries/web.js +1 -1
- package/dist/entries/web.js.map +1 -1
- package/dist/entries/web.mjs +1 -1
- package/dist/entries/web.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +1 -1
- package/dist/react/index.mjs.map +1 -1
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +2 -2
- package/ios/WatchtowerCore/StructuralRecorder.swift +163 -19
- package/ios/WatchtowerCore/WatchtowerEngine.swift +12 -0
- package/package.json +1 -1
|
@@ -141,8 +141,8 @@ public class SitePongScreenRecorderModule: Module {
|
|
|
141
141
|
Watchtower.captureScreenshot()
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
Function("watchtowerSetUser") { (distinctId: String?) in
|
|
145
|
-
Watchtower.setUser(distinctId)
|
|
144
|
+
Function("watchtowerSetUser") { (distinctId: String?, email: String?, name: String?) in
|
|
145
|
+
Watchtower.setUser(distinctId, email: email, name: name)
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
Function("watchtowerGetSessionId") { () -> String? in
|
|
@@ -12,8 +12,27 @@
|
|
|
12
12
|
//
|
|
13
13
|
// One platform-neutral core, two capture adapters (fiber / UIView), one stored
|
|
14
14
|
// stream, one dashboard Player.
|
|
15
|
+
//
|
|
16
|
+
// Parity with the RN structural SDK (all implemented here):
|
|
17
|
+
// • Sensitive masking — honors watchtowerSensitive UIViews + SwiftUI
|
|
18
|
+
// .watchtowerSensitive() regions (via WatchtowerRegistry), not just secure fields.
|
|
19
|
+
// • Scroll fidelity — native nodes carry ABSOLUTE window frames, so scroll shows
|
|
20
|
+
// up as frame deltas; we densify sampling while a UIScrollView is moving so fast
|
|
21
|
+
// scrolls aren't aliased (no SetNodeScroll — that would double-translate here).
|
|
22
|
+
// • Session follow — re-keys onto WatchtowerEngine's rolled session id (shared id,
|
|
23
|
+
// roll-in-lockstep) via .sitePongWatchtowerSessionDidRoll, the native analogue
|
|
24
|
+
// of the RN provider's watchForRoll.
|
|
25
|
+
// • Crash-flush — a best-effort synchronous flush on uncaught exception.
|
|
26
|
+
// • Adaptive sampling — a heavy tree walk backs the sampler off for a few ticks
|
|
27
|
+
// so capture never pins the main thread.
|
|
15
28
|
import UIKit
|
|
16
29
|
|
|
30
|
+
extension Notification.Name {
|
|
31
|
+
/// Posted by WatchtowerEngine on session start/roll so the structural recorder
|
|
32
|
+
/// can share the session id and roll in lockstep.
|
|
33
|
+
static let sitePongWatchtowerSessionDidRoll = Notification.Name("SitePongWatchtowerSessionDidRoll")
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
public final class SitePongStructuralCapture {
|
|
18
37
|
public static let shared = SitePongStructuralCapture()
|
|
19
38
|
|
|
@@ -22,12 +41,12 @@ public final class SitePongStructuralCapture {
|
|
|
22
41
|
private var projectId: String?
|
|
23
42
|
private var platform = "ios"
|
|
24
43
|
private var sessionId = ""
|
|
44
|
+
private var installId: String?
|
|
25
45
|
private var startedAt = Date()
|
|
26
46
|
private var ids: [ObjectIdentifier: Int] = [:]
|
|
27
47
|
private var nextId = 1
|
|
28
48
|
private var events: [[String: Any]] = []
|
|
29
49
|
private var screen = ""
|
|
30
|
-
private var sampleTimer: Timer?
|
|
31
50
|
private var flushTimer: Timer?
|
|
32
51
|
private var running = false
|
|
33
52
|
|
|
@@ -39,6 +58,20 @@ public final class SitePongStructuralCapture {
|
|
|
39
58
|
private var flushedEventCount = 0
|
|
40
59
|
private var sending = false
|
|
41
60
|
|
|
61
|
+
// Scroll fidelity: last seen content offset per node id; while any scroll view
|
|
62
|
+
// moves we sample faster so the frame-delta capture isn't aliased.
|
|
63
|
+
private var scrollOffsets: [Int: CGPoint] = [:]
|
|
64
|
+
private var scanScrolls: [(id: Int, off: CGPoint)] = []
|
|
65
|
+
private var scrollQuietTicks = 3
|
|
66
|
+
|
|
67
|
+
// Adaptive sampling: a walk over `walkBudgetMs` backs off proportionally.
|
|
68
|
+
private let walkBudgetMs: Double = 24
|
|
69
|
+
private let maxOverloadSkip = 8
|
|
70
|
+
private var overloadSkip = 0
|
|
71
|
+
|
|
72
|
+
// Crash-flush chains any pre-existing uncaught-exception handler.
|
|
73
|
+
private static var previousExceptionHandler: (@convention(c) (NSException) -> Void)?
|
|
74
|
+
|
|
42
75
|
private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
|
|
43
76
|
|
|
44
77
|
/// Start capturing. `endpoint` is the ingest base URL (e.g.
|
|
@@ -50,6 +83,7 @@ public final class SitePongStructuralCapture {
|
|
|
50
83
|
self.projectId = projectId
|
|
51
84
|
self.sessionId = sessionId
|
|
52
85
|
self.platform = platform
|
|
86
|
+
self.installId = UIDevice.current.identifierForVendor?.uuidString
|
|
53
87
|
self.startedAt = Date()
|
|
54
88
|
self.running = true
|
|
55
89
|
|
|
@@ -63,7 +97,7 @@ public final class SitePongStructuralCapture {
|
|
|
63
97
|
g.delegate = SPStructuralTapDelegate.shared
|
|
64
98
|
w.addGestureRecognizer(g)
|
|
65
99
|
}
|
|
66
|
-
|
|
100
|
+
scheduleSample()
|
|
67
101
|
flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
|
|
68
102
|
|
|
69
103
|
// Flush on background so the tail isn't stranded when timers suspend.
|
|
@@ -71,6 +105,12 @@ public final class SitePongStructuralCapture {
|
|
|
71
105
|
self, selector: #selector(onBackground),
|
|
72
106
|
name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
73
107
|
)
|
|
108
|
+
// Follow WatchtowerEngine's session so structural + tap streams share one id.
|
|
109
|
+
NotificationCenter.default.addObserver(
|
|
110
|
+
self, selector: #selector(onSessionRoll(_:)),
|
|
111
|
+
name: .sitePongWatchtowerSessionDidRoll, object: nil
|
|
112
|
+
)
|
|
113
|
+
installCrashFlush()
|
|
74
114
|
}
|
|
75
115
|
|
|
76
116
|
@objc private func onBackground() {
|
|
@@ -78,6 +118,29 @@ public final class SitePongStructuralCapture {
|
|
|
78
118
|
flush()
|
|
79
119
|
}
|
|
80
120
|
|
|
121
|
+
/// WatchtowerEngine rolled (or started) a session — re-key onto it so the
|
|
122
|
+
/// structural stream follows the same (linked) session id. Mirrors the RN
|
|
123
|
+
/// provider's stop()+begin() on a session roll.
|
|
124
|
+
@objc private func onSessionRoll(_ note: Notification) {
|
|
125
|
+
guard running, let newId = note.userInfo?["sessionId"] as? String, newId != sessionId else { return }
|
|
126
|
+
// Best-effort ship the old-session tail under the OLD id (fire-and-forget,
|
|
127
|
+
// decoupled from the flush cursor so it can't corrupt the new session).
|
|
128
|
+
if !events.isEmpty, let w = keyWindow() {
|
|
129
|
+
sendBestEffort(batch: events, seqStart: flushedEventCount, sessionId: sessionId,
|
|
130
|
+
viewport: (Int(w.bounds.width), Int(w.bounds.height)))
|
|
131
|
+
}
|
|
132
|
+
// Reset to a clean new session and take a fresh full snapshot.
|
|
133
|
+
sessionId = newId
|
|
134
|
+
events.removeAll(keepingCapacity: true)
|
|
135
|
+
ids.removeAll(keepingCapacity: true)
|
|
136
|
+
nextId = 1
|
|
137
|
+
flushedEventCount = 0
|
|
138
|
+
scrollOffsets.removeAll(keepingCapacity: true)
|
|
139
|
+
screen = ""
|
|
140
|
+
startedAt = Date()
|
|
141
|
+
sample()
|
|
142
|
+
}
|
|
143
|
+
|
|
81
144
|
public func setScreen(_ name: String) {
|
|
82
145
|
guard running, name != screen else { return }
|
|
83
146
|
sample()
|
|
@@ -88,11 +151,8 @@ public final class SitePongStructuralCapture {
|
|
|
88
151
|
public func stop() {
|
|
89
152
|
guard running else { return }
|
|
90
153
|
running = false
|
|
91
|
-
sampleTimer?.invalidate(); sampleTimer = nil
|
|
92
154
|
flushTimer?.invalidate(); flushTimer = nil
|
|
93
|
-
NotificationCenter.default.removeObserver(
|
|
94
|
-
self, name: UIApplication.didEnterBackgroundNotification, object: nil
|
|
95
|
-
)
|
|
155
|
+
NotificationCenter.default.removeObserver(self)
|
|
96
156
|
flush()
|
|
97
157
|
}
|
|
98
158
|
|
|
@@ -136,27 +196,39 @@ public final class SitePongStructuralCapture {
|
|
|
136
196
|
return nil
|
|
137
197
|
}
|
|
138
198
|
|
|
139
|
-
private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
|
|
199
|
+
private func serialize(_ v: UIView, in window: UIWindow, sensitiveRects: [CGRect]) -> [String: Any]? {
|
|
140
200
|
if v.isHidden || v.alpha < 0.02 { return nil }
|
|
141
201
|
let f = v.convert(v.bounds, to: window)
|
|
202
|
+
// Sensitive if the view opts in (UIKit flag), is a secure field, or its
|
|
203
|
+
// center falls inside a SwiftUI .watchtowerSensitive() region — the same
|
|
204
|
+
// sources Screenshotter blurs, so structural replay masks them too.
|
|
205
|
+
let center = CGPoint(x: f.midX, y: f.midY)
|
|
206
|
+
let inSensitive = v.watchtowerSensitive
|
|
207
|
+
|| ((v as? UITextField)?.isSecureTextEntry ?? false)
|
|
208
|
+
|| sensitiveRects.contains { $0.contains(center) }
|
|
209
|
+
|
|
142
210
|
var attrs: [String: String] = [:]
|
|
143
211
|
if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
|
|
144
|
-
if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
|
|
212
|
+
if let al = v.accessibilityLabel, !al.isEmpty, !inSensitive { attrs["accessibilityLabel"] = al }
|
|
145
213
|
if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
|
|
146
214
|
if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
|
|
147
|
-
if
|
|
215
|
+
if inSensitive { attrs["wtSensitive"] = "true" }
|
|
148
216
|
if let l = v as? UILabel {
|
|
149
217
|
attrs["color"] = hex(l.textColor) ?? "#111111"
|
|
150
218
|
attrs["fontSize"] = String(Int(l.font.pointSize))
|
|
151
219
|
}
|
|
152
220
|
attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
|
|
221
|
+
|
|
222
|
+
if let sv = v as? UIScrollView { scanScrolls.append((idFor(v), sv.contentOffset)) }
|
|
223
|
+
|
|
153
224
|
let tag = tagFor(v)
|
|
154
225
|
var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
|
|
155
|
-
|
|
226
|
+
// Never serialize text inside a sensitive region.
|
|
227
|
+
if !inSensitive, let t = textFor(v), !t.isEmpty { node["text"] = t }
|
|
156
228
|
// Don't descend into text/controls' internal subviews (keeps the tree clean).
|
|
157
229
|
if !(v is UILabel) && !(v is UIButton) {
|
|
158
230
|
var kids: [[String: Any]] = []
|
|
159
|
-
for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
|
|
231
|
+
for sub in v.subviews { if let s = serialize(sub, in: window, sensitiveRects: sensitiveRects) { kids.append(s) } }
|
|
160
232
|
node["children"] = kids
|
|
161
233
|
} else {
|
|
162
234
|
node["children"] = []
|
|
@@ -165,8 +237,38 @@ public final class SitePongStructuralCapture {
|
|
|
165
237
|
}
|
|
166
238
|
|
|
167
239
|
private func sample() {
|
|
168
|
-
guard running, let w = keyWindow()
|
|
240
|
+
guard running, let w = keyWindow() else { return }
|
|
241
|
+
// Adaptive back-off: while recovering from a heavy walk, skip the body.
|
|
242
|
+
if overloadSkip > 0 { overloadSkip -= 1; return }
|
|
243
|
+
|
|
244
|
+
scanScrolls.removeAll(keepingCapacity: true)
|
|
245
|
+
let sensitiveRects = WatchtowerRegistry.shared.sensitiveRects(in: w)
|
|
246
|
+
let t0 = Date()
|
|
247
|
+
guard let root = serialize(w, in: w, sensitiveRects: sensitiveRects) else { return }
|
|
248
|
+
let walkMs = Date().timeIntervalSince(t0) * 1000
|
|
249
|
+
if walkMs > walkBudgetMs { overloadSkip = min(maxOverloadSkip, Int(walkMs / walkBudgetMs)) }
|
|
250
|
+
|
|
169
251
|
events.append(["t": now(), "type": "snapshot", "tree": root])
|
|
252
|
+
|
|
253
|
+
// Scroll detection: did any scroll view's offset move? Densify sampling
|
|
254
|
+
// for a few ticks so fast scrolls (captured as frame deltas) aren't aliased.
|
|
255
|
+
var scrolled = false
|
|
256
|
+
for (id, off) in scanScrolls {
|
|
257
|
+
if let last = scrollOffsets[id], abs(last.x - off.x) > 0.5 || abs(last.y - off.y) > 0.5 { scrolled = true }
|
|
258
|
+
scrollOffsets[id] = off
|
|
259
|
+
}
|
|
260
|
+
scrollQuietTicks = scrolled ? 0 : scrollQuietTicks + 1
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Self-scheduling sampler: 60ms while scrolling, 120ms at rest.
|
|
264
|
+
private func scheduleSample() {
|
|
265
|
+
guard running else { return }
|
|
266
|
+
let interval = scrollQuietTicks < 3 ? 0.06 : 0.12
|
|
267
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { [weak self] in
|
|
268
|
+
guard let self = self, self.running else { return }
|
|
269
|
+
self.sample()
|
|
270
|
+
self.scheduleSample()
|
|
271
|
+
}
|
|
170
272
|
}
|
|
171
273
|
|
|
172
274
|
@objc private func onTap(_ g: UITapGestureRecognizer) {
|
|
@@ -207,26 +309,35 @@ public final class SitePongStructuralCapture {
|
|
|
207
309
|
viewport: (Int(w.bounds.width), Int(w.bounds.height)), attempt: 0)
|
|
208
310
|
}
|
|
209
311
|
|
|
210
|
-
private func
|
|
211
|
-
|
|
312
|
+
private func requestBody(batch: [[String: Any]], seqStart: Int, sessionId: String, viewport: (Int, Int)) -> Data? {
|
|
313
|
+
var payload: [String: Any] = [
|
|
212
314
|
"session_id": sessionId,
|
|
213
315
|
"platform": platform,
|
|
214
316
|
"viewport": ["w": viewport.0, "h": viewport.1],
|
|
215
317
|
"seq_start": seqStart,
|
|
216
318
|
"events": batch,
|
|
217
319
|
]
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
320
|
+
if let iid = installId { payload["install_id"] = iid }
|
|
321
|
+
return try? JSONSerialization.data(withJSONObject: payload)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func makeRequest(body: Data) -> URLRequest? {
|
|
325
|
+
guard let url = URL(string: "\(endpoint)/api/replay-stream/native") else { return nil }
|
|
223
326
|
var req = URLRequest(url: url)
|
|
224
327
|
req.httpMethod = "POST"
|
|
225
328
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
226
329
|
req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
|
227
330
|
if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
|
|
228
331
|
req.httpBody = body
|
|
332
|
+
return req
|
|
333
|
+
}
|
|
229
334
|
|
|
335
|
+
private func post(batch: [[String: Any]], seqStart: Int, viewport: (Int, Int), attempt: Int) {
|
|
336
|
+
guard let body = requestBody(batch: batch, seqStart: seqStart, sessionId: sessionId, viewport: viewport),
|
|
337
|
+
let req = makeRequest(body: body) else {
|
|
338
|
+
DispatchQueue.main.async { self.sending = false }
|
|
339
|
+
return
|
|
340
|
+
}
|
|
230
341
|
URLSession.shared.dataTask(with: req) { [weak self] _, resp, err in
|
|
231
342
|
guard let self = self else { return }
|
|
232
343
|
let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
|
|
@@ -248,6 +359,39 @@ public final class SitePongStructuralCapture {
|
|
|
248
359
|
}
|
|
249
360
|
}.resume()
|
|
250
361
|
}
|
|
362
|
+
|
|
363
|
+
/// Fire-and-forget send under an explicit session id — used for the old-session
|
|
364
|
+
/// tail on a roll, decoupled from the flush cursor so it can't corrupt the new
|
|
365
|
+
/// session's seq. Best-effort (no retry / no re-queue).
|
|
366
|
+
private func sendBestEffort(batch: [[String: Any]], seqStart: Int, sessionId: String, viewport: (Int, Int)) {
|
|
367
|
+
guard !endpoint.isEmpty,
|
|
368
|
+
let body = requestBody(batch: batch, seqStart: seqStart, sessionId: sessionId, viewport: viewport),
|
|
369
|
+
let req = makeRequest(body: body) else { return }
|
|
370
|
+
URLSession.shared.dataTask(with: req).resume()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// MARK: - crash flush
|
|
374
|
+
private func installCrashFlush() {
|
|
375
|
+
SitePongStructuralCapture.previousExceptionHandler = NSGetUncaughtExceptionHandler()
|
|
376
|
+
NSSetUncaughtExceptionHandler { ex in
|
|
377
|
+
SitePongStructuralCapture.shared.crashFlush()
|
|
378
|
+
SitePongStructuralCapture.previousExceptionHandler?(ex)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Synchronous best-effort flush from an uncaught-exception handler (the run
|
|
383
|
+
/// loop is dead, so we block briefly on URLSession's own queue). Mirrors the
|
|
384
|
+
/// JS ErrorUtils crash-flush.
|
|
385
|
+
func crashFlush() {
|
|
386
|
+
guard running, !events.isEmpty,
|
|
387
|
+
let w = keyWindow(),
|
|
388
|
+
let body = requestBody(batch: events, seqStart: flushedEventCount, sessionId: sessionId,
|
|
389
|
+
viewport: (Int(w.bounds.width), Int(w.bounds.height))),
|
|
390
|
+
let req = makeRequest(body: body) else { return }
|
|
391
|
+
let sem = DispatchSemaphore(value: 0)
|
|
392
|
+
URLSession.shared.dataTask(with: req) { _, _, _ in sem.signal() }.resume()
|
|
393
|
+
_ = sem.wait(timeout: .now() + 1.5)
|
|
394
|
+
}
|
|
251
395
|
}
|
|
252
396
|
|
|
253
397
|
/// Allow the capture recognizer to run alongside buttons/scroll gestures.
|
|
@@ -113,6 +113,13 @@ final class WatchtowerEngine {
|
|
|
113
113
|
observeBackground()
|
|
114
114
|
isRunning = true
|
|
115
115
|
emit(baseEvent(type: "session_start"))
|
|
116
|
+
// Announce the session so the structural recorder can share this id and
|
|
117
|
+
// roll in lockstep (the native analogue of the RN provider adopting the
|
|
118
|
+
// native session id).
|
|
119
|
+
NotificationCenter.default.post(
|
|
120
|
+
name: .sitePongWatchtowerSessionDidRoll,
|
|
121
|
+
object: nil, userInfo: ["sessionId": sessionId]
|
|
122
|
+
)
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
func stop() {
|
|
@@ -254,6 +261,11 @@ final class WatchtowerEngine {
|
|
|
254
261
|
emit(baseEvent(type: "session_start"))
|
|
255
262
|
pendingPreviousSessionId = nil
|
|
256
263
|
pendingSessionGapMs = nil
|
|
264
|
+
// Structural recorder re-keys onto the rolled session id.
|
|
265
|
+
NotificationCenter.default.post(
|
|
266
|
+
name: .sitePongWatchtowerSessionDidRoll,
|
|
267
|
+
object: nil, userInfo: ["sessionId": sessionId]
|
|
268
|
+
)
|
|
257
269
|
}
|
|
258
270
|
|
|
259
271
|
// MARK: - Screen views (§1.1 screen_view)
|