Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changeset/v4-major-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ PostHog iOS 4.0. Every breaking change below names what to use instead.

- Fix session replay recording nothing for apps whose window root is a SwiftUI view.
- Fix secure-entry text fields being unmasked inside a `postHogNoMask()` or `ph-no-mask` subtree.
- Fix session replay leaving masked content visible where it is drawn outside its view (unclipped images, layer shadows, button titles) and image attachments in text unmasked when `maskAllImages` is on.
5 changes: 3 additions & 2 deletions PostHog/Replay/PostHogMaskSettleAnalysis.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@
self.rect = rect
}

/// Covers everything the view draws itself, including content past its bounds.
init(_ view: UIView, in window: UIWindow?) {
self.init(view, rect: view.toPresentationRect(window))
self.init(view, rect: view.toPresentationRect(window, outsets: view.drawnOutsets))
}

init(_ layer: CALayer, in window: UIWindow?) {
self.init(layer, rect: layer.toPresentationRect(window))
self.init(layer, rect: layer.toPresentationRect(window, outsets: layer.drawnOutsets(of: layer.bounds)))
}
}

Expand Down
33 changes: 31 additions & 2 deletions PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,8 @@
if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView
if isTextViewSensitive(textView) {
maskableWidgets.append(.init(view, in: window))
// an unclipped subview can draw outside the text view's mask
findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture())
return
}
}
Expand Down Expand Up @@ -978,6 +980,8 @@
if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
if isLabelSensitive(label) {
maskableWidgets.append(.init(view, in: window))
// an unclipped subview can draw outside the label's mask
findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture())
return
}
}
Expand All @@ -995,6 +999,8 @@
if let button = view as? UIButton {
if isButtonSensitive(button) {
maskableWidgets.append(.init(view, in: window))
// title and image insets can lay the button's subviews out past its bounds
findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture())
return
}
}
Expand Down Expand Up @@ -1350,15 +1356,17 @@
}

private func isLabelSensitive(_ view: UILabel) -> Bool {
isTextInputSensitive(view) && hasText(view.text)
(isTextInputSensitive(view) && hasText(view.text)) || hasSensitiveImageAttachment(view.attributedText)
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
}

private func isButtonSensitive(_ view: UIButton) -> Bool {
isTextInputSensitive(view) && hasText(view.titleLabel?.text)
}

private func isTextViewSensitive(_ view: UITextView) -> Bool {
(isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)
// textStorage rather than attributedText, which copies the whole text
((isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text))
|| hasSensitiveImageAttachment(view.textStorage)
}

private func isSwitchSensitive(_ view: UISwitch) -> Bool {
Expand All @@ -1369,6 +1377,27 @@
(isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder))
}

/// Image attachments in text are drawn by the label or text view itself, with no image view to
/// mask, so masking images masks text that holds one. Like image views, symbols and
/// asset-catalog images stay visible; an attachment whose image can't be read is masked.
private func hasSensitiveImageAttachment(_ text: NSAttributedString?) -> Bool {
guard config?.sessionReplayConfig.maskAllImages == true, let text, text.length > 0 else {
return false
}
var found = false
text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, _, stop in
guard let attachment = value as? NSTextAttachment else {
return
}
if let image = attachment.image, image.isSymbolImage || isAssetsImage(image) {
return
}
found = true
stop.pointee = true
}
return found
}

private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool {
swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) })
}
Expand Down
90 changes: 84 additions & 6 deletions PostHog/Replay/UIView+Util.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,12 +161,67 @@
/// Mask geometry only. During a CA animation the model layer parks at the destination
/// while the presentation layer holds the in-flight position the screenshot renders, so
/// mask rects have to come from the presentation tree.
func toPresentationRect(_ window: UIWindow?) -> CGRect {
/// `outsets` grows the rect past the bounds, for content drawn outside them.
func toPresentationRect(_ window: UIWindow?, outsets: UIEdgeInsets = .zero) -> CGRect {
guard layer.presentation() != nil else {
// UIView's own convert, not the layer's: it resolves a nil window to the view's.
return convert(bounds, to: window)
return convert(bounds.outset(by: outsets), to: window)
}
return layer.toPresentationRect(window)
return layer.toPresentationRect(window, outsets: outsets)
}

/// How far past its bounds the view draws its own content: an image view that doesn't
/// clip draws its image at the size its content mode gives it, and an unclipped layer
/// shadow repeats that content at an offset.
var drawnOutsets: UIEdgeInsets {
var content = bounds
if !clipsToBounds, let imageView = self as? UIImageView {
content = imageView.drawnImageExtent
}
return layer.drawnOutsets(of: content)
}
}

