sitepong 0.2.3 → 0.2.4

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.
Files changed (57) hide show
  1. package/android/build.gradle +39 -0
  2. package/android/src/main/java/com/sitepong/deviceid/DeviceIdHelper.kt +45 -0
  3. package/android/src/main/java/com/sitepong/deviceid/DeviceIdModule.kt +45 -0
  4. package/android/src/main/java/com/sitepong/screenrecorder/ChunkUploader.kt +78 -0
  5. package/android/src/main/java/com/sitepong/screenrecorder/H264Encoder.kt +163 -0
  6. package/android/src/main/java/com/sitepong/screenrecorder/ScreenCapture.kt +103 -0
  7. package/android/src/main/java/com/sitepong/screenrecorder/ScreenRecorderModule.kt +80 -0
  8. package/android/src/main/java/com/sitepong/screenrecorder/SensitiveViewManager.kt +19 -0
  9. package/app.plugin.js +426 -0
  10. package/dist/cdn/sitepong.min.js +5 -5
  11. package/dist/cdn/sitepong.min.js.map +1 -1
  12. package/dist/entries/rn.d.ts +188 -16
  13. package/dist/entries/rn.js +188 -35
  14. package/dist/entries/rn.js.map +1 -1
  15. package/dist/entries/web.js +2 -1
  16. package/dist/entries/web.js.map +1 -1
  17. package/dist/entries/web.mjs +2 -1
  18. package/dist/entries/web.mjs.map +1 -1
  19. package/dist/index.d.mts +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +2 -1
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/react/index.js +2 -1
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/index.mjs +2 -1
  28. package/dist/react/index.mjs.map +1 -1
  29. package/expo-module.config.json +16 -0
  30. package/ios/DeviceId/DeviceIdModule.swift +55 -0
  31. package/ios/DeviceId/KeychainHelper.swift +68 -0
  32. package/ios/LiveActivity/ConfigStore.swift +43 -0
  33. package/ios/LiveActivity/DSLNode.swift +188 -0
  34. package/ios/LiveActivity/SitePongActivityAttributes.swift +112 -0
  35. package/ios/LiveActivity/SitePongLiveActivityModule.swift +250 -0
  36. package/ios/LiveActivity/widget/ColorHex.swift +30 -0
  37. package/ios/LiveActivity/widget/DSLAnimations.swift +128 -0
  38. package/ios/LiveActivity/widget/DSLRenderer.swift +538 -0
  39. package/ios/LiveActivity/widget/SitePongLiveActivityWidget.swift +393 -0
  40. package/ios/LiveActivity/widget/SitePongWidgetBundle.swift +84 -0
  41. package/ios/ScreenRecorder/ChunkUploader.swift +52 -0
  42. package/ios/ScreenRecorder/H264Encoder.swift +229 -0
  43. package/ios/ScreenRecorder/ScreenCapture.swift +149 -0
  44. package/ios/ScreenRecorder/ScreenRecorderModule.swift +179 -0
  45. package/ios/ScreenRecorder/SensitiveViewManager.swift +63 -0
  46. package/ios/Sitepong.podspec +57 -0
  47. package/ios/WatchtowerCore/Screenshotter.swift +163 -0
  48. package/ios/WatchtowerCore/SwiftUIModifiers.swift +78 -0
  49. package/ios/WatchtowerCore/Swizzling.swift +59 -0
  50. package/ios/WatchtowerCore/TapEvent.swift +61 -0
  51. package/ios/WatchtowerCore/Transport.swift +136 -0
  52. package/ios/WatchtowerCore/UIViewExtensions.swift +31 -0
  53. package/ios/WatchtowerCore/Watchtower.swift +80 -0
  54. package/ios/WatchtowerCore/WatchtowerEngine.swift +565 -0
  55. package/ios/WatchtowerCore/WatchtowerHash.swift +120 -0
  56. package/ios/WatchtowerCore/WatchtowerRegistry.swift +87 -0
  57. package/package.json +17 -10
