From a5cf5f1c55adadcac0e531a07e65a5fb4fbc42df Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Sun, 19 Apr 2026 12:41:09 -0700 Subject: [PATCH 01/10] iOS26 --- TestApp/Sources/AppDelegate.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index 33c9d6f6..bb4ae992 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -35,6 +35,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { privacy: .init( maskTextInputs: true, maskWebViews: false, + maskLabels: true, maskImages: false, maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip", "10"], ) From 279534a37bc9f78bff509a116c7f6757de25bfd4 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Sat, 2 May 2026 18:34:56 -0700 Subject: [PATCH 02/10] remove all --- TestApp/Sources/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index bb4ae992..bff0bcc4 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -35,7 +35,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { privacy: .init( maskTextInputs: true, maskWebViews: false, - maskLabels: true, + maskLabels: false, maskImages: false, maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip", "10"], ) From bc9644537e9abc2cb11bb31253d6a76bef2eab58 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Mon, 4 May 2026 17:27:30 -0700 Subject: [PATCH 03/10] mask on the label --- TestApp/Sources/MainMenuView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestApp/Sources/MainMenuView.swift b/TestApp/Sources/MainMenuView.swift index 48a5b822..257580c1 100644 --- a/TestApp/Sources/MainMenuView.swift +++ b/TestApp/Sources/MainMenuView.swift @@ -329,7 +329,7 @@ private struct MaskingGridRow: View { var body: some View { HStack(spacing: 12) { Text(title) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading).ldMask() Button("UIKit") { uikitAction?() } .disabled(uikitAction == nil) .frame(maxWidth: .infinity) From abc547e38412d40bbdb09d9863e73c7dd9dc32a1 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 7 May 2026 23:13:50 -0700 Subject: [PATCH 04/10] improve readability --- .../API/ObservabilityOptions.swift | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift b/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift index ec4834a7..74b8fd87 100644 --- a/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift +++ b/Sources/LaunchDarklyObservability/API/ObservabilityOptions.swift @@ -1,24 +1,11 @@ import Foundation import OSLog @_exported import OpenTelemetryApi -/// -/// Configuration options for the Observability plugin. -/// -/// - serviceName The service name for the application. Defaults to the app package name if not set. -/// - serviceVersion The version of the service. Defaults to the app version if not set. -/// - otlpEndpoint The OTLP exporter endpoint. Defaults to LaunchDarkly endpoint. -/// - backendUrl The backend URL for non-OTLP operations. Defaults to LaunchDarkly url. -/// - resourceAttributes Additional resource attributes to include in telemetry data. -/// - customHeaders Custom headers to include with OTLP exports. -/// - sessionBackgroundTimeout Session timeout if app is backgrounded. Defaults to 15 minutes. 15 * 60 -/// - isDebug Enables verbose telemetry logging if true as well as other debug functionality. Defaults to false. -/// - disableLogs Disables logs if true. Defaults to false. -/// - disableTraces Disables traces if true. Defaults to false. -/// - disableMetrics Disables metrics if true. Defaults to false. -/// - logAdapter The log adapter to use. Defaults to using the LaunchDarkly SDK's LDTimberLogging.adapter(). ///Use LDAndroidLogging.adapter() to use the Android logging adapter. -/// - loggerName The name of the logger to use. Defaults to "LaunchDarklyObservabilityPlugin". -/// +/// Configuration options for the LaunchDarkly Observability plugin. +/// +/// Pass an instance to the plugin at initialisation to control the OTLP exporter +/// endpoint, telemetry levels, automatic instrumentation, and crash reporting. public struct ObservabilityOptions { public enum Defaults { public static let otlpEndpoint = "https://otel.observability.app.launchdarkly.com:4318" @@ -205,6 +192,45 @@ public struct ObservabilityOptions { public var crashReporting: CrashReporting public var instrumentation: Instrumentation + /// Creates a configuration for the Observability plugin. + /// + /// - Parameters: + /// - isEnabled: Whether the plugin emits telemetry. When `false` the plugin is installed + /// but no logs, traces, or metrics are exported. Defaults to `true`. + /// - serviceName: The OpenTelemetry `service.name` attribute reported with every signal. + /// Defaults to `"observability-swift"`. + /// - serviceVersion: The OpenTelemetry `service.version` attribute reported with every + /// signal. Defaults to `"0.1.0"`. + /// - otlpEndpoint: The OTLP/HTTP exporter endpoint. `nil` or an empty string falls back + /// to ``Defaults/otlpEndpoint``. + /// - backendUrl: The backend URL used for non-OTLP operations (e.g. session metadata). + /// `nil` or an empty string falls back to ``Defaults/backendUrl``. + /// - contextFriendlyName: An optional human-readable name attached to the LaunchDarkly + /// context for this session. Defaults to `nil`. + /// - resourceAttributes: Additional OpenTelemetry resource attributes merged into every + /// signal. Defaults to an empty dictionary. + /// - customHeaders: Extra HTTP headers added to OTLP exports (e.g. for proxies or auth). + /// Defaults to an empty dictionary. + /// - tracingOrigins: Which outgoing request origins should propagate distributed tracing + /// headers. Defaults to ``TracingOriginsOption/disabled``. + /// - urlBlocklist: URL patterns to exclude from automatic URLSession instrumentation. + /// Defaults to an empty array. + /// - sessionBackgroundTimeout: How long the app may stay in the background before the + /// current session is ended. Defaults to 15 minutes. + /// - isDebug: Enables verbose internal logging and other debug behaviour. Defaults to + /// `false`. + /// - logsApiLevel: Minimum severity of logs forwarded to the OpenTelemetry logs pipeline. + /// Use ``LogLevel/none`` to disable logs entirely. Defaults to ``LogLevel/info``. + /// - tracesApi: Controls automatic trace generation (errors and spans). Use + /// ``AppTracing/disabled`` to turn tracing off. Defaults to ``AppTracing/enabled``. + /// - metricsApi: Controls metric export. Use ``AppMetrics/disabled`` to turn metrics + /// off. Defaults to ``AppMetrics/enabled``. + /// - log: The `OSLog` used for the plugin's own diagnostic output. Defaults to a logger + /// under subsystem `"com.launchdarkly"` and category `"LaunchDarklyObservabilityPlugin"`. + /// - crashReporting: Crash-reporting configuration, including which provider to use + /// (KSCrash or MetricKit). Defaults to ``CrashReporting/enabled`` (KSCrash). + /// - instrumentation: Per-feature toggles for automatic instrumentation (URLSession, + /// user taps, memory, CPU, launch times, …). Defaults to all features disabled. public init( isEnabled: Bool = true, serviceName: String = "observability-swift", From ef444ee1c8d4467311ab4c2ab47fbe8f1fa8b2c0 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 00:55:21 -0700 Subject: [PATCH 05/10] 2 way --- .../API/SessionReplayModifier.swift | 11 + .../Fruits/Shared/Smoothie/SmoothieRow.swift | 3 +- ...essionReplayModifierPropagationTests.swift | 424 ++++++++++++++++++ 3 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift diff --git a/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift b/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift index acb90354..5d4e4590 100644 --- a/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift +++ b/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift @@ -24,6 +24,17 @@ struct SessionReplayViewRepresentable: UIViewRepresentable { self.isIgnored = isIgnored } + /// Marker view inserted by `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` / + /// `.ldPrivate(...)` SwiftUI modifiers. + /// + /// Because `SessionReplayModifier` attaches itself via `.overlay()`, this + /// view ends up as a *sibling* of the modified content in the UIKit + /// hierarchy — not an ancestor. The view itself carries the explicit + /// mask/ignore state via associated objects; `MaskCollector` then detects + /// these markers at collection time, walks up to the lowest common + /// ancestor of the overlay branch and the content branch, and propagates + /// the explicit state to that ancestor so it reaches the modified + /// content. class MaskView: UIView { override func didMoveToSuperview() { super.didMoveToSuperview() diff --git a/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift b/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift index 07cfc8c0..70971b83 100644 --- a/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift +++ b/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieRow.swift @@ -25,7 +25,7 @@ struct SmoothieRow: View { .clipShape(imageClipShape) .overlay(imageClipShape.strokeBorder(.quaternary, lineWidth: 0.5)) .accessibility(hidden: true) - .ldPrivate() + .ldMask() VStack(alignment: .leading) { Text(smoothie.title) @@ -45,7 +45,6 @@ struct SmoothieRow: View { } .font(.subheadline) .accessibilityElement(children: .combine) - .ldIgnore() } var listedIngredients: String { diff --git a/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift b/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift new file mode 100644 index 00000000..434ecced --- /dev/null +++ b/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift @@ -0,0 +1,424 @@ +import Testing +@testable import LaunchDarklySessionReplay +import SwiftUI +import UIKit + +/// Verifies that SwiftUI's `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` +/// modifiers — which insert their marker view via `.overlay()` and +/// therefore land as a *sibling* of the modified content (in the +/// simplest case) or even completely disjoint from it (when SwiftUI +/// renders content directly into a CALayer on iOS 26 Liquid Glass) — +/// still affect the modified content. +/// +/// `MaskCollector` does this by recording each marker's frame in the +/// root layer's coordinate space (a `MarkerArea`) and, during the visit +/// pass, applying the marker's explicit state to any layer/view whose +/// frame is contained inside it. That works regardless of whether the +/// modified content is a sibling, a deeply nested descendant, or a pure +/// CALayer. +@MainActor +struct SessionReplayModifierPropagationTests { + typealias MaskView = SessionReplayViewRepresentable.MaskView + + /// Mimics the simplest SwiftUI shape: an overlay branch and a + /// content branch sharing the same multi-child host with equal + /// frames. The content branch is fully contained within the + /// marker's projected area. + private func makeOverlayHierarchy() -> (window: UIWindow, contentBranch: UIView, mask: MaskView) { + let bounds = CGRect(x: 0, y: 0, width: 200, height: 200) + let window = UIWindow(frame: bounds) + let commonHost = UIView(frame: bounds) + let contentBranch = UIView(frame: bounds) + let overlayBranch = UIView(frame: bounds) + let representableHost = UIView(frame: bounds) + let mask = MaskView(frame: bounds) + + commonHost.addSubview(contentBranch) + commonHost.addSubview(overlayBranch) + overlayBranch.addSubview(representableHost) + representableHost.addSubview(mask) + + window.addSubview(commonHost) + window.isHidden = false + window.layoutIfNeeded() + + return (window, contentBranch, mask) + } + + // MARK: - computeMarkerAreas + + @Test("computeMarkerAreasAndOverlayBranches projects each marker into root-layer coordinates with the developer's explicit state") + func computeMarkerAreasProjectsToRoot() { + let (window, _, mask) = makeOverlayHierarchy() + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: false) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) + let (areas, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + #expect(areas.count == 1) + #expect(areas.first?.mask == false) + #expect(areas.first?.ignore == nil) + #expect(areas.first?.frameInRoot.equalTo(CGRect(x: 0, y: 0, width: 200, height: 200)) == true) + // The overlay branch wrapper chain must include at least the + // marker view itself. + #expect(overlayBranchViews.contains(ObjectIdentifier(mask))) + } + + @Test("computeMarkerAreasAndOverlayBranches records ignore=true for an .ldIgnore() marker") + func computeMarkerAreasIgnore() { + let (window, _, mask) = makeOverlayHierarchy() + SessionReplayAssociatedObjects.ignoreUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) + let (areas, _) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + #expect(areas.count == 1) + #expect(areas.first?.ignore == true) + } + + @Test("computeMarkerAreasAndOverlayBranches skips MaskView instances that are detached from the window") + func computeMarkerAreasSkipsDetached() { + let detachedHost = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + detachedHost.addSubview(mask) + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + let attached = UIView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + window.addSubview(attached) + window.isHidden = false + window.layoutIfNeeded() + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) + let (areas, _) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + #expect(areas.isEmpty) + } + + @Test("computeMarkerAreasAndOverlayBranches stops at a single-child host whose bounds exceed the marker's") + func computeMarkerAreasStopsAtLargerSingleChildHost() { + // Reproduces the live MainMenuView shape on iOS 26: the marker + // wrappers end inside a `CellHostingView`-equivalent that has + // exactly one subview (the wrapper chain) but is much larger + // than the marker because it owns the cell's rendered content + // as sublayers. We must NOT treat that host as part of the + // overlay branch — otherwise `visit` would short-circuit there + // and never reach the real content. + let cellContent = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 64)) + let markerWrapper = UIView(frame: CGRect(x: 16, y: 22, width: 105, height: 20)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 105, height: 20)) + markerWrapper.addSubview(mask) + cellContent.addSubview(markerWrapper) + + let cellHost = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 64)) + cellHost.addSubview(cellContent) + + // Force the chain above `cellHost` to be a single-child chain + // too, mimicking iOS 26's `_UICollectionViewListCellContentView` + // → `ListCollectionViewCell` shape. + let outerWrapper = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 64)) + outerWrapper.addSubview(cellHost) + + let multiChildAncestor = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 200)) + let unrelatedSibling = UIView(frame: CGRect(x: 0, y: 100, width: 370, height: 100)) + multiChildAncestor.addSubview(outerWrapper) + multiChildAncestor.addSubview(unrelatedSibling) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 400)) + window.addSubview(multiChildAncestor) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) + let (_, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + + // Overlay-branch chain stops at the marker wrapper because + // `cellContent` is much larger than the marker. + #expect(overlayBranchViews.contains(ObjectIdentifier(mask))) + #expect(overlayBranchViews.contains(ObjectIdentifier(markerWrapper))) + #expect(!overlayBranchViews.contains(ObjectIdentifier(cellContent))) + #expect(!overlayBranchViews.contains(ObjectIdentifier(cellHost))) + #expect(!overlayBranchViews.contains(ObjectIdentifier(outerWrapper))) + } + + @Test("End-to-end: a label sublayer inside a single-child host larger than the marker is masked") + func collectorMasksLabelInLargerSingleChildHost() { + // End-to-end version of the failure that hit the live + // MainMenuView: the only direct subview of the host is the + // marker wrapper, but the host's actual content (a label here) + // is rendered as a sibling sublayer/subview. Without the size + // check, the host would be classified as part of the overlay + // branch and the label would never be visited. + let cellHost = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 64)) + + let label = UILabel(frame: CGRect(x: 16, y: 22, width: 105, height: 20)) + label.text = "title" + + let markerWrapper = UIView(frame: CGRect(x: 16, y: 22, width: 105, height: 20)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 105, height: 20)) + markerWrapper.addSubview(mask) + + // Two subviews keeps `cellHost` from looking like a wrapper + // even with the looser old heuristic; the regression is about + // the chain *above* `cellHost`, which now stays single-child. + cellHost.addSubview(label) + cellHost.addSubview(markerWrapper) + + // Single-child chain above `cellHost` mirrors + // `_UICollectionViewListCellContentView` → `CellHostingView`. + let contentWrapper = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 64)) + contentWrapper.addSubview(cellHost) + let cell = UIView(frame: CGRect(x: 16, y: 48, width: 370, height: 64)) + cell.addSubview(contentWrapper) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 200)) + window.addSubview(cell) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false, maskLabels: false)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // Exactly one mask op covering the label — the cell, content + // wrapper, and host must remain unmasked even though they sit + // on the path between the marker and the multi-child ancestor. + #expect(result.maskOperations.count == 1) + } + + @Test("computeMarkerAreasAndOverlayBranches collects the single-child wrapper chain above each marker") + func computeMarkerAreasCollectsOverlayChain() { + // Wrappers with one child each above the MaskView form the + // overlay branch and must be skipped during the visit pass to + // avoid duplicate mask ops. + let host = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let outerWrapper = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let innerWrapper = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + innerWrapper.addSubview(mask) + outerWrapper.addSubview(innerWrapper) + // A second sibling at this level ensures the chain stops at + // `host` (the first multi-child ancestor). + let unrelatedSibling = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + host.addSubview(unrelatedSibling) + host.addSubview(outerWrapper) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + window.addSubview(host) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) + let (_, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + + #expect(overlayBranchViews.contains(ObjectIdentifier(mask))) + #expect(overlayBranchViews.contains(ObjectIdentifier(innerWrapper))) + #expect(overlayBranchViews.contains(ObjectIdentifier(outerWrapper))) + #expect(!overlayBranchViews.contains(ObjectIdentifier(host))) + #expect(!overlayBranchViews.contains(ObjectIdentifier(unrelatedSibling))) + } + + @Test("MarkerOverride.combine: mask=true beats mask=false on the same area") + func combineMaskPrecedence() { + var override = MaskCollector.MarkerOverride() + override.combine(mask: false, ignore: nil) + override.combine(mask: true, ignore: nil) + #expect(override.mask == true) + + var override2 = MaskCollector.MarkerOverride() + override2.combine(mask: true, ignore: nil) + override2.combine(mask: false, ignore: nil) + #expect(override2.mask == true) + } + + // MARK: - End-to-end through MaskCollector.collectViewMasks + + @Test("End-to-end: a globally-masked TextInput inside an .ldUnmask() SwiftUI marker is not masked (sibling shape)") + func collectorRespectsAncestorUnmaskFromSwiftUIModifier() { + let (window, contentBranch, mask) = makeOverlayHierarchy() + + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + contentBranch.addSubview(textField) + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: false) + window.layoutIfNeeded() + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: true)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // Without propagation, `maskTextInputs=true` would have masked + // the text field. The marker's `unmask` area covers the text + // field, so it stays visible. + #expect(result.maskOperations.isEmpty) + } + + @Test("End-to-end: a flattened TextInput sibling inside an .ldUnmask() marker area is not masked") + func collectorUnmasksFlattenedTextFieldSibling() { + // Reproduces the live TestApp shape: SwiftUI flattens the + // `.ldUnmask()`-decorated VStack so the inner TextField is a + // smaller sibling of the overlay branch, contained within the + // marker's frame. + let host = UIView(frame: CGRect(x: 0, y: 0, width: 402, height: 569)) + let textField = UITextField(frame: CGRect(x: 24, y: 191, width: 354, height: 34)) + let overlayBranch = UIView(frame: CGRect(x: 16, y: 183, width: 370, height: 50)) + let representableHost = UIView(frame: CGRect(x: 0, y: 0, width: 370, height: 50)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 370, height: 50)) + + host.addSubview(textField) + host.addSubview(overlayBranch) + overlayBranch.addSubview(representableHost) + representableHost.addSubview(mask) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 600)) + window.addSubview(host) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: false) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: true)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // The text field's frame in root coords (24, 191, 354, 34) is + // inside the marker's area (16, 183, 370, 50), so it inherits + // mask=false and stays visible despite `maskTextInputs=true`. + #expect(result.maskOperations.isEmpty) + } + + @Test("End-to-end: a deeply-nested label inside an .ldMask() marker area is masked even with maskLabels=false") + func collectorMasksDeeplyNestedLabel() { + // Reproduces the iOS 26 + List-row shape: the marker is the + // *only* direct subview of an outer hosting cell that's much + // larger than the marker itself, with the actual label sitting + // somewhere inside that hosting cell at the marker's position. + let bounds = CGRect(x: 0, y: 0, width: 400, height: 300) + let window = UIWindow(frame: bounds) + + // Cell-row layout: a system-background sibling at the row's + // full size next to the cell hosting view that contains the + // label and the marker. The system background must NOT get + // masked just because it overlaps the marker on screen. + let outer = UIView(frame: bounds) + let cellBackground = UIView(frame: CGRect(x: 0, y: 50, width: 400, height: 80)) + let cellHostingView = UIView(frame: CGRect(x: 0, y: 50, width: 400, height: 80)) + let label = UILabel(frame: CGRect(x: 16, y: 22, width: 105, height: 20)) + label.text = "title" + let overlayBranch = UIView(frame: CGRect(x: 16, y: 22, width: 105, height: 20)) + let representableHost = UIView(frame: CGRect(x: 0, y: 0, width: 105, height: 20)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 105, height: 20)) + + cellHostingView.addSubview(label) + cellHostingView.addSubview(overlayBranch) + overlayBranch.addSubview(representableHost) + representableHost.addSubview(mask) + outer.addSubview(cellBackground) + outer.addSubview(cellHostingView) + window.addSubview(outer) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false, maskLabels: false)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // Exactly one mask op covering the label — the cell background + // (much larger than the marker area) must remain unmasked. + #expect(result.maskOperations.count == 1) + if let op = result.maskOperations.first { + #expect(op.effectiveFrame.equalTo(CGRect(x: 16, y: 72, width: 105, height: 20))) + } + } + + @Test("End-to-end: an .ldMask() marker on a single Text in an HStack masks only that Text, not the buttons") + func collectorMasksOnlyTextInHStack() { + // The exact failure mode the user reported: `.ldMask()` on a + // `Text` in an HStack alongside two buttons must mask only the + // text column, not the buttons or the row background. + let bounds = CGRect(x: 0, y: 0, width: 360, height: 60) + let window = UIWindow(frame: bounds) + let row = UIView(frame: bounds) + let textColumn = UILabel(frame: CGRect(x: 0, y: 20, width: 100, height: 20)) + textColumn.text = "title" + let buttonOne = UIButton(frame: CGRect(x: 120, y: 15, width: 100, height: 30)) + let buttonTwo = UIButton(frame: CGRect(x: 240, y: 15, width: 100, height: 30)) + let overlayBranch = UIView(frame: CGRect(x: 0, y: 20, width: 100, height: 20)) + let representableHost = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 20)) + let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 100, height: 20)) + + row.addSubview(textColumn) + row.addSubview(buttonOne) + row.addSubview(buttonTwo) + row.addSubview(overlayBranch) + overlayBranch.addSubview(representableHost) + representableHost.addSubview(mask) + window.addSubview(row) + window.isHidden = false + window.layoutIfNeeded() + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false, maskLabels: false)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + #expect(result.maskOperations.count == 1) + if let op = result.maskOperations.first { + #expect(op.effectiveFrame.equalTo(textColumn.frame)) + } + } + + @Test("End-to-end: a Text label inside an .ldMask() SwiftUI marker is masked even with maskLabels=false") + func collectorRespectsAncestorMaskFromSwiftUIModifier() { + let (window, contentBranch, mask) = makeOverlayHierarchy() + + let label = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + contentBranch.addSubview(label) + + SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) + window.layoutIfNeeded() + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: false, maskLabels: false)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // The marker's `mask` area covers the content sibling, so the + // label inside it is masked. + #expect(result.maskOperations.isEmpty == false) + } + + @Test("End-to-end: a TextInput inside an .ldIgnore() SwiftUI marker is skipped entirely") + func collectorSkipsIgnoredSwiftUIMarker() { + let (window, contentBranch, mask) = makeOverlayHierarchy() + + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + contentBranch.addSubview(textField) + + SessionReplayAssociatedObjects.ignoreUIView(mask, isEnabled: true) + window.layoutIfNeeded() + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: true)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // The marker's `ignore` area covers the text field; visit + // skips it entirely. + #expect(result.maskOperations.isEmpty) + } + + @Test("End-to-end: a baseline TextInput with no marker is still masked when maskTextInputs=true") + func collectorMasksTextInputWithoutModifier() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + let host = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) + host.addSubview(textField) + window.addSubview(host) + window.isHidden = false + window.layoutIfNeeded() + + let collector = MaskCollector(privacySettings: .init(maskTextInputs: true)) + let result = collector.collectViewMasks(in: window, window: window, scale: 1) + + // Sanity baseline: when no SwiftUI marker is present, + // `maskTextInputs` still masks the field. + #expect(result.maskOperations.count == 1) + } +} From e0e889de1077dd868f231238b8e533c06649be37 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 01:14:27 -0700 Subject: [PATCH 06/10] opt --- .../API/SessionReplayModifier.swift | 46 +++++++++++++++++++ .../Fruits/Shared/Smoothie/SmoothieList.swift | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift b/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift index 5d4e4590..964c3a30 100644 --- a/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift +++ b/Sources/LaunchDarklySessionReplay/API/SessionReplayModifier.swift @@ -36,6 +36,37 @@ struct SessionReplayViewRepresentable: UIViewRepresentable { /// the explicit state to that ancestor so it reaches the modified /// content. class MaskView: UIView { + // Tracks how many markers are currently attached to a window so + // `MaskCollector` can skip its per-frame UIView walk entirely + // when the running app uses no `.ldMask()` / `.ldUnmask()` / + // `.ldIgnore()` modifiers. + // + // Mutated only on the main thread in `didMoveToWindow`. Reads + // happen on the screen-capture queue; a stale read at worst + // costs one frame of skipped/extra work, which is acceptable + // because the next capture will see the corrected value. + private static let liveMarkerLock = NSLock() + private static var liveMarkerCount: Int = 0 + private var isCounted: Bool = false + + static var hasLiveMarkers: Bool { + liveMarkerLock.lock() + defer { liveMarkerLock.unlock() } + return liveMarkerCount > 0 + } + + private static func incrementLiveMarkers() { + liveMarkerLock.lock() + liveMarkerCount += 1 + liveMarkerLock.unlock() + } + + private static func decrementLiveMarkers() { + liveMarkerLock.lock() + liveMarkerCount = max(0, liveMarkerCount - 1) + liveMarkerLock.unlock() + } + override func didMoveToSuperview() { super.didMoveToSuperview() // We want to make sure the wrapper view created by SwiftUI also doesn't intercept touches @@ -45,6 +76,21 @@ struct SessionReplayViewRepresentable: UIViewRepresentable { override func didMoveToWindow() { super.didMoveToWindow() superview?.isUserInteractionEnabled = false + + let isAttached = window != nil + if isAttached, !isCounted { + isCounted = true + Self.incrementLiveMarkers() + } else if !isAttached, isCounted { + isCounted = false + Self.decrementLiveMarkers() + } + } + + deinit { + if isCounted { + Self.decrementLiveMarkers() + } } override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { diff --git a/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieList.swift b/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieList.swift index 883f9e1e..3d0a2ec0 100644 --- a/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieList.swift +++ b/TestApp/Sources/Fruits/Shared/Smoothie/SmoothieList.swift @@ -26,7 +26,7 @@ struct SmoothieList: View { NavigationLink(tag: smoothie.id, selection: $model.selectedSmoothieID) { SmoothieView(smoothie: smoothie).environmentObject(model) } label: { - SmoothieRow(smoothie: smoothie).ldIgnore() + SmoothieRow(smoothie: smoothie) } .onChange(of: model.selectedSmoothieID) { newValue in // Need to make sure the Smoothie exists. From c3fbb3e5775771d23ff40b9c2ff0ac6715cc86f6 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 11:49:32 -0700 Subject: [PATCH 07/10] Split --- .../ScreenCapture/ImageCaptureService.swift | 3 +- .../ScreenCapture/MarkerScanner.swift | 141 ++++++++++ .../ScreenCapture/MaskGeometry.swift | 49 ++++ .../ScreenCapture/MaskStabilizer.swift | 64 +++++ .../ScreenCapture/MaskingPolicy.swift | 246 ++++++++++++++++++ .../MaskCollectorPrecedenceTests.swift | 2 +- ...essionReplayModifierPropagationTests.swift | 29 +-- 7 files changed, 515 insertions(+), 19 deletions(-) create mode 100644 Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift create mode 100644 Sources/LaunchDarklySessionReplay/ScreenCapture/MaskGeometry.swift create mode 100644 Sources/LaunchDarklySessionReplay/ScreenCapture/MaskStabilizer.swift create mode 100644 Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift index f0befa3a..ddc084a6 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/ImageCaptureService.swift @@ -21,6 +21,7 @@ public struct RawFrame { public final class ImageCaptureService { private let maskingService = MaskApplier() private let maskCollector: MaskCollector + private let maskStabilizer = MaskStabilizer() private let windowCaptureManager = WindowCaptureManager() @MainActor private var shouldCapture = false @@ -76,7 +77,7 @@ public final class ImageCaptureService { var applyOperations = [[MaskOperation]]() var areas = [OffsettedArea]() for (before, after) in zip(maskOperationsBefore, maskOperationsAfter) { - if let newOperations = maskCollector.duplicateUnsimilar(before: before.maskOperations, after: after.maskOperations) { + if let newOperations = self.maskStabilizer.duplicateUnsimilar(before: before.maskOperations, after: after.maskOperations) { areas.append(contentsOf: before.offsetRects) applyOperations.append(newOperations) } else { diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift new file mode 100644 index 00000000..5532932c --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift @@ -0,0 +1,141 @@ +import Foundation +import UIKit +import SwiftUI + +/// Scans a UIView hierarchy for SwiftUI marker views inserted by +/// `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` and projects their +/// frames into the root layer's coordinate space. +/// +/// Because `SessionReplayModifier` attaches its marker view via +/// `.overlay(...)`, the marker ends up as a *sibling* (or completely +/// disjoint, on iOS 26 Liquid Glass) of the modified content in the +/// UIKit hierarchy. Direct ancestor propagation therefore can't reach +/// the content. Instead, `MaskCollector` uses the rectangles returned +/// here as governing areas: any layer/view whose frame is contained in +/// one of these areas inherits the marker's explicit state. +final class MarkerScanner { + /// Aggregated explicit state combined from any number of SwiftUI + /// marker views whose areas contain the layer being evaluated. + struct MarkerOverride { + var mask: Bool? + var ignore: Bool? + + /// Mask precedence: any `mask=true` wins; otherwise any `mask=false` wins. + /// Ignore is OR-combined. + mutating func combine(mask newMask: Bool?, ignore newIgnore: Bool?) { + if newMask == true { + mask = true + } else if newMask == false, mask != true { + mask = false + } + if newIgnore == true { + ignore = true + } + } + } + + /// A SwiftUI marker view's projected frame in the root layer's + /// coordinate space, plus the explicit state the developer attached + /// to it via `.ldMask()` / `.ldUnmask()` / `.ldIgnore()`. + /// + /// `.overlay()` always sizes the marker to the modified content's + /// rendered bounds, so this rectangle *is* the area the modifier is + /// supposed to govern — regardless of how SwiftUI flattens the + /// surrounding UIKit/CALayer hierarchy. During collection we apply + /// the marker's state to any layer whose own frame is contained + /// inside this rectangle. + struct MarkerArea { + var frameInRoot: CGRect + var mask: Bool? + var ignore: Bool? + } + + /// Walks the UIView hierarchy under `rootView` and records: + /// 1. A `MarkerArea` for every `SessionReplayViewRepresentable.MaskView`, + /// whose rectangle is the marker's bounds projected into + /// `rPresentation`'s coordinate space. + /// 2. The set of UIViews that form the *overlay branch wrapper + /// chain* — the marker view itself plus every ancestor with + /// exactly one subview, walking up until we hit a multi-child + /// ancestor. These wrappers are co-located with the marker area + /// but contain no visible content of their own; the visit pass + /// must skip them or every wrapper would receive a duplicate + /// mask operation. + /// + /// Because SwiftUI's `.overlay(...)` always sizes the marker to the + /// modified content's bounding box, this rectangle is exactly the + /// area the developer's `.ldMask()` / `.ldUnmask()` / `.ldIgnore()` + /// modifier governs — regardless of whether SwiftUI flattens that + /// content into a UIView sibling, a deeply nested UIView, or a pure + /// CALayer (iOS 26 Liquid Glass). + func scan( + in rootView: UIView, + rPresentation: CALayer + ) -> (areas: [MarkerArea], overlayBranchViews: Set) { + var areas: [MarkerArea] = [] + var overlayBranchViews: Set = [] + + // Iterative DFS using a reusable stack avoids the per-call + // closure/frame allocation of recursion on busy screens. A + // typical screen has 100-500 UIViews and this runs once per + // capture frame. + var stack: [UIView] = [rootView] + stack.reserveCapacity(64) + while let view = stack.popLast() { + if let marker = view as? SessionReplayViewRepresentable.MaskView, + marker.window != nil { + Self.recordOverlayBranch(of: marker, into: &overlayBranchViews) + + let mask = SessionReplayAssociatedObjects.shouldMaskUIView(marker) + let ignore = SessionReplayAssociatedObjects.shouldIgnoreUIView(marker) + if mask != nil || ignore != nil { + let frameInRoot = rPresentation.convert(marker.layer.bounds, from: marker.layer) + if frameInRoot.width > 0, frameInRoot.height > 0 { + areas.append(MarkerArea(frameInRoot: frameInRoot, mask: mask, ignore: ignore)) + } + } + // `MaskView` is a leaf in our hierarchy — we never add + // subviews to it. UIKit also won't add any. Skip + // descending. + continue + } + stack.append(contentsOf: view.subviews) + } + + return (areas, overlayBranchViews) + } + + /// Adds the `MaskView` and the wrapper chain immediately above it + /// to `set`. The wrappers we want to skip are bridging views whose + /// only purpose is to host the marker itself; they are + /// distinguishable by two simultaneous properties: + /// + /// 1. The parent has exactly one subview (the chain wrapper). + /// 2. The parent's bounds are the same size as the marker. + /// + /// Property 1 alone is *not* enough: a real content host such as + /// SwiftUI's `CellHostingView` can also have a single subview (the + /// marker's wrapper) while still owning the actual rendered content + /// as sublayers — and its bounds are much larger than the marker. + /// Walking past that host would cause `visit` to skip the entire + /// content subtree, eliminating every mask op. + private static func recordOverlayBranch( + of marker: SessionReplayViewRepresentable.MaskView, + into set: inout Set + ) { + let markerSize = marker.bounds.size + let tolerance: CGFloat = 1.0 + + var current: UIView = marker + while true { + set.insert(ObjectIdentifier(current)) + guard let parent = current.superview, + parent.subviews.count == 1, + abs(parent.bounds.width - markerSize.width) <= tolerance, + abs(parent.bounds.height - markerSize.height) <= tolerance else { + break + } + current = parent + } + } +} diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskGeometry.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskGeometry.swift new file mode 100644 index 00000000..a400064d --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskGeometry.swift @@ -0,0 +1,49 @@ +import Foundation +import UIKit + +/// Stateless geometry helpers used by the mask-collection pipeline. +/// +/// These functions don't depend on any privacy configuration or +/// hierarchy state — they're pure CGRect/CALayer math kept separate +/// from `MaskCollector` so the orchestrator can stay focused on the +/// visit loop. +enum MaskGeometry { + /// Builds a `Mask` describing where `layer` lands inside + /// `rPresentation` when drawn at the given `scale`. Returns `nil` + /// when the layer has zero area or uses a non-affine transform we + /// don't yet handle. + static func createMask(rPresentation: CALayer, layer: CALayer, scale: CGFloat) -> Mask? { + let lBounds = layer.bounds + guard lBounds.width > 0, lBounds.height > 0 else { return nil } + + if CATransform3DIsAffine(layer.transform) { + let corner0 = layer.convert(CGPoint.zero, to: rPresentation) + let corner1 = layer.convert(CGPoint(x: lBounds.width, y: 0), to: rPresentation) + let corner3 = layer.convert(CGPoint(x: 0, y: lBounds.height), to: rPresentation) + + let tx = corner0.x, ty = corner0.y + let affineTransform = CGAffineTransform(a: (corner1.x - tx) / max(lBounds.width, 0.0001), + b: (corner1.y - ty) / max(lBounds.width, 0.0001), + c: (corner3.x - tx) / max(lBounds.height, 0.0001), + d: (corner3.y - ty) / max(lBounds.height, 0.0001), + tx: tx, + ty: ty).scaledBy(x: scale, y: scale) + return Mask.affine(rect: lBounds, transform: affineTransform) + } else { + // TODO: finish 3D animations + } + + return nil + } + + /// `true` if `inner` is fully inside `container` (within `tolerance` + /// in every direction). Used both as the geometry check that decides + /// which layers a marker area governs and as a building block for + /// `MarkerArea` lookups during the visit pass. + static func frameContains(_ container: CGRect, _ inner: CGRect, tolerance: CGFloat) -> Bool { + inner.minX >= container.minX - tolerance && + inner.minY >= container.minY - tolerance && + inner.maxX <= container.maxX + tolerance && + inner.maxY <= container.maxY + tolerance + } +} diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskStabilizer.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskStabilizer.swift new file mode 100644 index 00000000..7b57e384 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskStabilizer.swift @@ -0,0 +1,64 @@ +import Foundation +import UIKit + +/// Reconciles two mask collections captured for the same set of windows +/// across two consecutive runloop ticks (the "before" and "after" +/// passes around `ImageCaptureService.captureRawFrame`). When views +/// shift slightly between the two passes — typical during scrolling or +/// keyboard animations — the same logical mask occupies two nearby +/// frames; we keep both and tag the second one as +/// ``MaskOperation/Kind/fillDuplicate`` so the renderer covers the +/// transition area instead of leaving a sliver of unmasked content. +/// +/// The reconciliation is purely functional: it doesn't read any +/// privacy settings or hierarchy state, only the geometry of the two +/// `MaskOperation` lists. +final class MaskStabilizer { + /// Movement under this many points (in any axis) is treated as the + /// same position; the corresponding "after" op is discarded as a + /// duplicate of "before". + private let moveTolerance: CGFloat = 1.0 + + /// Required slack between the observed delta and the mask's own + /// width/height: if a mask drifted further than itself between the + /// two passes the gap can't be safely covered, so the whole frame + /// is dropped. + private let overlapTolerance: CGFloat = 1.1 + + /// Returns a merged operation list that includes every operation + /// from `operationsBefore` plus a `fillDuplicate` copy of any + /// `operationsAfter` element that shifted enough to expose + /// previously-masked content. Returns `nil` (the caller should + /// drop the frame) when an op moved further than its own size, + /// because we can't guarantee coverage of the in-between area. + func duplicateUnsimilar(before operationsBefore: [MaskOperation], after operationsAfter: [MaskOperation]) -> [MaskOperation]? { + guard operationsBefore.count == operationsAfter.count else { + return nil + } + + var result = operationsBefore + for (before, after) in zip(operationsBefore, operationsAfter) { + let diffX = abs(before.effectiveFrame.minX - after.effectiveFrame.minX) + let diffY = abs(before.effectiveFrame.minY - after.effectiveFrame.minY) + + guard max(diffX, diffY) > moveTolerance else { + // Movement is within tolerance; the "before" mask + // already covers the same area. + continue + } + + guard diffX * overlapTolerance < before.effectiveFrame.width - moveTolerance, + diffY * overlapTolerance < before.effectiveFrame.height - moveTolerance else { + // Moved further than its own size; the gap between + // before and after can't be safely covered. + return nil + } + + var after = after + after.kind = .fillDuplicate + result.append(after) + } + + return result + } +} diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift new file mode 100644 index 00000000..cf811998 --- /dev/null +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift @@ -0,0 +1,246 @@ +import Foundation +#if canImport(WebKit) +import WebKit +#endif +import UIKit +#if LD_COCOAPODS +import LaunchDarklyObservability +#else +import Common +#endif + +/// Pure rule engine that decides whether a given UIView/CALayer should +/// be masked, based on the privacy options the host app configured plus +/// any per-view rules attached via `.ldMask()` / `.ldUnmask()` / +/// `.ldIgnore()` (associated objects), accessibility identifiers, or +/// view-class lists. +/// +/// `MaskingPolicy` does not traverse hierarchies — it answers +/// per-element questions. The actual walk lives in `MaskCollector`, and +/// the SwiftUI marker pre-pass lives in `MarkerScanner`. Both call back +/// into a shared `MaskingPolicy` instance. +final class MaskingPolicy { + enum Constants { + static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"]) + + // Private UIKit view types SwiftUI uses to render `Text` on iOS <= 18 + // (Core Graphics drawn content). Matching by type name because these + // classes are not publicly exposed. + static let swiftUITextViewTypes = Set([ + "CGDrawingView", + "_UIGraphicsView", + "SwiftUI.CGDrawingView", + "SwiftUI._UIGraphicsView", + ]) + + // Private CALayer subclasses SwiftUI uses to render content directly + // (no backing UIView) starting on iOS 26 "Liquid Glass". Matching by + // the layer's class name via `String(describing:)`. + static let swiftUITextLayerTypes = Set([ + "CGDrawingLayer", + "SwiftUI.CGDrawingLayer", + ]) + static let swiftUIImageLayerTypes = Set([ + "ImageLayer", + "ColorShapeLayer", + "SwiftUI.ImageLayer", + "SwiftUI.ColorShapeLayer", + ]) + } + + var maskTextInputs: Bool + var maskLabels: Bool + var maskWebViews: Bool + var maskImages: Bool + var minimumAlpha: Float + var maximumAlpha: Float + var maskUIViews: Set + var unmaskUIViews: Set + var ignoreUIViews: Set + + var maskAccessibilityIdentifiers: Set + var unmaskAccessibilityIdentifiers: Set + var ignoreAccessibilityIdentifiers: Set + + init(privacySettings: PrivacySettings) { + self.maskTextInputs = privacySettings.maskTextInputs + self.maskLabels = privacySettings.maskLabels + self.maskWebViews = privacySettings.maskWebViews + self.maskImages = privacySettings.maskImages + self.minimumAlpha = Float(privacySettings.minimumAlpha) + self.maximumAlpha = Float(1 - privacySettings.minimumAlpha) + + self.maskUIViews = Set(privacySettings.maskUIViews.map(ObjectIdentifier.init)) + self.unmaskUIViews = Set(privacySettings.unmaskUIViews.map(ObjectIdentifier.init)) + self.ignoreUIViews = Set(privacySettings.ignoreUIViews.map(ObjectIdentifier.init)) + + self.maskAccessibilityIdentifiers = Set(privacySettings.maskAccessibilityIdentifiers) + self.unmaskAccessibilityIdentifiers = Set(privacySettings.unmaskAccessibilityIdentifiers) + self.ignoreAccessibilityIdentifiers = Set(privacySettings.ignoreAccessibilityIdentifiers) + } + + func shouldIgnore(_ view: UIView, viewType: AnyClass) -> Bool { + if SessionReplayAssociatedObjects.shouldIgnoreUIView(view) == true { + return true + } + + if ignoreUIViews.contains(ObjectIdentifier(viewType)) { + return true + } + + if let accessibilityIdentifier = view.accessibilityIdentifier, + ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) { + return true + } + + return false + } + + func isExplicitlyMasked(_ view: UIView, viewType: AnyClass) -> Bool { + if SessionReplayAssociatedObjects.shouldMaskUIView(view) == true { + return true + } + if maskUIViews.contains(ObjectIdentifier(viewType)) { + return true + } + if let accessibilityIdentifier = view.accessibilityIdentifier, + maskAccessibilityIdentifiers.contains(accessibilityIdentifier) { + return true + } + return false + } + + func isExplicitlyUnmasked(_ view: UIView, viewType: AnyClass) -> Bool { + if SessionReplayAssociatedObjects.shouldMaskUIView(view) == false { + return true + } + if unmaskUIViews.contains(ObjectIdentifier(viewType)) { + return true + } + if let accessibilityIdentifier = view.accessibilityIdentifier, + unmaskAccessibilityIdentifiers.contains(accessibilityIdentifier) { + return true + } + return false + } + + func shouldMaskFromGlobalConfig(_ view: UIView, viewType: AnyClass) -> Bool { + // Cheap concrete-type checks first; these short-circuit the + // common cases (`UILabel`, `UIImageView`, `WKWebView`, plain + // `UITextField`/`UITextView`) without ever computing the + // `String(describing: viewType)` representation. + if maskWebViews { +#if canImport(WebKit) + if view is WKWebView { + return true + } +#endif + } + + if maskLabels, view is UILabel { + return true + } + + if maskImages, view is UIImageView { + return true + } + + // `UITextInput` is a protocol; this check still avoids any + // string allocation for the vast majority of views (the + // protocol conformance is implemented in cached witness + // tables on a small set of UIKit classes). +#if canImport(WebKit) + if maskTextInputs, view is UITextInput { + let stringViewType = String(describing: viewType) + if stringViewType != "WKContentView" { + return true + } + } +#else + if maskTextInputs, view is UITextInput { + return true + } +#endif + + // The remaining checks all key off the type's name. Compute + // it once for whichever fallthrough branch is still + // reachable. + let stringViewType = String(describing: viewType) + + if Constants.maskiOS26ViewTypes.contains(stringViewType) { + return true + } + + if maskTextInputs, stringViewType == "UIKeyboard" { + return true + } + + if maskLabels, Constants.swiftUITextViewTypes.contains(stringViewType) { + return true + } + + return false + } + + /// Returns the explicit mask state of `view` itself, ignoring ancestors: + /// `true` = explicitly masked, `false` = explicitly unmasked, `nil` = no explicit rule. + /// Mask wins over unmask when both apply to the same view. + func explicitMaskState(_ view: UIView, viewType: AnyClass) -> Bool? { + if isExplicitlyMasked(view, viewType: viewType) { + return true + } + if isExplicitlyUnmasked(view, viewType: viewType) { + return false + } + return nil + } + + /// Combines the inherited explicit state from ancestors with `view`'s own explicit state. + /// Short-circuits when an ancestor is already masked: mask propagation wins outright. + func resolveExplicitMask(_ view: UIView, viewType: AnyClass, inheritedExplicitMask: Bool?) -> Bool? { + if inheritedExplicitMask == true { return true } + return explicitMaskState(view, viewType: viewType) ?? inheritedExplicitMask + } + + /// Final precedence: an explicit (resolved) state wins; otherwise fall back to global config. + func shouldMask(_ view: UIView, viewType: AnyClass, resolvedExplicitMask: Bool?) -> Bool { + return resolvedExplicitMask ?? shouldMaskFromGlobalConfig(view, viewType: viewType) + } + + /// Evaluates whether a `CALayer` that has no backing `UIView` should be masked. + /// + /// Starting on iOS 26 ("Liquid Glass"), SwiftUI renders `Text`, `Image`, and SF + /// Symbols directly as private `CALayer` subclasses without wrapping them in + /// `UIView`s. The usual `shouldMask(_ view:)` path can't see these, so we + /// match by the layer's class name. + func shouldMaskLayer(_ layer: CALayer) -> Bool { + let layerType = String(describing: type(of: layer)) + if maskLabels, Constants.swiftUITextLayerTypes.contains(layerType) { + return true + } + if maskImages, Constants.swiftUIImageLayerTypes.contains(layerType) { + return true + } + return false + } + + /// Combines the per-view explicit state from associated objects / + /// configuration with an explicit state inherited from a SwiftUI marker + /// (`markerMask`) and from ancestors (`inheritedExplicitMask`). + /// + /// Mask precedence is preserved: any `true` from any source wins; any + /// `false` from any source wins over a `nil`. + func resolveExplicitMaskWithMarker( + view: UIView, + viewType: AnyClass, + inheritedExplicitMask: Bool?, + markerMask: Bool? + ) -> Bool? { + if inheritedExplicitMask == true || markerMask == true { return true } + + let own = explicitMaskState(view, viewType: viewType) + if own == true { return true } + + return own ?? markerMask ?? inheritedExplicitMask + } +} diff --git a/Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift b/Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift index c91c90e2..b9de391d 100644 --- a/Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift +++ b/Tests/SessionReplayTests/MaskCollectorPrecedenceTests.swift @@ -4,7 +4,7 @@ import UIKit @MainActor struct MaskCollectorPrecedenceTests { - typealias Settings = MaskCollector.Settings + typealias Settings = MaskingPolicy typealias PrivacyOptions = SessionReplayOptions.PrivacyOptions private func makeSettings(_ privacy: PrivacyOptions = PrivacyOptions(maskTextInputs: false)) -> Settings { diff --git a/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift b/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift index 434ecced..3cab45fb 100644 --- a/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift +++ b/Tests/SessionReplayTests/SessionReplayModifierPropagationTests.swift @@ -47,13 +47,12 @@ struct SessionReplayModifierPropagationTests { // MARK: - computeMarkerAreas - @Test("computeMarkerAreasAndOverlayBranches projects each marker into root-layer coordinates with the developer's explicit state") + @Test("MarkerScanner.scan projects each marker into root-layer coordinates with the developer's explicit state") func computeMarkerAreasProjectsToRoot() { let (window, _, mask) = makeOverlayHierarchy() SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: false) - let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) - let (areas, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + let (areas, overlayBranchViews) = MarkerScanner().scan(in: window, rPresentation: window.layer) #expect(areas.count == 1) #expect(areas.first?.mask == false) #expect(areas.first?.ignore == nil) @@ -63,18 +62,17 @@ struct SessionReplayModifierPropagationTests { #expect(overlayBranchViews.contains(ObjectIdentifier(mask))) } - @Test("computeMarkerAreasAndOverlayBranches records ignore=true for an .ldIgnore() marker") + @Test("MarkerScanner.scan records ignore=true for an .ldIgnore() marker") func computeMarkerAreasIgnore() { let (window, _, mask) = makeOverlayHierarchy() SessionReplayAssociatedObjects.ignoreUIView(mask, isEnabled: true) - let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) - let (areas, _) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + let (areas, _) = MarkerScanner().scan(in: window, rPresentation: window.layer) #expect(areas.count == 1) #expect(areas.first?.ignore == true) } - @Test("computeMarkerAreasAndOverlayBranches skips MaskView instances that are detached from the window") + @Test("MarkerScanner.scan skips MaskView instances that are detached from the window") func computeMarkerAreasSkipsDetached() { let detachedHost = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) let mask = MaskView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) @@ -87,12 +85,11 @@ struct SessionReplayModifierPropagationTests { window.isHidden = false window.layoutIfNeeded() - let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) - let (areas, _) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + let (areas, _) = MarkerScanner().scan(in: window, rPresentation: window.layer) #expect(areas.isEmpty) } - @Test("computeMarkerAreasAndOverlayBranches stops at a single-child host whose bounds exceed the marker's") + @Test("MarkerScanner.scan stops at a single-child host whose bounds exceed the marker's") func computeMarkerAreasStopsAtLargerSingleChildHost() { // Reproduces the live MainMenuView shape on iOS 26: the marker // wrappers end inside a `CellHostingView`-equivalent that has @@ -128,8 +125,7 @@ struct SessionReplayModifierPropagationTests { SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) - let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) - let (_, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + let (_, overlayBranchViews) = MarkerScanner().scan(in: window, rPresentation: window.layer) // Overlay-branch chain stops at the marker wrapper because // `cellContent` is much larger than the marker. @@ -186,7 +182,7 @@ struct SessionReplayModifierPropagationTests { #expect(result.maskOperations.count == 1) } - @Test("computeMarkerAreasAndOverlayBranches collects the single-child wrapper chain above each marker") + @Test("MarkerScanner.scan collects the single-child wrapper chain above each marker") func computeMarkerAreasCollectsOverlayChain() { // Wrappers with one child each above the MaskView form the // overlay branch and must be skipped during the visit pass to @@ -209,8 +205,7 @@ struct SessionReplayModifierPropagationTests { SessionReplayAssociatedObjects.maskUIView(mask, isEnabled: true) - let collector = MaskCollector(privacySettings: .init(maskTextInputs: false)) - let (_, overlayBranchViews) = collector.computeMarkerAreasAndOverlayBranches(in: window, rPresentation: window.layer) + let (_, overlayBranchViews) = MarkerScanner().scan(in: window, rPresentation: window.layer) #expect(overlayBranchViews.contains(ObjectIdentifier(mask))) #expect(overlayBranchViews.contains(ObjectIdentifier(innerWrapper))) @@ -221,12 +216,12 @@ struct SessionReplayModifierPropagationTests { @Test("MarkerOverride.combine: mask=true beats mask=false on the same area") func combineMaskPrecedence() { - var override = MaskCollector.MarkerOverride() + var override = MarkerScanner.MarkerOverride() override.combine(mask: false, ignore: nil) override.combine(mask: true, ignore: nil) #expect(override.mask == true) - var override2 = MaskCollector.MarkerOverride() + var override2 = MarkerScanner.MarkerOverride() override2.combine(mask: true, ignore: nil) override2.combine(mask: false, ignore: nil) #expect(override2.mask == true) From a999e55aa2ff6ca103326e96557326cda8495bb6 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 12:17:07 -0700 Subject: [PATCH 08/10] fix --- .../ScreenCapture/MarkerScanner.swift | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift index 5532932c..e630c761 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MarkerScanner.swift @@ -89,7 +89,24 @@ final class MarkerScanner { let mask = SessionReplayAssociatedObjects.shouldMaskUIView(marker) let ignore = SessionReplayAssociatedObjects.shouldIgnoreUIView(marker) if mask != nil || ignore != nil { - let frameInRoot = rPresentation.convert(marker.layer.bounds, from: marker.layer) + // `MaskCollector.collectViewMasks` recurses through + // `rPresentation.sublayers`, so every `effectiveFrame` + // it later compares against is computed in pure + // presentation coordinates. We must project the + // marker through its own presentation layer too — + // otherwise during an active animation (e.g. a + // horizontal navigation push/pop) the `from:` chain + // reads model `transform`/`position` while the + // receiver `rPresentation` is mid-animation, the + // resulting `frameInRoot` lands in the wrong + // coordinate system, and `frameContains` checks fail + // for every visited layer until the animation + // finishes. `presentation()` returns nil when the + // layer isn't animating, in which case model and + // presentation are identical and the fallback is + // exact. + let markerLayer = marker.layer.presentation() ?? marker.layer + let frameInRoot = rPresentation.convert(markerLayer.bounds, from: markerLayer) if frameInRoot.width > 0, frameInRoot.height > 0 { areas.append(MarkerArea(frameInRoot: frameInRoot, mask: mask, ignore: ignore)) } From 9a1993be4eb1a0306b56964fee03541e3909ac2c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 12:32:35 -0700 Subject: [PATCH 09/10] fix rebase --- .../ScreenCapture/MaskCollector.swift | 408 ++++++++---------- 1 file changed, 168 insertions(+), 240 deletions(-) diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift index 210eaa35..a184eea4 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskCollector.swift @@ -1,7 +1,4 @@ import Foundation -#if canImport(WebKit) -import WebKit -#endif import UIKit import SwiftUI #if LD_COCOAPODS @@ -22,290 +19,221 @@ public struct OffsettedArea { } } +/// Top-level orchestrator: walks every CALayer under a window and +/// produces a list of `MaskOperation`s that will be drawn over the +/// captured frame. +/// +/// The heavy lifting is delegated to focused collaborators: +/// - `MaskingPolicy` — per-view/per-layer rule decisions. +/// - `MarkerScanner` — SwiftUI `.ldMask()` / `.ldUnmask()` / +/// `.ldIgnore()` marker discovery and projection. +/// - `MaskGeometry` — pure CGRect/CALayer math. +/// +/// `MaskCollector` itself only owns the visit loop and the +/// transparency heuristic that lets opaque ancestors absorb their +/// children's masks. final class MaskCollector { - enum Constants { - static let maskiOS26ViewTypes = Set(["CameraUI.ChromeSwiftUIView"]) - } - - struct Settings { - var maskiOS26ViewTypes: Set - var maskTextInputs: Bool - var maskLabels: Bool - var maskWebViews: Bool - var maskImages: Bool - var minimumAlpha: Float - var maximumAlpha: Float - var maskUIViews: Set - var unmaskUIViews: Set - var ignoreUIViews: Set - - var maskAccessibilityIdentifiers: Set - var unmaskAccessibilityIdentifiers: Set - var ignoreAccessibilityIdentifiers: Set - - init(privacySettings: PrivacySettings) { - self.maskiOS26ViewTypes = Constants.maskiOS26ViewTypes - self.maskTextInputs = privacySettings.maskTextInputs - self.maskLabels = privacySettings.maskLabels - self.maskWebViews = privacySettings.maskWebViews - self.maskImages = privacySettings.maskImages - self.minimumAlpha = Float(privacySettings.minimumAlpha) - self.maximumAlpha = Float(1 - privacySettings.minimumAlpha) - - self.maskUIViews = Set(privacySettings.maskUIViews.map(ObjectIdentifier.init)) - self.unmaskUIViews = Set(privacySettings.unmaskUIViews.map(ObjectIdentifier.init)) - self.ignoreUIViews = Set(privacySettings.ignoreUIViews.map(ObjectIdentifier.init)) - - self.maskAccessibilityIdentifiers = Set(privacySettings.maskAccessibilityIdentifiers) - self.unmaskAccessibilityIdentifiers = Set(privacySettings.unmaskAccessibilityIdentifiers) - self.ignoreAccessibilityIdentifiers = Set(privacySettings.ignoreAccessibilityIdentifiers) - } - - func shouldIgnore(_ view: UIView, viewType: AnyClass) -> Bool { - if SessionReplayAssociatedObjects.shouldIgnoreUIView(view) == true { - return true - } + let policy: MaskingPolicy + private let markerScanner = MarkerScanner() - if ignoreUIViews.contains(ObjectIdentifier(viewType)) { - return true - } - - if let accessibilityIdentifier = view.accessibilityIdentifier, - ignoreAccessibilityIdentifiers.contains(accessibilityIdentifier) { - return true - } + public init(privacySettings: PrivacySettings) { + self.policy = MaskingPolicy(privacySettings: privacySettings) + } - return false - } + func collectViewMasks(in rootView: UIView, window: UIWindow, scale: CGFloat) -> (maskOperations: [MaskOperation], offsetRects: [OffsettedArea]) { + var operations = [MaskOperation]() + var offsetRects = [OffsettedArea]() - func isExplicitlyMasked(_ view: UIView, viewType: AnyClass) -> Bool { - if SessionReplayAssociatedObjects.shouldMaskUIView(view) == true { - return true - } - if maskUIViews.contains(ObjectIdentifier(viewType)) { - return true - } - if let accessibilityIdentifier = view.accessibilityIdentifier, - maskAccessibilityIdentifiers.contains(accessibilityIdentifier) { - return true - } - return false + let root = rootView.layer + let rPresentation = root.presentation() ?? root + + // Pre-pass: find every SwiftUI marker view in the subtree and + // record its frame in root coordinates plus its explicit state. + // SwiftUI's `.overlay(...)` sizes the marker to exactly the + // bounding box of the modified content, so this rectangle is the + // area the developer's modifier governs — independent of how the + // surrounding UIKit hierarchy is shaped (siblings, deeply nested + // wrappers, or layer-only content on iOS 26). + // + // We also collect the UIViews that form the marker's overlay + // branch (the single-child wrapper chain leading from each + // `MaskView` up to its first multi-child ancestor). Those views + // sit at the exact same position as the marker's area; without + // explicit suppression the geometric pass would emit duplicate + // masks for each of them. + // + // When the app has no live SwiftUI markers we skip the pre-pass + // entirely — both `markerAreas` and `overlayBranchViews` are + // empty and the visit loop avoids every per-layer marker + // lookup. + let markerAreas: [MarkerScanner.MarkerArea] + let overlayBranchViews: Set + if SessionReplayViewRepresentable.MaskView.hasLiveMarkers { + (markerAreas, overlayBranchViews) = markerScanner.scan(in: rootView, rPresentation: rPresentation) + } else { + markerAreas = [] + overlayBranchViews = [] } - func isExplicitlyUnmasked(_ view: UIView, viewType: AnyClass) -> Bool { - if SessionReplayAssociatedObjects.shouldMaskUIView(view) == false { - return true - } - if unmaskUIViews.contains(ObjectIdentifier(viewType)) { - return true + // Hoist the empty-state checks out of the hot `visit` loop so + // every per-layer iteration becomes a branch on a captured + // `Bool` rather than a property/function call on the + // collections. + let hasMarkerAreas = !markerAreas.isEmpty + let hasOverlayBranches = !overlayBranchViews.isEmpty + + // Combines the markers whose areas contain `frameInRoot` into a + // single override. Mask precedence is preserved by `combine`. + // Caller is responsible for the `hasMarkerAreas` short-circuit; + // this function is only invoked when at least one area exists. + func markerOverride(forFrameInRoot frameInRoot: CGRect) -> MarkerScanner.MarkerOverride? { + guard frameInRoot.width > 0, frameInRoot.height > 0 else { + return nil } - if let accessibilityIdentifier = view.accessibilityIdentifier, - unmaskAccessibilityIdentifiers.contains(accessibilityIdentifier) { - return true + var override: MarkerScanner.MarkerOverride? + for area in markerAreas { + if MaskGeometry.frameContains(area.frameInRoot, frameInRoot, tolerance: 1.0) { + if override == nil { override = MarkerScanner.MarkerOverride() } + override?.combine(mask: area.mask, ignore: area.ignore) + } } - return false + return override } - func shouldMaskFromGlobalConfig(_ view: UIView, viewType: AnyClass) -> Bool { - let stringViewType = String(describing: viewType) + // Returns `true` if a mask was emitted for this view (the caller should stop recursing). + func emitViewMask(view: UIView, layer: CALayer, viewType: AnyClass, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool { + let shouldMask = policy.shouldMask(view, viewType: viewType, resolvedExplicitMask: resolvedExplicitMask) - if maskiOS26ViewTypes.contains(stringViewType) { + if shouldMask, let mask = MaskGeometry.createMask(rPresentation: rPresentation, layer: layer, scale: scale) { + var operation = MaskOperation(mask: mask, kind: .fill, effectiveFrame: effectiveFrame) +#if DEBUG + operation.accessibilityIdentifier = view.accessibilityIdentifier +#endif + operations.append(operation) return true } - if maskWebViews { -#if canImport(WebKit) - if view is WKWebView { - return true + if let scrollView = view as? UIScrollView { + let offset = scrollView.contentOffset + if offset.x != 0 || offset.y != 0 { + offsetRects.append(OffsettedArea(rect: effectiveFrame, offset: offset)) } -#endif } - if maskTextInputs { - if view is UITextInput { -#if canImport(WebKit) - if stringViewType != "WKContentView" { - return true - } -#else - return true -#endif - } - if stringViewType == "UIKeyboard" { - return true - } - } - - if maskLabels && view is UILabel { - return true - } - - if maskImages && view is UIImageView { - return true + // An opaque container fully covers any masks we already emitted inside it, + // so those masks become redundant and can be dropped. + if operations.isNotEmpty, !isTransparent(view: view, pLayer: layer) { + operations.removeAll { effectiveFrame.contains($0.effectiveFrame) } } return false } - /// Returns the explicit mask state of `view` itself, ignoring ancestors: - /// `true` = explicitly masked, `false` = explicitly unmasked, `nil` = no explicit rule. - /// Mask wins over unmask when both apply to the same view. - func explicitMaskState(_ view: UIView, viewType: AnyClass) -> Bool? { - if isExplicitlyMasked(view, viewType: viewType) { - return true - } - if isExplicitlyUnmasked(view, viewType: viewType) { + // iOS 26+ SwiftUI renders `Text`/`Image` directly into CALayer subclasses with no + // backing UIView, so the UIView-based path can't see them. Match by layer class name + // while still honouring an inherited or marker-area explicit state. + // Returns `true` if a mask was emitted (the caller should stop recursing). + func emitLayerOnlyMask(layer: CALayer, effectiveFrame: CGRect, resolvedExplicitMask: Bool?) -> Bool { + let shouldMask = resolvedExplicitMask ?? policy.shouldMaskLayer(layer) + guard shouldMask, let mask = MaskGeometry.createMask(rPresentation: rPresentation, layer: layer, scale: scale) else { return false } - return nil + operations.append(MaskOperation(mask: mask, kind: .fill, effectiveFrame: effectiveFrame)) + return true } - /// Combines the inherited explicit state from ancestors with `view`'s own explicit state. - /// Short-circuits when an ancestor is already masked: mask propagation wins outright. - func resolveExplicitMask(_ view: UIView, viewType: AnyClass, inheritedExplicitMask: Bool?) -> Bool? { - if inheritedExplicitMask == true { return true } - return explicitMaskState(view, viewType: viewType) ?? inheritedExplicitMask - } - - /// Final precedence: an explicit (resolved) state wins; otherwise fall back to global config. - func shouldMask(_ view: UIView, viewType: AnyClass, resolvedExplicitMask: Bool?) -> Bool { - return resolvedExplicitMask ?? shouldMaskFromGlobalConfig(view, viewType: viewType) - } - } - - var settings: Settings - - public init(privacySettings: PrivacySettings) { - self.settings = Settings(privacySettings: privacySettings) - } - - func collectViewMasks(in rootView: UIView, window: UIWindow, scale: CGFloat) -> (maskOperations: [MaskOperation], offsetRects: [OffsettedArea]) { - var operations = [MaskOperation]() - var offsetRects = [OffsettedArea]() - - let root = rootView.layer - let rPresenation = root.presentation() ?? root - func visit(layer: CALayer, inheritedExplicitMask: Bool?) { - guard let view = layer.delegate as? UIView else { return } - guard !view.isHidden, - view.window != nil, - layer.opacity >= settings.minimumAlpha else { return } - - let viewType: AnyClass = type(of: view) - - guard !settings.shouldIgnore(view, viewType: viewType) else { return } - - let effectiveFrame = rPresenation.convert(layer.frame, from: layer.superlayer) + guard !layer.isHidden, layer.opacity >= policy.minimumAlpha else { return } + + // Frame in root coords is needed both for marker-area lookup + // and for `effectiveFrame`/`MaskOperation`. Compute it once. + let effectiveFrame = rPresentation.convert(layer.frame, from: layer.superlayer) + let markerOverrideForLayer = hasMarkerAreas + ? markerOverride(forFrameInRoot: effectiveFrame) + : nil + + let childInheritedMask: Bool? + if let view = layer.delegate as? UIView { + guard view.window != nil, !view.isHidden else { return } + + // The marker's overlay branch (the `MaskView` itself plus + // the single-child wrapper chain above it) is invisible + // and exactly co-located with the marker's area. Skip it + // entirely so the geometric containment pass doesn't + // emit a duplicate mask op for each wrapper. + if hasOverlayBranches, overlayBranchViews.contains(ObjectIdentifier(view)) { + return + } - let resolvedExplicitMask = settings.resolveExplicitMask(view, viewType: viewType, inheritedExplicitMask: inheritedExplicitMask) - let shouldMask = settings.shouldMask(view, viewType: viewType, resolvedExplicitMask: resolvedExplicitMask) - if shouldMask, let mask = createMask(rPresenation, layer: layer, scale: scale) { - var operation = MaskOperation(mask: mask, kind: .fill, effectiveFrame: effectiveFrame) -#if DEBUG - operation.accessibilityIdentifier = view.accessibilityIdentifier -#endif - operations.append(operation) - return - } + let viewType: AnyClass = type(of: view) - if let scrollView = view as? UIScrollView { - let offset = scrollView.contentOffset - if offset.x != 0 || offset.y != 0 { - offsetRects.append(OffsettedArea(rect: effectiveFrame, offset: offset)) + if policy.shouldIgnore(view, viewType: viewType) || markerOverrideForLayer?.ignore == true { + return } - } - if operations.isNotEmpty, !isSystem(view: view, pLayer: layer), !isTransparent(view: view, pLayer: layer) { - operations.removeAll { - effectiveFrame.contains($0.effectiveFrame) + let resolvedExplicitMask = policy.resolveExplicitMaskWithMarker( + view: view, + viewType: viewType, + inheritedExplicitMask: inheritedExplicitMask, + markerMask: markerOverrideForLayer?.mask + ) + if emitViewMask(view: view, layer: layer, viewType: viewType, effectiveFrame: effectiveFrame, resolvedExplicitMask: resolvedExplicitMask) { + return + } + childInheritedMask = resolvedExplicitMask + } else { + if markerOverrideForLayer?.ignore == true { return } + + let resolvedExplicitMask: Bool? + if inheritedExplicitMask == true || markerOverrideForLayer?.mask == true { + resolvedExplicitMask = true + } else { + resolvedExplicitMask = inheritedExplicitMask ?? markerOverrideForLayer?.mask } + if emitLayerOnlyMask(layer: layer, effectiveFrame: effectiveFrame, resolvedExplicitMask: resolvedExplicitMask) { + return + } + childInheritedMask = resolvedExplicitMask } - if let sublayers = layer.sublayers?.sorted(by: { $0.zPosition < $1.zPosition }) { - sublayers.forEach { visit(layer: $0, inheritedExplicitMask: resolvedExplicitMask) } + // Recurse into sublayers in z-order. Skip the `sorted()` + // allocation for the common case of zero or one + // sublayers (wrapper views, leaf nodes). + guard let sublayers = layer.sublayers, !sublayers.isEmpty else { return } + if sublayers.count == 1 { + visit(layer: sublayers[0], inheritedExplicitMask: childInheritedMask) + } else { + sublayers.sorted { $0.zPosition < $1.zPosition } + .forEach { visit(layer: $0, inheritedExplicitMask: childInheritedMask) } } } - rPresenation.sublayers?.sorted { $0.zPosition < $1.zPosition }.forEach { visit(layer: $0, inheritedExplicitMask: nil) } - - return (operations, offsetRects) - } - - func duplicateUnsimilar(before operationsBefore: [MaskOperation], after operationsAfter: [MaskOperation]) -> [MaskOperation]? { - guard operationsBefore.count == operationsAfter.count else { - return nil - } - - var result = operationsBefore - let moveTollerance = 1.0 - let overlapTollerance = 1.1 - for (before, after) in zip(operationsBefore, operationsAfter) { - let diffX = abs(before.effectiveFrame.minX - after.effectiveFrame.minX) - let diffY = abs(before.effectiveFrame.minY - after.effectiveFrame.minY) - - guard max(diffX, diffY) > moveTollerance else { - // If movement is present we duplicate the frame - continue - } - - guard diffX * overlapTollerance < before.effectiveFrame.width - moveTollerance, - diffY * overlapTollerance < before.effectiveFrame.height - moveTollerance else { - // If movement is bigger the size we drop the frame - return nil + if let rootSublayers = rPresentation.sublayers, !rootSublayers.isEmpty { + if rootSublayers.count == 1 { + visit(layer: rootSublayers[0], inheritedExplicitMask: nil) + } else { + rootSublayers.sorted { $0.zPosition < $1.zPosition } + .forEach { visit(layer: $0, inheritedExplicitMask: nil) } } - - var after = after - after.kind = .fillDuplicate - result.append(after) } - - return result + + return (operations, offsetRects) } - + // this method should be biased into transparency private func isTransparent(view: UIView, pLayer: CALayer) -> Bool { - pLayer.opacity < settings.maximumAlpha + pLayer.opacity < policy.maximumAlpha || view.backgroundColor == nil - || (view.backgroundColor?.cgColor.alpha ?? 0) < CGFloat(settings.maximumAlpha) + || (view.backgroundColor?.cgColor.alpha ?? 0) < CGFloat(policy.maximumAlpha) } - - private func isSystem(view: UIView, pLayer: CALayer) -> Bool { - return false - } - - func createMask(_ rPresenation: CALayer, layer: CALayer, scale: CGFloat) -> Mask? { - let lBounds = layer.bounds - guard lBounds.width > 0, lBounds.height > 0 else { return nil } - - if CATransform3DIsAffine(layer.transform) { - let corner0 = layer.convert(CGPoint.zero, to: rPresenation) - let corner1 = layer.convert(CGPoint(x: lBounds.width, y: 0), to: rPresenation) - let corner3 = layer.convert(CGPoint(x: 0, y: lBounds.height), to: rPresenation) - - let tx = corner0.x, ty = corner0.y - let affineTransform = CGAffineTransform(a: (corner1.x - tx) / max(lBounds.width, 0.0001), - b: (corner1.y - ty) / max(lBounds.width, 0.0001), - c: (corner3.x - tx) / max(lBounds.height, 0.0001), - d: (corner3.y - ty) / max(lBounds.height, 0.0001), - tx: tx, - ty: ty).scaledBy(x: scale, y: scale) - return Mask.affine(rect: lBounds, transform: affineTransform) - } else { - // TODO: finish 3D animations - } - - return nil - } - - func rectFromPresentation(_ rPresenation: CALayer, root: CALayer, layer: CALayer) -> CGRect { - let lPresenation = layer.presentation() ?? layer - let corner1 = lPresenation.convert(CGPoint(x: 0, y: 0), to: root) - let corner2 = lPresenation.convert(CGPoint(x: lPresenation.bounds.width, y: lPresenation.bounds.height), to: root) + + func rectFromPresentation(_ rPresentation: CALayer, root: CALayer, layer: CALayer) -> CGRect { + let lPresentation = layer.presentation() ?? layer + let corner1 = lPresentation.convert(CGPoint(x: 0, y: 0), to: root) + let corner2 = lPresentation.convert(CGPoint(x: lPresentation.bounds.width, y: lPresentation.bounds.height), to: root) return CGRect(x: min(corner1.x, corner2.x), y: min(corner1.y, corner2.y), width: abs(corner2.x - corner1.x), height: abs(corner2.y - corner1.y)) } } - From 52db5f05b87d1ed2784c5a5a5be1412fa5db8475 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 8 May 2026 12:37:11 -0700 Subject: [PATCH 10/10] String(describing: viewType) optimization --- .../ScreenCapture/MaskingPolicy.swift | 15 +++++++++------ TestApp/Sources/AppDelegate.swift | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift index cf811998..642fd619 100644 --- a/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift +++ b/Sources/LaunchDarklySessionReplay/ScreenCapture/MaskingPolicy.swift @@ -149,23 +149,26 @@ final class MaskingPolicy { // string allocation for the vast majority of views (the // protocol conformance is implemented in cached witness // tables on a small set of UIKit classes). + // + // The remaining checks all key off the type's name, so once + // we've allocated the string for the `WKContentView` + // discrimination we keep reusing it instead of recomputing. #if canImport(WebKit) + let stringViewType: String if maskTextInputs, view is UITextInput { - let stringViewType = String(describing: viewType) + stringViewType = String(describing: viewType) if stringViewType != "WKContentView" { return true } + } else { + stringViewType = String(describing: viewType) } #else if maskTextInputs, view is UITextInput { return true } -#endif - - // The remaining checks all key off the type's name. Compute - // it once for whichever fallthrough branch is still - // reachable. let stringViewType = String(describing: viewType) +#endif if Constants.maskiOS26ViewTypes.contains(stringViewType) { return true diff --git a/TestApp/Sources/AppDelegate.swift b/TestApp/Sources/AppDelegate.swift index bff0bcc4..fc555f39 100644 --- a/TestApp/Sources/AppDelegate.swift +++ b/TestApp/Sources/AppDelegate.swift @@ -34,7 +34,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { isEnabled: true, privacy: .init( maskTextInputs: true, - maskWebViews: false, + maskWebViews: true, maskLabels: false, maskImages: false, maskAccessibilityIdentifiers: ["email-field", "password-field", "card-brand-chip", "10"],