Skip to content

Commit 3ac3fe6

Browse files
committed
optimizations
1 parent 3d0d58b commit 3ac3fe6

3 files changed

Lines changed: 78 additions & 59 deletions

File tree

Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ final class MaskCollector {
101101
}
102102

103103
// Returns `true` if a mask was emitted for this view (the caller should stop recursing).
104-
func emitViewMask(view: UIView, layer: CALayer, viewType: AnyClass, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool {
105-
let shouldMask = policy.shouldMask(view, viewType: viewType, resolvedExplicitMask: resolvedExplicitMask)
104+
func emitViewMask(view: UIView, layer: CALayer, viewType: AnyClass, className: String, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool {
105+
let shouldMask = policy.shouldMask(view, viewType: viewType, className: className, resolvedExplicitMask: resolvedExplicitMask)
106106

107107
if shouldMask, let mask = MaskGeometry.createMask(rPresentation: rPresentation, layer: layer, scale: scale) {
108108
var operation = MaskOperation(mask: mask, effectiveFrame: effectiveFrame)
@@ -133,26 +133,24 @@ final class MaskCollector {
133133
// backing UIView, so the UIView-based path can't see them. Match by layer class name
134134
// while still honouring an inherited or marker-area explicit state.
135135
// Returns `true` if a mask was emitted (the caller should stop recursing).
136-
func emitLayerOnlyMask(layer: CALayer, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool {
137-
let shouldMask = resolvedExplicitMask ?? policy.shouldMaskLayer(layer)
136+
func emitLayerOnlyMask(layerClassName: String, layer: CALayer, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool {
137+
let shouldMask = resolvedExplicitMask ?? policy.shouldMaskLayer(className: layerClassName)
138138
guard shouldMask, let mask = MaskGeometry.createMask(rPresentation: rPresentation, layer: layer, scale: scale) else {
139139
return false
140140
}
141141
operations.append(MaskOperation(mask: mask, effectiveFrame: effectiveFrame))
142142
return true
143143
}
144144

145-
func visit(layer: CALayer, inheritedExplicitMask: Bool?) {
145+
func visit(layer: CALayer, layerClassName: String, inheritedExplicitMask: Bool?) {
146146
// On iOS 26+, CameraUI private CALayer subclasses (e.g. ModeLoupeLayer) do not
147147
// implement init(layer:). Guard at the very top — before ANY property access —
148148
// because even isHidden/opacity access can trigger CA::Layer::presentation_layer()
149-
// on a layer that lacks the initializer. The same guard is applied at every
150-
// sublayer iteration site so CameraUI layers are never passed to visit() at all;
151-
// this check is belt-and-suspenders for any path not covered there.
152-
if NSStringFromClass(type(of: layer)).hasPrefix("CameraUI") { return }
149+
// on a layer that lacks the initializer. `layerClassName` is computed once by
150+
// the caller so we never call NSStringFromClass twice for the same layer.
151+
if policy.shouldSkipLayer(className: layerClassName) { return }
153152

154153
guard !layer.isHidden, layer.opacity >= policy.minimumAlpha else { return }
155-
if policy.shouldSkipLayer(layer) { return }
156154

157155
// Frame in root coords is needed both for marker-area lookup
158156
// and for `effectiveFrame`/`MaskOperation`. Compute it once.
@@ -175,8 +173,9 @@ final class MaskCollector {
175173
}
176174

177175
let viewType: AnyClass = type(of: view)
176+
let viewClassName = NSStringFromClass(viewType)
178177

179-
if policy.shouldIgnore(view, viewType: viewType) || markerOverrideForLayer?.ignore == true {
178+
if policy.shouldIgnore(view, viewType: viewType, className: viewClassName) || markerOverrideForLayer?.ignore == true {
180179
return
181180
}
182181

@@ -186,7 +185,12 @@ final class MaskCollector {
186185
inheritedExplicitMask: inheritedExplicitMask,
187186
markerMask: markerOverrideForLayer?.mask
188187
)
189-
if emitViewMask(view: view, layer: layer, viewType: viewType, effectiveFrame: effectiveFrame, resolvedExplicitMask: resolvedExplicitMask) {
188+
if emitViewMask(view: view,
189+
layer: layer,
190+
viewType: viewType,
191+
className: viewClassName,
192+
effectiveFrame: effectiveFrame,
193+
resolvedExplicitMask: resolvedExplicitMask) {
190194
return
191195
}
192196
childInheritedMask = resolvedExplicitMask
@@ -199,7 +203,7 @@ final class MaskCollector {
199203
} else {
200204
resolvedExplicitMask = inheritedExplicitMask ?? markerOverrideForLayer?.mask
201205
}
202-
if emitLayerOnlyMask(layer: layer, effectiveFrame: effectiveFrame, resolvedExplicitMask: resolvedExplicitMask) {
206+
if emitLayerOnlyMask(layerClassName: layerClassName, layer: layer, effectiveFrame: effectiveFrame, resolvedExplicitMask: resolvedExplicitMask) {
203207
return
204208
}
205209
childInheritedMask = resolvedExplicitMask
@@ -215,31 +219,41 @@ final class MaskCollector {
215219
// layers avoids this. Mask positions may be slightly off during active
216220
// animations, but correctness under normal (non-animating) state is preserved.
217221
guard let modelSublayers = layer.model().sublayers, !modelSublayers.isEmpty else { return }
218-
let safeSublayers = modelSublayers.filter {
219-
!NSStringFromClass(type(of: $0)).hasPrefix("CameraUI")
222+
var safeSublayers: [(CALayer, String)] = []
223+
safeSublayers.reserveCapacity(modelSublayers.count)
224+
for sublayer in modelSublayers {
225+
let sublayerClassName = NSStringFromClass(type(of: sublayer))
226+
if !policy.shouldSkipLayer(className: sublayerClassName) {
227+
safeSublayers.append((sublayer, sublayerClassName))
228+
}
220229
}
221230
guard !safeSublayers.isEmpty else { return }
222231
if safeSublayers.count == 1 {
223-
visit(layer: safeSublayers[0], inheritedExplicitMask: childInheritedMask)
232+
visit(layer: safeSublayers[0].0, layerClassName: safeSublayers[0].1, inheritedExplicitMask: childInheritedMask)
224233
} else {
225-
safeSublayers.sorted { $0.zPosition < $1.zPosition }
226-
.forEach { visit(layer: $0, inheritedExplicitMask: childInheritedMask) }
234+
safeSublayers.sorted { $0.0.zPosition < $1.0.zPosition }
235+
.forEach { visit(layer: $0.0, layerClassName: $0.1, inheritedExplicitMask: childInheritedMask) }
227236
}
228237
}
229238

230239
// Use model sublayers at the root level for the same reason: .sublayers on a
231240
// presentation layer creates presentation copies of all children, crashing on
232241
// iOS 26 if any child is or contains CameraUI.ModeLoupeLayer.
233242
let rootModelSublayers = rPresentation.model().sublayers ?? []
234-
let safeRootSublayers = rootModelSublayers.filter {
235-
!NSStringFromClass(type(of: $0)).hasPrefix("CameraUI")
243+
var safeRootSublayers: [(CALayer, String)] = []
244+
safeRootSublayers.reserveCapacity(rootModelSublayers.count)
245+
for sublayer in rootModelSublayers {
246+
let sublayerClassName = NSStringFromClass(type(of: sublayer))
247+
if !policy.shouldSkipLayer(className: sublayerClassName) {
248+
safeRootSublayers.append((sublayer, sublayerClassName))
249+
}
236250
}
237251
if !safeRootSublayers.isEmpty {
238252
if safeRootSublayers.count == 1 {
239-
visit(layer: safeRootSublayers[0], inheritedExplicitMask: nil)
253+
visit(layer: safeRootSublayers[0].0, layerClassName: safeRootSublayers[0].1, inheritedExplicitMask: nil)
240254
} else {
241-
safeRootSublayers.sorted { $0.zPosition < $1.zPosition }
242-
.forEach { visit(layer: $0, inheritedExplicitMask: nil) }
255+
safeRootSublayers.sorted { $0.0.zPosition < $1.0.zPosition }
256+
.forEach { visit(layer: $0.0, layerClassName: $0.1, inheritedExplicitMask: nil) }
243257
}
244258
}
245259

Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,21 @@ import Common
2222
final class MaskingPolicy {
2323
enum Constants {
2424
// Private iOS 26 camera UI views whose layer subtrees contain CALayer
25-
// subclasses (e.g. `ModeLoupeLayer`) that trap on `init(layer:)` when
26-
// session replay walks or snapshots the hierarchy. Mask the enclosing
27-
// view and stop recursing so those layers are never touched.
25+
// subclasses that trap on `init(layer:)` when session replay walks the
26+
// hierarchy. Mask the enclosing view and stop recursing.
2827
static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"])
2928

30-
// Layer-only nodes in the same subtrees that must not be traversed when
31-
// reached without a backing UIView (the pre-refactor collector skipped
32-
// all layer-only nodes; the iOS 26 layer walk must still skip these).
33-
static let maskiOS26LayerTypes = Set(["CameraUI.ModeLoupeLayer"])
29+
// Private iOS 26 camera UI layers that must not be traversed — they lack
30+
// `init(layer:)` and crash when Core Animation copies them.
31+
static let skipiOS26LayerTypes = Set(["CameraUI.ModeLoupeLayer"])
32+
33+
static func isCameraUIView(className: String) -> Bool {
34+
maskiOS26ViewTypes.contains(className)
35+
}
36+
37+
static func isCameraUILayer(className: String) -> Bool {
38+
skipiOS26LayerTypes.contains(className)
39+
}
3440

3541
// Private UIKit view types SwiftUI uses to render `Text` on iOS <= 18
3642
// (Core Graphics drawn content). Matching by type name because these
@@ -88,13 +94,13 @@ final class MaskingPolicy {
8894
self.ignoreAccessibilityIdentifiers = Set(privacySettings.ignoreAccessibilityIdentifiers)
8995
}
9096

91-
func shouldIgnore(_ view: UIView, viewType: AnyClass) -> Bool {
97+
func shouldIgnore(_ view: UIView, viewType: AnyClass, className: String) -> Bool {
9298
// Skip entire CameraUI subtrees on iOS 26+. CameraUI.ModeLoupeLayer (a private
9399
// CALayer subclass in this hierarchy) does not implement init(layer:). Accessing
94100
// .sublayers on its parent causes CA::Layer::presentation_layer() to call the
95101
// missing initializer, producing a fatal EXC_BREAKPOINT crash. Returning true
96102
// here stops recursion into the subtree before we ever reach that layer.
97-
if String(describing: viewType).hasPrefix("CameraUI") { return true }
103+
if Constants.isCameraUIView(className: className) { return true }
98104

99105
if SessionReplayAssociatedObjects.shouldIgnoreUIView(view) == true {
100106
return true
@@ -140,19 +146,15 @@ final class MaskingPolicy {
140146
return false
141147
}
142148

143-
func shouldMaskFromGlobalConfig(_ view: UIView, viewType: AnyClass) -> Bool {
144-
let stringViewType = String(describing: viewType)
145-
149+
func shouldMaskFromGlobalConfig(_ view: UIView, className: String) -> Bool {
146150
// Checked first so iOS 26 camera chrome is always masked regardless of
147151
// other privacy toggles. Masking stops subtree traversal, avoiding
148152
// `init(layer:)` crashes in private CameraUI layers.
149-
if Constants.maskiOS26ViewTypes.contains(stringViewType) {
150-
return true
151-
}
153+
if Constants.isCameraUIView(className: className) { return true }
152154

153155
// Cheap concrete-type checks first; these short-circuit the
154156
// common cases (`UILabel`, `UIImageView`, `WKWebView`, plain
155-
// `UITextField`/`UITextView`) without recomputing `stringViewType`.
157+
// `UITextField`/`UITextView`) without further string lookups.
156158
if maskWebViews {
157159
#if canImport(WebKit)
158160
if view is WKWebView {
@@ -169,23 +171,23 @@ final class MaskingPolicy {
169171
return true
170172
}
171173

172-
// `UITextInput` is a protocol; reuse `stringViewType` for the
174+
// `UITextInput` is a protocol; reuse `className` for the
173175
// `WKContentView` discrimination below.
174176
if maskTextInputs, view is UITextInput {
175177
#if canImport(WebKit)
176-
if stringViewType != "WKContentView" {
178+
if className != "WKContentView" {
177179
return true
178180
}
179181
#else
180182
return true
181183
#endif
182184
}
183185

184-
if maskTextInputs, stringViewType == "UIKeyboard" {
186+
if maskTextInputs, className == "UIKeyboard" {
185187
return true
186188
}
187189

188-
if maskLabels, Constants.swiftUITextViewTypes.contains(stringViewType) {
190+
if maskLabels, Constants.swiftUITextViewTypes.contains(className) {
189191
return true
190192
}
191193

@@ -213,16 +215,15 @@ final class MaskingPolicy {
213215
}
214216

215217
/// Final precedence: an explicit (resolved) state wins; otherwise fall back to global config.
216-
func shouldMask(_ view: UIView, viewType: AnyClass, resolvedExplicitMask: Bool?) -> Bool {
217-
return resolvedExplicitMask ?? shouldMaskFromGlobalConfig(view, viewType: viewType)
218+
func shouldMask(_ view: UIView, viewType: AnyClass, className: String, resolvedExplicitMask: Bool?) -> Bool {
219+
return resolvedExplicitMask ?? shouldMaskFromGlobalConfig(view, className: className)
218220
}
219221

220222
/// Private iOS 26 camera layers that trap when session replay walks or
221-
/// snapshots them. The pre-refactor collector never descended into
222-
/// layer-only nodes; skip these outright instead of calling geometry
223+
/// snapshots them. Skip these outright instead of calling geometry
223224
/// helpers that can trigger `init(layer:)`.
224-
func shouldSkipLayer(_ layer: CALayer) -> Bool {
225-
Constants.maskiOS26LayerTypes.contains(String(describing: type(of: layer)))
225+
func shouldSkipLayer(className: String) -> Bool {
226+
Constants.isCameraUILayer(className: className)
226227
}
227228

228229
/// Evaluates whether a `CALayer` that has no backing `UIView` should be masked.
@@ -231,12 +232,11 @@ final class MaskingPolicy {
231232
/// Symbols directly as private `CALayer` subclasses without wrapping them in
232233
/// `UIView`s. The usual `shouldMask(_ view:)` path can't see these, so we
233234
/// match by the layer's class name.
234-
func shouldMaskLayer(_ layer: CALayer) -> Bool {
235-
let layerType = String(describing: type(of: layer))
236-
if maskLabels, Constants.swiftUITextLayerTypes.contains(layerType) {
235+
func shouldMaskLayer(className: String) -> Bool {
236+
if maskLabels, Constants.swiftUITextLayerTypes.contains(className) {
237237
return true
238238
}
239-
if maskImages, Constants.swiftUIImageLayerTypes.contains(layerType) {
239+
if maskImages, Constants.swiftUIImageLayerTypes.contains(className) {
240240
return true
241241
}
242242
return false

Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,21 +99,24 @@ struct MaskCollectorPrecedenceTests {
9999
@Test("shouldMask returns true when the resolved explicit state is true")
100100
func shouldMaskExplicitMaskWins() {
101101
let view = UIView()
102-
#expect(makeSettings().shouldMask(view, viewType: type(of: view), resolvedExplicitMask: true) == true)
102+
let className = NSStringFromClass(type(of: view))
103+
#expect(makeSettings().shouldMask(view, viewType: type(of: view), className: className, resolvedExplicitMask: true) == true)
103104
}
104105

105106
@Test("shouldMask: resolved unmask overrides a global rule that would otherwise mask")
106107
func shouldMaskExplicitUnmaskOverridesGlobal() {
107108
let settings = makeSettings(.init(maskLabels: true))
108109
let view = UILabel()
109-
#expect(settings.shouldMask(view, viewType: type(of: view), resolvedExplicitMask: false) == false)
110+
let className = NSStringFromClass(type(of: view))
111+
#expect(settings.shouldMask(view, viewType: type(of: view), className: className, resolvedExplicitMask: false) == false)
110112
}
111113

112114
@Test("shouldMask: with no explicit rule, the global config decides")
113115
func shouldMaskGlobalFallback() {
114116
let settings = makeSettings(.init(maskLabels: true))
115117
let view = UILabel()
116-
#expect(settings.shouldMask(view, viewType: type(of: view), resolvedExplicitMask: nil) == true)
118+
let className = NSStringFromClass(type(of: view))
119+
#expect(settings.shouldMask(view, viewType: type(of: view), className: className, resolvedExplicitMask: nil) == true)
117120
}
118121

119122
// MARK: - iOS 26 camera UI (ModeLoupeLayer crash regression)
@@ -133,13 +136,15 @@ struct MaskCollectorPrecedenceTests {
133136
))
134137
guard let cameraClass = NSClassFromString("CameraUI.ChromeSwiftUIView") else { return }
135138
let view = (cameraClass as! UIView.Type).init()
136-
#expect(settings.shouldMask(view, viewType: cameraClass, resolvedExplicitMask: nil) == true)
139+
let className = NSStringFromClass(cameraClass)
140+
#expect(settings.shouldMask(view, viewType: cameraClass, className: className, resolvedExplicitMask: nil) == true)
137141
}
138142

139143
@Test("shouldSkipLayer skips CameraUI.ModeLoupeLayer")
140144
func skipsIOS26CameraLayers() {
141145
let settings = makeSettings()
142-
#expect(MaskingPolicy.Constants.maskiOS26LayerTypes.contains("CameraUI.ModeLoupeLayer"))
143-
#expect(settings.shouldSkipLayer(CALayer()) == false)
146+
#expect(MaskingPolicy.Constants.skipiOS26LayerTypes.contains("CameraUI.ModeLoupeLayer"))
147+
#expect(settings.shouldSkipLayer(className: "CameraUI.ModeLoupeLayer"))
148+
#expect(settings.shouldSkipLayer(className: NSStringFromClass(type(of: CALayer()))) == false)
144149
}
145150
}

0 commit comments

Comments
 (0)