@@ -0,0 +1,163 @@
1
+ #if canImport(UIKit)
2
+ import UIKit
3
+ import CoreImage
4
+
5
+ /// One-shot screenshot capture (§3.4) + redaction (§5).
6
+ /// Renders the key window at 0.5× via `UIGraphicsImageRenderer`, blurs sensitive
7
+ /// rects BEFORE hashing/upload, then hands back redacted RGBA + PNG.
8
+ enum Screenshotter {
9
+
10
+ static let captureScale: CGFloat = 0.5
11
+ static let blurRadius: Double = 16 // radius >= 16 px at capture scale (§5)
12
+
13
+ struct Capture {
14
+ let rgba: [UInt8]
15
+ let width: Int
16
+ let height: Int
17
+ let pngBase64: String
18
+ }
19
+
20
+ /// True when the window's layout is mid-animation and a captured frame would be
21
+ /// a transient in-between state rather than the settled, canonical screen (§3.4).
22
+ /// Covers: a scroll view actively dragging or decelerating (a swipe gesture whose
23
+ /// `.ended` fires mid-fling), and a view-controller push/pop transition in flight.
24
+ /// Used to defer template storage so dedup stays stable across runs.
25
+ static func isLayoutInFlight(in window: UIWindow) -> Bool {
26
+ if scrollViewAnimating(in: window) { return true }
27
+ if viewControllerTransitioning(window: window) { return true }
28
+ return false
29
+ }
30
+
31
+ private static func scrollViewAnimating(in view: UIView) -> Bool {
32
+ if let sv = view as? UIScrollView, sv.isDragging || sv.isDecelerating || sv.isZooming {
33
+ return true
34
+ }
35
+ for sub in view.subviews where scrollViewAnimating(in: sub) { return true }
36
+ return false
37
+ }
38
+
39
+ private static func viewControllerTransitioning(window: UIWindow) -> Bool {
40
+ guard let root = window.rootViewController else { return false }
41
+ return viewControllerTransitioning(root)
42
+ }
43
+
44
+ /// Recurse the FULL view-controller tree: children as well as the presented
45
+ /// chain. A React Native app nests its navigation controller (RNSScreen /
46
+ /// react-native-screens) inside the root RCTRootViewController as a CHILD,
47
+ /// so walking only root.presentedViewController misses every RN push/pop
48
+ /// transition and mid-slide frames get stored as canonical templates.
49
+ private static func viewControllerTransitioning(_ vc: UIViewController) -> Bool {
50
+ if vc.transitionCoordinator != nil { return true }
51
+ if let presented = vc.presentedViewController,
52
+ viewControllerTransitioning(presented) { return true }
53
+ for child in vc.children where viewControllerTransitioning(child) {
54
+ return true
55
+ }
56
+ return false
57
+ }
58
+
59
+ /// Collect sensitive rects (window space) for a window:
60
+ /// secureTextEntry fields + watchtowerSensitive UIViews + SwiftUI .watchtowerSensitive().
61
+ static func sensitiveRects(in window: UIWindow) -> [CGRect] {
62
+ var rects: [CGRect] = []
63
+ func walk(_ view: UIView) {
64
+ var isSensitive = view.watchtowerSensitive
65
+ if let tf = view as? UITextField, tf.isSecureTextEntry { isSensitive = true }
66
+ if isSensitive {
67
+ rects.append(view.convert(view.bounds, to: window))
68
+ }
69
+ for sub in view.subviews { walk(sub) }
70
+ }
71
+ walk(window)
72
+ rects.append(contentsOf: WatchtowerRegistry.shared.sensitiveRects(in: window))
73
+ return rects
74
+ }
75
+
76
+ struct RawCapture {
77
+ let image: UIImage
78
+ let sensitiveRects: [CGRect]
79
+ let scale: CGFloat
80
+ }
81
+
82
+ /// Main-thread-only work: raw render (UIKit drawing) + sensitive-rect walk.
83
+ /// Everything downstream (blur, RGBA extraction, hashing, PNG encode) runs
84
+ /// off-main via `redact(_:)` — never call this inside the touch-delivery
85
+ /// call stack; hop to the next runloop turn first.
86
+ static func renderRaw(window: UIWindow) -> RawCapture? {
87
+ let bounds = window.bounds
88
+ guard bounds.width > 0, bounds.height > 0 else { return nil }
89
+
90
+ let format = UIGraphicsImageRendererFormat()
91
+ format.scale = captureScale
92
+ format.opaque = true
93
+ let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
94
+
95
+ let rects = sensitiveRects(in: window)
96
+
97
+ let image = renderer.image { _ in
98
+ window.drawHierarchy(in: bounds, afterScreenUpdates: false)
99
+ }
100
+ return RawCapture(image: image, sensitiveRects: rects, scale: captureScale)
101
+ }
102
+
103
+ /// Blur the sensitive rects of a raw capture (§5). CoreImage only — safe to
104
+ /// run off the main thread.
105
+ static func redact(_ raw: RawCapture) -> UIImage {
106
+ guard !raw.sensitiveRects.isEmpty else { return raw.image }
107
+ return blur(image: raw.image, rects: raw.sensitiveRects, captureScale: raw.scale)
108
+ }
109
+
110
+ private static func blur(image: UIImage, rects: [CGRect], captureScale: CGFloat) -> UIImage {
111
+ guard let cgImage = image.cgImage else { return image }
112
+ let ciContext = CIContext(options: nil)
113
+ let pixelW = cgImage.width
114
+ let pixelH = cgImage.height
115
+ let base = CIImage(cgImage: cgImage)
116
+
117
+ // Build a fully-blurred version of the whole image once.
118
+ let clamped = base.clampedToExtent()
119
+ guard let blurFilter = CIFilter(name: "CIGaussianBlur") else { return image }
120
+ blurFilter.setValue(clamped, forKey: kCIInputImageKey)
121
+ blurFilter.setValue(blurRadius, forKey: kCIInputRadiusKey)
122
+ guard let blurredFull = blurFilter.outputImage?.cropped(to: base.extent) else { return image }
123
+
124
+ // Composite: start with sharp base, paste blurred patches over sensitive rects.
125
+ var composite = base
126
+ for rect in rects {
127
+ // Convert window-space (points) rect to pixel space (origin top-left ->
128
+ // CoreImage origin bottom-left).
129
+ let px = rect.origin.x * captureScale
130
+ let pyTop = rect.origin.y * captureScale
131
+ let pw = rect.size.width * captureScale
132
+ let ph = rect.size.height * captureScale
133
+ let pyBottomLeft = CGFloat(pixelH) - pyTop - ph
134
+ let pixelRect = CGRect(x: px, y: pyBottomLeft, width: pw, height: ph)
135
+ .intersection(CGRect(x: 0, y: 0, width: pixelW, height: pixelH))
136
+ guard !pixelRect.isNull, pixelRect.width > 0, pixelRect.height > 0 else { continue }
137
+
138
+ let patch = blurredFull.cropped(to: pixelRect)
139
+ composite = patch.composited(over: composite)
140
+ }
141
+
142
+ guard let outCG = ciContext.createCGImage(composite, from: base.extent) else { return image }
143
+ return UIImage(cgImage: outCG)
144
+ }
145
+
146
+ /// Extract tightly-packed RGBA bytes from a CGImage.
147
+ static func rgbaBytes(from image: UIImage) -> (rgba: [UInt8], width: Int, height: Int)? {
148
+ guard let cg = image.cgImage else { return nil }
149
+ let width = cg.width
150
+ let height = cg.height
151
+ var data = [UInt8](repeating: 0, count: width * height * 4)
152
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
153
+ let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
154
+ guard let ctx = CGContext(
155
+ data: &data, width: width, height: height,
156
+ bitsPerComponent: 8, bytesPerRow: width * 4,
157
+ space: colorSpace, bitmapInfo: bitmapInfo
158
+ ) else { return nil }
159
+ ctx.draw(cg, in: CGRect(x: 0, y: 0, width: width, height: height))
160
+ return (data, width, height)
161
+ }
162
+ }
163
+ #endif
@@ -0,0 +1,78 @@
1
+ #if canImport(SwiftUI) && canImport(UIKit)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ /// Internal reporter view that publishes its window-space frame into the
6
+ /// registry whenever layout changes, then resolves cleanly on disappear.
7
+ private struct WatchtowerReporter: UIViewRepresentable {
8
+ let id: String?
9
+ let screenName: String?
10
+ let sensitive: Bool
11
+
12
+ func makeUIView(context: Context) -> ReporterView {
13
+ let v = ReporterView()
14
+ v.isUserInteractionEnabled = false
15
+ v.backgroundColor = .clear
16
+ v.tagId = id
17
+ v.screenName = screenName
18
+ v.sensitive = sensitive
19
+ return v
20
+ }
21
+
22
+ func updateUIView(_ uiView: ReporterView, context: Context) {
23
+ uiView.tagId = id
24
+ uiView.screenName = screenName
25
+ uiView.sensitive = sensitive
26
+ uiView.publish()
27
+ }
28
+
29
+ static func dismantleUIView(_ uiView: ReporterView, coordinator: ()) {
30
+ WatchtowerRegistry.shared.remove(token: ObjectIdentifier(uiView))
31
+ }
32
+
33
+ final class ReporterView: UIView {
34
+ var tagId: String?
35
+ var screenName: String?
36
+ var sensitive: Bool = false
37
+
38
+ override func didMoveToWindow() {
39
+ super.didMoveToWindow()
40
+ publish()
41
+ }
42
+
43
+ override func layoutSubviews() {
44
+ super.layoutSubviews()
45
+ publish()
46
+ }
47
+
48
+ func publish() {
49
+ guard let window = window, let superview = superview else { return }
50
+ // The reporter is a zero-or-overlay view; report the *superview*'s
51
+ // frame in window space, which corresponds to the modified content.
52
+ let frame = superview.convert(superview.bounds, to: window)
53
+ let region = TaggedRegion(id: tagId, screenName: screenName,
54
+ sensitive: sensitive, frameInWindow: frame,
55
+ window: window)
56
+ WatchtowerRegistry.shared.update(token: ObjectIdentifier(self), region: region)
57
+ }
58
+ }
59
+ }
60
+
61
+ public extension View {
62
+ /// Declares the enclosing SwiftUI subtree as a named screen (§3.2).
63
+ func watchtowerScreen(_ name: String) -> some View {
64
+ self.background(WatchtowerReporter(id: nil, screenName: name, sensitive: false))
65
+ }
66
+
67
+ /// Tags this view with a stable element id used as `element_id` (§3.3).
68
+ func watchtowerTag(_ id: String) -> some View {
69
+ self.background(WatchtowerReporter(id: id, screenName: nil, sensitive: false))
70
+ }
71
+
72
+ /// Marks this view's region as sensitive; it is blurred before hashing /
73
+ /// upload (§5).
74
+ func watchtowerSensitive() -> some View {
75
+ self.background(WatchtowerReporter(id: nil, screenName: nil, sensitive: true))
76
+ }
77
+ }
78
+ #endif
@@ -0,0 +1,59 @@
1
+ #if canImport(UIKit)
2
+ import UIKit
3
+ import ObjectiveC
4
+
5
+ /// Centralized method swizzling for UIWindow.sendEvent and
6
+ /// UIViewController.viewDidAppear. Installed once, idempotent. All capture work
7
+ /// is wrapped so it can never crash the host app.
8
+ enum Swizzling {
9
+ private static var installed = false
10
+
11
+ static func installIfNeeded() {
12
+ guard !installed else { return }
13
+ installed = true
14
+ swizzle(
15
+ cls: UIWindow.self,
16
+ original: #selector(UIWindow.sendEvent(_:)),
17
+ swizzled: #selector(UIWindow.wt_sendEvent(_:))
18
+ )
19
+ swizzle(
20
+ cls: UIViewController.self,
21
+ original: #selector(UIViewController.viewDidAppear(_:)),
22
+ swizzled: #selector(UIViewController.wt_viewDidAppear(_:))
23
+ )
24
+ }
25
+
26
+ private static func swizzle(cls: AnyClass, original: Selector, swizzled: Selector) {
27
+ guard let orig = class_getInstanceMethod(cls, original),
28
+ let swiz = class_getInstanceMethod(cls, swizzled) else { return }
29
+ let didAdd = class_addMethod(cls, original,
30
+ method_getImplementation(swiz),
31
+ method_getTypeEncoding(swiz))
32
+ if didAdd {
33
+ class_replaceMethod(cls, swizzled,
34
+ method_getImplementation(orig),
35
+ method_getTypeEncoding(orig))
36
+ } else {
37
+ method_exchangeImplementations(orig, swiz)
38
+ }
39
+ }
40
+ }
41
+
42
+ extension UIWindow {
43
+ @objc func wt_sendEvent(_ event: UIEvent) {
44
+ // Call original first (this is the swizzled-in original IMP).
45
+ self.wt_sendEvent(event)
46
+ // Capture is best-effort and must never throw / crash.
47
+ guard WatchtowerEngine.shared.isRunning else { return }
48
+ WatchtowerEngine.shared.handle(event: event, in: self)
49
+ }
50
+ }
51
+
52
+ extension UIViewController {
53
+ @objc func wt_viewDidAppear(_ animated: Bool) {
54
+ self.wt_viewDidAppear(animated)
55
+ guard WatchtowerEngine.shared.isRunning else { return }
56
+ WatchtowerEngine.shared.didAppear(viewController: self)
57
+ }
58
+ }
59
+ #endif
@@ -0,0 +1,61 @@
1
+ import Foundation
2
+
3
+ /// A single capture event — the tagged union from spec §1.1. `type` selects
4
+ /// which optional fields are populated ("tap" | "screen_view" | "session_start"
5
+ /// | "session_end"). Synthesized Codable omits nil optionals on the wire.
6
+ public struct CaptureEvent: Codable {
7
+ // Common fields (every type)
8
+ public var type: String
9
+ public var session_id: String
10
+ public var sequence: UInt64
11
+ public var timestamp: String
12
+ public var platform: String
13
+ public var app_version: String?
14
+ public var distinct_id: String?
15
+ public var device_model: String?
16
+ public var os_version: String?
17
+
18
+ // screen_view
19
+ public var screen_name: String?
20
+ public var prev_screen_name: String?
21
+
22
+ // session_end
23
+ public var duration_ms: UInt64?
24
+ public var reason: String?
25
+
26
+ // tap
27
+ public var screen_fp: String?
28
+ public var element_id: String?
29
+ public var element_label: String?
30
+ public var element_role: String?
31
+ public var x: Double?
32
+ public var y: Double?
33
+ public var viewport_w: UInt?
34
+ public var viewport_h: UInt?
35
+ public var frame_hash: String?
36
+ /// Client-side heuristic (§1.1): tap produced no observable response.
37
+ /// nil when false or undetermined — never sent as false.
38
+ public var dead: Bool?
39
+ }
40
+
41
+ struct TapsBatch: Codable {
42
+ let events: [CaptureEvent]
43
+ }
44
+
45
+ struct TemplateUpload: Codable {
46
+ let route_name: String
47
+ let p_hash: String
48
+ let width: Int
49
+ let height: Int
50
+ let image_base64: String
51
+ }
52
+
53
+ struct FrameUpload: Codable {
54
+ let session_id: String
55
+ let timestamp: String
56
+ let screen_name: String
57
+ let frame_hash: String
58
+ let width: Int
59
+ let height: Int
60
+ let image_base64: String
61
+ }
@@ -0,0 +1,136 @@
1
+ import Foundation
2
+
3
+ /// URLSession-based transport: batches tap events (§1.3) and uploads screenshot
4
+ /// templates on a miss (§1.2). All work is dispatched onto a private serial
5
+ /// queue; failures retry with backoff and drop after N attempts. Never throws to
6
+ /// the host app.
7
+ final class Transport {
8
+ private let apiKey: String
9
+ private let projectId: String
10
+ private let endpoint: URL
11
+ private let session: URLSession
12
+ private let queue = DispatchQueue(label: "com.watchtower.transport")
13
+
14
+ private var buffer: [CaptureEvent] = []
15
+ private var flushTimer: DispatchSourceTimer?
16
+
17
+ private let batchSize = 50
18
+ private let flushInterval: TimeInterval = 10
19
+ private let maxRetries = 3
20
+
21
+ init(apiKey: String, projectId: String, endpoint: URL) {
22
+ self.apiKey = apiKey
23
+ self.projectId = projectId
24
+ self.endpoint = endpoint
25
+ let config = URLSessionConfiguration.default
26
+ config.timeoutIntervalForRequest = 15
27
+ self.session = URLSession(configuration: config)
28
+ startTimer()
29
+ }
30
+
31
+ private func startTimer() {
32
+ let t = DispatchSource.makeTimerSource(queue: queue)
33
+ t.schedule(deadline: .now() + flushInterval, repeating: flushInterval)
34
+ t.setEventHandler { [weak self] in self?.flushLocked() }
35
+ t.resume()
36
+ flushTimer = t
37
+ }
38
+
39
+ func enqueue(_ event: CaptureEvent) {
40
+ queue.async { [weak self] in
41
+ guard let self = self else { return }
42
+ self.buffer.append(event)
43
+ if self.buffer.count >= self.batchSize {
44
+ self.flushLocked()
45
+ }
46
+ }
47
+ }
48
+
49
+ /// Flush remaining events (called on background / teardown).
50
+ func flush() {
51
+ queue.async { [weak self] in self?.flushLocked() }
52
+ }
53
+
54
+ /// Synchronous flush used at teardown to give pending events a chance to send.
55
+ func flushAndWait(timeout: TimeInterval = 3) {
56
+ let sem = DispatchSemaphore(value: 0)
57
+ queue.async { [weak self] in
58
+ self?.flushLocked()
59
+ sem.signal()
60
+ }
61
+ _ = sem.wait(timeout: .now() + timeout)
62
+ }
63
+
64
+ private func flushLocked() {
65
+ guard !buffer.isEmpty else { return }
66
+ let batch = buffer
67
+ buffer.removeAll(keepingCapacity: true)
68
+ postTaps(batch, attempt: 0)
69
+ }
70
+
71
+ private func headers(for request: inout URLRequest) {
72
+ request.setValue(projectId, forHTTPHeaderField: "X-Project-ID")
73
+ request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
74
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
75
+ }
76
+
77
+ private func postTaps(_ events: [CaptureEvent], attempt: Int) {
78
+ guard let data = try? JSONEncoder().encode(TapsBatch(events: events)) else { return }
79
+ var req = URLRequest(url: endpoint.appendingPathComponent("api/taps"))
80
+ req.httpMethod = "POST"
81
+ headers(for: &req)
82
+ req.httpBody = data
83
+
84
+ session.dataTask(with: req) { [weak self] _, resp, err in
85
+ guard let self = self else { return }
86
+ let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
87
+ if !ok || err != nil {
88
+ if attempt < self.maxRetries {
89
+ let delay = pow(2.0, Double(attempt)) // 1,2,4s
90
+ self.queue.asyncAfter(deadline: .now() + delay) {
91
+ self.postTaps(events, attempt: attempt + 1)
92
+ }
93
+ }
94
+ // else: drop silently (never crash the host).
95
+ }
96
+ }.resume()
97
+ }
98
+
99
+ /// Upload a redacted screenshot template. Calls `completion(isNew)` on success.
100
+ func postTemplate(routeName: String, pHash: String, width: Int, height: Int,
101
+ pngBase64: String, completion: ((Bool) -> Void)? = nil) {
102
+ let payload = TemplateUpload(route_name: routeName, p_hash: pHash,
103
+ width: width, height: height, image_base64: pngBase64)
104
+ guard let data = try? JSONEncoder().encode(payload) else { completion?(false); return }
105
+ var req = URLRequest(url: endpoint.appendingPathComponent("api/taps/template"))
106
+ req.httpMethod = "POST"
107
+ headers(for: &req)
108
+ req.httpBody = data
109
+
110
+ session.dataTask(with: req) { data, resp, _ in
111
+ let ok = (resp as? HTTPURLResponse).map { (200..<300).contains($0.statusCode) } ?? false
112
+ var isNew = false
113
+ if ok, let data = data,
114
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
115
+ isNew = (obj["isNew"] as? Bool) ?? false
116
+ }
117
+ completion?(ok ? isNew : false)
118
+ }.resume()
119
+ }
120
+
121
+ /// Upload a Layer-3 sampled session frame (§1.4). Fire-and-forget: frames
122
+ /// are best-effort and never retried (they are replay enrichment, not the
123
+ /// canonical record).
124
+ func postFrame(sessionId: String, timestamp: String, screenName: String,
125
+ frameHash: String, width: Int, height: Int, pngBase64: String) {
126
+ let payload = FrameUpload(session_id: sessionId, timestamp: timestamp,
127
+ screen_name: screenName, frame_hash: frameHash,
128
+ width: width, height: height, image_base64: pngBase64)
129
+ guard let data = try? JSONEncoder().encode(payload) else { return }
130
+ var req = URLRequest(url: endpoint.appendingPathComponent("api/frames"))
131
+ req.httpMethod = "POST"
132
+ headers(for: &req)
133
+ req.httpBody = data
134
+ session.dataTask(with: req).resume()
135
+ }
136
+ }
@@ -0,0 +1,31 @@
1
+ #if canImport(UIKit)
2
+ import UIKit
3
+ import ObjectiveC
4
+
5
+ private var watchtowerTagKey: UInt8 = 0
6
+ private var watchtowerSensitiveKey: UInt8 = 0
7
+
8
+ public extension UIView {
9
+ /// A stable element id for tap attribution (§3.3, resolution order #2).
10
+ var watchtowerTag: String? {
11
+ get { objc_getAssociatedObject(self, &watchtowerTagKey) as? String }
12
+ set { objc_setAssociatedObject(self, &watchtowerTagKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) }
13
+ }
14
+
15
+ /// Marks this UIKit view as sensitive; blurred before hashing / upload (§5).
16
+ var watchtowerSensitive: Bool {
17
+ get { (objc_getAssociatedObject(self, &watchtowerSensitiveKey) as? NSNumber)?.boolValue ?? false }
18
+ set { objc_setAssociatedObject(self, &watchtowerSensitiveKey, NSNumber(value: newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
19
+ }
20
+ }
21
+
22
+ private var watchtowerScreenNameKey: UInt8 = 0
23
+
24
+ public extension UIViewController {
25
+ /// Explicit screen identity override (§3.2, highest precedence).
26
+ var watchtowerScreenName: String? {
27
+ get { objc_getAssociatedObject(self, &watchtowerScreenNameKey) as? String }
28
+ set { objc_setAssociatedObject(self, &watchtowerScreenNameKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) }
29
+ }
30
+ }
31
+ #endif
@@ -0,0 +1,80 @@
1
+ import Foundation
2
+
3
+ /// Public entrypoint for the Watchtower capture engine (spec §3).
4
+ /// Bridge-free: zero React/RN imports. All methods are exception-safe and a
5
+ /// no-op if `start` was not called (except `start` itself).
6
+ public enum Watchtower {
7
+
8
+ /// Begin capturing taps + gated screenshots and uploading to `endpoint`.
9
+ ///
10
+ /// `platform` stamps the `platform` field on every emitted TapEvent (spec
11
+ /// §1.1). It defaults to `"ios"` for native hosts; the React Native bridge
12
+ /// (§4) passes `"react-native-ios"`. This is the only host-overridable
13
+ /// identity knob — capture logic is identical regardless of platform.
14
+ public static func start(apiKey: String, projectId: String,
15
+ endpoint: URL, sampleRate: Double = 0.1,
16
+ platform: String = "ios") {
17
+ #if canImport(UIKit)
18
+ // Ensure UIKit work happens on the main thread.
19
+ if Thread.isMainThread {
20
+ WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
21
+ endpoint: endpoint, sampleRate: sampleRate,
22
+ platform: platform)
23
+ } else {
24
+ DispatchQueue.main.async {
25
+ WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
26
+ endpoint: endpoint, sampleRate: sampleRate,
27
+ platform: platform)
28
+ }
29
+ }
30
+ #endif
31
+ }
32
+
33
+ public static func stop() {
34
+ #if canImport(UIKit)
35
+ WatchtowerEngine.shared.stop()
36
+ #endif
37
+ }
38
+
39
+ /// Explicit screen identity, overrides auto-detection until the next call
40
+ /// (or the next UIKit screen appearance).
41
+ public static func setScreen(_ name: String) {
42
+ #if canImport(UIKit)
43
+ WatchtowerEngine.shared.setScreen(name)
44
+ #endif
45
+ }
46
+
47
+ /// Force a one-shot capture of the current screen (gated on dedup).
48
+ public static func captureScreenshot() {
49
+ #if canImport(UIKit)
50
+ if Thread.isMainThread {
51
+ WatchtowerEngine.shared.captureScreenshot()
52
+ } else {
53
+ DispatchQueue.main.async { WatchtowerEngine.shared.captureScreenshot() }
54
+ }
55
+ #endif
56
+ }
57
+
58
+ /// Identity (§1.1 distinct_id): stamped on every subsequent event. Pass nil
59
+ /// to return to anonymous. The RN layer feeds the SDK's identify() through
60
+ /// this; native hosts call it directly.
61
+ public static func setUser(_ distinctId: String?) {
62
+ #if canImport(UIKit)
63
+ if Thread.isMainThread {
64
+ WatchtowerEngine.shared.setUser(distinctId)
65
+ } else {
66
+ DispatchQueue.main.async { WatchtowerEngine.shared.setUser(distinctId) }
67
+ }
68
+ #endif
69
+ }
70
+
71
+ /// The engine's current session id, or nil when not running. Attach this to
72
+ /// the host SDK's error/analytics events — it is the replay/error join key.
73
+ public static var sessionId: String? {
74
+ #if canImport(UIKit)
75
+ return WatchtowerEngine.shared.currentSessionId
76
+ #else
77
+ return nil
78
+ #endif
79
+ }
80
+ }