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,229 @@
1
+ import VideoToolbox
2
+ import CoreMedia
3
+
4
+ class H264Encoder {
5
+ private var session: VTCompressionSession?
6
+ private let bitrate: Int
7
+ private let bufferDurationMs: Int
8
+ private var width: Int32 = 0
9
+ private var height: Int32 = 0
10
+ private var sessionCreated = false
11
+
12
+ /// Rolling buffer of encoded NAL units with timestamps
13
+ private var nalBuffer: [(data: Data, isKeyframe: Bool, timestamp: CFTimeInterval)] = []
14
+ private let bufferLock = NSLock()
15
+
16
+ init(bitrate: Int, bufferDurationMs: Int) {
17
+ self.bitrate = bitrate
18
+ self.bufferDurationMs = bufferDurationMs
19
+ }
20
+
21
+ func start() {
22
+ // Session is created lazily on first frame (we need dimensions)
23
+ }
24
+
25
+ func stop() {
26
+ guard let session = session else { return }
27
+ VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
28
+ VTCompressionSessionInvalidate(session)
29
+ self.session = nil
30
+ self.sessionCreated = false
31
+
32
+ bufferLock.lock()
33
+ nalBuffer.removeAll()
34
+ bufferLock.unlock()
35
+ }
36
+
37
+ func encode(pixelBuffer: CVPixelBuffer, timestamp: CMTime) {
38
+ let w = Int32(CVPixelBufferGetWidth(pixelBuffer))
39
+ let h = Int32(CVPixelBufferGetHeight(pixelBuffer))
40
+
41
+ // Create or recreate session if dimensions changed
42
+ if !sessionCreated || w != width || h != height {
43
+ createSession(width: w, height: h)
44
+ }
45
+
46
+ guard let session = session else { return }
47
+
48
+ var flags = VTEncodeInfoFlags()
49
+ VTCompressionSessionEncodeFrame(
50
+ session,
51
+ imageBuffer: pixelBuffer,
52
+ presentationTimeStamp: timestamp,
53
+ duration: .invalid,
54
+ frameProperties: nil,
55
+ sourceFrameRefcon: nil,
56
+ infoFlagsOut: &flags
57
+ )
58
+
59
+ // Trim the rolling buffer
60
+ trimBuffer()
61
+ }
62
+
63
+ func flushPendingFrames() {
64
+ guard let session = session else { return }
65
+ VTCompressionSessionCompleteFrames(session, untilPresentationTimeStamp: .invalid)
66
+ }
67
+
68
+ /// Get all buffered NAL data for upload, then clear the buffer.
69
+ func drainBuffer() -> Data {
70
+ bufferLock.lock()
71
+ defer { bufferLock.unlock() }
72
+
73
+ var combined = Data()
74
+ for entry in nalBuffer {
75
+ combined.append(entry.data)
76
+ }
77
+ nalBuffer.removeAll()
78
+ return combined
79
+ }
80
+
81
+ private func createSession(width: Int32, height: Int32) {
82
+ if let existing = session {
83
+ VTCompressionSessionInvalidate(existing)
84
+ }
85
+
86
+ self.width = width
87
+ self.height = height
88
+
89
+ let status = VTCompressionSessionCreate(
90
+ allocator: kCFAllocatorDefault,
91
+ width: width,
92
+ height: height,
93
+ codecType: kCMVideoCodecType_H264,
94
+ encoderSpecification: nil,
95
+ imageBufferAttributes: nil,
96
+ compressedDataAllocator: nil,
97
+ outputCallback: encoderCallback,
98
+ refcon: Unmanaged.passUnretained(self).toOpaque(),
99
+ compressionSessionOut: &session
100
+ )
101
+
102
+ guard status == noErr, let session = session else {
103
+ print("[SitePong ScreenRecorder] Failed to create VTCompressionSession: \(status)")
104
+ return
105
+ }
106
+
107
+ // Configure encoder
108
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, value: kVTProfileLevel_H264_Baseline_AutoLevel)
109
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, value: NSNumber(value: bitrate))
110
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue)
111
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse)
112
+ // Keyframe every 5 seconds
113
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, value: NSNumber(value: 15))
114
+ VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, value: NSNumber(value: 5.0))
115
+
116
+ VTCompressionSessionPrepareToEncodeFrames(session)
117
+ sessionCreated = true
118
+ }
119
+
120
+ private func trimBuffer() {
121
+ bufferLock.lock()
122
+ defer { bufferLock.unlock() }
123
+
124
+ let cutoff = CACurrentMediaTime() - Double(bufferDurationMs) / 1000.0
125
+ // Remove entries older than cutoff, but keep at least the most recent keyframe
126
+ var lastKeyframeIndex: Int? = nil
127
+ for (i, entry) in nalBuffer.enumerated() {
128
+ if entry.isKeyframe {
129
+ lastKeyframeIndex = i
130
+ }
131
+ }
132
+
133
+ var trimTo = 0
134
+ for (i, entry) in nalBuffer.enumerated() {
135
+ if entry.timestamp >= cutoff {
136
+ trimTo = i
137
+ break
138
+ }
139
+ trimTo = i + 1
140
+ }
141
+
142
+ // Don't trim past the last keyframe before cutoff
143
+ if let lki = lastKeyframeIndex, trimTo > lki {
144
+ trimTo = lki
145
+ }
146
+
147
+ if trimTo > 0 {
148
+ nalBuffer.removeFirst(trimTo)
149
+ }
150
+ }
151
+
152
+ func handleEncodedFrame(status: OSStatus, sampleBuffer: CMSampleBuffer?) {
153
+ guard status == noErr, let sampleBuffer = sampleBuffer else { return }
154
+
155
+ // Check if keyframe
156
+ let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false)
157
+ var isKeyframe = false
158
+ if let attachments = attachments, CFArrayGetCount(attachments) > 0 {
159
+ let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFDictionary.self)
160
+ let key = kCMSampleAttachmentKey_NotSync
161
+ isKeyframe = !CFDictionaryContainsKey(dict, Unmanaged.passUnretained(key).toOpaque())
162
+ }
163
+
164
+ var data = Data()
165
+
166
+ // For keyframes, prepend SPS/PPS parameter sets
167
+ if isKeyframe {
168
+ if let formatDesc = CMSampleBufferGetFormatDescription(sampleBuffer) {
169
+ // SPS
170
+ var spsSize: Int = 0
171
+ var spsCount: Int = 0
172
+ var spsPointer: UnsafePointer<UInt8>?
173
+ if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, parameterSetIndex: 0, parameterSetPointerOut: &spsPointer, parameterSetSizeOut: &spsSize, parameterSetCountOut: &spsCount, nalUnitHeaderLengthOut: nil) == noErr,
174
+ let sps = spsPointer {
175
+ // Annex-B start code
176
+ data.append(contentsOf: [0x00, 0x00, 0x00, 0x01])
177
+ data.append(sps, count: spsSize)
178
+ }
179
+ // PPS
180
+ var ppsSize: Int = 0
181
+ var ppsPointer: UnsafePointer<UInt8>?
182
+ if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDesc, parameterSetIndex: 1, parameterSetPointerOut: &ppsPointer, parameterSetSizeOut: &ppsSize, parameterSetCountOut: nil, nalUnitHeaderLengthOut: nil) == noErr,
183
+ let pps = ppsPointer {
184
+ data.append(contentsOf: [0x00, 0x00, 0x00, 0x01])
185
+ data.append(pps, count: ppsSize)
186
+ }
187
+ }
188
+ }
189
+
190
+ // Extract NAL units from sample buffer
191
+ guard let dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else { return }
192
+ var totalLength: Int = 0
193
+ var bufferPointer: UnsafeMutablePointer<Int8>?
194
+ CMBlockBufferGetDataPointer(dataBuffer, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &totalLength, dataPointerOut: &bufferPointer)
195
+
196
+ guard let pointer = bufferPointer else { return }
197
+
198
+ var offset = 0
199
+ while offset < totalLength - 4 {
200
+ var nalUnitLength: UInt32 = 0
201
+ memcpy(&nalUnitLength, pointer.advanced(by: offset), 4)
202
+ nalUnitLength = nalUnitLength.bigEndian
203
+ offset += 4
204
+
205
+ // Annex-B start code
206
+ data.append(contentsOf: [0x00, 0x00, 0x00, 0x01])
207
+ data.append(Data(bytes: pointer.advanced(by: offset), count: Int(nalUnitLength)))
208
+ offset += Int(nalUnitLength)
209
+ }
210
+
211
+ // Add to rolling buffer (trimmed by time in encode())
212
+ bufferLock.lock()
213
+ nalBuffer.append((data: data, isKeyframe: isKeyframe, timestamp: CACurrentMediaTime()))
214
+ bufferLock.unlock()
215
+ }
216
+ }
217
+
218
+ // C callback for VTCompressionSession
219
+ private func encoderCallback(
220
+ outputCallbackRefCon: UnsafeMutableRawPointer?,
221
+ sourceFrameRefCon: UnsafeMutableRawPointer?,
222
+ status: OSStatus,
223
+ infoFlags: VTEncodeInfoFlags,
224
+ sampleBuffer: CMSampleBuffer?
225
+ ) {
226
+ guard let refCon = outputCallbackRefCon else { return }
227
+ let encoder = Unmanaged<H264Encoder>.fromOpaque(refCon).takeUnretainedValue()
228
+ encoder.handleEncodedFrame(status: status, sampleBuffer: sampleBuffer)
229
+ }
@@ -0,0 +1,149 @@
1
+ import UIKit
2
+ import CoreVideo
3
+ import CoreMedia
4
+
5
+ class ScreenCapture {
6
+ private let fps: Int
7
+ private let sensitiveViewManager: SensitiveViewManager
8
+ private let onFrame: (CVPixelBuffer, CMTime) -> Void
9
+ private var timer: Timer?
10
+ private var frameCount: Int = 0
11
+ private var startTime: CFTimeInterval = 0
12
+
13
+ init(fps: Int, sensitiveViewManager: SensitiveViewManager, onFrame: @escaping (CVPixelBuffer, CMTime) -> Void) {
14
+ self.fps = max(1, min(fps, 10))
15
+ self.sensitiveViewManager = sensitiveViewManager
16
+ self.onFrame = onFrame
17
+ }
18
+
19
+ func start() {
20
+ guard timer == nil else { return }
21
+
22
+ startTime = CACurrentMediaTime()
23
+ frameCount = 0
24
+
25
+ let interval = 1.0 / Double(fps)
26
+ let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
27
+ self?.tick()
28
+ }
29
+ RunLoop.main.add(t, forMode: .common)
30
+ timer = t
31
+
32
+ // Capture the first frame on main thread
33
+ if Thread.isMainThread {
34
+ tick()
35
+ } else {
36
+ DispatchQueue.main.async { [weak self] in
37
+ self?.tick()
38
+ }
39
+ }
40
+ }
41
+
42
+ func stop() {
43
+ timer?.invalidate()
44
+ timer = nil
45
+ }
46
+
47
+ private func tick() {
48
+ guard let window = Self.keyWindow else { return }
49
+
50
+ let elapsed = CACurrentMediaTime() - startTime
51
+ let timestamp = CMTimeMakeWithSeconds(elapsed, preferredTimescale: 600)
52
+
53
+ // Capture at half resolution to reduce size
54
+ let scale: CGFloat = 0.5
55
+ let scaledSize = CGSize(
56
+ width: window.bounds.width * scale,
57
+ height: window.bounds.height * scale
58
+ )
59
+ let renderer = UIGraphicsImageRenderer(size: scaledSize)
60
+ let image = renderer.image { ctx in
61
+ ctx.cgContext.scaleBy(x: scale, y: scale)
62
+ window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
63
+ }
64
+
65
+ // Get sensitive view rects in window coordinates
66
+ let sensitiveRects = sensitiveViewManager.getSensitiveRects(in: window)
67
+
68
+ // Draw black rectangles over sensitive areas directly on the image
69
+ let maskedImage: UIImage
70
+ if sensitiveRects.isEmpty {
71
+ maskedImage = image
72
+ } else {
73
+ let maskRenderer = UIGraphicsImageRenderer(size: scaledSize)
74
+ maskedImage = maskRenderer.image { ctx in
75
+ // Draw the original screenshot
76
+ image.draw(at: .zero)
77
+ // Draw black rectangles over sensitive areas (scaled to half resolution)
78
+ ctx.cgContext.setFillColor(UIColor.black.cgColor)
79
+ for rect in sensitiveRects {
80
+ let scaledRect = CGRect(
81
+ x: rect.origin.x * scale,
82
+ y: rect.origin.y * scale,
83
+ width: rect.size.width * scale,
84
+ height: rect.size.height * scale
85
+ )
86
+ ctx.cgContext.fill(scaledRect)
87
+ }
88
+ }
89
+ }
90
+
91
+ // Convert UIImage to CVPixelBuffer
92
+ guard let pixelBuffer = Self.pixelBuffer(from: maskedImage) else { return }
93
+
94
+ onFrame(pixelBuffer, timestamp)
95
+ frameCount += 1
96
+ }
97
+
98
+ private static var keyWindow: UIWindow? {
99
+ if #available(iOS 15.0, *) {
100
+ return UIApplication.shared.connectedScenes
101
+ .compactMap { $0 as? UIWindowScene }
102
+ .flatMap { $0.windows }
103
+ .first { $0.isKeyWindow }
104
+ } else {
105
+ return UIApplication.shared.windows.first { $0.isKeyWindow }
106
+ }
107
+ }
108
+
109
+ static func pixelBuffer(from image: UIImage) -> CVPixelBuffer? {
110
+ guard let cgImage = image.cgImage else { return nil }
111
+
112
+ let width = cgImage.width
113
+ let height = cgImage.height
114
+
115
+ let attrs: [String: Any] = [
116
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
117
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
118
+ kCVPixelBufferIOSurfacePropertiesKey as String: [:],
119
+ ]
120
+
121
+ var pixelBuffer: CVPixelBuffer?
122
+ let status = CVPixelBufferCreate(
123
+ kCFAllocatorDefault,
124
+ width, height,
125
+ kCVPixelFormatType_32BGRA,
126
+ attrs as CFDictionary,
127
+ &pixelBuffer
128
+ )
129
+
130
+ guard status == kCVReturnSuccess, let buffer = pixelBuffer else { return nil }
131
+
132
+ CVPixelBufferLockBaseAddress(buffer, [])
133
+ defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
134
+
135
+ guard let context = CGContext(
136
+ data: CVPixelBufferGetBaseAddress(buffer),
137
+ width: width,
138
+ height: height,
139
+ bitsPerComponent: 8,
140
+ bytesPerRow: CVPixelBufferGetBytesPerRow(buffer),
141
+ space: CGColorSpaceCreateDeviceRGB(),
142
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
143
+ ) else { return nil }
144
+
145
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
146
+
147
+ return buffer
148
+ }
149
+ }
@@ -0,0 +1,179 @@
1
+ import ExpoModulesCore
2
+ // The single shared capture engine (spec §3/§4) is compiled into this same
3
+ // `Sitepong` pod (ios/WatchtowerCore/*.swift), so it is reachable in-module —
4
+ // no `import WatchtowerCore` and no separate pod. This module reimplements no
5
+ // capture/screenshot/hash/redact/upload logic; it only forwards to the engine.
6
+
7
+ public class SitePongScreenRecorderModule: Module {
8
+ private var screenCapture: ScreenCapture?
9
+ private var encoder: H264Encoder?
10
+ private var uploader: ChunkUploader?
11
+ private var sensitiveViewManager = SensitiveViewManager()
12
+ private var recording = false
13
+
14
+ public func definition() -> ModuleDefinition {
15
+ Name("SitePongScreenRecorder")
16
+
17
+ Function("isRecording") { () -> Bool in
18
+ return self.recording
19
+ }
20
+
21
+ AsyncFunction("startRecording") { (options: [String: Any], promise: Promise) in
22
+ guard !self.recording else {
23
+ promise.resolve(nil)
24
+ return
25
+ }
26
+
27
+ let apiKey = options["apiKey"] as? String ?? ""
28
+ let endpoint = options["endpoint"] as? String ?? "https://ingest.sitepong.com"
29
+ let sessionId = options["sessionId"] as? String ?? ""
30
+ let fps = options["fps"] as? Int ?? 1
31
+ let quality = options["quality"] as? String ?? "standard"
32
+ let bufferDuration = options["bufferDuration"] as? Int ?? 60_000
33
+
34
+ let bitrate = self.bitrateForQuality(quality)
35
+
36
+ self.uploader = ChunkUploader(
37
+ endpoint: endpoint,
38
+ apiKey: apiKey,
39
+ sessionId: sessionId
40
+ )
41
+
42
+ self.encoder = H264Encoder(
43
+ bitrate: bitrate,
44
+ bufferDurationMs: bufferDuration
45
+ )
46
+
47
+ self.screenCapture = ScreenCapture(
48
+ fps: fps,
49
+ sensitiveViewManager: self.sensitiveViewManager,
50
+ onFrame: { [weak self] pixelBuffer, timestamp in
51
+ self?.encoder?.encode(pixelBuffer: pixelBuffer, timestamp: timestamp)
52
+ }
53
+ )
54
+
55
+ self.encoder?.start()
56
+ self.screenCapture?.start()
57
+ self.recording = true
58
+ promise.resolve(nil)
59
+ }
60
+
61
+ AsyncFunction("stopRecording") { (promise: Promise) in
62
+ self.stopInternal()
63
+ promise.resolve(nil)
64
+ }
65
+
66
+ AsyncFunction("flushBuffer") { (promise: Promise) in
67
+ guard self.recording else {
68
+ promise.resolve(nil)
69
+ return
70
+ }
71
+ // Flush pending frames from encoder to its rolling buffer
72
+ self.encoder?.flushPendingFrames()
73
+ // Drain the encoder's time-trimmed rolling buffer
74
+ let data = self.encoder?.drainBuffer() ?? Data()
75
+ let uploader = self.uploader
76
+ DispatchQueue.global(qos: .utility).async {
77
+ let semaphore = DispatchSemaphore(value: 0)
78
+ Task {
79
+ do {
80
+ try await uploader?.uploadData(data)
81
+ } catch {
82
+ print("[SitePong ScreenRecorder] Flush error: \(error)")
83
+ }
84
+ semaphore.signal()
85
+ }
86
+ semaphore.wait()
87
+ DispatchQueue.main.async {
88
+ promise.resolve(nil)
89
+ }
90
+ }
91
+ }
92
+
93
+ Function("addSensitiveView") { (viewTag: Int) in
94
+ self.sensitiveViewManager.addView(tag: viewTag)
95
+ // Also mark the underlying UIView so WatchtowerCore's redaction (§5/§4.3)
96
+ // sees it — same UIView, no new capture code. Marking happens on main.
97
+ DispatchQueue.main.async {
98
+ if let view = self.findUIView(tag: viewTag) {
99
+ view.watchtowerSensitive = true
100
+ }
101
+ }
102
+ }
103
+
104
+ Function("removeSensitiveView") { (viewTag: Int) in
105
+ self.sensitiveViewManager.removeView(tag: viewTag)
106
+ DispatchQueue.main.async {
107
+ if let view = self.findUIView(tag: viewTag) {
108
+ view.watchtowerSensitive = false
109
+ }
110
+ }
111
+ }
112
+
113
+ // MARK: - Watchtower thin bridge (spec §4.1)
114
+ // Each function ONLY forwards to the shared WatchtowerCore engine (§3).
115
+ // No tap capture, screenshot, hashing, redaction, or upload logic here.
116
+
117
+ Function("watchtowerStart") { (config: [String: Any]) in
118
+ let apiKey = config["apiKey"] as? String ?? ""
119
+ let projectId = config["projectId"] as? String ?? ""
120
+ let endpointStr = config["endpoint"] as? String ?? ""
121
+ let sampleRate = config["sampleRate"] as? Double ?? 0.1
122
+ // RN host stamps platform "react-native-ios" (§1.1/§4.4).
123
+ let platform = config["platform"] as? String ?? "react-native-ios"
124
+ guard let endpoint = URL(string: endpointStr) else { return }
125
+ Watchtower.start(apiKey: apiKey, projectId: projectId,
126
+ endpoint: endpoint, sampleRate: sampleRate,
127
+ platform: platform)
128
+ }
129
+
130
+ Function("watchtowerStop") {
131
+ Watchtower.stop()
132
+ }
133
+
134
+ Function("watchtowerSetScreen") { (name: String) in
135
+ Watchtower.setScreen(name)
136
+ }
137
+
138
+ Function("watchtowerCaptureScreenshot") {
139
+ Watchtower.captureScreenshot()
140
+ }
141
+
142
+ Function("watchtowerSetUser") { (distinctId: String?) in
143
+ Watchtower.setUser(distinctId)
144
+ }
145
+
146
+ Function("watchtowerGetSessionId") { () -> String? in
147
+ return Watchtower.sessionId
148
+ }
149
+ }
150
+
151
+ /// Find a UIView by React tag across all key windows (mirrors
152
+ /// SensitiveViewManager's lookup but at the window level for redaction).
153
+ private func findUIView(tag: Int) -> UIView? {
154
+ for scene in UIApplication.shared.connectedScenes {
155
+ guard let windowScene = scene as? UIWindowScene else { continue }
156
+ for window in windowScene.windows {
157
+ if let v = window.viewWithTag(tag), v !== window { return v }
158
+ }
159
+ }
160
+ return nil
161
+ }
162
+
163
+ private func stopInternal() {
164
+ self.screenCapture?.stop()
165
+ self.encoder?.stop()
166
+ self.screenCapture = nil
167
+ self.encoder = nil
168
+ self.uploader = nil
169
+ self.recording = false
170
+ }
171
+
172
+ private func bitrateForQuality(_ quality: String) -> Int {
173
+ switch quality {
174
+ case "low": return 50_000
175
+ case "high": return 300_000
176
+ default: return 150_000 // standard
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,63 @@
1
+ import UIKit
2
+
3
+ class SensitiveViewManager {
4
+ private var sensitiveViewTags: Set<Int> = []
5
+ private let lock = NSLock()
6
+
7
+ func addView(tag: Int) {
8
+ lock.lock()
9
+ sensitiveViewTags.insert(tag)
10
+ lock.unlock()
11
+ }
12
+
13
+ func removeView(tag: Int) {
14
+ lock.lock()
15
+ sensitiveViewTags.remove(tag)
16
+ lock.unlock()
17
+ }
18
+
19
+ /// Return the window-coordinate rects of all registered sensitive views.
20
+ /// Used by ScreenCapture to draw black rectangles over these areas.
21
+ func getSensitiveRects(in window: UIWindow) -> [CGRect] {
22
+ lock.lock()
23
+ let tags = sensitiveViewTags
24
+ lock.unlock()
25
+
26
+ var rects: [CGRect] = []
27
+
28
+ for tag in tags {
29
+ guard let sensitiveView = findView(tag: tag, in: window) else {
30
+ continue
31
+ }
32
+
33
+ let frame = sensitiveView.convert(sensitiveView.bounds, to: window)
34
+ guard !frame.isEmpty, frame.width > 0, frame.height > 0 else { continue }
35
+
36
+ rects.append(frame)
37
+ }
38
+
39
+ return rects
40
+ }
41
+
42
+ /// Find a UIView by its tag by walking the entire hierarchy.
43
+ private func findView(tag: Int, in window: UIWindow) -> UIView? {
44
+ // UIKit's built-in viewWithTag (works in Fabric since UIView.tag is set)
45
+ if let view = window.viewWithTag(tag), view !== window {
46
+ return view
47
+ }
48
+ // Full recursive walk as fallback
49
+ return findViewRecursively(in: window, tag: tag)
50
+ }
51
+
52
+ private func findViewRecursively(in view: UIView, tag: Int) -> UIView? {
53
+ for subview in view.subviews {
54
+ if subview.tag == tag {
55
+ return subview
56
+ }
57
+ if let found = findViewRecursively(in: subview, tag: tag) {
58
+ return found
59
+ }
60
+ }
61
+ return nil
62
+ }
63
+ }
@@ -0,0 +1,57 @@
1
+ require 'json'
2
+
3
+ # This podspec lives in ios/ (where Expo autolinking discovers module podspecs),
4
+ # so package.json is one level up.
5
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
6
+
7
+ # ─────────────────────────────────────────────────────────────────────────────
8
+ # ONE pod for the entire SitePong SDK.
9
+ #
10
+ # Every native capability customers used to install as a separate npm package —
11
+ # screen recording, the Watchtower tap-capture engine, the persistent device id,
12
+ # and Live Activities — now compiles into this single `Sitepong` pod. Apps
13
+ # install ONE package (`sitepong`); Expo autolinking finds this podspec and links
14
+ # all of it. Nothing is linked via a Podfile patch anymore: the Watchtower engine
15
+ # (ios/WatchtowerCore/*.swift) is part of this same Swift module, so the bridge
16
+ # reaches it directly without `import WatchtowerCore` or a `:path` pod.
17
+ #
18
+ # The Live Activity Widget Extension (ios/LiveActivity/widget/**) is the only
19
+ # native code NOT in this pod — a WidgetKit extension is a separate app target,
20
+ # injected opt-in by app.plugin.js when `liveActivities` is enabled.
21
+ # ─────────────────────────────────────────────────────────────────────────────
22
+ Pod::Spec.new do |s|
23
+ s.name = 'Sitepong'
24
+ s.version = package['version']
25
+ s.summary = package['description'] || 'Official SitePong SDK'
26
+ s.description = package['description'] || 'Official SitePong SDK'
27
+ s.license = package['license'] || 'MIT'
28
+ s.author = 'SitePong'
29
+ s.homepage = 'https://sitepong.com'
30
+ # 15.1 is the highest floor across the merged modules (screen recorder /
31
+ # live activity). ActivityKit / WidgetKit symbols are gated at runtime with
32
+ # @available(iOS 16.2, *), so a 15.1 deployment target is safe.
33
+ s.platforms = { :ios => '15.1' }
34
+ s.swift_version = '5.9'
35
+ s.source = { git: 'https://github.com/programmrz/sitepong.git' }
36
+ s.static_framework = true
37
+
38
+ s.dependency 'ExpoModulesCore'
39
+
40
+ # UIKit/CoreImage/Foundation — Watchtower engine. VideoToolbox/CoreMedia/
41
+ # CoreVideo — the H.264 screen recorder. SwiftUI — engine + widget modifiers.
42
+ s.frameworks = 'UIKit', 'CoreImage', 'Foundation',
43
+ 'VideoToolbox', 'CoreMedia', 'CoreVideo', 'SwiftUI'
44
+ # ActivityKit / WidgetKit are iOS 16.2+; weak-link so a 15.1 app still loads.
45
+ s.weak_frameworks = 'ActivityKit', 'WidgetKit'
46
+
47
+ s.pod_target_xcconfig = {
48
+ 'DEFINES_MODULE' => 'YES',
49
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
50
+ }
51
+
52
+ # Paths are relative to this podspec (ios/). Everything under ios/ EXCEPT the
53
+ # Live Activity widget extension, which is a separate WidgetKit app-extension
54
+ # target added by the config plugin.
55
+ s.source_files = '**/*.{h,m,mm,swift}'
56
+ s.exclude_files = 'LiveActivity/widget/**/*'
57
+ end