From 4b085fc69e6bb38c1983d969e44689a466a30423 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:15:48 -0400 Subject: [PATCH 1/3] fix(replay): mask full-window ph-no-capture views A view tagged ph-no-capture now always masks its own rect. Previously a tagged view that filled the window was skipped and only its descendants were masked, so its own drawing reached the screenshot. Co-Authored-By: Claude Opus 5.5 --- .../mask-full-window-no-capture-view.md | 5 +++ PostHog/Replay/PostHogReplayIntegration.swift | 43 ++++++------------- PostHogTests/PostHogMaskFailClosedTest.swift | 37 +++------------- .../PostHogMaskPresentationPrivacyTest.swift | 4 +- .../PostHogReactNativeMaskingTest.swift | 26 +++++++++++ 5 files changed, 54 insertions(+), 61 deletions(-) create mode 100644 .changeset/mask-full-window-no-capture-view.md diff --git a/.changeset/mask-full-window-no-capture-view.md b/.changeset/mask-full-window-no-capture-view.md new file mode 100644 index 000000000..9e82f3135 --- /dev/null +++ b/.changeset/mask-full-window-no-capture-view.md @@ -0,0 +1,5 @@ +--- +"posthog-ios": patch +--- + +Mask the whole rect of a `ph-no-capture` view in session replay screenshots, including a view that fills the window. diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index efe8c25af..cdd005f46 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -940,7 +940,7 @@ return (hasText, hasGraphic) } - private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion], _ maskChildren: Bool) { + private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { // Checked first so an explicit unmask wins over the sensitive-type early-returns // below, matching the modifier's precedence. if view.isNoMask() { @@ -981,7 +981,7 @@ if isImageViewSensitive(image) { maskableWidgets.append(.init(view, in: window)) // subviews can draw outside the image view's bounds, so its mask may not cover them - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets) return } } @@ -1103,31 +1103,22 @@ } } - // on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask - // in such cases - var maskDescendants = maskChildren - if view.isNoCapture() || maskChildren { - let viewRect = view.toAbsoluteRect(window) - let windowRect = window.frame - - // Check if the rectangles do not match - if !viewRect.equalTo(windowRect) { - maskableWidgets.append(.init(view, in: window)) - } else { - maskDescendants = true - } + // Masked whole, even when it fills the window: its own drawing is not covered by + // its descendants' masks. + if view.isNoCapture() { + maskableWidgets.append(.init(view, in: window)) } - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskDescendants) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets) } - private func findMaskableWidgets(inSubviewsOf view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion], _ maskChildren: Bool) { + private func findMaskableWidgets(inSubviewsOf view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { for child in view.subviews { if !child.isVisibleForMasking() { continue } - findMaskableWidgets(child, window, &maskableWidgets, maskChildren) + findMaskableWidgets(child, window, &maskableWidgets) } } @@ -2000,7 +1991,7 @@ /// below them replays those rules first. func findMaskableWidgets(under cover: UIView?, in window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { guard let cover else { - findMaskableWidgets(window, window, &maskableWidgets, false) + findMaskableWidgets(window, window, &maskableWidgets) return } @@ -2017,25 +2008,19 @@ // Top down, the order the walk would have met them in. Only the two rules that reach // a whole subtree are replayed: the sensitive-type checks describe an ancestor's own // pixels, and the cover hides those. - var maskChildren = false for ancestor in ancestors.reversed() { // `ph-no-mask` drops every heuristic mask below it, the cover included. if ancestor.isNoMask() { return } - guard ancestor.isNoCapture() || maskChildren else { - continue - } - if ancestor.toAbsoluteRect(window).equalTo(window.frame) { - maskChildren = true - } else { - // An ancestor that is not the window's size is redacted as one rect, and the - // cover it holds is redacted with it. + // A `ph-no-capture` ancestor is redacted as one rect, and the cover it holds is + // redacted with it. + if ancestor.isNoCapture() { maskableWidgets.append(.init(ancestor, in: window)) } } - findMaskableWidgets(cover, window, &maskableWidgets, maskChildren) + findMaskableWidgets(cover, window, &maskableWidgets) } } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index b9b90f91c..57c20fca6 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -294,40 +294,17 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } - @Test("a preceding sibling cannot clear an inherited no-capture mask", arguments: [true, false]) - func inheritedMaskSurvivesSiblingTraversal(zeroSizeFirstChild: Bool) throws { + @Test("a full-window no-capture view masks its own drawing, not only its subviews") + func fullWindowNoCaptureViewIsMasked() { let sut = makeSut() defer { teardown(sut) } - let parent = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) - parent.accessibilityIdentifier = "ph-no-capture" - let firstChild = UIView(frame: zeroSizeFirstChild ? .zero : CGRect(x: 0, y: 0, width: 20, height: 20)) - firstChild.clipsToBounds = false - parent.addSubview(firstChild) - let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) - parent.addSubview(secret) - let window = makeWindow(containing: parent) - - let rects = try #require(sut.integration.collectMaskableRects(in: window)) - #expect(rects.contains(secret.frame)) - } - - @Test("inherited masking respects explicit no-mask and stays within the marked subtree") - func inheritedMaskRespectsScopeAndNoMask() { - let sut = makeSut() - defer { teardown(sut) } + let tagged = CustomDrawnSecretView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + tagged.accessibilityIdentifier = "ph-no-capture" + tagged.addSubview(UIView(frame: CGRect(x: 24, y: 600, width: 20, height: 20))) + let window = makeWindow(containing: tagged) - let parent = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) - parent.accessibilityIdentifier = "ph-no-capture" - let unmaskedChild = CustomDrawnSecretView(frame: CGRect(x: 24, y: 80, width: 200, height: 32)) - unmaskedChild.accessibilityIdentifier = "ph-no-mask" - parent.addSubview(unmaskedChild) - let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) - parent.addSubview(secret) - let window = makeWindow(containing: parent) - window.addSubview(CustomDrawnSecretView(frame: CGRect(x: 24, y: 160, width: 200, height: 32))) - - #expect(sut.integration.collectMaskableRects(in: window) == [secret.frame]) + #expect(sut.integration.collectMaskableRects(in: window) == [window.bounds]) } @Test("a collapsed clipping parent is walked while its presentation bounds remain visible", arguments: [true, false]) diff --git a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift index ef8f4fe56..42a25e074 100644 --- a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift +++ b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift @@ -470,8 +470,8 @@ let h = try Harness(root: base.controller) defer { h.close() } // The app marks the whole window sensitive, and a presentation stays inside that - // subtree. The plain view carries nothing the heuristics can read, so the rule - // inherited from the window is the only thing that can mask it. + // subtree. The plain view carries nothing the heuristics can read, so the window's + // own no-capture rect is the only thing that can mask it. h.window.accessibilityIdentifier = "ph-no-capture" let cover = UIViewController() cover.view.backgroundColor = .white diff --git a/PostHogTests/PostHogReactNativeMaskingTest.swift b/PostHogTests/PostHogReactNativeMaskingTest.swift index 310e6ada2..e1b95083f 100644 --- a/PostHogTests/PostHogReactNativeMaskingTest.swift +++ b/PostHogTests/PostHogReactNativeMaskingTest.swift @@ -25,6 +25,14 @@ @objc(RNSVGText) private final class SvgTextViewStub: SvgGroupViewStub {} @objc(RNSVGImage) private final class SvgImageViewStub: SvgRenderableViewStub {} + /// Derives its label from its subviews the way RCTView does when none is assigned. + private final class DerivedLabelContainerView: UIView { + override var accessibilityLabel: String? { + get { subviews.compactMap(\.accessibilityLabel).joined(separator: " ") } + set { _ = newValue } + } + } + @Suite("Replay masking for React Native (Fabric) component views", .serialized) @MainActor struct PostHogReactNativeMaskingTest { @@ -175,6 +183,24 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } + @Test("A full-window container whose derived label contains a ph-no-capture child is not masked whole") + func fullWindowContainerWithDerivedLabelIsNotMasked() { + let sut = makeSut(maskText: false, maskImages: false) + defer { teardown(sut) } + + // RCTView answers `accessibilityLabel` with its descendants' labels joined + // (RCTRecursiveAccessibilityLabel), so the root container of a screen holding a + // PostHogMaskView reports the token too. Only the wrapper is masked. + let root = DerivedLabelContainerView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + let maskView = UIView(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) + maskView.accessibilityLabel = "ph-no-capture" + root.addSubview(maskView) + let window = makeWindow(containing: root) + + #expect(root.accessibilityLabel == "ph-no-capture") + #expect(sut.integration.collectMaskableRects(in: window) == [maskView.frame]) + } + @Test("nothing is masked with both flags off") func nothingMaskedWithFlagsOff() { let sut = makeSut(maskText: false, maskImages: false) From e52f7b2a6d3581b6a9803b83c0c4451c00793d8c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 12:47:31 -0400 Subject: [PATCH 2/3] fix(replay): keep masking descendants of ph-no-capture views A tagged view's mask is its presentation rect, so an unclipped descendant drawing outside it (for example while the view's bounds animate) leaked. A tagged view now masks its own rect and every descendant, whatever its size, and the tests that pin descendant masking are restored. Co-Authored-By: Claude Opus 5.5 --- PostHog/Replay/PostHogReplayIntegration.swift | 31 +++++----- PostHogTests/PostHogMaskFailClosedTest.swift | 62 ++++++++++++++++++- .../PostHogMaskPresentationPrivacyTest.swift | 4 +- 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index cdd005f46..d825c6b1b 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -940,7 +940,7 @@ return (hasText, hasGraphic) } - private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { + private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion], _ maskChildren: Bool) { // Checked first so an explicit unmask wins over the sensitive-type early-returns // below, matching the modifier's precedence. if view.isNoMask() { @@ -981,7 +981,7 @@ if isImageViewSensitive(image) { maskableWidgets.append(.init(view, in: window)) // subviews can draw outside the image view's bounds, so its mask may not cover them - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren) return } } @@ -1103,22 +1103,23 @@ } } - // Masked whole, even when it fills the window: its own drawing is not covered by - // its descendants' masks. - if view.isNoCapture() { + // Masked whole, even when it fills the window, and so is every descendant: an + // unclipped descendant can draw outside the view's rect. + let maskDescendants = view.isNoCapture() || maskChildren + if maskDescendants { maskableWidgets.append(.init(view, in: window)) } - findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets) + findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskDescendants) } - private func findMaskableWidgets(inSubviewsOf view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { + private func findMaskableWidgets(inSubviewsOf view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion], _ maskChildren: Bool) { for child in view.subviews { if !child.isVisibleForMasking() { continue } - findMaskableWidgets(child, window, &maskableWidgets) + findMaskableWidgets(child, window, &maskableWidgets, maskChildren) } } @@ -1991,7 +1992,7 @@ /// below them replays those rules first. func findMaskableWidgets(under cover: UIView?, in window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { guard let cover else { - findMaskableWidgets(window, window, &maskableWidgets) + findMaskableWidgets(window, window, &maskableWidgets, false) return } @@ -2008,19 +2009,21 @@ // Top down, the order the walk would have met them in. Only the two rules that reach // a whole subtree are replayed: the sensitive-type checks describe an ancestor's own // pixels, and the cover hides those. + var maskChildren = false for ancestor in ancestors.reversed() { // `ph-no-mask` drops every heuristic mask below it, the cover included. if ancestor.isNoMask() { return } - // A `ph-no-capture` ancestor is redacted as one rect, and the cover it holds is - // redacted with it. - if ancestor.isNoCapture() { - maskableWidgets.append(.init(ancestor, in: window)) + guard ancestor.isNoCapture() || maskChildren else { + continue } + // Redacted as one rect, and the cover it holds is masked with its descendants. + maskableWidgets.append(.init(ancestor, in: window)) + maskChildren = true } - findMaskableWidgets(cover, window, &maskableWidgets) + findMaskableWidgets(cover, window, &maskableWidgets, maskChildren) } } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index 57c20fca6..b4dd8b77c 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -294,6 +294,42 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } + @Test("a preceding sibling cannot clear an inherited no-capture mask", arguments: [true, false]) + func inheritedMaskSurvivesSiblingTraversal(zeroSizeFirstChild: Bool) throws { + let sut = makeSut() + defer { teardown(sut) } + + let parent = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + parent.accessibilityIdentifier = "ph-no-capture" + let firstChild = UIView(frame: zeroSizeFirstChild ? .zero : CGRect(x: 0, y: 0, width: 20, height: 20)) + firstChild.clipsToBounds = false + parent.addSubview(firstChild) + let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) + parent.addSubview(secret) + let window = makeWindow(containing: parent) + + let rects = try #require(sut.integration.collectMaskableRects(in: window)) + #expect(rects.contains(secret.frame)) + } + + @Test("inherited masking respects explicit no-mask and stays within the marked subtree") + func inheritedMaskRespectsScopeAndNoMask() { + let sut = makeSut() + defer { teardown(sut) } + + let parent = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + parent.accessibilityIdentifier = "ph-no-capture" + let unmaskedChild = CustomDrawnSecretView(frame: CGRect(x: 24, y: 80, width: 200, height: 32)) + unmaskedChild.accessibilityIdentifier = "ph-no-mask" + parent.addSubview(unmaskedChild) + let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 120, width: 200, height: 32)) + parent.addSubview(secret) + let window = makeWindow(containing: parent) + window.addSubview(CustomDrawnSecretView(frame: CGRect(x: 24, y: 160, width: 200, height: 32))) + + #expect(sut.integration.collectMaskableRects(in: window) == [parent.frame, secret.frame]) + } + @Test("a full-window no-capture view masks its own drawing, not only its subviews") func fullWindowNoCaptureViewIsMasked() { let sut = makeSut() @@ -301,10 +337,32 @@ let tagged = CustomDrawnSecretView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) tagged.accessibilityIdentifier = "ph-no-capture" - tagged.addSubview(UIView(frame: CGRect(x: 24, y: 600, width: 20, height: 20))) + let child = UIView(frame: CGRect(x: 24, y: 600, width: 20, height: 20)) + tagged.addSubview(child) + let window = makeWindow(containing: tagged) + + #expect(sut.integration.collectMaskableRects(in: window) == [window.bounds, child.frame]) + } + + @Test("an unclipped child drawing outside a shrinking no-capture view stays masked") + func overflowingChildOfShrinkingNoCaptureViewIsMasked() throws { + let sut = makeSut() + defer { teardown(sut) } + + // The model frame fills the window while the rendered bounds animate down to 100pt. + let tagged = CollapsingView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + tagged.accessibilityIdentifier = "ph-no-capture" + let layer = try #require(tagged.layer as? CollapsingLayer) + layer.renderedBounds = CGRect(x: 0, y: 0, width: 100, height: 100) + let secret = CustomDrawnSecretView(frame: CGRect(x: 24, y: 600, width: 200, height: 32)) + tagged.addSubview(secret) let window = makeWindow(containing: tagged) - #expect(sut.integration.collectMaskableRects(in: window) == [window.bounds]) + let rects = try #require(sut.integration.collectMaskableRects(in: window)) + let taggedRect = tagged.toPresentationRect(window) + let secretRect = secret.toPresentationRect(window) + #expect(!taggedRect.contains(secretRect)) + #expect(rects == [taggedRect, secretRect]) } @Test("a collapsed clipping parent is walked while its presentation bounds remain visible", arguments: [true, false]) diff --git a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift index 42a25e074..ef8f4fe56 100644 --- a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift +++ b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift @@ -470,8 +470,8 @@ let h = try Harness(root: base.controller) defer { h.close() } // The app marks the whole window sensitive, and a presentation stays inside that - // subtree. The plain view carries nothing the heuristics can read, so the window's - // own no-capture rect is the only thing that can mask it. + // subtree. The plain view carries nothing the heuristics can read, so the rule + // inherited from the window is the only thing that can mask it. h.window.accessibilityIdentifier = "ph-no-capture" let cover = UIViewController() cover.view.backgroundColor = .white From bacde5d399ec6d81cbf399b0c9e68c041f4afbf9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:13:15 -0400 Subject: [PATCH 3/3] fix(replay): collect a mask reporter inside a ph-no-capture view once The native walk and the mask registry both collected it, and the repeated owner made the settle check read a still frame as motion. Co-Authored-By: Claude Opus 5.5 --- PostHog/Replay/PostHogReplayIntegration.swift | 4 ++++ .../PostHogMaskingCharacterizationTest.swift | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index d825c6b1b..376620984 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -1214,6 +1214,10 @@ var maskableWidgets: [MaskedRegion] = [] findMaskableWidgets(under: cover, in: window, &maskableWidgets) + // A reporter inside a `ph-no-capture` view is also reached by the walk. Keep one + // entry per reporter: a repeated owner reads as motion to the settle check. + let reporters = Set(masked.regions.map(\.owner)) + maskableWidgets.removeAll { reporters.contains($0.owner) } maskableWidgets.append(contentsOf: masked.regions) return maskableWidgets } diff --git a/PostHogTests/PostHogMaskingCharacterizationTest.swift b/PostHogTests/PostHogMaskingCharacterizationTest.swift index 45ab9ecce..a7f8511cd 100644 --- a/PostHogTests/PostHogMaskingCharacterizationTest.swift +++ b/PostHogTests/PostHogMaskingCharacterizationTest.swift @@ -579,6 +579,24 @@ #expect(rects == [CGRect(x: 10, y: 100, width: 100, height: 40)], "reporter rect flows through the capture collection") } + @Test("a reporter inside a ph-no-capture view is collected once") + func reporterInsideNoCaptureViewIsCollectedOnce() { + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + let container = UIView(frame: CGRect(x: 20, y: 80, width: 200, height: 200)) + container.accessibilityIdentifier = "ph-no-capture" + window.addSubview(container) + + let reporter = PostHogMaskReporterUIView(frame: CGRect(x: 10, y: 20, width: 100, height: 40)) + container.addSubview(reporter) + defer { reporter.removeFromSuperview() } + window.layoutIfNeeded() + + let integration = PostHogReplayIntegration() + let rects = integration.collectMaskableRects(in: window) + + #expect(rects == [CGRect(x: 20, y: 80, width: 200, height: 200), CGRect(x: 30, y: 100, width: 100, height: 40)]) + } + // MARK: - Pre-first-layout gap (fail closed) @Test("a reporter completes its first layout even at zero size")