sitepong 0.2.7 → 0.2.9

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.
@@ -146,6 +146,32 @@ public class SitePongScreenRecorderModule: Module {
146
146
  Function("watchtowerGetSessionId") { () -> String? in
147
147
  return Watchtower.sessionId
148
148
  }
149
+
150
+ // MARK: - Structural replay thin bridge
151
+ // Forwards to SitePongStructuralCapture (UIView-tree structural capture →
152
+ // POST /api/replay-stream/native → server-side differ). Complements the
153
+ // screenshot-based Watchtower engine above.
154
+
155
+ Function("structuralStart") { (config: [String: Any]) in
156
+ let apiKey = config["apiKey"] as? String ?? ""
157
+ let projectId = config["projectId"] as? String
158
+ let endpoint = config["endpoint"] as? String ?? ""
159
+ let sessionId = config["sessionId"] as? String ?? "wt-\(Int(Date().timeIntervalSince1970 * 1000))"
160
+ let platform = config["platform"] as? String ?? "ios"
161
+ guard !endpoint.isEmpty, !apiKey.isEmpty else { return }
162
+ SitePongStructuralCapture.shared.start(
163
+ endpoint: endpoint, apiKey: apiKey, projectId: projectId,
164
+ sessionId: sessionId, platform: platform
165
+ )
166
+ }
167
+
168
+ Function("structuralStop") {
169
+ SitePongStructuralCapture.shared.stop()
170
+ }
171
+
172
+ Function("structuralSetScreen") { (name: String) in
173
+ SitePongStructuralCapture.shared.setScreen(name)
174
+ }
149
175
  }
150
176
 
151
177
  /// Find a UIView by React tag across all key windows (mirrors
