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.
- package/android/build.gradle +39 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdHelper.kt +45 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdModule.kt +45 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ChunkUploader.kt +78 -0
- package/android/src/main/java/com/sitepong/screenrecorder/H264Encoder.kt +163 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenCapture.kt +103 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenRecorderModule.kt +80 -0
- package/android/src/main/java/com/sitepong/screenrecorder/SensitiveViewManager.kt +19 -0
- package/app.plugin.js +426 -0
- package/dist/cdn/sitepong.min.js +5 -5
- package/dist/cdn/sitepong.min.js.map +1 -1
- package/dist/entries/rn.d.ts +188 -16
- package/dist/entries/rn.js +188 -35
- package/dist/entries/rn.js.map +1 -1
- package/dist/entries/web.js +2 -1
- package/dist/entries/web.js.map +1 -1
- package/dist/entries/web.mjs +2 -1
- package/dist/entries/web.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +2 -1
- package/dist/react/index.mjs.map +1 -1
- package/expo-module.config.json +16 -0
- package/ios/DeviceId/DeviceIdModule.swift +55 -0
- package/ios/DeviceId/KeychainHelper.swift +68 -0
- package/ios/LiveActivity/ConfigStore.swift +43 -0
- package/ios/LiveActivity/DSLNode.swift +188 -0
- package/ios/LiveActivity/SitePongActivityAttributes.swift +112 -0
- package/ios/LiveActivity/SitePongLiveActivityModule.swift +250 -0
- package/ios/LiveActivity/widget/ColorHex.swift +30 -0
- package/ios/LiveActivity/widget/DSLAnimations.swift +128 -0
- package/ios/LiveActivity/widget/DSLRenderer.swift +538 -0
- package/ios/LiveActivity/widget/SitePongLiveActivityWidget.swift +393 -0
- package/ios/LiveActivity/widget/SitePongWidgetBundle.swift +84 -0
- package/ios/ScreenRecorder/ChunkUploader.swift +52 -0
- package/ios/ScreenRecorder/H264Encoder.swift +229 -0
- package/ios/ScreenRecorder/ScreenCapture.swift +149 -0
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +179 -0
- package/ios/ScreenRecorder/SensitiveViewManager.swift +63 -0
- package/ios/Sitepong.podspec +57 -0
- package/ios/WatchtowerCore/Screenshotter.swift +163 -0
- package/ios/WatchtowerCore/SwiftUIModifiers.swift +78 -0
- package/ios/WatchtowerCore/Swizzling.swift +59 -0
- package/ios/WatchtowerCore/TapEvent.swift +61 -0
- package/ios/WatchtowerCore/Transport.swift +136 -0
- package/ios/WatchtowerCore/UIViewExtensions.swift +31 -0
- package/ios/WatchtowerCore/Watchtower.swift +80 -0
- package/ios/WatchtowerCore/WatchtowerEngine.swift +565 -0
- package/ios/WatchtowerCore/WatchtowerHash.swift +120 -0
- package/ios/WatchtowerCore/WatchtowerRegistry.swift +87 -0
- 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
|
+
}
|