Skip to content

Commit 3d0d58b

Browse files
committed
shouldSkipLayer
1 parent 6efb2b5 commit 3d0d58b

6 files changed

Lines changed: 177 additions & 24 deletions

File tree

Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ final class MaskCollector {
152152
if NSStringFromClass(type(of: layer)).hasPrefix("CameraUI") { return }
153153

154154
guard !layer.isHidden, layer.opacity >= policy.minimumAlpha else { return }
155+
if policy.shouldSkipLayer(layer) { return }
155156

156157
// Frame in root coords is needed both for marker-area lookup
157158
// and for `effectiveFrame`/`MaskOperation`. Compute it once.

Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,17 @@ import Common
2121
/// into a shared `MaskingPolicy` instance.
2222
final class MaskingPolicy {
2323
enum Constants {
24+
// 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.
2428
static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"])
2529

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"])
34+
2635
// Private UIKit view types SwiftUI uses to render `Text` on iOS <= 18
2736
// (Core Graphics drawn content). Matching by type name because these
2837
// classes are not publicly exposed.
@@ -132,10 +141,18 @@ final class MaskingPolicy {
132141
}
133142

134143
func shouldMaskFromGlobalConfig(_ view: UIView, viewType: AnyClass) -> Bool {
144+
let stringViewType = String(describing: viewType)
145+
146+
// Checked first so iOS 26 camera chrome is always masked regardless of
147+
// other privacy toggles. Masking stops subtree traversal, avoiding
148+
// `init(layer:)` crashes in private CameraUI layers.
149+
if Constants.maskiOS26ViewTypes.contains(stringViewType) {
150+
return true
151+
}
152+
135153
// Cheap concrete-type checks first; these short-circuit the
136154
// common cases (`UILabel`, `UIImageView`, `WKWebView`, plain
137-
// `UITextField`/`UITextView`) without ever computing the
138-
// `String(describing: viewType)` representation.
155+
// `UITextField`/`UITextView`) without recomputing `stringViewType`.
139156
if maskWebViews {
140157
#if canImport(WebKit)
141158
if view is WKWebView {
@@ -152,33 +169,16 @@ final class MaskingPolicy {
152169
return true
153170
}
154171

155-
// `UITextInput` is a protocol; this check still avoids any
156-
// string allocation for the vast majority of views (the
157-
// protocol conformance is implemented in cached witness
158-
// tables on a small set of UIKit classes).
159-
//
160-
// The remaining checks all key off the type's name, so once
161-
// we've allocated the string for the `WKContentView`
162-
// discrimination we keep reusing it instead of recomputing.
163-
#if canImport(WebKit)
164-
let stringViewType: String
172+
// `UITextInput` is a protocol; reuse `stringViewType` for the
173+
// `WKContentView` discrimination below.
165174
if maskTextInputs, view is UITextInput {
166-
stringViewType = String(describing: viewType)
175+
#if canImport(WebKit)
167176
if stringViewType != "WKContentView" {
168177
return true
169178
}
170-
} else {
171-
stringViewType = String(describing: viewType)
172-
}
173179
#else
174-
if maskTextInputs, view is UITextInput {
175180
return true
176-
}
177-
let stringViewType = String(describing: viewType)
178181
#endif
179-
180-
if Constants.maskiOS26ViewTypes.contains(stringViewType) {
181-
return true
182182
}
183183

184184
if maskTextInputs, stringViewType == "UIKeyboard" {
@@ -217,6 +217,14 @@ final class MaskingPolicy {
217217
return resolvedExplicitMask ?? shouldMaskFromGlobalConfig(view, viewType: viewType)
218218
}
219219

220+
/// 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+
/// helpers that can trigger `init(layer:)`.
224+
func shouldSkipLayer(_ layer: CALayer) -> Bool {
225+
Constants.maskiOS26LayerTypes.contains(String(describing: type(of: layer)))
226+
}
227+
220228
/// Evaluates whether a `CALayer` that has no backing `UIView` should be masked.
221229
///
222230
/// Starting on iOS 26 ("Liquid Glass"), SwiftUI renders `Text`, `Image`, and SF

TestApp/Info.plist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
<string>$(otlpEndpoint)</string>
99
<key>backendUrl</key>
1010
<string>$(backendUrl)</string>
11+
<key>NSCameraUsageDescription</key>
12+
<string>Used to present the system camera UI to reproduce the CameraUI.ModeLoupeLayer Session Replay crash.</string>
13+
<key>NSMicrophoneUsageDescription</key>
14+
<string>Used by the system camera UI when capturing video.</string>
15+
<key>NSPhotoLibraryUsageDescription</key>
16+
<string>Used by the system camera UI to save or pick captured media.</string>
1117
<key>NSAppTransportSecurity</key>
1218
<dict>
1319
<key>NSAllowsLocalNetworking</key>

TestApp/Sources/MainMenuView.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ struct MainMenuView: View {
3939
case .notebook: NotebookView()
4040
case .dialogsUIKit: DialogsUIKitView()
4141
case .dialogsSwiftUI: DialogsSwiftUIView()
42+
case .camera: CameraSampleView()
4243
#endif
4344
#if canImport(WebKit)
4445
case .webView: WebViewControllertView()
@@ -130,19 +131,25 @@ struct MainMenuView: View {
130131
#endif
131132
HStack {
132133
#if os(iOS)
133-
Button("Drawing") {
134+
Button("Draw") {
134135
activeSheet = .notebook
135136
}
136137
.accessibilityIdentifier("a-drawing")
137138
.buttonStyle(.borderedProminent)
139+
140+
Button("Camera") {
141+
activeSheet = .camera
142+
}
143+
.accessibilityIdentifier("a-camera")
144+
.buttonStyle(.borderedProminent)
138145
#endif
139146

140147
Button("Storyboard") {
141148
activeSheet = .storyboard
142149
}
143150
.buttonStyle(.borderedProminent)
144151
#if canImport(WebKit)
145-
Button("WebView") {
152+
Button("Web View") {
146153
activeSheet = .webView
147154
}
148155
.buttonStyle(.borderedProminent)
@@ -353,6 +360,7 @@ private enum MenuSheet: Identifiable {
353360
case notebook
354361
case dialogsUIKit
355362
case dialogsSwiftUI
363+
case camera
356364
#endif
357365
#if canImport(WebKit)
358366
case webView
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#if os(iOS)
2+
3+
import SwiftUI
4+
import UIKit
5+
import LaunchDarklyObservability
6+
7+
/// Presents the system camera UI (`UIImagePickerController` with `sourceType = .camera`).
8+
///
9+
/// The purpose of this screen is to reproduce a fatal `EXC_BREAKPOINT` crash that occurs on
10+
/// iOS 26 when LaunchDarkly Session Replay is enabled and the system camera UI is on screen:
11+
///
12+
/// Fatal error: Use of unimplemented initializer 'init(layer:)' for class
13+
/// 'CameraUI.ModeLoupeLayer'
14+
///
15+
/// While Session Replay captures a frame it walks the on-screen layer tree and copies layers.
16+
/// The private `CameraUI.ModeLoupeLayer` (the zoom / mode loupe shown in the camera controls)
17+
/// does not implement `init(layer:)`, so the copy traps.
18+
///
19+
/// Requires running on a physical device with a camera (the Simulator has no camera source).
20+
struct CameraSampleView: View {
21+
@Environment(\.dismiss) private var dismiss
22+
23+
var body: some View {
24+
Group {
25+
if UIImagePickerController.isSourceTypeAvailable(.camera) {
26+
CameraPickerRepresentable {
27+
dismiss()
28+
}
29+
.ignoresSafeArea()
30+
} else {
31+
CameraUnavailableView {
32+
dismiss()
33+
}
34+
}
35+
}
36+
.trackScreen("Camera Sample")
37+
}
38+
}
39+
40+
/// Wraps `UIImagePickerController` so the full system camera UI (including the `ModeLoupeLayer`
41+
/// zoom / mode controls) is presented on screen.
42+
private struct CameraPickerRepresentable: UIViewControllerRepresentable {
43+
let onFinish: () -> Void
44+
45+
func makeCoordinator() -> Coordinator {
46+
Coordinator(onFinish: onFinish)
47+
}
48+
49+
func makeUIViewController(context: Context) -> UIImagePickerController {
50+
let picker = UIImagePickerController()
51+
picker.sourceType = .camera
52+
picker.delegate = context.coordinator
53+
return picker
54+
}
55+
56+
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
57+
58+
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
59+
let onFinish: () -> Void
60+
61+
init(onFinish: @escaping () -> Void) {
62+
self.onFinish = onFinish
63+
}
64+
65+
func imagePickerController(
66+
_ picker: UIImagePickerController,
67+
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
68+
) {
69+
onFinish()
70+
}
71+
72+
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
73+
onFinish()
74+
}
75+
}
76+
}
77+
78+
private struct CameraUnavailableView: View {
79+
let onDismiss: () -> Void
80+
81+
var body: some View {
82+
VStack(spacing: 16) {
83+
Image(systemName: "camera.fill")
84+
.font(.system(size: 48))
85+
.foregroundStyle(.secondary)
86+
Text("Camera Unavailable")
87+
.font(.headline)
88+
Text("Run on a physical device with a camera to reproduce the CameraUI.ModeLoupeLayer crash.")
89+
.font(.subheadline)
90+
.foregroundStyle(.secondary)
91+
.multilineTextAlignment(.center)
92+
Button("Close", action: onDismiss)
93+
.buttonStyle(.borderedProminent)
94+
}
95+
.padding(32)
96+
}
97+
}
98+
99+
#Preview {
100+
CameraSampleView()
101+
}
102+
103+
#endif

Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,31 @@ struct MaskCollectorPrecedenceTests {
115115
let view = UILabel()
116116
#expect(settings.shouldMask(view, viewType: type(of: view), resolvedExplicitMask: nil) == true)
117117
}
118+
119+
// MARK: - iOS 26 camera UI (ModeLoupeLayer crash regression)
120+
121+
@Test("maskiOS26ViewTypes includes CameraUI.ChromeSwiftUIView")
122+
func maskiOS26ViewTypesIncludesCameraChrome() {
123+
#expect(MaskingPolicy.Constants.maskiOS26ViewTypes.contains("CameraUI.ChromeSwiftUIView"))
124+
}
125+
126+
@Test("shouldMaskFromGlobalConfig masks iOS26 camera chrome even when privacy toggles are off")
127+
func masksIOS26CameraChromeRegardlessOfPrivacyToggles() {
128+
let settings = makeSettings(.init(
129+
maskTextInputs: false,
130+
maskWebViews: false,
131+
maskLabels: false,
132+
maskImages: false
133+
))
134+
guard let cameraClass = NSClassFromString("CameraUI.ChromeSwiftUIView") else { return }
135+
let view = (cameraClass as! UIView.Type).init()
136+
#expect(settings.shouldMask(view, viewType: cameraClass, resolvedExplicitMask: nil) == true)
137+
}
138+
139+
@Test("shouldSkipLayer skips CameraUI.ModeLoupeLayer")
140+
func skipsIOS26CameraLayers() {
141+
let settings = makeSettings()
142+
#expect(MaskingPolicy.Constants.maskiOS26LayerTypes.contains("CameraUI.ModeLoupeLayer"))
143+
#expect(settings.shouldSkipLayer(CALayer()) == false)
144+
}
118145
}

0 commit comments

Comments
 (0)