Skip to content

Commit

Permalink
feat: add postHogNoMask SwiftUI view modifier (#277)
Browse files Browse the repository at this point in the history
* feat: add postHogNoMask SwiftUI view modifier

* chore: update CHANGELOG

* fix: improve SwiftUI view tagging

* fix: lint

* fix: remove swifltint build script
  • Loading branch information
ioannisj authored Dec 27, 2024
1 parent 39df17d commit b2a7466
Show file tree
Hide file tree
Showing 9 changed files with 527 additions and 91 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Next

- feat: add `postHogNoMask` SwiftUI view modifier to explicitly mark any View as non-maskable ([#277](https://github.com/PostHog/posthog-ios/pull/277))

## 3.17.2 - 2024-12-23

- fix: ignore additional keyboard windows for $screen event ([#279](https://github.com/PostHog/posthog-ios/pull/279))
Expand Down
20 changes: 18 additions & 2 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@
DA5AA7192CE245D2004EFB99 /* UIApplication+.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5AA7132CE245CD004EFB99 /* UIApplication+.swift */; };
DA5B85882CD21CBB00686389 /* AutocaptureEventProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */; };
DA979D7B2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */; };
DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */; };
DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */; };
DAC699D62CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */; };
DAC699EC2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */; };
DACF6D5D2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */; };
Expand Down Expand Up @@ -403,6 +405,8 @@
DA5B85872CD21CBB00686389 /* AutocaptureEventProcessing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocaptureEventProcessing.swift; sourceTree = "<group>"; };
DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = "<group>"; };
DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureEventTrackerSpec.swift; sourceTree = "<group>"; };
DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostHogNoMaskViewModifier.swift; sourceTree = "<group>"; };
DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogTagViewModifier.swift; sourceTree = "<group>"; };
DAC699D52CC790D9000D1D6B /* PostHogAutocaptureIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegration.swift; sourceTree = "<group>"; };
DAC699EB2CCA73E5000D1D6B /* ForwardingPickerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardingPickerViewDelegate.swift; sourceTree = "<group>"; };
DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAutocaptureIntegrationSpec.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -596,9 +600,8 @@
69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */,
693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */,
69ED1A5B2C7F15F300FE7A91 /* PostHogSessionManager.swift */,
DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */,
69ED1A872C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift */,
69ED1A9E2C8F451B00FE7A91 /* PostHogPersonProfiles.swift */,
DAB565D82D14C51E0088F720 /* SwiftUI */,
);
path = PostHog;
sourceTree = "<group>";
Expand Down Expand Up @@ -794,6 +797,17 @@
name = Products;
sourceTree = "<group>";
};
DAB565D82D14C51E0088F720 /* SwiftUI */ = {
isa = PBXGroup;
children = (
DAB565DE2D14C55C0088F720 /* PostHogTagViewModifier.swift */,
DAB565C92D142F8F0088F720 /* PostHogNoMaskViewModifier.swift */,
69ED1A872C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift */,
DAD5DD072CB6DEE70087387B /* PostHogMaskViewModifier.swift */,
);
path = SwiftUI;
sourceTree = "<group>";
};
DAD76A222D006BF7003E1A43 /* SwiftUI */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -1161,6 +1175,7 @@
690FF05F2AE7E2D400A0B06B /* Data+Gzip.swift in Sources */,
69EE82BE2BA9C8AA00EB9542 /* ViewLayoutTracker.swift in Sources */,
69261D1F2AD9681300232EC7 /* PostHogConsumerPayload.swift in Sources */,
DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */,
6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */,
69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */,
DA0CA6F12CFF6B6300AF9500 /* UIWindow+.swift in Sources */,
Expand Down Expand Up @@ -1213,6 +1228,7 @@
69EE82BA2BA9C50400EB9542 /* PostHogReplayIntegration.swift in Sources */,
3AE3FB472992AB0000AFFC18 /* Hedgelog.swift in Sources */,
69B7F60C2CF7703400A48BCC /* UIImage+Util.swift in Sources */,
DAB565CA2D142F8F0088F720 /* PostHogNoMaskViewModifier.swift in Sources */,
69261D132AD5685B00232EC7 /* PostHogFeatureFlags.swift in Sources */,
699C5FE62C20178E007DB818 /* UUIDUtils.swift in Sources */,
690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */,
Expand Down
70 changes: 0 additions & 70 deletions PostHog/PostHogMaskViewModifier.swift

This file was deleted.

7 changes: 6 additions & 1 deletion PostHog/Replay/PostHogReplayIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@
}