@@ -0,0 +1,258 @@
1
+ // SitePong — NATIVE (UIKit/Swift) structural session-replay capture.
2
+ //
3
+ // The RN adapter sources its tree from the React fiber tree; this native adapter
4
+ // sources it from the UIView hierarchy — the same traversal WatchtowerCore
5
+ // already does for redaction (Screenshotter walks rootViewController's subviews).
6
+ // Each UIView is serialized to a node with its REAL frame (native gives exact
7
+ // geometry, unlike RN where the dashboard replays flexbox), its class-derived
8
+ // tag, text, and accessibilityIdentifier (== the RN testID). Snapshots + taps
9
+ // are POSTed to the ingest server, where the SAME @sitepong/watchtower-replay
10
+ // core diffs them into the SAME rrweb-style message stream the RN adapter posts
11
+ // directly (POST /api/replay-stream/native → server-side differ).
12
+ //
13
+ // One platform-neutral core, two capture adapters (fiber / UIView), one stored
14
+ // stream, one dashboard Player.
15
+ import UIKit
16
+
17
+ public final class SitePongStructuralCapture {
18
+ public static let shared = SitePongStructuralCapture()
19
+
20
+ private var endpoint = ""
21
+ private var apiKey = ""
22
+ private var projectId: String?
23
+ private var platform = "ios"
24
+ private var sessionId = ""
25
+ private var startedAt = Date()
26
+ private var ids: [ObjectIdentifier: Int] = [:]
27
+ private var nextId = 1
28
+ private var events: [[String: Any]] = []
29
+ private var screen = ""
30
+ private var sampleTimer: Timer?
31
+ private var flushTimer: Timer?
32
+ private var running = false
33
+
34
+ // Incremental delivery: count of events CONFIRMED stored (== next seq_start).
35
+ // The device ships only the delta each flush and clears its buffer, so memory
36
+ // stays bounded and the uplink never re-sends the whole session. Node ids
37
+ // (`ids`/`nextId`) stay PERSISTENT across flushes — the server stitches the
38
+ // deltas by id, so resetting them would corrupt the tree.
39
+ private var flushedEventCount = 0
40
+ private var sending = false
41
+
42
+ private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
43
+
44
+ /// Start capturing. `endpoint` is the ingest base URL (e.g.
45
+ /// https://ingest.sitepong.com); `apiKey` is the project key.
46
+ public func start(endpoint: String, apiKey: String, projectId: String?, sessionId: String, platform: String = "ios") {
47
+ guard !running else { return }
48
+ self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
49
+ self.apiKey = apiKey
50
+ self.projectId = projectId
51
+ self.sessionId = sessionId
52
+ self.platform = platform
53
+ self.startedAt = Date()
54
+ self.running = true
55
+
56
+ // Global tap capture: a recognizer on the key window that never steals the
57
+ // touch (cancelsTouchesInView=false, simultaneous) — the native analogue
58
+ // of the RN touch wrapper.
59
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
60
+ guard let self = self, let w = self.keyWindow() else { return }
61
+ let g = UITapGestureRecognizer(target: self, action: #selector(self.onTap(_:)))
62
+ g.cancelsTouchesInView = false
63
+ g.delegate = SPStructuralTapDelegate.shared
64
+ w.addGestureRecognizer(g)
65
+ }
66
+ sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in self?.sample() }
67
+ flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
68
+
69
+ // Flush on background so the tail isn't stranded when timers suspend.
70
+ NotificationCenter.default.addObserver(
71
+ self, selector: #selector(onBackground),
72
+ name: UIApplication.didEnterBackgroundNotification, object: nil
73
+ )
74
+ }
75
+
76
+ @objc private func onBackground() {
77
+ guard running else { return }
78
+ flush()
79
+ }
80
+
81
+ public func setScreen(_ name: String) {
82
+ guard running, name != screen else { return }
83
+ sample()
84
+ events.append(["t": now(), "type": "screen", "name": name])
85
+ screen = name
86
+ }
87
+
88
+ public func stop() {
89
+ guard running else { return }
90
+ running = false
91
+ sampleTimer?.invalidate(); sampleTimer = nil
92
+ flushTimer?.invalidate(); flushTimer = nil
93
+ NotificationCenter.default.removeObserver(
94
+ self, name: UIApplication.didEnterBackgroundNotification, object: nil
95
+ )
96
+ flush()
97
+ }
98
+
99
+ private func idFor(_ v: UIView) -> Int {
100
+ let k = ObjectIdentifier(v)
101
+ if let id = ids[k] { return id }
102
+ let id = nextId; nextId += 1; ids[k] = id
103
+ return id
104
+ }
105
+
106
+ private func keyWindow() -> UIWindow? {
107
+ UIApplication.shared.connectedScenes
108
+ .compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }
109
+ .first(where: { $0.isKeyWindow }) ??
110
+ UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }.first
111
+ }
112
+
113
+ // MARK: - tree serialization
114
+ private func tagFor(_ v: UIView) -> String {
115
+ switch v {
116
+ case is UILabel: return "Text"
117
+ case is UIImageView: return "Image"
118
+ case is UITextField, is UITextView: return "TextInput"
119
+ case is UIScrollView: return "ScrollView"
120
+ case is UIButton, is UIControl: return "Pressable"
121
+ default: return "View"
122
+ }
123
+ }
124
+
125
+ private func hex(_ c: UIColor?) -> String? {
126
+ guard let c = c else { return nil }
127
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
128
+ guard c.getRed(&r, green: &g, blue: &b, alpha: &a), a > 0.01 else { return nil }
129
+ return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255))
130
+ }
131
+
132
+ private func textFor(_ v: UIView) -> String? {
133
+ if let l = v as? UILabel { return l.text }
134
+ if let b = v as? UIButton { return b.title(for: .normal) ?? b.titleLabel?.text }
135
+ if let t = v as? UITextField { return t.isSecureTextEntry ? nil : t.text }
136
+ return nil
137
+ }
138
+
139
+ private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
140
+ if v.isHidden || v.alpha < 0.02 { return nil }
141
+ let f = v.convert(v.bounds, to: window)
142
+ var attrs: [String: String] = [:]
143
+ if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
144
+ if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
145
+ if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
146
+ if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
147
+ if let secure = (v as? UITextField)?.isSecureTextEntry, secure { attrs["wtSensitive"] = "true" }
148
+ if let l = v as? UILabel {
149
+ attrs["color"] = hex(l.textColor) ?? "#111111"
150
+ attrs["fontSize"] = String(Int(l.font.pointSize))
151
+ }
152
+ attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
153
+ let tag = tagFor(v)
154
+ var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
155
+ if let t = textFor(v), !t.isEmpty { node["text"] = t }
156
+ // Don't descend into text/controls' internal subviews (keeps the tree clean).
157
+ if !(v is UILabel) && !(v is UIButton) {
158
+ var kids: [[String: Any]] = []
159
+ for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
160
+ node["children"] = kids
161
+ } else {
162
+ node["children"] = []
163
+ }
164
+ return node
165
+ }
166
+
167
+ private func sample() {
168
+ guard running, let w = keyWindow(), let root = serialize(w, in: w) else { return }
169
+ events.append(["t": now(), "type": "snapshot", "tree": root])
170
+ }
171
+
172
+ @objc private func onTap(_ g: UITapGestureRecognizer) {
173
+ guard running, let w = keyWindow() else { return }
174
+ let p = g.location(in: w)
175
+ let hit = w.hitTest(p, with: nil)
176
+ sample()
177
+ var nodeId = 0
178
+ if let hit = hit { nodeId = idFor(deepestInteresting(hit)) }
179
+ let bounds = w.bounds
180
+ events.append([
181
+ "t": now(), "type": "tap", "nodeId": nodeId,
182
+ "x": Double(p.x / max(bounds.width, 1)), "y": Double(p.y / max(bounds.height, 1)),
183
+ ])
184
+ }
185
+
186
+ private func deepestInteresting(_ v: UIView) -> UIView {
187
+ var cur: UIView? = v
188
+ while let c = cur {
189
+ if c.accessibilityIdentifier?.isEmpty == false { return c }
190
+ if c is UIControl { return c }
191
+ cur = c.superview
192
+ }
193
+ return v
194
+ }
195
+
196
+ /// Ship the pending event delta (only what's accumulated since the last
197
+ /// confirmed flush), then clear the device buffer. The batch carries its
198
+ /// `seq_start` so the server dedups retries; a failed batch is re-queued so
199
+ /// nothing is lost offline. All buffer mutation stays on the main thread.
200
+ private func flush() {
201
+ guard !endpoint.isEmpty, !sending, !events.isEmpty, let w = keyWindow() else { return }
202
+ let batch = events
203
+ let seqStart = flushedEventCount
204
+ events.removeAll(keepingCapacity: true) // drain the device buffer
205
+ sending = true
206
+ post(batch: batch, seqStart: seqStart,
207
+ viewport: (Int(w.bounds.width), Int(w.bounds.height)), attempt: 0)
208
+ }
209
+
210
+ private func post(batch: [[String: Any]], seqStart: Int, viewport: (Int, Int), attempt: Int) {
211
+ let payload: [String: Any] = [
212
+ "session_id": sessionId,
213
+ "platform": platform,
214
+ "viewport": ["w": viewport.0, "h": viewport.1],
215
+ "seq_start": seqStart,
216
+ "events": batch,
217
+ ]
218
+ guard let body = try? JSONSerialization.data(withJSONObject: payload),
219
+ let url = URL(string: "\(endpoint)/api/replay-stream/native") else {
220
+ DispatchQueue.main.async { self.sending = false }
221
+ return
222
+ }
223
+ var req = URLRequest(url: url)
224
+ req.httpMethod = "POST"
225
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
226
+ req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
227
+ if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
228
+ req.httpBody = body
229
+
230
+ URLSession.shared.dataTask(with: req) { [weak self] _, resp, err in
231
+ guard let self = self else { return }
232
+ let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
233
+ DispatchQueue.main.async {
234
+ if ok && err == nil {
235
+ self.flushedEventCount = seqStart + batch.count
236
+ self.sending = false
237
+ } else if attempt < 3 {
238
+ let delay = pow(2.0, Double(attempt)) // 1,2,4s
239
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
240
+ self.post(batch: batch, seqStart: seqStart, viewport: viewport, attempt: attempt + 1)
241
+ }
242
+ } else {
243
+ // Give up on this attempt: re-queue at the front so the seq
244
+ // stays contiguous and the delta re-sends on the next flush.
245
+ self.events.insert(contentsOf: batch, at: 0)
246
+ self.sending = false
247
+ }
248
+ }
249
+ }.resume()
250
+ }
251
+ }
252
+
253
+ /// Allow the capture recognizer to run alongside buttons/scroll gestures.
254
+ final class SPStructuralTapDelegate: NSObject, UIGestureRecognizerDelegate {
255
+ static let shared = SPStructuralTapDelegate()
256
+ func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
257
+ func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { true }
258
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitepong",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Official SitePong SDK for error tracking and monitoring",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -157,22 +157,23 @@
157
157
  "web-vitals": "^4.2.4"
158
158
  },
