sitepong 0.2.7 → 0.2.8

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,199 @@
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
+ private func now() -> Int { Int(Date().timeIntervalSince(startedAt) * 1000) }
35
+
36
+ /// Start capturing. `endpoint` is the ingest base URL (e.g.
37
+ /// https://ingest.sitepong.com); `apiKey` is the project key.
38
+ public func start(endpoint: String, apiKey: String, projectId: String?, sessionId: String, platform: String = "ios") {
39
+ guard !running else { return }
40
+ self.endpoint = endpoint.hasSuffix("/") ? String(endpoint.dropLast()) : endpoint
41
+ self.apiKey = apiKey
42
+ self.projectId = projectId
43
+ self.sessionId = sessionId
44
+ self.platform = platform
45
+ self.startedAt = Date()
46
+ self.running = true
47
+
48
+ // Global tap capture: a recognizer on the key window that never steals the
49
+ // touch (cancelsTouchesInView=false, simultaneous) — the native analogue
50
+ // of the RN touch wrapper.
51
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
52
+ guard let self = self, let w = self.keyWindow() else { return }
53
+ let g = UITapGestureRecognizer(target: self, action: #selector(self.onTap(_:)))
54
+ g.cancelsTouchesInView = false
55
+ g.delegate = SPStructuralTapDelegate.shared
56
+ w.addGestureRecognizer(g)
57
+ }
58
+ sampleTimer = Timer.scheduledTimer(withTimeInterval: 0.12, repeats: true) { [weak self] _ in self?.sample() }
59
+ flushTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: true) { [weak self] _ in self?.flush() }
60
+ }
61
+
62
+ public func setScreen(_ name: String) {
63
+ guard running, name != screen else { return }
64
+ sample()
65
+ events.append(["t": now(), "type": "screen", "name": name])
66
+ screen = name
67
+ }
68
+
69
+ public func stop() {
70
+ guard running else { return }
71
+ running = false
72
+ sampleTimer?.invalidate(); sampleTimer = nil
73
+ flushTimer?.invalidate(); flushTimer = nil
74
+ flush()
75
+ }
76
+
77
+ private func idFor(_ v: UIView) -> Int {
78
+ let k = ObjectIdentifier(v)
79
+ if let id = ids[k] { return id }
80
+ let id = nextId; nextId += 1; ids[k] = id
81
+ return id
82
+ }
83
+
84
+ private func keyWindow() -> UIWindow? {
85
+ UIApplication.shared.connectedScenes
86
+ .compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }
87
+ .first(where: { $0.isKeyWindow }) ??
88
+ UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.flatMap { $0.windows }.first
89
+ }
90
+
91
+ // MARK: - tree serialization
92
+ private func tagFor(_ v: UIView) -> String {
93
+ switch v {
94
+ case is UILabel: return "Text"
95
+ case is UIImageView: return "Image"
96
+ case is UITextField, is UITextView: return "TextInput"
97
+ case is UIScrollView: return "ScrollView"
98
+ case is UIButton, is UIControl: return "Pressable"
99
+ default: return "View"
100
+ }
101
+ }
102
+
103
+ private func hex(_ c: UIColor?) -> String? {
104
+ guard let c = c else { return nil }
105
+ var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
106
+ guard c.getRed(&r, green: &g, blue: &b, alpha: &a), a > 0.01 else { return nil }
107
+ return String(format: "#%02x%02x%02x", Int(r * 255), Int(g * 255), Int(b * 255))
108
+ }
109
+
110
+ private func textFor(_ v: UIView) -> String? {
111
+ if let l = v as? UILabel { return l.text }
112
+ if let b = v as? UIButton { return b.title(for: .normal) ?? b.titleLabel?.text }
113
+ if let t = v as? UITextField { return t.isSecureTextEntry ? nil : t.text }
114
+ return nil
115
+ }
116
+
117
+ private func serialize(_ v: UIView, in window: UIWindow) -> [String: Any]? {
118
+ if v.isHidden || v.alpha < 0.02 { return nil }
119
+ let f = v.convert(v.bounds, to: window)
120
+ var attrs: [String: String] = [:]
121
+ if let aid = v.accessibilityIdentifier, !aid.isEmpty { attrs["testID"] = aid }
122
+ if let al = v.accessibilityLabel, !al.isEmpty { attrs["accessibilityLabel"] = al }
123
+ if let bg = hex(v.backgroundColor) { attrs["backgroundColor"] = bg }
124
+ if v.layer.cornerRadius > 0 { attrs["borderRadius"] = String(Int(v.layer.cornerRadius)) }
125
+ if let secure = (v as? UITextField)?.isSecureTextEntry, secure { attrs["wtSensitive"] = "true" }
126
+ if let l = v as? UILabel {
127
+ attrs["color"] = hex(l.textColor) ?? "#111111"
128
+ attrs["fontSize"] = String(Int(l.font.pointSize))
129
+ }
130
+ attrs["frame"] = "\(Int(f.origin.x)),\(Int(f.origin.y)),\(Int(f.size.width)),\(Int(f.size.height))"
131
+ let tag = tagFor(v)
132
+ var node: [String: Any] = ["id": idFor(v), "tag": tag, "attrs": attrs]
133
+ if let t = textFor(v), !t.isEmpty { node["text"] = t }
134
+ // Don't descend into text/controls' internal subviews (keeps the tree clean).
135
+ if !(v is UILabel) && !(v is UIButton) {
136
+ var kids: [[String: Any]] = []
137
+ for sub in v.subviews { if let s = serialize(sub, in: window) { kids.append(s) } }
138
+ node["children"] = kids
139
+ } else {
140
+ node["children"] = []
141
+ }
142
+ return node
143
+ }
144
+
145
+ private func sample() {
146
+ guard running, let w = keyWindow(), let root = serialize(w, in: w) else { return }
147
+ events.append(["t": now(), "type": "snapshot", "tree": root])
148
+ }
149
+
150
+ @objc private func onTap(_ g: UITapGestureRecognizer) {
151
+ guard running, let w = keyWindow() else { return }
152
+ let p = g.location(in: w)
153
+ let hit = w.hitTest(p, with: nil)
154
+ sample()
155
+ var nodeId = 0
156
+ if let hit = hit { nodeId = idFor(deepestInteresting(hit)) }
157
+ let bounds = w.bounds
158
+ events.append([
159
+ "t": now(), "type": "tap", "nodeId": nodeId,
160
+ "x": Double(p.x / max(bounds.width, 1)), "y": Double(p.y / max(bounds.height, 1)),
161
+ ])
162
+ }
163
+
164
+ private func deepestInteresting(_ v: UIView) -> UIView {
165
+ var cur: UIView? = v
166
+ while let c = cur {
167
+ if c.accessibilityIdentifier?.isEmpty == false { return c }
168
+ if c is UIControl { return c }
169
+ cur = c.superview
170
+ }
171
+ return v
172
+ }
173
+
174
+ private func flush() {
175
+ guard !endpoint.isEmpty, !events.isEmpty, let w = keyWindow() else { return }
176
+ let payload: [String: Any] = [
177
+ "session_id": sessionId,
178
+ "platform": platform,
179
+ "viewport": ["w": Int(w.bounds.width), "h": Int(w.bounds.height)],
180
+ "events": events,
181
+ ]
182
+ guard let body = try? JSONSerialization.data(withJSONObject: payload),
183
+ let url = URL(string: "\(endpoint)/api/replay-stream/native") else { return }
184
+ var req = URLRequest(url: url)
185
+ req.httpMethod = "POST"
186
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
187
+ req.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
188
+ if let pid = projectId { req.setValue(pid, forHTTPHeaderField: "X-Project-ID") }
189
+ req.httpBody = body
190
+ URLSession.shared.dataTask(with: req).resume()
191
+ }
192
+ }
193
+
194
+ /// Allow the capture recognizer to run alongside buttons/scroll gestures.
195
+ final class SPStructuralTapDelegate: NSObject, UIGestureRecognizerDelegate {
196
+ static let shared = SPStructuralTapDelegate()
197
+ func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
198
+ func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { true }
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitepong",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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"