extension UIImageView {
/// The union of the bounds and where each image the view can draw lands, in bounds
/// coordinates. Only fill-to-bounds content modes keep the image inside the bounds.
var drawnImageExtent: CGRect {
switch contentMode {
case .scaleToFill, .scaleAspectFit, .redraw:
return bounds
default:
let sources = [image, highlightedImage].compactMap { $0 }
+ (animationImages ?? []) + (highlightedAnimationImages ?? [])
return sources.reduce(bounds) { $0.union(drawnRect(ofImageSized: $1.size)) }
}
}

private func drawnRect(ofImageSized imageSize: CGSize) -> CGRect {
var size = imageSize
if contentMode == .scaleAspectFill, imageSize.width > 0, imageSize.height > 0 {
let scale = max(bounds.width / imageSize.width, bounds.height / imageSize.height)
size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
}

let originX: CGFloat
switch contentMode {
case .left, .topLeft, .bottomLeft: originX = 0
case .right, .topRight, .bottomRight: originX = bounds.width - size.width
default: originX = (bounds.width - size.width) / 2
}
let originY: CGFloat
switch contentMode {
case .top, .topLeft, .topRight: originY = 0
case .bottom, .bottomLeft, .bottomRight: originY = bounds.height - size.height
default: originY = (bounds.height - size.height) / 2
}
return CGRect(x: bounds.minX + originX, y: bounds.minY + originY, width: size.width, height: size.height)
}
}

extension CGRect {
func outset(by outsets: UIEdgeInsets) -> CGRect {
inset(by: UIEdgeInsets(top: -outsets.top, left: -outsets.left, bottom: -outsets.bottom, right: -outsets.right))
}
}

Expand All @@ -192,11 +247,34 @@
}

extension CALayer {
func toPresentationRect(_ window: UIWindow?) -> CGRect {
func toPresentationRect(_ window: UIWindow?, outsets: UIEdgeInsets = .zero) -> CGRect {
guard let presentationLayer = presentation() else {
return convert(bounds, to: window?.layer)
return convert(bounds.outset(by: outsets), to: window?.layer)
}
return presentationLayer.convert(
presentationLayer.bounds.outset(by: outsets),
to: window?.layer.presentation() ?? window?.layer
)
}

/// How far past `bounds` the layer draws, given `content`, the extent of its own drawing
/// in bounds coordinates. A shadow without a `shadowPath` repeats the content's shape,
/// text included, so it counts; `masksToBounds` clips the shadow away.
func drawnOutsets(of content: CGRect) -> UIEdgeInsets {
var drawn = content
if !masksToBounds, shadowOpacity > 0, shadowColor != nil, shadowPath == nil {
// Pad by twice the blur radius so the shadow's soft edge is covered too.
let blur = shadowRadius * 2
drawn = drawn.union(
content.offsetBy(dx: shadowOffset.width, dy: shadowOffset.height).insetBy(dx: -blur, dy: -blur)
)
}
return presentationLayer.convert(presentationLayer.bounds, to: window?.layer.presentation() ?? window?.layer)
return UIEdgeInsets(
top: max(0, bounds.minY - drawn.minY),
left: max(0, bounds.minX - drawn.minX),
bottom: max(0, drawn.maxY - bounds.maxY),
right: max(0, drawn.maxX - bounds.maxX)
)
}

/// Backing flag for the SwiftUI `.postHogNoRageClick()` modifier on layer-backed
Expand Down
Loading
Loading