@multiplayer-app/session-recorder-react-native 0.0.1-beta.9 → 1.0.0-beta.1

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 (178) hide show
  1. package/README.md +708 -83
  2. package/SessionRecorderNative.podspec +26 -0
  3. package/android/build.gradle +2 -0
  4. package/copy-react-native-dist.sh +33 -9
  5. package/dist/components/ScreenRecorderView/ScreenRecorderView.js +1 -1
  6. package/dist/components/ScreenRecorderView/ScreenRecorderView.js.map +1 -1
  7. package/dist/components/SessionRecorderWidget/ErrorBanner.d.ts +7 -0
  8. package/dist/components/SessionRecorderWidget/ErrorBanner.js +1 -0
  9. package/dist/components/SessionRecorderWidget/ErrorBanner.js.map +1 -0
  10. package/dist/components/SessionRecorderWidget/FinalPopover.d.ts +4 -3
  11. package/dist/components/SessionRecorderWidget/FinalPopover.js +1 -1
  12. package/dist/components/SessionRecorderWidget/FinalPopover.js.map +1 -1
  13. package/dist/components/SessionRecorderWidget/InitialPopover.d.ts +6 -3
  14. package/dist/components/SessionRecorderWidget/InitialPopover.js +1 -1
  15. package/dist/components/SessionRecorderWidget/InitialPopover.js.map +1 -1
  16. package/dist/components/SessionRecorderWidget/ModalContainer.js +1 -1
  17. package/dist/components/SessionRecorderWidget/ModalContainer.js.map +1 -1
  18. package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js +1 -1
  19. package/dist/components/SessionRecorderWidget/SessionRecorderWidget.js.map +1 -1
  20. package/dist/components/SessionRecorderWidget/styles.d.ts +27 -7
  21. package/dist/components/SessionRecorderWidget/styles.js +1 -1
  22. package/dist/components/SessionRecorderWidget/styles.js.map +1 -1
  23. package/dist/components/index.d.ts +0 -1
  24. package/dist/components/index.js +1 -1
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/config/constants.js +1 -1
  27. package/dist/config/constants.js.map +1 -1
  28. package/dist/config/defaults.d.ts +4 -4
  29. package/dist/config/defaults.js +1 -1
  30. package/dist/config/defaults.js.map +1 -1
  31. package/dist/config/masking.d.ts +2 -2
  32. package/dist/config/masking.js +1 -1
  33. package/dist/config/masking.js.map +1 -1
  34. package/dist/config/session-recorder.js +1 -1
  35. package/dist/config/session-recorder.js.map +1 -1
  36. package/dist/config/validators.d.ts +1 -1
  37. package/dist/config/validators.js +1 -1
  38. package/dist/config/validators.js.map +1 -1
  39. package/dist/config/widget.d.ts +9 -0
  40. package/dist/config/widget.js +1 -0
  41. package/dist/config/widget.js.map +1 -0
  42. package/dist/context/SessionRecorderContext.d.ts +11 -4
  43. package/dist/context/SessionRecorderContext.js +1 -1
  44. package/dist/context/SessionRecorderContext.js.map +1 -1
  45. package/dist/context/SessionRecorderStore.d.ts +12 -0
  46. package/dist/context/SessionRecorderStore.js +1 -0
  47. package/dist/context/SessionRecorderStore.js.map +1 -0
  48. package/dist/context/useSessionRecorderStore.d.ts +8 -0
  49. package/dist/context/useSessionRecorderStore.js +1 -0
  50. package/dist/context/useSessionRecorderStore.js.map +1 -0
  51. package/dist/context/useStoreSelector.d.ts +4 -0
  52. package/dist/context/useStoreSelector.js +1 -0
  53. package/dist/context/useStoreSelector.js.map +1 -0
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.js +1 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/native/GestureRecorderNative.d.ts +57 -0
  58. package/dist/native/GestureRecorderNative.js +1 -0
  59. package/dist/native/GestureRecorderNative.js.map +1 -0
  60. package/dist/native/SessionRecorderNative.d.ts +17 -5
  61. package/dist/native/SessionRecorderNative.js +1 -1
  62. package/dist/native/SessionRecorderNative.js.map +1 -1
  63. package/dist/native/index.d.ts +2 -0
  64. package/dist/native/index.js +1 -0
  65. package/dist/native/index.js.map +1 -0
  66. package/dist/otel/index.js +1 -1
  67. package/dist/otel/index.js.map +1 -1
  68. package/dist/patch/xhr.js +1 -1
  69. package/dist/patch/xhr.js.map +1 -1
  70. package/dist/recorder/eventExporter.d.ts +4 -1
  71. package/dist/recorder/eventExporter.js +1 -1
  72. package/dist/recorder/eventExporter.js.map +1 -1
  73. package/dist/recorder/gestureRecorder.d.ts +28 -62
  74. package/dist/recorder/gestureRecorder.js +1 -1
  75. package/dist/recorder/gestureRecorder.js.map +1 -1
  76. package/dist/recorder/index.d.ts +2 -0
  77. package/dist/recorder/index.js +1 -1
  78. package/dist/recorder/index.js.map +1 -1
  79. package/dist/recorder/navigationTracker.d.ts +4 -19
  80. package/dist/recorder/navigationTracker.js +1 -1
  81. package/dist/recorder/navigationTracker.js.map +1 -1
  82. package/dist/recorder/screenRecorder.d.ts +10 -5
  83. package/dist/recorder/screenRecorder.js +1 -1
  84. package/dist/recorder/screenRecorder.js.map +1 -1
  85. package/dist/services/api.service.d.ts +12 -3
  86. package/dist/services/api.service.js +1 -1
  87. package/dist/services/api.service.js.map +1 -1
  88. package/dist/services/network.service.d.ts +46 -0
  89. package/dist/services/network.service.js +1 -0
  90. package/dist/services/network.service.js.map +1 -0
  91. package/dist/services/screenMaskingService.d.ts +13 -5
  92. package/dist/services/screenMaskingService.js +1 -1
  93. package/dist/services/screenMaskingService.js.map +1 -1
  94. package/dist/services/storage.service.js +1 -1
  95. package/dist/services/storage.service.js.map +1 -1
  96. package/dist/session-recorder.d.ts +17 -33
  97. package/dist/session-recorder.js +1 -1
  98. package/dist/session-recorder.js.map +1 -1
  99. package/dist/types/configs.d.ts +85 -0
  100. package/dist/types/configs.js +1 -0
  101. package/dist/types/configs.js.map +1 -0
  102. package/dist/types/index.d.ts +1 -0
  103. package/dist/types/index.js +1 -1
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/types/session-recorder.d.ts +105 -138
  106. package/dist/types/session-recorder.js +1 -1
  107. package/dist/types/session-recorder.js.map +1 -1
  108. package/dist/utils/constants.optional.d.ts +21 -0
  109. package/dist/utils/constants.optional.expo.d.ts +3 -0
  110. package/dist/utils/constants.optional.expo.js +1 -0
  111. package/dist/utils/constants.optional.expo.js.map +1 -0
  112. package/dist/utils/constants.optional.js +1 -0
  113. package/dist/utils/constants.optional.js.map +1 -0
  114. package/dist/utils/createStore.d.ts +8 -0
  115. package/dist/utils/createStore.js +1 -0
  116. package/dist/utils/createStore.js.map +1 -0
  117. package/dist/utils/logger.d.ts +2 -7
  118. package/dist/utils/logger.js +1 -1
  119. package/dist/utils/logger.js.map +1 -1
  120. package/dist/utils/platform.d.ts +8 -0
  121. package/dist/utils/platform.js +1 -1
  122. package/dist/utils/platform.js.map +1 -1
  123. package/dist/utils/rrweb-events.d.ts +4 -3
  124. package/dist/utils/rrweb-events.js +1 -1
  125. package/dist/utils/rrweb-events.js.map +1 -1
  126. package/dist/utils/session.d.ts +2 -1
  127. package/dist/utils/session.js +1 -1
  128. package/dist/utils/session.js.map +1 -1
  129. package/dist/utils/shallowEqual.d.ts +1 -0
  130. package/dist/utils/shallowEqual.js +1 -0
  131. package/dist/utils/shallowEqual.js.map +1 -0
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/dist/version.js.map +1 -1
  135. package/docs/AUTO_METADATA_DETECTION.md +108 -0
  136. package/ios/GestureRecorderNative.m +21 -0
  137. package/ios/GestureRecorderNative.swift +316 -0
  138. package/ios/SessionRecorderNative.m +5 -0
  139. package/ios/SessionRecorderNative.swift +478 -84
  140. package/package.json +13 -27
  141. package/react-native.config.js +7 -10
  142. package/app.plugin.js +0 -42
  143. package/dist/components/GestureCaptureWrapper/GestureCaptureWrapper.d.ts +0 -6
  144. package/dist/components/GestureCaptureWrapper/GestureCaptureWrapper.js +0 -1
  145. package/dist/components/GestureCaptureWrapper/GestureCaptureWrapper.js.map +0 -1
  146. package/dist/components/GestureCaptureWrapper/index.d.ts +0 -1
  147. package/dist/components/GestureCaptureWrapper/index.js +0 -1
  148. package/dist/components/GestureCaptureWrapper/index.js.map +0 -1
  149. package/dist/components/MaskableComponent.d.ts +0 -22
  150. package/dist/components/MaskableComponent.js +0 -1
  151. package/dist/components/MaskableComponent.js.map +0 -1
  152. package/dist/components/MaskableTextInput.d.ts +0 -14
  153. package/dist/components/MaskableTextInput.js +0 -1
  154. package/dist/components/MaskableTextInput.js.map +0 -1
  155. package/dist/native/ScreenMasking.d.ts +0 -21
  156. package/dist/native/ScreenMasking.js +0 -1
  157. package/dist/native/ScreenMasking.js.map +0 -1
  158. package/dist/recorder/screenshotManager.d.ts +0 -10
  159. package/dist/recorder/screenshotManager.js +0 -1
  160. package/dist/recorder/screenshotManager.js.map +0 -1
  161. package/dist/utils/componentRegistry.d.ts +0 -64
  162. package/dist/utils/componentRegistry.js +0 -1
  163. package/dist/utils/componentRegistry.js.map +0 -1
  164. package/dist/utils/nativeModuleTest.d.ts +0 -8
  165. package/dist/utils/nativeModuleTest.js +0 -1
  166. package/dist/utils/nativeModuleTest.js.map +0 -1
  167. package/dist/utils/reactNativeHierarchyExtractor.d.ts +0 -38
  168. package/dist/utils/reactNativeHierarchyExtractor.js +0 -1
  169. package/dist/utils/reactNativeHierarchyExtractor.js.map +0 -1
  170. package/dist/utils/screenshotMasker.d.ts +0 -96
  171. package/dist/utils/screenshotMasker.js +0 -1
  172. package/dist/utils/screenshotMasker.js.map +0 -1
  173. package/dist/utils/viewHierarchyTracker.d.ts +0 -89
  174. package/dist/utils/viewHierarchyTracker.js +0 -1
  175. package/dist/utils/viewHierarchyTracker.js.map +0 -1
  176. package/docs/NATIVE_MODULE_SETUP.md +0 -177
  177. package/plugin/package.json +0 -20
  178. package/plugin/src/index.js +0 -42
