Skip to content

Commit 1372064

Browse files
feat: ignore masking (#80)
- ldIgnore() method to turn off masking for a whole tree - fix issue when ldUnmask() could make view untappable in SwiftUI - fix unmaskAccessibilityIdentifiers <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduce ignore and unmask controls across API and masking engine, add SwiftUI/UIView helpers, and update sample usage. > > - **API / Associated Objects**: > - Replace `swiftUIKey` with `ignoreUIViewKey`; add `ignoreUIView(_:)` and `shouldIgnoreUIView(_:)`. > - **SwiftUI Modifier**: > - `SessionReplayModifier`/`SessionReplayViewRepresentable` now accept optional `isEnabled` and `isIgnored`; set `maskUIView`/`ignoreUIView` accordingly. > - **Options**: > - `PrivacyOptions`: add `unmaskUIViews` and `unmaskAccessibilityIdentifiers`; keep `ignoreUIViews` and mask lists. > - **Masking Engine (`MaskCollector`)**: > - New `Settings.shouldIgnore(_:)` to skip views via associated flag, class, or accessibility id. > - `shouldMask(_:)` updated to respect unmask by associated flag, class, and accessibility id; use class-based `maskUIViews`; remove SwiftUI-specific mask check. > - **Public Helpers**: > - Add `View.ldMask()` and `View.ldIgnore()`; update `ldPrivate`/`ldUnmask` to new modifier; add `UIView.ldIgnore()`. > - **Test App**: > - Switch to production mobile key; extend masked accessibility ids; apply `.ldIgnore()` to `SmoothieRow`/list items; minor layout tweak in number grid. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1d4d2f5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6d1cd15 commit 1372064

File tree

10 files changed

+128
-66
lines changed

10 files changed

+128
-66
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import UIKit
2+
3+
@objc(LDMasking)
4+
public class ObjcLDMasking: NSObject {
5+
// Use explicit selectors so we control the Obj-C names.
6+
@objc(maskView:)
7+
public static func mask(view: UIView) {
8+
view.ldMask()
9+
}
10+
11+
@objc(unmaskView:)
12+
public static func unmask(view: UIView) {
13+
view.ldUnmask()
14+
}
15+
16+
@objc(ignoreView:)
17+
public static func ignore(view: UIView) {
18+
view.ldIgnore()
19+
}
20+
}
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import UIKit
22

33
class SessionReplayAssociatedObjects: NSObject {
4-
private static var swiftUIKey: Int = 0
4+
private static var ignoreUIViewKey: Int = 0
55
private static var uiViewMaskKey: Int = 0
66

77
private override init() {}
88

9-
static public func maskSwiftUI(_ view: UIView, isEnabled: Bool = true) {
10-
objc_setAssociatedObject(view, &swiftUIKey, isEnabled ? 1 : 0, .OBJC_ASSOCIATION_ASSIGN)
9+
static public func ignoreUIView(_ view: UIView, isEnabled: Bool = true) {
10+
objc_setAssociatedObject(view, &ignoreUIViewKey, isEnabled ? 1 : 0, .OBJC_ASSOCIATION_ASSIGN)
1111
}
1212

13-
static public func shouldMaskSwiftUI(_ view: UIView) -> Bool? {
14-
guard let value = (objc_getAssociatedObject(view, &swiftUIKey) as? Int) else { return nil }
13+
static public func shouldIgnoreUIView(_ view: UIView) -> Bool? {
14+
guard let value = (objc_getAssociatedObject(view, &ignoreUIViewKey) as? Int) else { return nil }
1515
return value == 1
1616
}
1717

@@ -23,7 +23,6 @@ class SessionReplayAssociatedObjects: NSObject {
2323
guard let value = (objc_getAssociatedObject(view, &uiViewMaskKey) as? Int) else { return nil }
2424
return value == 1
2525
}
26-
2726
}
2827

29-
28+

Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,26 @@ import SwiftUI
22
import UIKit
33

44
struct SessionReplayModifier: ViewModifier {
5-
let isEnabled: Bool
5+
let isEnabled: Bool?
6+
let isIgnored: Bool?
67

78
public func body(content: Content) -> some View {
8-
content.overlay(SessionReplayViewRepresentable(isEnabled: isEnabled)).disabled(true)
9+
content.overlay(
10+
SessionReplayViewRepresentable(isEnabled: isEnabled, isIgnored: isIgnored)
11+
.disabled(true)
12+
)
913
}
1014
}
1115

1216
struct SessionReplayViewRepresentable: UIViewRepresentable {
1317
public typealias Context = UIViewRepresentableContext<Self>
1418

15-
let isEnabled: Bool
19+
let isEnabled: Bool?
20+
let isIgnored: Bool?
1621

17-
public init(isEnabled: Bool) {
22+
public init(isEnabled: Bool?, isIgnored: Bool?) {
1823
self.isEnabled = isEnabled
24+
self.isIgnored = isIgnored
1925
}
2026

2127
class MaskView: UIView { }
@@ -25,6 +31,11 @@ struct SessionReplayViewRepresentable: UIViewRepresentable {
2531
}
2632

2733
public func updateUIView(_ uiView: MaskView, context: Context) {
28-
SessionReplayAssociatedObjects.maskSwiftUI(uiView, isEnabled: isEnabled)
34+
if let isEnabled {
35+
SessionReplayAssociatedObjects.maskUIView(uiView, isEnabled: isEnabled)
36+
}
37+
if let isIgnored {
38+
SessionReplayAssociatedObjects.ignoreUIView(uiView, isEnabled: isIgnored)
39+
}
2940
}
3041
}

Sources/LaunchDarklySessionReplay/API/SessionReplayOptions.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ public struct SessionReplayOptions {
1010
public var maskImages: Bool
1111

1212
public var maskUIViews: [AnyClass]
13+
public var unmaskUIViews: [AnyClass]
1314
public var ignoreUIViews: [AnyClass]
1415

1516
public var maskAccessibilityIdentifiers: [String]
17+
public var unmaskAccessibilityIdentifiers: [String]
1618
public var ignoreAccessibilityIdentifiers: [String]
1719

1820
public var minimumAlpha: CGFloat
@@ -22,17 +24,21 @@ public struct SessionReplayOptions {
2224
maskLabels: Bool = false,
2325
maskImages: Bool = false,
2426
maskUIViews: [AnyClass] = [],
27+
unmaskUIViews: [AnyClass] = [],
2528
ignoreUIViews: [AnyClass] = [],
2629
maskAccessibilityIdentifiers: [String] = [],
30+
unmaskAccessibilityIdentifiers: [String] = [],
2731
ignoreAccessibilityIdentifiers: [String] = [],
2832
minimumAlpha: CGFloat = 0.02) {
2933
self.maskTextInputs = maskTextInputs
3034
self.maskWebViews = maskWebViews
3135
self.maskLabels = maskLabels
3236
self.maskImages = maskImages
3337
self.maskUIViews = maskUIViews
38+
self.unmaskUIViews = unmaskUIViews
3439
self.ignoreUIViews = ignoreUIViews
3540
self.maskAccessibilityIdentifiers = maskAccessibilityIdentifiers
41+
self.unmaskAccessibilityIdentifiers = unmaskAccessibilityIdentifiers
3642
self.ignoreAccessibilityIdentifiers = ignoreAccessibilityIdentifiers
3743
self.minimumAlpha = minimumAlpha
3844
}

Sources/LaunchDarklySessionReplay/API/View+ldMask.swift

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import SwiftUI
22
import UIKit
33

44
public extension View {
5+
func ldMask() -> some View {
6+
modifier(SessionReplayModifier(isEnabled: true, isIgnored: nil))
7+
}
8+
59
func ldPrivate(isEnabled: Bool = true) -> some View {
6-
modifier(SessionReplayModifier(isEnabled: isEnabled))
10+
modifier(SessionReplayModifier(isEnabled: isEnabled, isIgnored: nil))
11+
}
12+
13+
func ldIgnore() -> some View {
14+
modifier(SessionReplayModifier(isEnabled: nil, isIgnored: true))
715
}
816

917
func ldUnmask() -> some View {
10-
modifier(SessionReplayModifier(isEnabled: false))
18+
modifier(SessionReplayModifier(isEnabled: false, isIgnored: nil))
1119
}
1220
}
1321

@@ -16,7 +24,15 @@ public extension UIView {
1624
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: isEnabled)
1725
}
1826

27+
func ldMask() {
28+
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: true)
29+
}
30+
1931
func ldUnmask() {
2032
SessionReplayAssociatedObjects.maskUIView(self, isEnabled: false)
2133
}
34+
35+
func ldIgnore() {
36+
SessionReplayAssociatedObjects.ignoreUIView(self)
37+
}
2238
}

Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift

Lines changed: 58 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@ final class MaskCollector {
1313
enum Constants {
1414
static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"])
1515
}
16-
16+
1717
struct Settings {
1818
var maskiOS26ViewTypes: Set<String>
1919
var maskTextInputs: Bool
2020
var maskWebViews: Bool
2121
var maskImages: Bool
2222
var minimumAlpha: CGFloat
23-
var maskClasses: Set<ObjectIdentifier>
23+
24+
var maskUIViews: Set<ObjectIdentifier>
25+
var unmaskUIViews: Set<ObjectIdentifier>
26+
var ignoreUIViews: Set<ObjectIdentifier>
27+
2428
var maskAccessibilityIdentifiers: Set<String>
29+
var unmaskAccessibilityIdentifiers: Set<String>
2530
var ignoreAccessibilityIdentifiers: Set<String>
2631

2732
init(privacySettings: PrivacySettings) {
@@ -30,23 +35,51 @@ final class MaskCollector {
3035
self.maskWebViews = privacySettings.maskWebViews
3136
self.maskImages = privacySettings.maskImages
3237
self.minimumAlpha = privacySettings.minimumAlpha
33-
self.maskClasses = privacySettings.buildMaskClasses()
38+
39+
self.maskUIViews = Set(privacySettings.maskUIViews.map(ObjectIdentifier.init))
40+
self.unmaskUIViews = Set(privacySettings.unmaskUIViews.map(ObjectIdentifier.init))
41+
self.ignoreUIViews = Set(privacySettings.ignoreUIViews.map(ObjectIdentifier.init))
42+
3443
self.maskAccessibilityIdentifiers = Set(privacySettings.maskAccessibilityIdentifiers)
44+
self.unmaskAccessibilityIdentifiers = Set(privacySettings.unmaskAccessibilityIdentifiers)
3545
self.ignoreAccessibilityIdentifiers = Set(privacySettings.ignoreAccessibilityIdentifiers)
3646
}
37-
47+
48+
func shouldIgnore(_ view: UIView) -> Bool {
49+
let viewType = type(of: view)
50+
if SessionReplayAssociatedObjects.shouldIgnoreUIView(view) == true {
51+
return true
52+
}
53+
54+
if ignoreUIViews.contains(ObjectIdentifier(viewType)) {
55+
return true
56+
}
57+
58+
if let accessibilityIdentifier = view.accessibilityIdentifier,
59+
ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) {
60+
return true
61+
}
62+
63+
return false
64+
}
65+
3866
func shouldMask(_ view: UIView) -> Bool {
3967
if let shouldUnmask = SessionReplayAssociatedObjects.shouldMaskUIView(view),
40-
!shouldUnmask {
68+
!shouldUnmask {
4169
return false
4270
}
4371

4472
if let accessibilityIdentifier = view.accessibilityIdentifier,
45-
ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) {
73+
unmaskAccessibilityIdentifiers.contains(accessibilityIdentifier) {
4674
return false
4775
}
48-
76+
4977
let viewType = type(of: view)
78+
let viewIdentifier = ObjectIdentifier(viewType)
79+
if unmaskUIViews.contains(viewIdentifier) {
80+
return false
81+
}
82+
5083
let stringViewType = String(describing: viewType)
5184

5285
if maskiOS26ViewTypes.contains(stringViewType) {
@@ -83,23 +116,19 @@ final class MaskCollector {
83116
return true
84117
}
85118

86-
if SessionReplayAssociatedObjects.shouldMaskSwiftUI(view) ?? false {
87-
return true
88-
}
89-
90-
if SessionReplayAssociatedObjects.shouldMaskUIView(view) ?? false {
119+
if maskUIViews.contains(viewIdentifier) {
91120
return true
92121
}
93122

94123
if let accessibilityIdentifier = view.accessibilityIdentifier,
95124
maskAccessibilityIdentifiers.contains(accessibilityIdentifier) {
96125
return true
97126
}
98-
99-
return false
127+
128+
return SessionReplayAssociatedObjects.shouldMaskUIView(view) == true
100129
}
101130
}
102-
131+
103132
var settings: Settings
104133

105134
public init(privacySettings: PrivacySettings) {
@@ -118,26 +147,26 @@ final class MaskCollector {
118147
view.alpha >= settings.minimumAlpha
119148
else { return }
120149

121-
//let layer = currentView.layer.presentation() ?? currentView.layer
150+
guard !settings.shouldIgnore(view) else { return }
151+
122152
let effectiveFrame = rPresenation.convert(layer.frame, from: layer.superlayer)
153+
123154
let shouldMask = settings.shouldMask(view)
124155
if shouldMask, let mask = createMask(rPresenation, root: root, layer: layer, scale: scale) {
125156
var operation = MaskOperation(mask: mask, kind: .fill, effectiveFrame: effectiveFrame)
126-
#if DEBUG
157+
#if DEBUG
127158
operation.accessibilityIdentifier = view.accessibilityIdentifier
128-
#endif
159+
#endif
129160
result.append(operation)
130161
return
131162
}
132163

133164
if !isSystem(view: view, pLayer: layer) && !isTransparent(view: view, pLayer: layer), result.isNotEmpty {
134-
// if view.accessibilityIdentifier != nil {
135-
result.removeAll {
136-
effectiveFrame.contains($0.effectiveFrame)
137-
}
138-
// }
165+
result.removeAll {
166+
effectiveFrame.contains($0.effectiveFrame)
167+
}
139168
}
140-
169+
141170
if let sublayers = layer.sublayers?.sorted(by: { $0.zPosition < $1.zPosition }) {
142171
sublayers.forEach(visit)
143172
}
@@ -153,16 +182,14 @@ final class MaskCollector {
153182
}
154183

155184
func isSystem(view: UIView, pLayer: CALayer) -> Bool {
156-
return false
185+
return false
157186
}
158187

159188
func createMask(_ rPresenation: CALayer, root: CALayer, layer: CALayer, scale: CGFloat) -> Mask? {
160-
let scale = 1.0 // scale is already in layers
161-
// let rBounds = rPresenation.bounds
189+
let scale = 1.0
162190
let lBounds = layer.bounds
163191
guard lBounds.width > 0, lBounds.height > 0 else { return nil }
164-
165-
//let lPresenation = layer.presentation() ?? layer
192+
166193
if CATransform3DIsAffine(layer.transform) {
167194
let corner0 = layer.convert(CGPoint.zero, to: root)
168195
let corner1 = layer.convert(CGPoint(x: lBounds.width, y: 0), to: root)
@@ -176,10 +203,8 @@ final class MaskCollector {
176203
tx: tx,
177204
ty: ty).scaledBy(x: scale, y: scale)
178205
return Mask.affine(rect: lBounds, transform: affineTransform)
179-
} else { // 3D animations
180-
// let corner0 = CGPoint.zero
181-
// let corner1 = CGPoint(x: lBounds.width, y: 0)
182-
206+
} else {
207+
// TODO: finish 3D animations
183208
}
184209

185210
return nil
@@ -193,21 +218,5 @@ final class MaskCollector {
193218
y: min(corner1.y, corner2.y),
194219
width: abs(corner2.x - corner1.x),
195220
height: abs(corner2.y - corner1.y))
196-
197-
}
198-
}
199-
200-
extension PrivacySettings {
201-
func buildMaskClasses() -> Set<ObjectIdentifier> {
202-
let ids = Set(maskUIViews.map(ObjectIdentifier.init))
203-
204-
205-
// if privacySettings.maskTextInputs {
206-
// [UITextField.self, UITextView.self, UIWebView.self, UISearchTextField.self,
207-
// SwiftUI.UITextView.self, SwiftUI.UITextView.self].forEach {
208-
// ids.insert(ObjectIdentifier($0))
209-
// }
210-
// }
211-
return ids
212221
}
213222
}

TestApp/Sources/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ let config = { () -> LDConfig in
3636
maskTextInputs: true,
3737
maskWebViews: false,
3838
maskImages: false,
39-
maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip"],
39+
maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip", "10"],
4040
)
4141
))
4242
]

TestApp/Sources/Fruits/Shared/Smoothie/SmoothieList.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ struct SmoothieList: View {
2626
NavigationLink(tag: smoothie.id, selection: $model.selectedSmoothieID) {
2727
SmoothieView(smoothie: smoothie).environmentObject(model)
2828
} label: {
29-
SmoothieRow(smoothie: smoothie)
29+
SmoothieRow(smoothie: smoothie).ldIgnore()
3030
}
3131
.onChange(of: model.selectedSmoothieID) { newValue in
3232
// Need to make sure the Smoothie exists.

TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ struct SmoothieRow: View {
4545
}
4646
.font(.subheadline)
4747
.accessibilityElement(children: .combine)
48+
.ldIgnore()
4849
}
4950

5051
var listedIngredients: String {

0 commit comments

Comments
 (0)