159
159
  "devDependencies": {
160
+ "@react-native-async-storage/async-storage": "^2.2.0",
161
+ "@sitepong/capture-core": "*",
162
+ "@sitepong/watchtower-recorder": "*",
160
163
  "@types/express": "^5.0.6",
161
164
  "@types/node": "^20.10.0",
162
165
  "@types/react": "^19.2.9",
163
166
  "@typescript-eslint/eslint-plugin": "^6.13.0",
164
167
  "@typescript-eslint/parser": "^6.13.0",
165
168
  "eslint": "^8.55.0",
169
+ "expo-modules-core": "~3.0.0",
170
+ "expo-sqlite": "~16.0.4",
166
171
  "next": "^16.1.4",
167
172
  "react": "^19.2.3",
173
+ "react-native": "0.83.2",
168
174
  "tsup": "^8.0.1",
169
175
  "typescript": "^5.3.0",
170
- "vitest": "^1.0.0",
171
- "react-native": "0.83.2",
172
- "@react-native-async-storage/async-storage": "^2.2.0",
173
- "expo-sqlite": "~16.0.4",
174
- "expo-modules-core": "~3.0.0",
175
- "@sitepong/capture-core": "*"
176
+ "vitest": "^1.0.0"
176
177
  },
177
178
  "engines": {
178
179
  "node": ">=16.0.0"