@@ -1,9 +1,49 @@
1
1
  import UIKit
2
2
  import React
3
+ import WebKit
3
4
 
4
5
  @objc(SessionRecorderNative)
5
6
  class SessionRecorderNative: NSObject {
6
7
 
8
+ // Configuration options
9
+ private var maskTextInputs: Bool = true
10
+ private var maskImages: Bool = false
11
+ private var maskButtons: Bool = false
12
+ private var maskLabels: Bool = false
13
+ private var maskWebViews: Bool = false
14
+ private var maskSandboxedViews: Bool = false
15
+ private var imageQuality: CGFloat = 0.05
16
+ private var scale: CGFloat = 1.0
17
+
18
+ // React Native view types
19
+ private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
20
+ private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
21
+ private let reactNativeTextInput: AnyClass? = NSClassFromString("RCTUITextField")
22
+ private let reactNativeTextInputView: AnyClass? = NSClassFromString("RCTUITextView")
23
+
24
+ // System sandboxed views (usually sensitive)
25
+ private let systemSandboxedView: AnyClass? = NSClassFromString("_UIRemoteView")
26
+
27
+ // SwiftUI view types
28
+ private let swiftUITextBasedViewTypes = [
29
+ "SwiftUI.CGDrawingView", // Text, Button
30
+ "SwiftUI.TextEditorTextView", // TextEditor
31
+ "SwiftUI.VerticalTextView", // TextField, vertical axis
32
+ ].compactMap(NSClassFromString)
33
+
34
+ private let swiftUIImageLayerTypes = [
35
+ "SwiftUI.ImageLayer",
36
+ ].compactMap(NSClassFromString)
37
+
38
+ private let swiftUIGenericTypes = [
39
+ "_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
40
+ ].compactMap(NSClassFromString)
41
+
42
+ // Safe layer types that shouldn't be masked
43
+ private let swiftUISafeLayerTypes: [AnyClass] = [
44
+ "SwiftUI.GradientLayer", // Views like LinearGradient, RadialGradient, or AngularGradient
45
+ ].compactMap(NSClassFromString)
46
+
7
47
  @objc func captureAndMask(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
8
48
  DispatchQueue.main.async {
9
49
  guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
@@ -24,9 +64,15 @@ class SessionRecorderNative: NSObject {
24
64
  // Apply masking to sensitive elements
25
65
  let maskedImage = self.applyMasking(to: image, in: window)
26
66
 
27
- if let data = maskedImage.jpegData(compressionQuality: 0.5) {
28
- let base64 = data.base64EncodedString()
29
- resolve(base64)
67
+ // Apply optional scaling (resolution downsample)
68
+ let finalImage = self.scale < 1.0 ? self.resizeImage(maskedImage, scale: self.scale) : maskedImage
69
+
70
+ // Debug logging
71
+ print("SessionRecorder captureAndMask: Scale = \(self.scale), Original size = \(maskedImage.size), Final size = \(finalImage.size)")
72
+
73
+ if let data = finalImage.jpegData(compressionQuality: self.imageQuality) {
74
+ let base64 = data.base64EncodedString()
75
+ resolve(base64)
30
76
  } else {
31
77
  reject("ENCODING_FAILED", "Failed to encode image", nil)
32
78
  }
@@ -35,6 +81,9 @@ class SessionRecorderNative: NSObject {
35
81
 
36
82
  @objc func captureAndMaskWithOptions(_ options: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
37
83
  DispatchQueue.main.async {
84
+ // Update configuration from options
85
+ self.updateConfiguration(from: options)
86
+
38
87
  guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
39
88
  reject("NO_WINDOW", "Unable to get key window", nil)
40
89
  return
@@ -53,7 +102,13 @@ class SessionRecorderNative: NSObject {
53
102
  // Apply masking with custom options
54
103
  let maskedImage = self.applyMaskingWithOptions(to: image, in: window, options: options)
55
104
 
56
- if let data = maskedImage.jpegData(compressionQuality: 0.5) {
105
+ // Apply optional scaling (resolution downsample)
106
+ let finalImage = self.scale < 1.0 ? self.resizeImage(maskedImage, scale: self.scale) : maskedImage
107
+
108
+ // Debug logging
109
+ print("SessionRecorder captureAndMaskWithOptions: Scale = \(self.scale), Original size = \(maskedImage.size), Final size = \(finalImage.size)")
110
+
111
+ if let data = finalImage.jpegData(compressionQuality: self.imageQuality) {
57
112
  let base64 = data.base64EncodedString()
58
113
  resolve(base64)
59
114
  } else {
@@ -62,6 +117,42 @@ class SessionRecorderNative: NSObject {
62
117
  }
63
118
  }
64
119
 
120
+ private func updateConfiguration(from options: NSDictionary) {
121
+ print("SessionRecorder: updateConfiguration called with options: \(options)")
122
+
123
+ if let maskTextInputs = options["maskTextInputs"] as? Bool {
124
+ self.maskTextInputs = maskTextInputs
125
+ }
126
+ if let maskImages = options["maskImages"] as? Bool {
127
+ self.maskImages = maskImages
128
+ }
129
+ if let maskSandboxedViews = options["maskSandboxedViews"] as? Bool {
130
+ self.maskSandboxedViews = maskSandboxedViews
131
+ }
132
+ if let maskButtons = options["maskButtons"] as? Bool {
133
+ self.maskButtons = maskButtons
134
+ }
135
+ if let maskLabels = options["maskLabels"] as? Bool {
136
+ self.maskLabels = maskLabels
137
+ }
138
+ if let maskWebViews = options["maskWebViews"] as? Bool {
139
+ self.maskWebViews = maskWebViews
140
+ }
141
+ if let quality = options["quality"] as? NSNumber {
142
+ self.imageQuality = CGFloat(quality.floatValue)
143
+ }
144
+ if let scale = options["scale"] as? NSNumber {
145
+ self.scale = CGFloat(scale.floatValue)
146
+ print("SessionRecorder: Scale updated to \(self.scale) (from NSNumber: \(scale))")
147
+ } else if let scale = options["scale"] as? Double {
148
+ self.scale = CGFloat(scale)
149
+ print("SessionRecorder: Scale updated to \(self.scale) (from Double: \(scale))")
150
+ } else if let scale = options["scale"] as? Float {
151
+ self.scale = CGFloat(scale)
152
+ print("SessionRecorder: Scale updated to \(self.scale) (from Float: \(scale))")
153
+ }
154
+ }
155
+
65
156
  private func applyMasking(to image: UIImage, in window: UIWindow) -> UIImage {
66
157
  return applyMaskingWithOptions(to: image, in: window, options: [:])
67
158
  }
@@ -73,23 +164,26 @@ class SessionRecorderNative: NSObject {
73
164
  // Draw the original image
74
165
  image.draw(in: CGRect(origin: .zero, size: image.size))
75
166
 
76
- // Find and mask sensitive elements
77
- let sensitiveElements = findSensitiveElements(in: window)
78
167
 
79
- for element in sensitiveElements {
80
- let frame = element.frame
81
- let maskingType = getMaskingType(for: element)
168
+ var maskableWidgets: [CGRect] = []
169
+ var maskChildren = false
170
+ findMaskableWidgets(window, window, &maskableWidgets, &maskChildren)
82
171
 
83
- switch maskingType {
84
- case .blur:
85
- applyBlurMask(in: context, frame: frame)
86
- case .rectangle:
87
- applyRectangleMask(in: context, frame: frame)
88
- case .pixelate:
89
- applyPixelateMask(in: context, frame: frame, image: image)
90
- case .none:
91
- break
172
+ for frame in maskableWidgets {
173
+ // Skip zero rects (which indicate invalid coordinates)
174
+ if frame == CGRect.zero { continue }
175
+
176
+ // Validate frame dimensions before processing
177
+ guard frame.size.width.isFinite && frame.size.height.isFinite &&
178
+ frame.origin.x.isFinite && frame.origin.y.isFinite else {
179
+ continue
92
180
  }
181
+
182
+ // Clip the frame to the image bounds to avoid drawing outside context
183
+ let clippedFrame = frame.intersection(CGRect(origin: .zero, size: image.size))
184
+ if clippedFrame.isNull || clippedFrame.isEmpty { continue }
185
+
186
+ applyCleanMask(in: context, frame: clippedFrame)
93
187
  }
94
188
 
95
189
  let maskedImage = UIGraphicsGetImageFromCurrentImageContext() ?? image
@@ -98,102 +192,322 @@ class SessionRecorderNative: NSObject {
98
192
  return maskedImage
99
193
  }
100
194
 
101
- private func findSensitiveElements(in view: UIView) -> [UIView] {
102
- var sensitiveElements: [UIView] = []
195
+ private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) {
196
+ // Skip hidden or transparent views
197
+ if !view.isVisible() {
198
+ return
199
+ }
200
+
201
+ // Check for UITextView (TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView)
202
+ if let textView = view as? UITextView {
203
+ if isTextViewSensitive(textView) {
204
+ maskableWidgets.append(view.toAbsoluteRect(window))
205
+ return
206
+ }
207
+ }
208
+
209
+ // Check for UITextField (SwiftUI: TextField, SecureField)
210
+ if let textField = view as? UITextField {
211
+ if isTextFieldSensitive(textField) {
212
+ maskableWidgets.append(view.toAbsoluteRect(window))
213
+ return
214
+ }
215
+ }
216
+
217
+ // React Native text views
218
+ if let reactNativeTextView = reactNativeTextView {
219
+ if view.isKind(of: reactNativeTextView), maskTextInputs {
220
+ maskableWidgets.append(view.toAbsoluteRect(window))
221
+ return
222
+ }
223
+ }
224
+
225
+ // React Native text inputs
226
+ if let reactNativeTextInput = reactNativeTextInput {
227
+ if view.isKind(of: reactNativeTextInput), maskTextInputs {
228
+ maskableWidgets.append(view.toAbsoluteRect(window))
229
+ return
230
+ }
231
+ }
232
+
233
+ if let reactNativeTextInputView = reactNativeTextInputView {
234
+ if view.isKind(of: reactNativeTextInputView), maskTextInputs {
235
+ maskableWidgets.append(view.toAbsoluteRect(window))
236
+ return
237
+ }
238
+ }
239
+
240
+ // UIImageView (SwiftUI: Some control images like the ones in Picker view)
241
+ if let imageView = view as? UIImageView {
242
+ if isImageViewSensitive(imageView) {
243
+ maskableWidgets.append(view.toAbsoluteRect(window))
244
+ return
245
+ }
246
+ }
247
+
248
+ // React Native image views
249
+ if let reactNativeImageView = reactNativeImageView {
250
+ if view.isKind(of: reactNativeImageView), maskImages {
251
+ maskableWidgets.append(view.toAbsoluteRect(window))
252
+ return
253
+ }
254
+ }
255
+
256
+ // UILabel (Text, this code might never be reachable in SwiftUI)
257
+ if let label = view as? UILabel {
258
+ if isLabelSensitive(label) {
259
+ maskableWidgets.append(view.toAbsoluteRect(window))
260
+ return
261
+ }
262
+ }
263
+
264
+ // WKWebView (Link, this code might never be reachable in SwiftUI)
265
+ if let webView = view as? WKWebView {
266
+ if isAnyInputSensitive(webView) {
267
+ maskableWidgets.append(view.toAbsoluteRect(window))
268
+ return
269
+ }
270
+ }
271
+
272
+ // UIButton (SwiftUI: SwiftUI.UIKitIconPreferringButton and other subclasses)
273
+ if let button = view as? UIButton {
274
+ if isButtonSensitive(button) {
275
+ maskableWidgets.append(view.toAbsoluteRect(window))
276
+ return
277
+ }
278
+ }
279
+
280
+ // UISwitch (SwiftUI: Toggle)
281
+ if let theSwitch = view as? UISwitch {
282
+ if isSwitchSensitive(theSwitch) {
283
+ maskableWidgets.append(view.toAbsoluteRect(window))
284
+ return
285
+ }
286
+ }
103
287
 
104
- func traverseView(_ view: UIView) {
105
- // Check if this view should be masked
106
- if shouldMaskView(view) {
107
- sensitiveElements.append(view)
288
+ // UIPickerView (SwiftUI: Picker with .pickerStyle(.wheel))
289
+ if let picker = view as? UIPickerView {
290
+ if isTextInputSensitive(picker), !view.subviews.isEmpty {
291
+ maskableWidgets.append(picker.toAbsoluteRect(window))
292
+ return
108
293
  }
294
+ }
109
295
 
110
- // Recursively check subviews
111
- for subview in view.subviews {
112
- traverseView(subview)
296
+ // Detect any views that don't belong to the current process (likely system views)
297
+ if maskSandboxedViews,
298
+ let systemSandboxedView,
299
+ view.isKind(of: systemSandboxedView) {
300
+ maskableWidgets.append(view.toAbsoluteRect(window))
301
+ return
302
+ }
303
+
304
+ let hasSubViews = !view.subviews.isEmpty
305
+
306
+ // SwiftUI: Text based views like Text, Button, TextEditor
307
+ if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
308
+ if isTextInputSensitive(view), !hasSubViews {
309
+ maskableWidgets.append(view.toAbsoluteRect(window))
310
+ return
113
311
  }
114
312
  }
115
313
 
116
- traverseView(view)
117
- return sensitiveElements
314
+ // SwiftUI: Image based views like Image, AsyncImage
315
+ if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
316
+ if isSwiftUIImageSensitive(view), !hasSubViews {
317
+ maskableWidgets.append(view.toAbsoluteRect(window))
318
+ return
319
+ }
320
+ }
321
+
322
+ // Generic SwiftUI types
323
+ if swiftUIGenericTypes.contains(where: { view.isKind(of: $0) }), !isSwiftUILayerSafe(view.layer) {
324
+ if isTextInputSensitive(view), !hasSubViews {
325
+ maskableWidgets.append(view.toAbsoluteRect(window))
326
+ return
327
+ }
328
+ }
329
+
330
+ // Recursively check subviews
331
+ if !view.subviews.isEmpty {
332
+ for child in view.subviews {
333
+ if !child.isVisible() {
334
+ continue
335
+ }
336
+ findMaskableWidgets(child, window, &maskableWidgets, &maskChildren)
337
+ }
338
+ }
339
+ maskChildren = false
118
340
  }
119
341
 
120
- private func shouldMaskView(_ view: UIView) -> Bool {
121
- // Check for UITextField - mask all text fields when inputMasking is enabled
122
- if view is UITextField {
342
+ // MARK: - Sensitive Content Detection Methods
343
+
344
+ private func isAnyInputSensitive(_ view: UIView) -> Bool {
345
+ return isTextInputSensitive(view) || maskImages
346
+ }
347
+
348
+ private func isTextInputSensitive(_ view: UIView) -> Bool {
349
+ return maskTextInputs || view.isNoCapture()
350
+ }
351
+
352
+ private func isLabelSensitive(_ view: UILabel) -> Bool {
353
+ return isTextInputSensitive(view) && hasText(view.text)
354
+ }
355
+
356
+ private func isButtonSensitive(_ view: UIButton) -> Bool {
357
+ return isTextInputSensitive(view) && hasText(view.titleLabel?.text)
358
+ }
359
+
360
+ private func isTextViewSensitive(_ view: UITextView) -> Bool {
361
+ return (isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)
362
+ }
363
+
364
+ private func isSwitchSensitive(_ view: UISwitch) -> Bool {
365
+ var containsText = true
366
+ if #available(iOS 14.0, *) {
367
+ containsText = hasText(view.title)
368
+ }
369
+ return isTextInputSensitive(view) && containsText
370
+ }
371
+
372
+ private func isTextFieldSensitive(_ view: UITextField) -> Bool {
373
+ return (isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder))
374
+ }
375
+
376
+ private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool {
377
+ return swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) })
378
+ }
379
+
380
+ private func hasText(_ text: String?) -> Bool {
381
+ if let text = text, !text.isEmpty {
123
382
  return true
383
+ } else {
384
+ // if there's no text, there's nothing to mask
385
+ return false
124
386
  }
387
+ }
388
+
389
+ private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
390
+ // No way of checking if this is an asset image or not
391
+ // No way of checking if there's actual content in the image or not
392
+ return maskImages || view.isNoCapture()
393
+ }
394
+
395
+ private func isImageViewSensitive(_ view: UIImageView) -> Bool {
396
+ // if there's no image, there's nothing to mask
397
+ guard let image = view.image else { return false }
125
398
 
126
- // Check for UITextView - mask all text views when inputMasking is enabled
127
- if view is UITextView {
399
+ // sensitive, regardless
400
+ if view.isNoCapture() {
128
401
  return true
129
402
  }
130
403
 
131
- return false
404
+ // asset images are probably not sensitive
405
+ if isAssetsImage(image) {
406
+ return false
407
+ }
408
+
409
+ // symbols are probably not sensitive
410
+ if image.isSymbolImage {
411
+ return false
412
+ }
413
+
414
+ return maskImages
132
415
  }
133
416
 
134
- private func getMaskingType(for view: UIView) -> MaskingType {
135
- // Default masking type for all text inputs
136
- return .rectangle
417
+ private func isAssetsImage(_ image: UIImage) -> Bool {
418
+ // https://github.com/daydreamboy/lldb_scripts#9-pimage
419
+ // do not mask if its an asset image, likely not PII anyway
420
+ return image.imageAsset?.value(forKey: "_containingBundle") != nil
137
421
  }
138
422
 
139
- private func applyBlurMask(in context: CGContext, frame: CGRect) {
140
- // Create a blur effect
141
- context.setFillColor(UIColor.black.withAlphaComponent(0.8).cgColor)
423
+ private func applyCleanMask(in context: CGContext, frame: CGRect) {
424
+ // Final validation before drawing to prevent CoreGraphics NaN errors
425
+ guard frame.size.width.isFinite && frame.size.height.isFinite &&
426
+ frame.origin.x.isFinite && frame.origin.y.isFinite &&
427
+ frame.size.width > 0 && frame.size.height > 0 else {
428
+ return
429
+ }
430
+
431
+ // Clean, consistent solid color masking approach
432
+ // Use system gray colors that adapt to light/dark mode
433
+ context.setFillColor(UIColor.systemGray5.cgColor)
142
434
  context.fill(frame)
143
435
 
144
- // Add some noise to make it look blurred
145
- context.setFillColor(UIColor.white.withAlphaComponent(0.3).cgColor)
146
- for _ in 0..<20 {
147
- let randomX = frame.origin.x + CGFloat.random(in: 0...frame.width)
148
- let randomY = frame.origin.y + CGFloat.random(in: 0...frame.height)
149
- let randomSize = CGFloat.random(in: 2...8)
150
- context.fillEllipse(in: CGRect(x: randomX, y: randomY, width: randomSize, height: randomSize))
151
- }
436
+ // Add subtle border for visual definition
437
+ context.setStrokeColor(UIColor.systemGray4.cgColor)
438
+ context.setLineWidth(0.5)
439
+ context.stroke(frame)
152
440
  }
153
441
 
154
- private func applyRectangleMask(in context: CGContext, frame: CGRect) {
155
- // Simple rectangle fill
156
- context.setFillColor(UIColor.gray.cgColor)
442
+ private func applyBlurMask(in context: CGContext, frame: CGRect) {
443
+ // Final validation before drawing to prevent CoreGraphics NaN errors
444
+ guard frame.size.width.isFinite && frame.size.height.isFinite &&
445
+ frame.origin.x.isFinite && frame.origin.y.isFinite &&
446
+ frame.size.width > 0 && frame.size.height > 0 else {
447
+ return
448
+ }
449
+
450
+ // Clean solid color masking
451
+ // Use a consistent gray color for consistent appearance
452
+ context.setFillColor(UIColor.systemGray5.cgColor)
157
453
  context.fill(frame)
158
454
 
159
- // Add some text-like pattern
160
- context.setFillColor(UIColor.darkGray.cgColor)
161
- let lineHeight: CGFloat = 4
162
- let spacing: CGFloat = 8
455
+ // Add subtle border for visual definition
456
+ context.setStrokeColor(UIColor.systemGray4.cgColor)
457
+ context.setLineWidth(1.0)
458
+ context.stroke(frame)
459
+ }
163
460
 
164
- for i in stride(from: frame.origin.y + spacing, to: frame.origin.y + frame.height - spacing, by: lineHeight + spacing) {
165
- let lineWidth = CGFloat.random(in: frame.width * 0.3...frame.width * 0.8)
166
- let lineX = frame.origin.x + CGFloat.random(in: 0...(frame.width - lineWidth))
167
- context.fill(CGRect(x: lineX, y: i, width: lineWidth, height: lineHeight))
461
+ private func applyRectangleMask(in context: CGContext, frame: CGRect) {
462
+ // Final validation before drawing to prevent CoreGraphics NaN errors
463
+ guard frame.size.width.isFinite && frame.size.height.isFinite &&
464
+ frame.origin.x.isFinite && frame.origin.y.isFinite &&
465
+ frame.size.width > 0 && frame.size.height > 0 else {
466
+ return
168
467
  }
468
+
469
+ // Clean solid rectangle masking
470
+ context.setFillColor(UIColor.systemGray5.cgColor)
471
+ context.fill(frame)
472
+
473
+ // Add subtle border
474
+ context.setStrokeColor(UIColor.systemGray4.cgColor)
475
+ context.setLineWidth(1.0)
476
+ context.stroke(frame)
169
477
  }
170
478
 
171
479
  private func applyPixelateMask(in context: CGContext, frame: CGRect, image: UIImage) {
172
- // Create a pixelated effect
173
- let pixelSize: CGFloat = 8
174
- let pixelCountX = Int(frame.width / pixelSize)
175
- let pixelCountY = Int(frame.height / pixelSize)
176
-
177
- for x in 0..<pixelCountX {
178
- for y in 0..<pixelCountY {
179
- let pixelFrame = CGRect(
180
- x: frame.origin.x + CGFloat(x) * pixelSize,
181
- y: frame.origin.y + CGFloat(y) * pixelSize,
182
- width: pixelSize,
183
- height: pixelSize
184
- )
185
-
186
- // Use a random color for each pixel
187
- let randomColor = UIColor(
188
- red: CGFloat.random(in: 0...1),
189
- green: CGFloat.random(in: 0...1),
190
- blue: CGFloat.random(in: 0...1),
191
- alpha: 1.0
192
- )
193
- context.setFillColor(randomColor.cgColor)
194
- context.fill(pixelFrame)
195
- }
480
+ // Final validation before drawing to prevent CoreGraphics NaN errors
481
+ guard frame.size.width.isFinite && frame.size.height.isFinite &&
482
+ frame.origin.x.isFinite && frame.origin.y.isFinite &&
483
+ frame.size.width > 0 && frame.size.height > 0 else {
484
+ return
196
485
  }
486
+
487
+ // Clean solid color masking (consistent with other methods)
488
+ context.setFillColor(UIColor.systemGray5.cgColor)
489
+ context.fill(frame)
490
+
491
+ // Add subtle border
492
+ context.setStrokeColor(UIColor.systemGray4.cgColor)
493
+ context.setLineWidth(1.0)
494
+ context.stroke(frame)
495
+ }
496
+
497
+ private func resizeImage(_ image: UIImage, scale: CGFloat) -> UIImage {
498
+ // Simple approach: scale the logical size directly
499
+ let newSize = CGSize(
500
+ width: max(1.0, image.size.width * scale),
501
+ height: max(1.0, image.size.height * scale)
502
+ )
503
+
504
+
505
+ // Use the same scale as the original image to maintain quality
506
+ UIGraphicsBeginImageContextWithOptions(newSize, false, image.scale)
507
+ image.draw(in: CGRect(origin: .zero, size: newSize))
508
+ let newImage = UIGraphicsGetImageFromCurrentImageContext()
509
+ UIGraphicsEndImageContext()
510
+ return newImage ?? image
197
511
  }
198
512
  }
199
513
 
@@ -203,3 +517,83 @@ private enum MaskingType {
203
517
  case pixelate
204
518
  case none
205
519
  }
520
+
521
+
522
+ extension UIView {
523
+ func isVisible() -> Bool {
524
+ // Check for NaN values in frame dimensions
525
+ let frame = self.frame
526
+ let width = frame.size.width
527
+ let height = frame.size.height
528
+
529
+ // Validate that dimensions are finite numbers (not NaN or infinite)
530
+ guard width.isFinite && height.isFinite else {
531
+ return false
532
+ }
533
+
534
+ return !isHidden && alpha > 0.01 && width > 0 && height > 0
535
+ }
536
+
537
+ func toAbsoluteRect(_ window: UIWindow) -> CGRect {
538
+ let bounds = self.bounds
539
+
540
+ // Validate bounds before conversion to prevent NaN values
541
+ guard bounds.size.width.isFinite && bounds.size.height.isFinite &&
542
+ bounds.origin.x.isFinite && bounds.origin.y.isFinite else {
543
+ // Return a zero rect if bounds contain NaN values
544
+ return CGRect.zero
545
+ }
546
+
547
+ let convertedRect = convert(bounds, to: window)
548
+
549
+ // Validate the converted rect to ensure no NaN values were introduced
550
+ guard convertedRect.size.width.isFinite && convertedRect.size.height.isFinite &&
551
+ convertedRect.origin.x.isFinite && convertedRect.origin.y.isFinite else {
552
+ // Return a zero rect if conversion resulted in NaN values
553
+ return CGRect.zero
554
+ }
555
+
556
+ return convertedRect
557
+ }
558
+
559
+ func isNoCapture() -> Bool {
560
+ // Check for common patterns that indicate sensitive content
561
+
562
+
563
+ // Check accessibility label for sensitive keywords
564
+ if let accessibilityLabel = accessibilityLabel?.lowercased() {
565
+ let sensitiveKeywords = ["password", "secret", "private", "sensitive", "confidential"]
566
+ if sensitiveKeywords.contains(where: accessibilityLabel.contains) {
567
+ return true
568
+ }
569
+ }
570
+
571
+ // Check for secure text entry
572
+ if let textField = self as? UITextField {
573
+ return textField.isSecureTextEntry
574
+ }
575
+
576
+ // Check for password-related class names or accessibility identifiers
577
+ if let accessibilityIdentifier = accessibilityIdentifier?.lowercased() {
578
+ let sensitiveIdentifiers = ["password", "secret", "private", "sensitive", "confidential"]
579
+ if sensitiveIdentifiers.contains(where: accessibilityIdentifier.contains) {
580
+ return true
581
+ }
582
+ }
583
+
584
+ return false
585
+ }
586
+
587
+ func isSensitiveText() -> Bool {
588
+ // Check if this view contains sensitive text content
589
+ if let textField = self as? UITextField {
590
+ return textField.isSecureTextEntry || isNoCapture()
591
+ }
592
+
593
+ if let textView = self as? UITextView {
594
+ return isNoCapture()
595
+ }
596
+
597
+ return false
598
+ }
599
+ }