Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add postHogNoMask SwiftUI view modifier #277

Merged
merged 6 commits into from
Dec 27, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
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.1 - 2024-12-18

- fix: avoid masking SwiftUI Gradient views ([#275](https://github.com/PostHog/posthog-ios/pull/275))
Expand Down
43 changes: 41 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 @@ -839,6 +853,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 3AC745C9296D6FE60025C109 /* Build configuration list for PBXNativeTarget "PostHog" */;
buildPhases = (
DAB565E02D155AD10088F720 /* SwiftLint */,
3AC745B0296D6FE60025C109 /* Headers */,
3AC745B1296D6FE60025C109 /* Sources */,
3AC745B2296D6FE60025C109 /* Frameworks */,
Expand Down Expand Up @@ -1141,6 +1156,28 @@
};
/* End PBXResourcesBuildPhase section */

/* Begin PBXShellScriptBuildPhase section */
DAB565E02D155AD10088F720 /* SwiftLint */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = SwiftLint;
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */

/* Begin PBXSourcesBuildPhase section */
3AA34CF3296D951A003398F4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
Expand All @@ -1161,6 +1198,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 +1251,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 {
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
ioannisj marked this conversation as resolved.
Show resolved Hide resolved
/**
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
Loading