private func findMaskableWidgets(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [CGRect], _ maskChildren: inout Bool) {
// User explicitly marked this view (and its subviews) as non-maskable through `.postHogNoMask()` view modifier
if view.postHogNoMask {
return
}

if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView
if isTextViewSensitive(textView) {
maskableWidgets.append(view.toAbsoluteRect(window))
Expand Down Expand Up @@ -343,7 +348,7 @@
}
}

// manually masked views through view modifier `PostHogMaskViewModifier`
// manually masked views through `.postHogMask()` view modifier
if view.postHogNoCapture {
maskableWidgets.append(view.toAbsoluteRect(window))
return
Expand Down
53 changes: 53 additions & 0 deletions PostHog/SwiftUI/PostHogMaskViewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// PostHogMaskViewModifier.swift
// PostHog
//
// Created by Yiannis Josephides on 09/10/2024.
//

#if os(iOS) && canImport(SwiftUI)

import SwiftUI

public extension View {
/**
Marks a SwiftUI View to be masked in PostHog session replay recordings.

Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit),
we can't always be 100% confident that a view should be masked and may accidentally mark a
sensitive view as non-sensitive instead.

Use this modifier to explicitly mask sensitive views in session replay recordings.

For example:
```swift
// This view will be masked in recordings
SensitiveDataView()
.postHogMask()

// Conditionally mask based on a flag
SensitiveDataView()
.postHogMask(shouldMask)
```

- Parameter isEnabled: Whether masking should be enabled. Defaults to true.
- Returns: A modified view that will be masked in session replay recordings when enabled
*/
func postHogMask(_ isEnabled: Bool = true) -> some View {
modifier(
PostHogTagViewModifier { uiViews in
uiViews.forEach { $0.postHogNoCapture = isEnabled }
} onRemove: { uiViews in
uiViews.forEach { $0.postHogNoCapture = false }
}
)
}
}

extension UIView {
var postHogNoCapture: Bool {
get { objc_getAssociatedObject(self, &AssociatedKeys.phNoCapture) as? Bool ?? false }
set { objc_setAssociatedObject(self, &AssociatedKeys.phNoCapture, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
#endif
54 changes: 54 additions & 0 deletions PostHog/SwiftUI/PostHogNoMaskViewModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// PostHogNoMaskViewModifier.swift
// PostHog
//
// Created by Yiannis Josephides on 09/10/2024.
//

#if os(iOS) && canImport(SwiftUI)

import SwiftUI

public extension View {
/**
Marks a SwiftUI View to be excluded from masking in PostHog session replay recordings.

There are cases where PostHog SDK will unintentionally mask some SwiftUI views.

Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit),
we can't always be 100% confident that a view should be masked. For that reason, we prefer to
take a proactive and prefer to mask views if we're not sure.

Use this modifier to prevent views from being masked in session replay recordings.

For example:
```swift
// This view may be accidentally masked by PostHog SDK
SomeSafeView()

// This custom view (and all its subviews) will not be masked in recordings
SomeSafeView()
.postHogNoMask()
```

- Returns: A modified view that will not be masked in session replay recordings
*/
func postHogNoMask() -> some View {
modifier(
PostHogTagViewModifier { uiViews in
uiViews.forEach { $0.postHogNoMask = true }
} onRemove: { uiViews in
uiViews.forEach { $0.postHogNoMask = false }
}
)
}
}

extension UIView {
var postHogNoMask: Bool {
get { objc_getAssociatedObject(self, &AssociatedKeys.phNoMask) as? Bool ?? false }
set { objc_setAssociatedObject(self, &AssociatedKeys.phNoMask, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,15 @@
import Foundation
import SwiftUI

struct PostHogSwiftUIViewModifier: ViewModifier {
let viewEventName: String

let screenEvent: Bool

let properties: [String: Any]?

func body(content: Content) -> some View {
content.onAppear {
if screenEvent {
PostHogSDK.shared.screen(viewEventName, properties: properties)
} else {
PostHogSDK.shared.capture(viewEventName, properties: properties)
}
}
}
}

public extension View {
/**
Marks a SwiftUI View to be tracked as a $screen event in PostHog when onAppear is called.

- Parameters:
- screenName: The name of the screen. Defaults to the type of the view.
- properties: Additional properties to be tracked with the screen.
- Returns: A modified view that will be tracked as a screen in PostHog.
*/
func postHogScreenView(_ screenName: String? = nil,
_ properties: [String: Any]? = nil) -> some View
{
Expand All @@ -46,4 +36,22 @@
}
}

private struct PostHogSwiftUIViewModifier: ViewModifier {
let viewEventName: String

let screenEvent: Bool

let properties: [String: Any]?

func body(content: Content) -> some View {
content.onAppear {
if screenEvent {
PostHogSDK.shared.screen(viewEventName, properties: properties)
} else {
PostHogSDK.shared.capture(viewEventName, properties: properties)
}
}
}
}

#endif
Loading

0 comments on commit b2a7466

Please sign in to comment.