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,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
|