ads tracking
This commit is contained in:
16
Pods/Manifest.lock
generated
Normal file
16
Pods/Manifest.lock
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
PODS:
|
||||
- PostHog (3.34.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- PostHog (~> 3.0)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- PostHog
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
PostHog: bbb7eaecb2f5a286d9da3c833cbb18ae08799655
|
||||
|
||||
PODFILE CHECKSUM: b59bd921fb9a91e981795c18b6ff238370434172
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
Normal file
2254
Pods/Pods.xcodeproj/project.pbxproj
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6835ABF5E9176D16603D3FAED02C1229"
|
||||
BuildableName = "Pods_Cable_CableUITests.framework"
|
||||
BlueprintName = "Pods-Cable-CableUITests"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "86C5E834AAC4A69D5D37D96BAF8B8330"
|
||||
BuildableName = "Pods_Cable_CableUITestsScreenshot.framework"
|
||||
BlueprintName = "Pods-Cable-CableUITestsScreenshot"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-Cable.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-Cable.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8012054959C338A62834AD9706977FB0"
|
||||
BuildableName = "Pods_Cable.framework"
|
||||
BlueprintName = "Pods-Cable"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-CableTests.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/Pods-CableTests.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "52F449E691F258410D3E74F5BAFD41CD"
|
||||
BuildableName = "Pods_CableTests.framework"
|
||||
BlueprintName = "Pods-CableTests"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog-PostHog.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog-PostHog.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "E326EE08AE4CF9FA8C947B96B6F8AB07"
|
||||
BuildableName = "PostHog.bundle"
|
||||
BlueprintName = "PostHog-PostHog"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog.xcscheme
generated
Normal file
58
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/PostHog.xcscheme
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1600"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "8879D5F28A55518ACFB247594F87F75A"
|
||||
BuildableName = "PostHog.framework"
|
||||
BlueprintName = "PostHog"
|
||||
ReferencedContainer = "container:Pods.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
41
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/xcschememanagement.plist
generated
Normal file
41
Pods/Pods.xcodeproj/xcuserdata/lange-hegermann.xcuserdatad/xcschemes/xcschememanagement.plist
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Pods-Cable-CableUITests.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>Pods-Cable-CableUITestsScreenshot.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>Pods-Cable.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>Pods-CableTests.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>PostHog-PostHog.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>PostHog.xcscheme</key>
|
||||
<dict>
|
||||
<key>isShown</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</plist>
|
||||
21
Pods/PostHog/LICENSE
generated
Normal file
21
Pods/PostHog/LICENSE
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) [2023] [PostHog]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
199
Pods/PostHog/PostHog/App Life Cycle/ApplicationLifecyclePublisher.swift
generated
Normal file
199
Pods/PostHog/PostHog/App Life Cycle/ApplicationLifecyclePublisher.swift
generated
Normal file
@@ -0,0 +1,199 @@
|
||||
//
|
||||
// ApplicationLifecyclePublisher.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 16/12/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#elseif os(watchOS)
|
||||
import WatchKit
|
||||
#endif
|
||||
|
||||
typealias AppLifecycleHandler = () -> Void
|
||||
|
||||
protocol AppLifecyclePublishing: AnyObject {
|
||||
/// Registers a callback for the `didBecomeActive` event.
|
||||
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
||||
/// Registers a callback for the `didEnterBackground` event.
|
||||
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
||||
/// Registers a callback for the `didFinishLaunching` event.
|
||||
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken
|
||||
}
|
||||
|
||||
/**
|
||||
A publisher that handles application lifecycle events and allows registering callbacks for them.
|
||||
|
||||
This class provides a way to observe application lifecycle events like when the app becomes active,
|
||||
enters background, or finishes launching. Callbacks can be registered for each event type and will
|
||||
be automatically unregistered when their registration token is deallocated.
|
||||
|
||||
Example usage:
|
||||
```
|
||||
let token = ApplicationLifecyclePublisher.shared.onDidBecomeActive {
|
||||
// App became active logic
|
||||
}
|
||||
// Keep `token` in memory to keep the registration active
|
||||
// When token is deallocated, the callback will be automatically unregistered
|
||||
```
|
||||
*/
|
||||
final class ApplicationLifecyclePublisher: BaseApplicationLifecyclePublisher {
|
||||
/// Shared instance to allow easy access across the app.
|
||||
static let shared = ApplicationLifecyclePublisher()
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
|
||||
let defaultCenter = NotificationCenter.default
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidFinishLaunching),
|
||||
name: UIApplication.didFinishLaunchingNotification,
|
||||
object: nil)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
#elseif os(visionOS)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidFinishLaunching),
|
||||
name: UIApplication.didFinishLaunchingNotification,
|
||||
object: nil)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIScene.willDeactivateNotification,
|
||||
object: nil)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: nil)
|
||||
#elseif os(macOS)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidFinishLaunching),
|
||||
name: NSApplication.didFinishLaunchingNotification,
|
||||
object: nil)
|
||||
// macOS does not have didEnterBackgroundNotification, so we use didResignActiveNotification
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: NSApplication.didResignActiveNotification,
|
||||
object: nil)
|
||||
defaultCenter.addObserver(self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
#elseif os(watchOS)
|
||||
if #available(watchOS 7.0, *) {
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: WKApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
} else {
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(appDidBecomeActive),
|
||||
name: .init("UIApplicationDidBecomeActiveNotification"),
|
||||
object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Handlers
|
||||
|
||||
@objc private func appDidEnterBackground() {
|
||||
notifyHandlers(didEnterBackgroundHandlers)
|
||||
}
|
||||
|
||||
@objc private func appDidBecomeActive() {
|
||||
notifyHandlers(didBecomeActiveHandlers)
|
||||
}
|
||||
|
||||
@objc private func appDidFinishLaunching() {
|
||||
notifyHandlers(didFinishLaunchingHandlers)
|
||||
}
|
||||
|
||||
private func notifyHandlers(_ handlers: [AppLifecycleHandler]) {
|
||||
for handler in handlers {
|
||||
notifyHander(handler)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyHander(_ handler: @escaping AppLifecycleHandler) {
|
||||
if Thread.isMainThread {
|
||||
handler()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BaseApplicationLifecyclePublisher: AppLifecyclePublishing {
|
||||
private let registrationLock = NSLock()
|
||||
|
||||
private var didBecomeActiveCallbacks: [UUID: AppLifecycleHandler] = [:]
|
||||
private var didEnterBackgroundCallbacks: [UUID: AppLifecycleHandler] = [:]
|
||||
private var didFinishLaunchingCallbacks: [UUID: AppLifecycleHandler] = [:]
|
||||
|
||||
var didBecomeActiveHandlers: [AppLifecycleHandler] {
|
||||
registrationLock.withLock { Array(didBecomeActiveCallbacks.values) }
|
||||
}
|
||||
|
||||
var didEnterBackgroundHandlers: [AppLifecycleHandler] {
|
||||
registrationLock.withLock { Array(didEnterBackgroundCallbacks.values) }
|
||||
}
|
||||
|
||||
var didFinishLaunchingHandlers: [AppLifecycleHandler] {
|
||||
registrationLock.withLock { Array(didFinishLaunchingCallbacks.values) }
|
||||
}
|
||||
|
||||
/// Registers a callback for the `didBecomeActive` event.
|
||||
func onDidBecomeActive(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
||||
register(handler: callback, on: \.didBecomeActiveCallbacks)
|
||||
}
|
||||
|
||||
/// Registers a callback for the `didEnterBackground` event.
|
||||
func onDidEnterBackground(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
||||
register(handler: callback, on: \.didEnterBackgroundCallbacks)
|
||||
}
|
||||
|
||||
/// Registers a callback for the `didFinishLaunching` event.
|
||||
func onDidFinishLaunching(_ callback: @escaping AppLifecycleHandler) -> RegistrationToken {
|
||||
register(handler: callback, on: \.didFinishLaunchingCallbacks)
|
||||
}
|
||||
|
||||
func register(
|
||||
handler callback: @escaping AppLifecycleHandler,
|
||||
on keyPath: ReferenceWritableKeyPath<BaseApplicationLifecyclePublisher, [UUID: AppLifecycleHandler]>
|
||||
) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self[keyPath: keyPath][id] = callback
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
self.registrationLock.withLock {
|
||||
self[keyPath: keyPath][id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class RegistrationToken {
|
||||
private let onDealloc: () -> Void
|
||||
|
||||
init(_ onDealloc: @escaping () -> Void) {
|
||||
self.onDealloc = onDealloc
|
||||
}
|
||||
|
||||
deinit {
|
||||
onDealloc()
|
||||
}
|
||||
}
|
||||
214
Pods/PostHog/PostHog/App Life Cycle/PostHogAppLifeCycleIntegration.swift
generated
Normal file
214
Pods/PostHog/PostHog/App Life Cycle/PostHogAppLifeCycleIntegration.swift
generated
Normal file
@@ -0,0 +1,214 @@
|
||||
//
|
||||
// PostHogAppLifeCycleIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/02/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
Add capability to capture application lifecycle events.
|
||||
|
||||
This integration:
|
||||
- captures an `App Installed` event on the first launch of the app
|
||||
- captures an `App Updated` event on any subsequent launch with a different version
|
||||
- captures an `App Opened` event when the app is opened (including the first launch)
|
||||
- captures an `App Backgrounded` event when the app moves to the background
|
||||
*/
|
||||
final class PostHogAppLifeCycleIntegration: PostHogIntegration {
|
||||
var requiresSwizzling: Bool { false }
|
||||
|
||||
private static var integrationInstalledLock = NSLock()
|
||||
private static var integrationInstalled = false
|
||||
private static var didCaptureAppInstallOrUpdate = false
|
||||
|
||||
private weak var postHog: PostHogSDK?
|
||||
|
||||
// True if the app is launched for the first time
|
||||
private var isFreshAppLaunch = true
|
||||
// Manually maintained flag to determine background status of the app
|
||||
private var isAppBackgrounded: Bool = true
|
||||
|
||||
private var didBecomeActiveToken: RegistrationToken?
|
||||
private var didEnterBackgroundToken: RegistrationToken?
|
||||
private var didFinishLaunchingToken: RegistrationToken?
|
||||
|
||||
func install(_ postHog: PostHogSDK) throws {
|
||||
try PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
|
||||
if PostHogAppLifeCycleIntegration.integrationInstalled {
|
||||
throw InternalPostHogError(description: "App life cycle integration already installed to another PostHogSDK instance.")
|
||||
}
|
||||
PostHogAppLifeCycleIntegration.integrationInstalled = true
|
||||
}
|
||||
|
||||
self.postHog = postHog
|
||||
|
||||
start()
|
||||
captureAppInstallOrUpdated()
|
||||
}
|
||||
|
||||
func uninstall(_ postHog: PostHogSDK) {
|
||||
// uninstall only for integration instance
|
||||
if self.postHog === postHog || self.postHog == nil {
|
||||
stop()
|
||||
self.postHog = nil
|
||||
PostHogAppLifeCycleIntegration.integrationInstalledLock.withLock {
|
||||
PostHogAppLifeCycleIntegration.integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Start capturing app lifecycles events
|
||||
*/
|
||||
func start() {
|
||||
let publisher = DI.main.appLifecyclePublisher
|
||||
didFinishLaunchingToken = publisher.onDidFinishLaunching { [weak self] in
|
||||
self?.captureAppInstallOrUpdated()
|
||||
}
|
||||
didBecomeActiveToken = publisher.onDidBecomeActive { [weak self] in
|
||||
self?.captureAppOpened()
|
||||
}
|
||||
didEnterBackgroundToken = publisher.onDidEnterBackground { [weak self] in
|
||||
self?.captureAppBackgrounded()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Stop capturing app lifecycle events
|
||||
*/
|
||||
func stop() {
|
||||
didFinishLaunchingToken = nil
|
||||
didBecomeActiveToken = nil
|
||||
didEnterBackgroundToken = nil
|
||||
}
|
||||
|
||||
private func captureAppInstallOrUpdated() {
|
||||
// Check if Application Installed or Application Updated was already checked in the lifecycle of this app
|
||||
// This can be called multiple times in case of optOut, multiple instances or start/stop integration
|
||||
guard let postHog, !PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate else { return }
|
||||
|
||||
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = true
|
||||
|
||||
if !postHog.config.captureApplicationLifecycleEvents {
|
||||
hedgeLog("Skipping Application Installed/Application Updated event - captureApplicationLifecycleEvents is disabled in configuration")
|
||||
return
|
||||
}
|
||||
|
||||
let bundle = Bundle.main
|
||||
|
||||
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
|
||||
|
||||
// capture app installed/updated
|
||||
let userDefaults = UserDefaults.standard
|
||||
|
||||
let previousVersion = userDefaults.string(forKey: "PHGVersionKey")
|
||||
let previousVersionCode = userDefaults.string(forKey: "PHGBuildKeyV2")
|
||||
|
||||
var props: [String: Any] = [:]
|
||||
var event: String
|
||||
if previousVersionCode == nil {
|
||||
// installed
|
||||
event = "Application Installed"
|
||||
} else {
|
||||
event = "Application Updated"
|
||||
|
||||
// Do not send version updates if its the same
|
||||
if previousVersionCode == versionCode {
|
||||
return
|
||||
}
|
||||
|
||||
if previousVersion != nil {
|
||||
props["previous_version"] = previousVersion
|
||||
}
|
||||
props["previous_build"] = previousVersionCode
|
||||
}
|
||||
|
||||
var syncDefaults = false
|
||||
if versionName != nil {
|
||||
props["version"] = versionName
|
||||
userDefaults.setValue(versionName, forKey: "PHGVersionKey")
|
||||
syncDefaults = true
|
||||
}
|
||||
|
||||
if versionCode != nil {
|
||||
props["build"] = versionCode
|
||||
userDefaults.setValue(versionCode, forKey: "PHGBuildKeyV2")
|
||||
syncDefaults = true
|
||||
}
|
||||
|
||||
if syncDefaults {
|
||||
userDefaults.synchronize()
|
||||
}
|
||||
|
||||
postHog.capture(event, properties: props)
|
||||
}
|
||||
|
||||
private func captureAppOpened() {
|
||||
guard let postHog else { return }
|
||||
|
||||
guard isAppBackgrounded else {
|
||||
hedgeLog("Skipping Application Opened event - app already in foreground")
|
||||
return
|
||||
}
|
||||
|
||||
isAppBackgrounded = false
|
||||
|
||||
if !postHog.config.captureApplicationLifecycleEvents {
|
||||
hedgeLog("Skipping Application Opened event - captureApplicationLifecycleEvents is disabled in configuration")
|
||||
return
|
||||
}
|
||||
|
||||
var props: [String: Any] = [:]
|
||||
props["from_background"] = !isFreshAppLaunch
|
||||
|
||||
if isFreshAppLaunch {
|
||||
let bundle = Bundle.main
|
||||
|
||||
let versionName = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String
|
||||
|
||||
if versionName != nil {
|
||||
props["version"] = versionName
|
||||
}
|
||||
if versionCode != nil {
|
||||
props["build"] = versionCode
|
||||
}
|
||||
|
||||
isFreshAppLaunch = false
|
||||
}
|
||||
|
||||
postHog.capture("Application Opened", properties: props)
|
||||
}
|
||||
|
||||
private func captureAppBackgrounded() {
|
||||
guard let postHog else { return }
|
||||
|
||||
guard !isAppBackgrounded else {
|
||||
hedgeLog("Skipping Application Opened event - app already in background")
|
||||
return
|
||||
}
|
||||
|
||||
isAppBackgrounded = true
|
||||
|
||||
if !postHog.config.captureApplicationLifecycleEvents {
|
||||
hedgeLog("Skipping Application Backgrounded event - captureApplicationLifecycleEvents is disabled in configuration")
|
||||
return
|
||||
}
|
||||
|
||||
postHog.capture("Application Backgrounded")
|
||||
}
|
||||
}
|
||||
|
||||
#if TESTING
|
||||
extension PostHogAppLifeCycleIntegration {
|
||||
static func clearInstalls() {
|
||||
PostHogAppLifeCycleIntegration.didCaptureAppInstallOrUpdate = false
|
||||
integrationInstalledLock.withLock {
|
||||
integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
159
Pods/PostHog/PostHog/ApplicationViewLayoutPublisher.swift
generated
Normal file
159
Pods/PostHog/PostHog/ApplicationViewLayoutPublisher.swift
generated
Normal file
@@ -0,0 +1,159 @@
|
||||
//
|
||||
// ApplicationViewLayoutPublisher.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
|
||||
typealias ApplicationViewLayoutHandler = () -> Void
|
||||
|
||||
protocol ViewLayoutPublishing: AnyObject {
|
||||
/// Registers a callback for getting notified when a UIView is laid out.
|
||||
/// Note: callback guaranteed to be called on main thread
|
||||
func onViewLayout(throttle: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken
|
||||
}
|
||||
|
||||
final class ApplicationViewLayoutPublisher: BaseApplicationViewLayoutPublisher {
|
||||
static let shared = ApplicationViewLayoutPublisher()
|
||||
|
||||
private var hasSwizzled: Bool = false
|
||||
|
||||
func start() {
|
||||
swizzleLayoutSubviews()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unswizzleLayoutSubviews()
|
||||
}
|
||||
|
||||
func swizzleLayoutSubviews() {
|
||||
guard !hasSwizzled else { return }
|
||||
hasSwizzled = true
|
||||
|
||||
swizzle(
|
||||
forClass: UIView.self,
|
||||
original: #selector(UIView.layoutSublayers(of:)),
|
||||
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
|
||||
)
|
||||
}
|
||||
|
||||
func unswizzleLayoutSubviews() {
|
||||
guard hasSwizzled else { return }
|
||||
hasSwizzled = false
|
||||
|
||||
// swizzling twice will exchange implementations back to original
|
||||
swizzle(
|
||||
forClass: UIView.self,
|
||||
original: #selector(UIView.layoutSublayers(of:)),
|
||||
new: #selector(UIView.ph_swizzled_layoutSublayers(of:))
|
||||
)
|
||||
}
|
||||
|
||||
override func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onViewLayoutCallbacks[id] = ThrottledHandler(handler: callback, interval: interval)
|
||||
}
|
||||
|
||||
// start on first callback registration
|
||||
if !hasSwizzled {
|
||||
start()
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
let handlerCount = self.registrationLock.withLock {
|
||||
self.onViewLayoutCallbacks[id] = nil
|
||||
return self.onViewLayoutCallbacks.values.count
|
||||
}
|
||||
|
||||
// stop when there are no more callbacks
|
||||
if handlerCount <= 0 {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called from swizzled `UIView.layoutSubviews`
|
||||
fileprivate func layoutSubviews() {
|
||||
notifyHandlers()
|
||||
}
|
||||
|
||||
#if TESTING
|
||||
func simulateLayoutSubviews() {
|
||||
layoutSubviews()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class BaseApplicationViewLayoutPublisher: ViewLayoutPublishing {
|
||||
fileprivate let registrationLock = NSLock()
|
||||
|
||||
var onViewLayoutCallbacks: [UUID: ThrottledHandler] = [:]
|
||||
|
||||
final class ThrottledHandler {
|
||||
static let throttleQueue = DispatchQueue(label: "com.posthog.ThrottledHandler",
|
||||
target: .global(qos: .utility))
|
||||
|
||||
let interval: TimeInterval
|
||||
let handler: ApplicationViewLayoutHandler
|
||||
|
||||
private var lastFired: Date = .distantPast
|
||||
|
||||
init(handler: @escaping ApplicationViewLayoutHandler, interval: TimeInterval) {
|
||||
self.handler = handler
|
||||
self.interval = interval
|
||||
}
|
||||
|
||||
func throttleHandler() {
|
||||
let now = now()
|
||||
let timeSinceLastFired = now.timeIntervalSince(lastFired)
|
||||
|
||||
if timeSinceLastFired >= interval {
|
||||
lastFired = now
|
||||
// notify on main
|
||||
DispatchQueue.main.async(execute: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onViewLayout(throttle interval: TimeInterval, _ callback: @escaping ApplicationViewLayoutHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onViewLayoutCallbacks[id] = ThrottledHandler(
|
||||
handler: callback,
|
||||
interval: interval
|
||||
)
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
self.registrationLock.withLock {
|
||||
self.onViewLayoutCallbacks[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notifyHandlers() {
|
||||
ThrottledHandler.throttleQueue.async {
|
||||
// Don't lock on main
|
||||
let handlers = self.registrationLock.withLock { self.onViewLayoutCallbacks.values }
|
||||
for handler in handlers {
|
||||
handler.throttleHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
@objc func ph_swizzled_layoutSublayers(of layer: CALayer) {
|
||||
ph_swizzled_layoutSublayers(of: layer) // call original, not altering execution logic
|
||||
ApplicationViewLayoutPublisher.shared.layoutSubviews()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
14
Pods/PostHog/PostHog/Autocapture/AutocaptureEventProcessing.swift
generated
Normal file
14
Pods/PostHog/PostHog/Autocapture/AutocaptureEventProcessing.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// AutocaptureEventProcessing.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 30/10/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import Foundation
|
||||
|
||||
protocol AutocaptureEventProcessing: AnyObject {
|
||||
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData)
|
||||
}
|
||||
#endif
|
||||
71
Pods/PostHog/PostHog/Autocapture/ForwardingPickerViewDelegate.swift
generated
Normal file
71
Pods/PostHog/PostHog/Autocapture/ForwardingPickerViewDelegate.swift
generated
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// ForwardingPickerViewDelegate.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 24/10/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
final class ForwardingPickerViewDelegate: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
|
||||
// this needs to be weak since `actualDelegate` will hold a strong reference to `ForwardingPickerViewDelegate`
|
||||
weak var actualDelegate: UIPickerViewDelegate?
|
||||
private var valueChangedCallback: (() -> Void)?
|
||||
|
||||
// We respond to the same selectors that the original delegate responds to
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
actualDelegate?.responds(to: aSelector) ?? false
|
||||
}
|
||||
|
||||
init(delegate: UIPickerViewDelegate?, onValueChanged: @escaping () -> Void) {
|
||||
actualDelegate = delegate
|
||||
valueChangedCallback = onValueChanged
|
||||
}
|
||||
|
||||
// MARK: - UIPickerViewDataSource
|
||||
|
||||
func numberOfComponents(in pickerView: UIPickerView) -> Int {
|
||||
(actualDelegate as? UIPickerViewDataSource)?.numberOfComponents(in: pickerView) ?? 0
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
|
||||
(actualDelegate as? UIPickerViewDataSource)?.pickerView(pickerView, numberOfRowsInComponent: component) ?? 0
|
||||
}
|
||||
|
||||
// MARK: - UIPickerViewDelegate
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
|
||||
valueChangedCallback?()
|
||||
actualDelegate?.pickerView?(pickerView, didSelectRow: row, inComponent: component)
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, viewForRow row: Int, forComponent component: Int, reusing view: UIView?) -> UIView {
|
||||
actualDelegate?.pickerView?(pickerView, viewForRow: row, forComponent: component, reusing: view) ?? UIView()
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, widthForComponent component: Int) -> CGFloat {
|
||||
actualDelegate?.pickerView?(pickerView, widthForComponent: component) ?? .zero
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, rowHeightForComponent component: Int) -> CGFloat {
|
||||
actualDelegate?.pickerView?(pickerView, rowHeightForComponent: component) ?? .zero
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
|
||||
actualDelegate?.pickerView?(pickerView, titleForRow: row, forComponent: component)
|
||||
}
|
||||
|
||||
func pickerView(_ pickerView: UIPickerView, attributedTitleForRow row: Int, forComponent component: Int) -> NSAttributedString? {
|
||||
actualDelegate?.pickerView?(pickerView, attributedTitleForRow: row, forComponent: component)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIPickerViewDelegate {
|
||||
var ph_forwardingDelegate: UIPickerViewDelegate? {
|
||||
get { objc_getAssociatedObject(self, &AssociatedKeys.phForwardingDelegate) as? UIPickerViewDelegate }
|
||||
set { objc_setAssociatedObject(self, &AssociatedKeys.phForwardingDelegate, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
606
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift
generated
Normal file
606
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift
generated
Normal file
@@ -0,0 +1,606 @@
|
||||
//
|
||||
// PostHogAutocaptureEventTracker.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 14/10/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
class PostHogAutocaptureEventTracker {
|
||||
struct EventData {
|
||||
let touchCoordinates: CGPoint?
|
||||
let value: String?
|
||||
let screenName: String?
|
||||
let viewHierarchy: [Element]
|
||||
// values >0 means that this event will be debounced for `debounceInterval`
|
||||
let debounceInterval: TimeInterval
|
||||
}
|
||||
|
||||
struct Element {
|
||||
let text: String
|
||||
let targetClass: String
|
||||
let baseClass: String?
|
||||
let label: String?
|
||||
|
||||
var elementsChainEntry: String {
|
||||
var attributes = [String]()
|
||||
|
||||
if !text.isEmpty {
|
||||
attributes.append("text=\(text.quoted)")
|
||||
}
|
||||
if let baseClass, !baseClass.isEmpty {
|
||||
attributes.append("attr__class=\(baseClass.quoted)")
|
||||
}
|
||||
if let label, !label.isEmpty {
|
||||
attributes.append("attr_id=\(label.quoted)")
|
||||
}
|
||||
|
||||
return attributes.isEmpty ? targetClass : "\(targetClass):\(attributes.joined())"
|
||||
}
|
||||
}
|
||||
|
||||
enum EventSource {
|
||||
case notification(name: String)
|
||||
case actionMethod(description: String)
|
||||
case gestureRecognizer(description: String)
|
||||
}
|
||||
|
||||
static var eventProcessor: (any AutocaptureEventProcessing)? {
|
||||
willSet {
|
||||
if newValue != nil {
|
||||
swizzle()
|
||||
} else {
|
||||
unswizzle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static var hasSwizzled: Bool = false
|
||||
private static func swizzle() {
|
||||
guard !hasSwizzled else { return }
|
||||
hasSwizzled = true
|
||||
swizzleMethods()
|
||||
registerNotifications()
|
||||
}
|
||||
|
||||
private static func unswizzle() {
|
||||
guard hasSwizzled else { return }
|
||||
hasSwizzled = false
|
||||
swizzleMethods() // swizzling again will exchange implementations back to original
|
||||
unregisterNotifications()
|
||||
}
|
||||
|
||||
private static func swizzleMethods() {
|
||||
PostHog.swizzle(
|
||||
forClass: UIApplication.self,
|
||||
original: #selector(UIApplication.sendAction),
|
||||
new: #selector(UIApplication.ph_swizzled_uiapplication_sendAction)
|
||||
)
|
||||
|
||||
PostHog.swizzle(
|
||||
forClass: UIGestureRecognizer.self,
|
||||
original: #selector(setter: UIGestureRecognizer.state),
|
||||
new: #selector(UIGestureRecognizer.ph_swizzled_uigesturerecognizer_state_Setter)
|
||||
)
|
||||
|
||||
PostHog.swizzle(
|
||||
forClass: UIScrollView.self,
|
||||
original: #selector(setter: UIScrollView.contentOffset),
|
||||
new: #selector(UIScrollView.ph_swizzled_setContentOffset_Setter)
|
||||
)
|
||||
|
||||
PostHog.swizzle(
|
||||
forClass: UIPickerView.self,
|
||||
original: #selector(setter: UIPickerView.delegate),
|
||||
new: #selector(UIPickerView.ph_swizzled_setDelegate)
|
||||
)
|
||||
}
|
||||
|
||||
private static func registerNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
PostHogAutocaptureEventTracker.self,
|
||||
selector: #selector(didEndEditing),
|
||||
name: UITextField.textDidEndEditingNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
PostHogAutocaptureEventTracker.self,
|
||||
selector: #selector(didEndEditing),
|
||||
name: UITextView.textDidEndEditingNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func unregisterNotifications() {
|
||||
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextField.textDidEndEditingNotification, object: nil)
|
||||
NotificationCenter.default.removeObserver(PostHogAutocaptureEventTracker.self, name: UITextView.textDidEndEditingNotification, object: nil)
|
||||
}
|
||||
|
||||
// `UITextField` or `UITextView` did end editing notification
|
||||
@objc static func didEndEditing(_ notification: NSNotification) {
|
||||
guard let view = notification.object as? UIView, let eventData = view.eventData else { return }
|
||||
|
||||
eventProcessor?.process(source: .notification(name: "change"), event: eventData)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIApplication {
|
||||
@objc func ph_swizzled_uiapplication_sendAction(_ action: Selector, to target: Any?, from sender: Any?, for event: UIEvent?) -> Bool {
|
||||
defer {
|
||||
// Currently, the action methods pointing to a SwiftUI target are blocked.
|
||||
let targetClass = String(describing: object_getClassName(target))
|
||||
if targetClass.contains("SwiftUI") {
|
||||
hedgeLog("Action methods on SwiftUI targets are not yet supported.")
|
||||
} else if let control = sender as? UIControl,
|
||||
control.ph_shouldTrack(action, for: target),
|
||||
let eventData = control.eventData,
|
||||
let eventDescription = control.event(for: action, to: target)?.description(forControl: control)
|
||||
{
|
||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .actionMethod(description: eventDescription), event: eventData)
|
||||
}
|
||||
}
|
||||
|
||||
// first, call original method
|
||||
return ph_swizzled_uiapplication_sendAction(action, to: target, from: sender, for: event)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIGestureRecognizer {
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
@objc func ph_swizzled_uigesturerecognizer_state_Setter(_ state: UIGestureRecognizer.State) {
|
||||
// first, call original method
|
||||
ph_swizzled_uigesturerecognizer_state_Setter(state)
|
||||
|
||||
guard state == .ended, let view, shouldTrack(view) else { return }
|
||||
|
||||
// block scroll and zoom gestures for `UIScrollView`.
|
||||
if let scrollView = view as? UIScrollView {
|
||||
if self === scrollView.panGestureRecognizer {
|
||||
return
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if self === scrollView.pinchGestureRecognizer {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// block all gestures for `UISwitch` (already captured via `.valueChanged` action)
|
||||
if String(describing: type(of: view)).starts(with: "UISwitch") {
|
||||
return
|
||||
}
|
||||
// ignore gestures in `UIPickerColumnView`
|
||||
if String(describing: type(of: view)) == "UIPickerColumnView" {
|
||||
return
|
||||
}
|
||||
|
||||
let gestureDescription: String?
|
||||
switch self {
|
||||
case is UITapGestureRecognizer:
|
||||
gestureDescription = EventType.kTouch
|
||||
case is UISwipeGestureRecognizer:
|
||||
gestureDescription = EventType.kSwipe
|
||||
case is UIPanGestureRecognizer:
|
||||
gestureDescription = EventType.kPan
|
||||
case is UILongPressGestureRecognizer:
|
||||
gestureDescription = EventType.kLongPress
|
||||
#if !os(tvOS)
|
||||
case is UIPinchGestureRecognizer:
|
||||
gestureDescription = EventType.kPinch
|
||||
case is UIRotationGestureRecognizer:
|
||||
gestureDescription = EventType.kRotation
|
||||
case is UIScreenEdgePanGestureRecognizer:
|
||||
gestureDescription = EventType.kPan
|
||||
#endif
|
||||
default:
|
||||
gestureDescription = nil
|
||||
}
|
||||
|
||||
guard let gestureDescription else { return }
|
||||
|
||||
if let eventData = view.eventData {
|
||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: gestureDescription), event: eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIScrollView {
|
||||
@objc func ph_swizzled_setContentOffset_Setter(_ newContentOffset: CGPoint) {
|
||||
// first, call original method
|
||||
ph_swizzled_setContentOffset_Setter(newContentOffset)
|
||||
|
||||
guard shouldTrack(self) else {
|
||||
return
|
||||
}
|
||||
|
||||
// ignore all keyboard events
|
||||
if let window, window.isKeyboardWindow {
|
||||
return
|
||||
}
|
||||
|
||||
// scrollview did not scroll (contentOffset didn't change)
|
||||
guard contentOffset != newContentOffset else {
|
||||
return
|
||||
}
|
||||
|
||||
// block scrolls on UIPickerTableView. (captured via a forwarding delegate implementation)
|
||||
if String(describing: type(of: self)) == "UIPickerTableView" {
|
||||
return
|
||||
}
|
||||
|
||||
if let eventData {
|
||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kScroll), event: eventData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIPickerView {
|
||||
@objc func ph_swizzled_setDelegate(_ delegate: (any UIPickerViewDelegate)?) {
|
||||
guard let delegate else {
|
||||
// this just removes the delegate
|
||||
return ph_swizzled_setDelegate(delegate)
|
||||
}
|
||||
|
||||
// if delegate doesn't respond to this selector, then we can't intercept selection changes
|
||||
guard delegate.responds(to: #selector(UIPickerViewDelegate.pickerView(_:didSelectRow:inComponent:))) else {
|
||||
return ph_swizzled_setDelegate(delegate)
|
||||
}
|
||||
|
||||
// wrap in a forwarding delegate so we can intercept calls
|
||||
let forwardingDelegate = ForwardingPickerViewDelegate(delegate: delegate) { [weak self] in
|
||||
if let data = self?.eventData {
|
||||
PostHogAutocaptureEventTracker.eventProcessor?.process(source: .gestureRecognizer(description: EventType.kValueChange), event: data)
|
||||
}
|
||||
}
|
||||
|
||||
// Need to keep a strong reference to keep this forwarding delegate instance alive
|
||||
delegate.ph_forwardingDelegate = forwardingDelegate
|
||||
|
||||
// call original setter
|
||||
ph_swizzled_setDelegate(forwardingDelegate)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIView {
|
||||
var eventData: PostHogAutocaptureEventTracker.EventData? {
|
||||
guard shouldTrack(self) else { return nil }
|
||||
return PostHogAutocaptureEventTracker.EventData(
|
||||
touchCoordinates: nil,
|
||||
value: ph_autocaptureText
|
||||
.map(sanitizeText),
|
||||
screenName: nearestViewController
|
||||
.flatMap(UIViewController.ph_topViewController)
|
||||
.flatMap(UIViewController.getViewControllerName),
|
||||
viewHierarchy: sequence(first: self, next: \.superview)
|
||||
.map(\.toElement),
|
||||
debounceInterval: ph_autocaptureDebounceInterval
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
var toElement: PostHogAutocaptureEventTracker.Element {
|
||||
PostHogAutocaptureEventTracker.Element(
|
||||
text: ph_autocaptureText.map(sanitizeText) ?? "",
|
||||
targetClass: descriptiveTypeName,
|
||||
baseClass: baseTypeName,
|
||||
label: postHogLabel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIControl {
|
||||
func event(for action: Selector, to target: Any?) -> UIControl.Event? {
|
||||
var events: [UIControl.Event] = [
|
||||
.valueChanged,
|
||||
.touchDown,
|
||||
.touchDownRepeat,
|
||||
.touchDragInside,
|
||||
.touchDragOutside,
|
||||
.touchDragEnter,
|
||||
.touchDragExit,
|
||||
.touchUpInside,
|
||||
.touchUpOutside,
|
||||
.touchCancel,
|
||||
.editingDidBegin,
|
||||
.editingChanged,
|
||||
.editingDidEnd,
|
||||
.editingDidEndOnExit,
|
||||
.primaryActionTriggered,
|
||||
]
|
||||
|
||||
if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *) {
|
||||
events.append(.menuActionTriggered)
|
||||
}
|
||||
|
||||
// latest event for action
|
||||
return events.first { event in
|
||||
self.actions(forTarget: target, forControlEvent: event)?.contains(action.description) ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIControl.Event {
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func description(forControl control: UIControl) -> String? {
|
||||
if self == .primaryActionTriggered {
|
||||
if control is UIButton {
|
||||
return EventType.kTouch // UIButton triggers primaryAction with a touch interaction
|
||||
} else if control is UISegmentedControl {
|
||||
return EventType.kValueChange // UISegmentedControl changes its value
|
||||
} else if control is UITextField {
|
||||
return EventType.kSubmit // UITextField uses this for submit-like behavior
|
||||
} else if control is UISwitch {
|
||||
return EventType.kToggle
|
||||
} else if control is UIDatePicker {
|
||||
return EventType.kValueChange
|
||||
} else if control is UIStepper {
|
||||
return EventType.kValueChange
|
||||
} else {
|
||||
return EventType.kPrimaryAction
|
||||
}
|
||||
}
|
||||
|
||||
// General event descriptions
|
||||
if UIControl.Event.allTouchEvents.contains(self) {
|
||||
return EventType.kTouch
|
||||
} else if UIControl.Event.allEditingEvents.contains(self) {
|
||||
return EventType.kChange
|
||||
} else if self == .valueChanged {
|
||||
if control is UISwitch {
|
||||
// toggle better describes a value chagne in a switch control
|
||||
return EventType.kToggle
|
||||
}
|
||||
return EventType.kValueChange
|
||||
} else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered {
|
||||
return EventType.kMenuAction
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIViewController {
|
||||
class func ph_topViewController(base: UIViewController? = UIApplication.getCurrentWindow()?.rootViewController) -> UIViewController? {
|
||||
if let nav = base as? UINavigationController {
|
||||
return ph_topViewController(base: nav.visibleViewController)
|
||||
|
||||
} else if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
|
||||
return ph_topViewController(base: selected)
|
||||
|
||||
} else if let presented = base?.presentedViewController {
|
||||
return ph_topViewController(base: presented)
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
extension UIResponder {
|
||||
var nearestViewController: UIViewController? {
|
||||
self as? UIViewController ?? next?.nearestViewController
|
||||
}
|
||||
}
|
||||
|
||||
private func typeName(of type: AnyClass) -> String {
|
||||
let typeName = String(describing: type)
|
||||
if let match = typeName.range(of: "^[^<]+", options: .regularExpression) {
|
||||
// Extracts everything before the first '<' to deal with generics
|
||||
return String(typeName[match])
|
||||
}
|
||||
return typeName
|
||||
}
|
||||
|
||||
// common base types in UIKit that should not be captured
|
||||
private let excludedBaseTypes: [AnyClass] = [
|
||||
NSObject.self,
|
||||
UIResponder.self,
|
||||
UIControl.self,
|
||||
UIView.self,
|
||||
UIScrollView.self,
|
||||
]
|
||||
|
||||
extension NSObject {
|
||||
var descriptiveTypeName: String {
|
||||
typeName(of: type(of: self))
|
||||
}
|
||||
|
||||
var baseTypeName: String? {
|
||||
guard
|
||||
let superclass = type(of: self).superclass(),
|
||||
!excludedBaseTypes.contains(where: { $0 == superclass })
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return typeName(of: superclass)
|
||||
}
|
||||
}
|
||||
|
||||
protocol AutoCapturable {
|
||||
var ph_autocaptureText: String? { get }
|
||||
var ph_autocaptureEvents: UIControl.Event { get }
|
||||
var ph_autocaptureDebounceInterval: TimeInterval { get }
|
||||
func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool
|
||||
}
|
||||
|
||||
extension UIView: AutoCapturable {
|
||||
@objc var ph_autocaptureEvents: UIControl.Event { .touchUpInside }
|
||||
@objc var ph_autocaptureText: String? { nil }
|
||||
@objc var ph_autocaptureDebounceInterval: TimeInterval { 0 }
|
||||
@objc func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
||||
false // by default views are not tracked. Can be overridden in subclasses
|
||||
}
|
||||
}
|
||||
|
||||
extension UIButton {
|
||||
override var ph_autocaptureText: String? { title(for: .normal) ?? title(for: .selected) }
|
||||
}
|
||||
|
||||
extension UIControl {
|
||||
@objc override func ph_shouldTrack(_ action: Selector, for target: Any?) -> Bool {
|
||||
guard shouldTrack(self) else { return false }
|
||||
return actions(forTarget: target, forControlEvent: ph_autocaptureEvents)?.contains(action.description) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
extension UIScrollView {
|
||||
override var ph_autocaptureDebounceInterval: TimeInterval { 0.4 }
|
||||
}
|
||||
|
||||
extension UISegmentedControl {
|
||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
override var ph_autocaptureText: String? {
|
||||
// -1 if no segment is selected
|
||||
if (0 ..< numberOfSegments) ~= selectedSegmentIndex {
|
||||
return titleForSegment(at: selectedSegmentIndex)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension UIPageControl {
|
||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
}
|
||||
|
||||
extension UISearchBar {
|
||||
override var ph_autocaptureEvents: UIControl.Event { .editingDidEnd }
|
||||
}
|
||||
|
||||
extension UIToolbar {
|
||||
override var ph_autocaptureEvents: UIControl.Event {
|
||||
if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered }
|
||||
}
|
||||
}
|
||||
|
||||
extension UITextField {
|
||||
override var ph_autocaptureText: String? { text ?? attributedText?.string ?? placeholder }
|
||||
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
||||
// Just making sure that in the future we don't intercept UIControl.Ecent (even though it's not currently emited)
|
||||
// Tracked via `UITextField.textDidEndEditingNotification`
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
extension UITextView {
|
||||
override var ph_autocaptureText: String? { text ?? attributedText?.string }
|
||||
override func ph_shouldTrack(_: Selector, for _: Any?) -> Bool {
|
||||
shouldTrack(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIStepper {
|
||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
override var ph_autocaptureText: String? { "\(value)" }
|
||||
}
|
||||
|
||||
extension UISlider {
|
||||
override var ph_autocaptureDebounceInterval: TimeInterval { 0.3 }
|
||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
override var ph_autocaptureText: String? { "\(value)" }
|
||||
}
|
||||
|
||||
extension UISwitch {
|
||||
@objc override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
override var ph_autocaptureText: String? { "\(isOn)" }
|
||||
}
|
||||
|
||||
extension UIPickerView {
|
||||
override var ph_autocaptureText: String? {
|
||||
(0 ..< numberOfComponents).reduce("") { result, component in
|
||||
// -1 if no row is selected
|
||||
let selectedRow = selectedRow(inComponent: component)
|
||||
let rowCount = numberOfRows(inComponent: component)
|
||||
|
||||
if (0 ..< rowCount) ~= selectedRow {
|
||||
if let title = delegate?.pickerView?(self, titleForRow: selectedRow, forComponent: component) {
|
||||
return result.isEmpty ? title : "\(result) \(title)"
|
||||
} else if let title = delegate?.pickerView?(self, attributedTitleForRow: selectedRow, forComponent: component) {
|
||||
return result.isEmpty ? title.string : "\(result) \(title.string)"
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
extension UIDatePicker {
|
||||
override var ph_autocaptureEvents: UIControl.Event { .valueChanged }
|
||||
}
|
||||
#endif
|
||||
|
||||
private func shouldTrack(_ view: UIView) -> Bool {
|
||||
if view.isHidden { return false }
|
||||
if !view.isUserInteractionEnabled { return false }
|
||||
if view.isNoCapture() { return false }
|
||||
if view.window?.isKeyboardWindow == true { return false }
|
||||
|
||||
if let textField = view as? UITextField, textField.isSensitiveText() {
|
||||
return false
|
||||
}
|
||||
if let textView = view as? UITextView, textView.isSensitiveText() {
|
||||
return false
|
||||
}
|
||||
|
||||
// check view hierarchy up
|
||||
if let superview = view.superview {
|
||||
return shouldTrack(superview)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TODO: Filter out or obfuscate strings that look like sensitive data
|
||||
// see: https://github.com/PostHog/posthog-js/blob/0cfffcac9bdf1da3fbb9478c1a51170a325bd57f/src/autocapture-utils.ts#L389
|
||||
private func sanitizeText(_ title: String) -> String {
|
||||
title
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) // trim
|
||||
.replacingOccurrences( // sequence of spaces, returns and line breaks
|
||||
of: "[ \\r\\n]+",
|
||||
with: " ",
|
||||
options: .regularExpression
|
||||
)
|
||||
.replacingOccurrences( // sanitize zero-width unicode characters
|
||||
of: "[\\u{200B}\\u{200C}\\u{200D}\\u{FEFF}]",
|
||||
with: "",
|
||||
options: .regularExpression
|
||||
)
|
||||
.limit(to: 255)
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
static let kValueChange = "value_changed"
|
||||
static let kSubmit = "submit"
|
||||
static let kToggle = "toggle"
|
||||
static let kPrimaryAction = "primary_action"
|
||||
static let kMenuAction = "menu_action"
|
||||
static let kChange = "change"
|
||||
|
||||
static let kTouch = "touch"
|
||||
static let kSwipe = "swipe"
|
||||
static let kPinch = "pinch"
|
||||
static let kPan = "pan"
|
||||
static let kScroll = "scroll"
|
||||
static let kRotation = "rotation"
|
||||
static let kLongPress = "long_press"
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension String {
|
||||
func limit(to length: Int) -> String {
|
||||
if count > length {
|
||||
let index = index(startIndex, offsetBy: length)
|
||||
return String(self[..<index]) + "..."
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
var quoted: String {
|
||||
"\"\(self)\""
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
147
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureIntegration.swift
generated
Normal file
147
Pods/PostHog/PostHog/Autocapture/PostHogAutocaptureIntegration.swift
generated
Normal file
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// PostHogAutocaptureIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 22/10/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
private let elementsChainDelimiter = ";"
|
||||
|
||||
class PostHogAutocaptureIntegration: AutocaptureEventProcessing, PostHogIntegration {
|
||||
var requiresSwizzling: Bool { true }
|
||||
|
||||
private static var integrationInstalledLock = NSLock()
|
||||
private static var integrationInstalled = false
|
||||
|
||||
private weak var postHog: PostHogSDK?
|
||||
private var debounceTimers: [Int: Timer] = [:]
|
||||
|
||||
func install(_ postHog: PostHogSDK) throws {
|
||||
try PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
|
||||
if PostHogAutocaptureIntegration.integrationInstalled {
|
||||
throw InternalPostHogError(description: "Autocapture integration already installed to another PostHogSDK instance.")
|
||||
}
|
||||
PostHogAutocaptureIntegration.integrationInstalled = true
|
||||
}
|
||||
|
||||
self.postHog = postHog
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func uninstall(_ postHog: PostHogSDK) {
|
||||
// uninstall only for integration instance
|
||||
if self.postHog === postHog || self.postHog == nil {
|
||||
stop()
|
||||
self.postHog = nil
|
||||
PostHogAutocaptureIntegration.integrationInstalledLock.withLock {
|
||||
PostHogAutocaptureIntegration.integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Activates the autocapture integration by routing events from PostHogAutocaptureEventTracker to this instance.
|
||||
*/
|
||||
func start() {
|
||||
PostHogAutocaptureEventTracker.eventProcessor = self
|
||||
}
|
||||
|
||||
/**
|
||||
Disables the autocapture integration by clearing the PostHogAutocaptureEventTracker routing
|
||||
*/
|
||||
func stop() {
|
||||
if PostHogAutocaptureEventTracker.eventProcessor != nil {
|
||||
PostHogAutocaptureEventTracker.eventProcessor = nil
|
||||
debounceTimers.values.forEach { $0.invalidate() }
|
||||
debounceTimers.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Processes an autocapture event, with optional debounce logic for controls that emit frequent events.
|
||||
|
||||
- Parameters:
|
||||
- source: The source of the event (e.g., gesture recognizer, action method, or notification).
|
||||
- event: The autocapture event data, containing properties, screen name, and other metadata.
|
||||
|
||||
If the event has a `debounceInterval` greater than 0, the event is debounced.
|
||||
This is useful for UIControls like `UISlider` that emit frequent value changes, ensuring only the last value is captured.
|
||||
The debounce interval is defined per UIControl by the `ph_autocaptureDebounceInterval` property of `AutoCapturable`
|
||||
*/
|
||||
func process(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
|
||||
guard postHog?.isAutocaptureActive() == true else {
|
||||
return
|
||||
}
|
||||
|
||||
let eventHash = event.viewHierarchy.map(\.targetClass).hashValue
|
||||
// debounce frequent UIControl events (e.g., UISlider) to reduce event noise
|
||||
if event.debounceInterval > 0 {
|
||||
debounceTimers[eventHash]?.invalidate() // Keep cancelling existing
|
||||
debounceTimers[eventHash] = Timer.scheduledTimer(withTimeInterval: event.debounceInterval, repeats: false) { [weak self] _ in
|
||||
self?.handleEventProcessing(source: source, event: event)
|
||||
self?.debounceTimers.removeValue(forKey: eventHash) // Clean up once fired
|
||||
}
|
||||
} else {
|
||||
handleEventProcessing(source: source, event: event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Handles the processing of autocapture events by extracting event details, building properties, and sending them to PostHog.
|
||||
|
||||
- Parameters:
|
||||
- source: The source of the event (action method, gesture, or notification). Values are already mapped to `$event_type` earlier in the chain
|
||||
- event: The event data including view hierarchy, screen name, and other metadata.
|
||||
|
||||
This function extracts event details such as the event type, view hierarchy, and touch coordinates.
|
||||
It creates a structured payload with relevant properties (e.g., tag_name, elements, element_chain) and sends it to the
|
||||
associated PostHog instance for further processing.
|
||||
*/
|
||||
private func handleEventProcessing(source: PostHogAutocaptureEventTracker.EventSource, event: PostHogAutocaptureEventTracker.EventData) {
|
||||
guard let postHog else {
|
||||
return
|
||||
}
|
||||
|
||||
let eventType: String = switch source {
|
||||
case let .actionMethod(description): description
|
||||
case let .gestureRecognizer(description): description
|
||||
case let .notification(name): name
|
||||
}
|
||||
|
||||
var properties: [String: Any] = [:]
|
||||
|
||||
if let screenName = event.screenName {
|
||||
properties["$screen_name"] = screenName
|
||||
}
|
||||
|
||||
let elementsChain = event.viewHierarchy
|
||||
.map(\.elementsChainEntry)
|
||||
.joined(separator: elementsChainDelimiter)
|
||||
|
||||
if let coordinates = event.touchCoordinates {
|
||||
properties["$touch_x"] = coordinates.x
|
||||
properties["$touch_y"] = coordinates.y
|
||||
}
|
||||
|
||||
postHog.autocapture(
|
||||
eventType: eventType,
|
||||
elementsChain: elementsChain,
|
||||
properties: properties
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#if TESTING
|
||||
extension PostHogAutocaptureIntegration {
|
||||
static func clearInstalls() {
|
||||
integrationInstalledLock.withLock {
|
||||
integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
156
Pods/PostHog/PostHog/Autocapture/SwiftUI/View+PostHogLabel.swift
generated
Normal file
156
Pods/PostHog/PostHog/Autocapture/SwiftUI/View+PostHogLabel.swift
generated
Normal file
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// View+PostHogLabel.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 04/12/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
/**
|
||||
Adds a custom label to this view for use with PostHog's auto-capture functionality.
|
||||
|
||||
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
|
||||
|
||||
### Usage
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
Button("Login") {
|
||||
...
|
||||
}
|
||||
.postHogLabel("loginButton")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Parameter label: A custom label that uniquely identifies the element for analytics purposes.
|
||||
*/
|
||||
func postHogLabel(_ label: String?) -> some View {
|
||||
modifier(PostHogLabelTaggerViewModifier(label: label))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PostHogLabelTaggerViewModifier: ViewModifier {
|
||||
let label: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(viewTagger)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var viewTagger: some View {
|
||||
if let label {
|
||||
PostHogLabelViewTagger(label: label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PostHogLabelViewTagger: UIViewRepresentable {
|
||||
let label: String
|
||||
|
||||
func makeUIView(context _: Context) -> PostHogLabelTaggerView {
|
||||
PostHogLabelTaggerView(label: label)
|
||||
}
|
||||
|
||||
func updateUIView(_: PostHogLabelTaggerView, context _: Context) {
|
||||
// nothing
|
||||
}
|
||||
}
|
||||
|
||||
private class PostHogLabelTaggerView: UIView {
|
||||
private let label: String
|
||||
weak var taggedView: UIView?
|
||||
|
||||
init(label: String) {
|
||||
self.label = label
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
label = ""
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.didMoveToWindow()
|
||||
|
||||
// try to find a "taggable" cousin view in hierarchy
|
||||
//
|
||||
// ### Why cousin view?
|
||||
//
|
||||
// Because of SwiftUI-to-UIKit view bridging:
|
||||
//
|
||||
// OriginalView (SwiftUI)
|
||||
// L SwiftUITextFieldRepresentable (ViewRepresentable)
|
||||
// L UITextField (UIControl) <- we tag here
|
||||
// L PostHogLabelViewTagger (ViewRepresentable)
|
||||
// L PostHogLabelTaggerView (UIView) <- we are here
|
||||
//
|
||||
if let view = findCousinView(of: PostHogSwiftUITaggable.self) {
|
||||
taggedView = view
|
||||
view.postHogLabel = label
|
||||
} else {
|
||||
// just tag grandparent view
|
||||
//
|
||||
// ### Why grandparent view?
|
||||
//
|
||||
// Because of SwiftUI-to-UIKit view bridging:
|
||||
// OriginalView (SwiftUI) <- we tag here
|
||||
// L PostHogLabelViewTagger (ViewRepresentable)
|
||||
// L PostHogLabelTaggerView (UIView) <- we are here
|
||||
//
|
||||
taggedView = superview?.superview
|
||||
superview?.superview?.postHogLabel = label
|
||||
}
|
||||
}
|
||||
|
||||
override func removeFromSuperview() {
|
||||
super.removeFromSuperview()
|
||||
// remove custom label when removed from hierarchy
|
||||
taggedView?.postHogLabel = nil
|
||||
taggedView = nil
|
||||
}
|
||||
|
||||
private func findCousinView<T>(of _: T.Type) -> T? {
|
||||
for sibling in superview?.siblings() ?? [] {
|
||||
if let match = sibling.child(of: T.self) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension UIView {
|
||||
func siblings() -> [UIView] {
|
||||
superview?.subviews.reduce(into: []) { result, current in
|
||||
if current !== self { result.append(current) }
|
||||
} ?? []
|
||||
}
|
||||
|
||||
func child<T>(of type: T.Type) -> T? {
|
||||
for child in subviews {
|
||||
if let curT = child as? T ?? child.child(of: type) {
|
||||
return curT
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
protocol PostHogSwiftUITaggable: UIView { /**/ }
|
||||
|
||||
extension UIControl: PostHogSwiftUITaggable { /**/ }
|
||||
extension UIPickerView: PostHogSwiftUITaggable { /**/ }
|
||||
extension UITextView: PostHogSwiftUITaggable { /**/ }
|
||||
extension UICollectionView: PostHogSwiftUITaggable { /**/ }
|
||||
extension UITableView: PostHogSwiftUITaggable { /**/ }
|
||||
|
||||
#endif
|
||||
29
Pods/PostHog/PostHog/Autocapture/UIView+PostHogLabel.swift
generated
Normal file
29
Pods/PostHog/PostHog/Autocapture/UIView+PostHogLabel.swift
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// UIView+PostHogLabel.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 04/12/2024.
|
||||
//
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
public extension UIView {
|
||||
/**
|
||||
Adds a custom label to this view for use with PostHog's auto-capture functionality.
|
||||
|
||||
By setting a custom label, you can easily identify and filter interactions with this specific element in your analytics data.
|
||||
|
||||
### Usage
|
||||
```swift
|
||||
let myView = UIView()
|
||||
myView.postHogLabel = "customLabel"
|
||||
```
|
||||
*/
|
||||
var postHogLabel: String? {
|
||||
get { objc_getAssociatedObject(self, &AssociatedKeys.phLabel) as? String }
|
||||
set { objc_setAssociatedObject(self, &AssociatedKeys.phLabel, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
28
Pods/PostHog/PostHog/DI.swift
generated
Normal file
28
Pods/PostHog/PostHog/DI.swift
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// DI.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 17/12/2024.
|
||||
//
|
||||
|
||||
// swiftlint:disable:next type_name
|
||||
enum DI {
|
||||
static var main = Container()
|
||||
|
||||
final class Container {
|
||||
// publishes global app lifecycle events
|
||||
lazy var appLifecyclePublisher: AppLifecyclePublishing = ApplicationLifecyclePublisher.shared
|
||||
// publishes global screen view events (UIViewController.viewDidAppear)
|
||||
lazy var screenViewPublisher: ScreenViewPublishing = ApplicationScreenViewPublisher.shared
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
// publishes global application events (UIApplication.sendEvent)
|
||||
lazy var applicationEventPublisher: ApplicationEventPublishing = ApplicationEventPublisher.shared
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
// publishes global view layout events within a throttle interval (UIView.layoutSubviews)
|
||||
lazy var viewLayoutPublisher: ViewLayoutPublishing = ApplicationViewLayoutPublisher.shared
|
||||
#endif
|
||||
}
|
||||
}
|
||||
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
Normal file
102
Pods/PostHog/PostHog/Models/PostHogEvent.swift
generated
Normal file
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// PostHogEvent.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc(PostHogEvent) public class PostHogEvent: NSObject {
|
||||
@objc public var event: String
|
||||
@objc public var distinctId: String
|
||||
@objc public var properties: [String: Any]
|
||||
@objc public var timestamp: Date
|
||||
@objc public private(set) var uuid: UUID
|
||||
// Only used for Replay
|
||||
var apiKey: String?
|
||||
|
||||
init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), apiKey: String? = nil) {
|
||||
self.event = event
|
||||
self.distinctId = distinctId
|
||||
self.properties = properties ?? [:]
|
||||
self.timestamp = timestamp
|
||||
self.uuid = uuid
|
||||
self.apiKey = apiKey
|
||||
}
|
||||
|
||||
// NOTE: Ideally we would use the NSCoding behaviour but it gets needlessly complex
|
||||
// given we only need this for sending to the API
|
||||
static func fromJSON(_ data: Data) -> PostHogEvent? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fromJSON(json)
|
||||
}
|
||||
|
||||
static func fromJSON(_ json: [String: Any]) -> PostHogEvent? {
|
||||
guard let event = json["event"] as? String else { return nil }
|
||||
|
||||
let timestamp = json["timestamp"] as? String ?? toISO8601String(Date())
|
||||
|
||||
let timestampDate = toISO8601Date(timestamp) ?? Date()
|
||||
|
||||
var properties = (json["properties"] as? [String: Any]) ?? [:]
|
||||
|
||||
// back compatibility with v2
|
||||
let setProps = json["$set"] as? [String: Any]
|
||||
if setProps != nil {
|
||||
properties["$set"] = setProps
|
||||
}
|
||||
|
||||
guard let distinctId = (json["distinct_id"] as? String) ?? (properties["distinct_id"] as? String) else { return nil }
|
||||
|
||||
let uuid = ((json["uuid"] as? String) ?? (json["message_id"] as? String)) ?? UUID.v7().uuidString
|
||||
let uuidObj = UUID(uuidString: uuid) ?? UUID.v7()
|
||||
|
||||
let apiKey = json["api_key"] as? String
|
||||
|
||||
return PostHogEvent(
|
||||
event: event,
|
||||
distinctId: distinctId,
|
||||
properties: properties,
|
||||
timestamp: timestampDate,
|
||||
uuid: uuidObj,
|
||||
apiKey: apiKey
|
||||
)
|
||||
}
|
||||
|
||||
func toJSON() -> [String: Any] {
|
||||
var json: [String: Any] = [
|
||||
"event": event,
|
||||
"distinct_id": distinctId,
|
||||
"properties": properties,
|
||||
"timestamp": toISO8601String(timestamp),
|
||||
"uuid": uuid.uuidString,
|
||||
]
|
||||
|
||||
if let apiKey {
|
||||
json["api_key"] = apiKey
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogKnownUnsafeEditableEvent: String {
|
||||
case snapshot = "$snapshot"
|
||||
case screen = "$screen"
|
||||
case set = "$set"
|
||||
case surveyDismissed = "survey dismissed"
|
||||
case surveySent = "survey sent"
|
||||
case surveyShown = "survey shown"
|
||||
case identify = "$identify"
|
||||
case groupidentify = "$groupidentify"
|
||||
case createAlias = "$create_alias"
|
||||
case featureFlagCalled = "$feature_flag_called"
|
||||
|
||||
static func contains(_ name: String) -> Bool {
|
||||
PostHogKnownUnsafeEditableEvent(rawValue: name) != nil
|
||||
}
|
||||
}
|
||||
122
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey+Display.swift
generated
Normal file
122
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey+Display.swift
generated
Normal file
@@ -0,0 +1,122 @@
|
||||
#if os(iOS) || TESTING
|
||||
import Foundation
|
||||
|
||||
extension PostHogSurvey {
|
||||
func toDisplaySurvey() -> PostHogDisplaySurvey {
|
||||
PostHogDisplaySurvey(
|
||||
id: id,
|
||||
name: name,
|
||||
questions: questions.compactMap { $0.toDisplayQuestion() },
|
||||
appearance: appearance?.toDisplayAppearance(),
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurveyQuestion {
|
||||
func toDisplayQuestion() -> PostHogDisplaySurveyQuestion? {
|
||||
switch self {
|
||||
case let .open(question):
|
||||
return PostHogDisplayOpenQuestion(
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
questionDescription: question.description,
|
||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
||||
isOptional: question.optional ?? false,
|
||||
buttonText: question.buttonText
|
||||
)
|
||||
|
||||
case let .link(question):
|
||||
return PostHogDisplayLinkQuestion(
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
questionDescription: question.description,
|
||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
||||
isOptional: question.optional ?? false,
|
||||
buttonText: question.buttonText,
|
||||
link: question.link ?? ""
|
||||
)
|
||||
|
||||
case let .rating(question):
|
||||
return PostHogDisplayRatingQuestion(
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
questionDescription: question.description,
|
||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
||||
isOptional: question.optional ?? false,
|
||||
buttonText: question.buttonText,
|
||||
ratingType: question.display.toDisplayRatingType(),
|
||||
scaleLowerBound: question.scale.range.lowerBound,
|
||||
scaleUpperBound: question.scale.range.upperBound,
|
||||
lowerBoundLabel: question.lowerBoundLabel,
|
||||
upperBoundLabel: question.upperBoundLabel
|
||||
)
|
||||
|
||||
case let .singleChoice(question), let .multipleChoice(question):
|
||||
return PostHogDisplayChoiceQuestion(
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
questionDescription: question.description,
|
||||
questionDescriptionContentType: question.descriptionContentType?.toDisplayContentType(),
|
||||
isOptional: question.optional ?? false,
|
||||
buttonText: question.buttonText,
|
||||
choices: question.choices,
|
||||
hasOpenChoice: question.hasOpenChoice ?? false,
|
||||
shuffleOptions: question.shuffleOptions ?? false,
|
||||
isMultipleChoice: isMultipleChoice
|
||||
)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var isMultipleChoice: Bool {
|
||||
switch self {
|
||||
case .multipleChoice: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurveyTextContentType {
|
||||
func toDisplayContentType() -> PostHogDisplaySurveyTextContentType {
|
||||
if case .html = self {
|
||||
return .html
|
||||
}
|
||||
return .text
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurveyRatingDisplayType {
|
||||
func toDisplayRatingType() -> PostHogDisplaySurveyRatingType {
|
||||
if case .emoji = self {
|
||||
return .emoji
|
||||
}
|
||||
return .number
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurveyAppearance {
|
||||
func toDisplayAppearance() -> PostHogDisplaySurveyAppearance {
|
||||
PostHogDisplaySurveyAppearance(
|
||||
fontFamily: fontFamily,
|
||||
backgroundColor: backgroundColor,
|
||||
borderColor: borderColor,
|
||||
submitButtonColor: submitButtonColor,
|
||||
submitButtonText: submitButtonText,
|
||||
submitButtonTextColor: submitButtonTextColor,
|
||||
descriptionTextColor: descriptionTextColor,
|
||||
ratingButtonColor: ratingButtonColor,
|
||||
ratingButtonActiveColor: ratingButtonActiveColor,
|
||||
placeholder: placeholder,
|
||||
displayThankYouMessage: displayThankYouMessage ?? true,
|
||||
thankYouMessageHeader: thankYouMessageHeader,
|
||||
thankYouMessageDescription: thankYouMessageDescription,
|
||||
thankYouMessageDescriptionContentType: thankYouMessageDescriptionContentType?.toDisplayContentType(),
|
||||
thankYouMessageCloseButtonText: thankYouMessageCloseButtonText
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
46
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey.swift
generated
Normal file
46
Pods/PostHog/PostHog/Models/Surveys/PostHogSurvey.swift
generated
Normal file
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// PostHogSurvey.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 20/01/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents the main survey object containing metadata, questions, conditions, and appearance settings.
|
||||
/// see: posthog-js/posthog-surveys-types.ts
|
||||
struct PostHogSurvey: Decodable, Identifiable {
|
||||
/// The unique identifier for the survey
|
||||
let id: String
|
||||
/// The name of the survey
|
||||
let name: String
|
||||
/// Type of the survey (e.g., "popover")
|
||||
let type: PostHogSurveyType
|
||||
/// The questions asked in the survey
|
||||
let questions: [PostHogSurveyQuestion]
|
||||
/// Multiple feature flag keys. Must all (AND) evaluate to true for the survey to be shown (optional)
|
||||
let featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]?
|
||||
/// Linked feature flag key. Must evaluate to true for the survey to be shown (optional)
|
||||
let linkedFlagKey: String?
|
||||
/// Targeting feature flag key. Must evaluate to true for the survey to be shown (optional)
|
||||
let targetingFlagKey: String?
|
||||
/// Internal targeting flag key. Must evaluate to true for the survey to be shown (optional)
|
||||
let internalTargetingFlagKey: String?
|
||||
/// Conditions for displaying the survey (optional)
|
||||
let conditions: PostHogSurveyConditions?
|
||||
/// Appearance settings for the survey (optional)
|
||||
let appearance: PostHogSurveyAppearance?
|
||||
/// The iteration number for the survey (optional)
|
||||
let currentIteration: Int?
|
||||
/// The start date for the current iteration of the survey (optional)
|
||||
let currentIterationStartDate: Date?
|
||||
/// Start date of the survey (optional)
|
||||
let startDate: Date?
|
||||
/// End date of the survey (optional)
|
||||
let endDate: Date?
|
||||
}
|
||||
|
||||
struct PostHogSurveyFeatureFlagKeyValue: Equatable, Decodable {
|
||||
let key: String
|
||||
let value: String?
|
||||
}
|
||||
38
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyAppearance.swift
generated
Normal file
38
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyAppearance.swift
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// PostHogSurveyAppearance.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 08/04/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents the appearance settings for the survey, such as colors, fonts, and layout
|
||||
struct PostHogSurveyAppearance: Decodable {
|
||||
let position: PostHogSurveyAppearancePosition?
|
||||
let fontFamily: String?
|
||||
let backgroundColor: String?
|
||||
let submitButtonColor: String?
|
||||
let submitButtonText: String?
|
||||
let submitButtonTextColor: String?
|
||||
let descriptionTextColor: String?
|
||||
let ratingButtonColor: String?
|
||||
let ratingButtonActiveColor: String?
|
||||
let ratingButtonHoverColor: String?
|
||||
let whiteLabel: Bool?
|
||||
let autoDisappear: Bool?
|
||||
let displayThankYouMessage: Bool?
|
||||
let thankYouMessageHeader: String?
|
||||
let thankYouMessageDescription: String?
|
||||
let thankYouMessageDescriptionContentType: PostHogSurveyTextContentType?
|
||||
let thankYouMessageCloseButtonText: String?
|
||||
let borderColor: String?
|
||||
let placeholder: String?
|
||||
let shuffleQuestions: Bool?
|
||||
let surveyPopupDelaySeconds: TimeInterval?
|
||||
// widget options
|
||||
let widgetType: PostHogSurveyAppearanceWidgetType?
|
||||
let widgetSelector: String?
|
||||
let widgetLabel: String?
|
||||
let widgetColor: String?
|
||||
}
|
||||
47
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyConditions.swift
generated
Normal file
47
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyConditions.swift
generated
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// PostHogSurveyConditions.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 08/04/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents conditions for displaying the survey, such as URL or event-based triggers
|
||||
struct PostHogSurveyConditions: Decodable {
|
||||
/// Target URL for the survey (optional)
|
||||
let url: String?
|
||||
/// The match type for the url condition (optional)
|
||||
let urlMatchType: PostHogSurveyMatchType?
|
||||
/// CSS selector for displaying the survey (optional)
|
||||
let selector: String?
|
||||
/// Device type based conditions for displaying the survey (optional)
|
||||
let deviceTypes: [String]?
|
||||
/// The match type for the device type condition (optional)
|
||||
let deviceTypesMatchType: PostHogSurveyMatchType?
|
||||
/// Minimum wait period before showing the survey again (optional)
|
||||
let seenSurveyWaitPeriodInDays: Int?
|
||||
/// Event-based conditions for displaying the survey (optional)
|
||||
let events: PostHogSurveyEventConditions?
|
||||
/// Action-based conditions for displaying the survey (optional)
|
||||
let actions: PostHogSurveyActionsConditions?
|
||||
}
|
||||
|
||||
/// Represents event-based conditions for displaying the survey
|
||||
struct PostHogSurveyEventConditions: Decodable {
|
||||
let repeatedActivation: Bool?
|
||||
/// List of events that trigger the survey
|
||||
let values: [PostHogEventCondition]
|
||||
}
|
||||
|
||||
/// Represents action-based conditions for displaying the survey
|
||||
struct PostHogSurveyActionsConditions: Decodable {
|
||||
/// List of events that trigger the survey
|
||||
let values: [PostHogEventCondition]
|
||||
}
|
||||
|
||||
/// Represents a single event condition used in survey targeting
|
||||
struct PostHogEventCondition: Decodable, Equatable {
|
||||
/// Name of the event (e.g., "content loaded")
|
||||
let name: String
|
||||
}
|
||||
280
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyEnums.swift
generated
Normal file
280
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyEnums.swift
generated
Normal file
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// PostHogSurveyEnums.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 08/04/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Supporting Types
|
||||
|
||||
enum PostHogSurveyType: Decodable, Equatable {
|
||||
case popover
|
||||
case api
|
||||
case widget
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "popover":
|
||||
self = .popover
|
||||
case "api":
|
||||
self = .api
|
||||
case "widget":
|
||||
self = .widget
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyQuestionType: Decodable, Equatable {
|
||||
case open
|
||||
case link
|
||||
case rating
|
||||
case multipleChoice
|
||||
case singleChoice
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "open":
|
||||
self = .open
|
||||
case "link":
|
||||
self = .link
|
||||
case "rating":
|
||||
self = .rating
|
||||
case "multiple_choice":
|
||||
self = .multipleChoice
|
||||
case "single_choice":
|
||||
self = .singleChoice
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyTextContentType: Decodable, Equatable {
|
||||
case html
|
||||
case text
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "html":
|
||||
self = .html
|
||||
case "text":
|
||||
self = .text
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyMatchType: Decodable, Equatable {
|
||||
case regex
|
||||
case notRegex
|
||||
case exact
|
||||
case isNot
|
||||
case iContains
|
||||
case notIContains
|
||||
case unknown(value: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let valueString = try container.decode(String.self)
|
||||
|
||||
switch valueString {
|
||||
case "regex":
|
||||
self = .regex
|
||||
case "not_regex":
|
||||
self = .notRegex
|
||||
case "exact":
|
||||
self = .exact
|
||||
case "is_not":
|
||||
self = .isNot
|
||||
case "icontains":
|
||||
self = .iContains
|
||||
case "not_icontains":
|
||||
self = .notIContains
|
||||
default:
|
||||
self = .unknown(value: valueString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyAppearancePosition: Decodable, Equatable {
|
||||
case topLeft
|
||||
case topCenter
|
||||
case topRight
|
||||
case middleLeft
|
||||
case middleCenter
|
||||
case middleRight
|
||||
case left
|
||||
case right
|
||||
case center
|
||||
case unknown(position: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let positionString = try container.decode(String.self)
|
||||
|
||||
switch positionString {
|
||||
case "top_left":
|
||||
self = .topLeft
|
||||
case "top_center":
|
||||
self = .topCenter
|
||||
case "top_right":
|
||||
self = .topRight
|
||||
case "middle_left":
|
||||
self = .middleLeft
|
||||
case "middle_center":
|
||||
self = .middleCenter
|
||||
case "middle_right":
|
||||
self = .middleRight
|
||||
case "left":
|
||||
self = .left
|
||||
case "right":
|
||||
self = .right
|
||||
case "center":
|
||||
self = .center
|
||||
default:
|
||||
self = .unknown(position: positionString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyAppearanceWidgetType: Decodable, Equatable {
|
||||
case button
|
||||
case tab
|
||||
case selector
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "button":
|
||||
self = .button
|
||||
case "tab":
|
||||
self = .tab
|
||||
case "selector":
|
||||
self = .selector
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyRatingDisplayType: Decodable, Equatable {
|
||||
case number
|
||||
case emoji
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "number":
|
||||
self = .number
|
||||
case "emoji":
|
||||
self = .emoji
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyRatingScale: Decodable, Equatable {
|
||||
case threePoint
|
||||
case fivePoint
|
||||
case sevenPoint
|
||||
case tenPoint
|
||||
case unknown(scale: Int)
|
||||
|
||||
var rawValue: Int {
|
||||
switch self {
|
||||
case .threePoint: 3
|
||||
case .fivePoint: 5
|
||||
case .sevenPoint: 7
|
||||
case .tenPoint: 10
|
||||
case let .unknown(scale): scale
|
||||
}
|
||||
}
|
||||
|
||||
var range: ClosedRange<Int> {
|
||||
switch self {
|
||||
case .threePoint: 1 ... 3
|
||||
case .fivePoint: 1 ... 5
|
||||
case .sevenPoint: 1 ... 7
|
||||
case .tenPoint: 0 ... 10
|
||||
case let .unknown(scale): 1 ... scale
|
||||
}
|
||||
}
|
||||
|
||||
init(range: ClosedRange<Int>) {
|
||||
switch range {
|
||||
case 1 ... 3: self = .threePoint
|
||||
case 1 ... 5: self = .fivePoint
|
||||
case 1 ... 7: self = .sevenPoint
|
||||
case 0 ... 10: self = .tenPoint
|
||||
default: self = .unknown(scale: range.upperBound)
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let scaleInt = try container.decode(Int.self)
|
||||
|
||||
switch scaleInt {
|
||||
case 3:
|
||||
self = .threePoint
|
||||
case 5:
|
||||
self = .fivePoint
|
||||
case 7:
|
||||
self = .sevenPoint
|
||||
case 10:
|
||||
self = .tenPoint
|
||||
default:
|
||||
self = .unknown(scale: scaleInt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PostHogSurveyQuestionBranchingType: Decodable, Equatable {
|
||||
case nextQuestion
|
||||
case end
|
||||
case responseBased
|
||||
case specificQuestion
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let typeString = try container.decode(String.self)
|
||||
|
||||
switch typeString {
|
||||
case "next_question":
|
||||
self = .nextQuestion
|
||||
case "end":
|
||||
self = .end
|
||||
case "response_based":
|
||||
self = .responseBased
|
||||
case "specific_question":
|
||||
self = .specificQuestion
|
||||
default:
|
||||
self = .unknown(type: typeString)
|
||||
}
|
||||
}
|
||||
}
|
||||
247
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyQuestion.swift
generated
Normal file
247
Pods/PostHog/PostHog/Models/Surveys/PostHogSurveyQuestion.swift
generated
Normal file
@@ -0,0 +1,247 @@
|
||||
//
|
||||
// PostHogSurveyQuestion.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 08/04/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Question Models
|
||||
|
||||
/// Protocol defining common properties for all survey question types
|
||||
protocol PostHogSurveyQuestionProperties {
|
||||
/// Question ID, empty if none
|
||||
var id: String { get }
|
||||
/// Question text
|
||||
var question: String { get }
|
||||
/// Additional description or instructions (optional)
|
||||
var description: String? { get }
|
||||
/// Content type of the description (e.g., "text", "html") (optional)
|
||||
var descriptionContentType: PostHogSurveyTextContentType? { get }
|
||||
/// Indicates if this question is optional (optional)
|
||||
var optional: Bool? { get }
|
||||
/// Text for the main CTA associated with this question (optional)
|
||||
var buttonText: String? { get }
|
||||
/// Original index of the question in the survey (optional)
|
||||
var originalQuestionIndex: Int? { get }
|
||||
/// Question branching logic if any (optional)
|
||||
var branching: PostHogSurveyQuestionBranching? { get }
|
||||
}
|
||||
|
||||
/// Represents different types of survey questions with their associated data
|
||||
enum PostHogSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
||||
case open(PostHogOpenSurveyQuestion)
|
||||
case link(PostHogLinkSurveyQuestion)
|
||||
case rating(PostHogRatingSurveyQuestion)
|
||||
case singleChoice(PostHogMultipleSurveyQuestion)
|
||||
case multipleChoice(PostHogMultipleSurveyQuestion)
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(PostHogSurveyQuestionType.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case .open:
|
||||
self = try .open(PostHogOpenSurveyQuestion(from: decoder))
|
||||
case .link:
|
||||
self = try .link(PostHogLinkSurveyQuestion(from: decoder))
|
||||
case .rating:
|
||||
self = try .rating(PostHogRatingSurveyQuestion(from: decoder))
|
||||
case .singleChoice:
|
||||
self = try .singleChoice(PostHogMultipleSurveyQuestion(from: decoder))
|
||||
case .multipleChoice:
|
||||
self = try .multipleChoice(PostHogMultipleSurveyQuestion(from: decoder))
|
||||
case let .unknown(type):
|
||||
self = .unknown(type: type)
|
||||
}
|
||||
}
|
||||
|
||||
var id: String {
|
||||
wrappedQuestion?.id ?? ""
|
||||
}
|
||||
|
||||
var question: String {
|
||||
wrappedQuestion?.question ?? ""
|
||||
}
|
||||
|
||||
var description: String? {
|
||||
wrappedQuestion?.description
|
||||
}
|
||||
|
||||
var descriptionContentType: PostHogSurveyTextContentType? {
|
||||
wrappedQuestion?.descriptionContentType
|
||||
}
|
||||
|
||||
var optional: Bool? {
|
||||
wrappedQuestion?.optional
|
||||
}
|
||||
|
||||
var buttonText: String? {
|
||||
wrappedQuestion?.buttonText
|
||||
}
|
||||
|
||||
var originalQuestionIndex: Int? {
|
||||
wrappedQuestion?.originalQuestionIndex
|
||||
}
|
||||
|
||||
var branching: PostHogSurveyQuestionBranching? {
|
||||
wrappedQuestion?.branching
|
||||
}
|
||||
|
||||
private var wrappedQuestion: PostHogSurveyQuestionProperties? {
|
||||
switch self {
|
||||
case let .open(question): question
|
||||
case let .link(question): question
|
||||
case let .rating(question): question
|
||||
case let .singleChoice(question): question
|
||||
case let .multipleChoice(question): question
|
||||
case .unknown: nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: CodingKey {
|
||||
case type
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a basic open-ended survey question
|
||||
struct PostHogOpenSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
||||
let id: String
|
||||
let question: String
|
||||
let description: String?
|
||||
let descriptionContentType: PostHogSurveyTextContentType?
|
||||
let optional: Bool?
|
||||
let buttonText: String?
|
||||
let originalQuestionIndex: Int?
|
||||
let branching: PostHogSurveyQuestionBranching?
|
||||
}
|
||||
|
||||
/// Represents a survey question with an associated link
|
||||
struct PostHogLinkSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
||||
let id: String
|
||||
let question: String
|
||||
let description: String?
|
||||
let descriptionContentType: PostHogSurveyTextContentType?
|
||||
let optional: Bool?
|
||||
let buttonText: String?
|
||||
let originalQuestionIndex: Int?
|
||||
let branching: PostHogSurveyQuestionBranching?
|
||||
/// URL link associated with the question
|
||||
let link: String?
|
||||
}
|
||||
|
||||
/// Represents a rating-based survey question
|
||||
struct PostHogRatingSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
||||
let id: String
|
||||
let question: String
|
||||
let description: String?
|
||||
let descriptionContentType: PostHogSurveyTextContentType?
|
||||
let optional: Bool?
|
||||
let buttonText: String?
|
||||
let originalQuestionIndex: Int?
|
||||
let branching: PostHogSurveyQuestionBranching?
|
||||
/// Display type for the rating ("number" or "emoji")
|
||||
let display: PostHogSurveyRatingDisplayType
|
||||
/// Scale of the rating (3, 5, 7, or 10)
|
||||
let scale: PostHogSurveyRatingScale
|
||||
let lowerBoundLabel: String
|
||||
let upperBoundLabel: String
|
||||
}
|
||||
|
||||
/// Represents a multiple-choice or single-choice survey question
|
||||
struct PostHogMultipleSurveyQuestion: PostHogSurveyQuestionProperties, Decodable {
|
||||
let id: String
|
||||
let question: String
|
||||
let description: String?
|
||||
let descriptionContentType: PostHogSurveyTextContentType?
|
||||
let optional: Bool?
|
||||
let buttonText: String?
|
||||
let originalQuestionIndex: Int?
|
||||
let branching: PostHogSurveyQuestionBranching?
|
||||
/// List of choices for multiple-choice or single-choice questions
|
||||
let choices: [String]
|
||||
/// Indicates if there is an open choice option (optional)
|
||||
let hasOpenChoice: Bool?
|
||||
/// Indicates if choices should be shuffled or not (optional)
|
||||
let shuffleOptions: Bool?
|
||||
}
|
||||
|
||||
/// Represents branching logic for a question based on user responses
|
||||
enum PostHogSurveyQuestionBranching: Decodable {
|
||||
case next
|
||||
case end
|
||||
case responseBased(responseValues: [String: Any])
|
||||
case specificQuestion(index: Int)
|
||||
case unknown(type: String)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let type = try container.decode(PostHogSurveyQuestionBranchingType.self, forKey: .type)
|
||||
|
||||
switch type {
|
||||
case .nextQuestion:
|
||||
self = .next
|
||||
case .end:
|
||||
self = .end
|
||||
case .responseBased:
|
||||
do {
|
||||
let responseValues = try container.decode(JSON.self, forKey: .responseValues)
|
||||
guard let dict = responseValues.value as? [String: Any] else {
|
||||
throw DecodingError.typeMismatch(
|
||||
[String: Any].self,
|
||||
DecodingError.Context(
|
||||
codingPath: container.codingPath,
|
||||
debugDescription: "Expected responseValues to be a dictionary"
|
||||
)
|
||||
)
|
||||
}
|
||||
self = .responseBased(responseValues: dict)
|
||||
} catch {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .responseValues,
|
||||
in: container,
|
||||
debugDescription: "responseValues is not a valid JSON object"
|
||||
)
|
||||
}
|
||||
case .specificQuestion:
|
||||
self = try .specificQuestion(index: container.decode(Int.self, forKey: .index))
|
||||
case let .unknown(type):
|
||||
self = .unknown(type: type)
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: CodingKey {
|
||||
case type, responseValues, index
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper type for decoding JSON values, which may be nested objects, arrays, strings, numbers, booleans, or nulls.
|
||||
private struct JSON: Decodable {
|
||||
let value: Any
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if container.decodeNil() {
|
||||
value = NSNull()
|
||||
} else if let object = try? container.decode([String: JSON].self) {
|
||||
value = object.mapValues { $0.value }
|
||||
} else if let array = try? container.decode([JSON].self) {
|
||||
value = array.map(\.value)
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let number = try? container.decode(Double.self) {
|
||||
value = NSNumber(value: number)
|
||||
} else if let number = try? container.decode(Int.self) {
|
||||
value = NSNumber(value: number)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "Invalid JSON value"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Pods/PostHog/PostHog/PostHog.h
generated
Normal file
60
Pods/PostHog/PostHog/PostHog.h
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// PostHog.h
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 10.01.23.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
//! Project version number for PostHog.
|
||||
FOUNDATION_EXPORT double PostHogVersionNumber;
|
||||
|
||||
//! Project version string for PostHog.
|
||||
FOUNDATION_EXPORT const unsigned char PostHogVersionString[];
|
||||
|
||||
// In this header, you should import all the public headers of your framework using statements like #import <PostHog/PublicHeader.h>
|
||||
#import <PostHog/ph_backward_references_enc.h>
|
||||
#import <PostHog/ph_bit_reader_utils.h>
|
||||
#import <PostHog/ph_bit_writer_utils.h>
|
||||
#import <PostHog/ph_color_cache_utils.h>
|
||||
#import <PostHog/ph_common_dec.h>
|
||||
#import <PostHog/ph_common_sse2.h>
|
||||
#import <PostHog/ph_common_sse41.h>
|
||||
#import <PostHog/ph_cost_enc.h>
|
||||
#import <PostHog/ph_cpu.h>
|
||||
#import <PostHog/ph_decode.h>
|
||||
#import <PostHog/ph_dsp.h>
|
||||
#import <PostHog/ph_encode.h>
|
||||
#import <PostHog/ph_endian_inl_utils.h>
|
||||
#import <PostHog/ph_filters_utils.h>
|
||||
#import <PostHog/ph_format_constants.h>
|
||||
#import <PostHog/ph_histogram_enc.h>
|
||||
#import <PostHog/ph_huffman_encode_utils.h>
|
||||
#import <PostHog/ph_lossless.h>
|
||||
#import <PostHog/ph_lossless_common.h>
|
||||
#import <PostHog/ph_mux.h>
|
||||
#import <PostHog/ph_muxi.h>
|
||||
#import <PostHog/ph_mux_types.h>
|
||||
#import <PostHog/ph_neon.h>
|
||||
#import <PostHog/ph_palette.h>
|
||||
#import <PostHog/ph_quant.h>
|
||||
#import <PostHog/ph_quant_levels_utils.h>
|
||||
#import <PostHog/ph_random_utils.h>
|
||||
#import <PostHog/ph_rescaler_utils.h>
|
||||
#import <PostHog/ph_sharpyuv.h>
|
||||
#import <PostHog/ph_sharpyuv_cpu.h>
|
||||
#import <PostHog/ph_sharpyuv_csp.h>
|
||||
#import <PostHog/ph_sharpyuv_dsp.h>
|
||||
#import <PostHog/ph_sharpyuv_gamma.h>
|
||||
#import <PostHog/ph_thread_utils.h>
|
||||
#import <PostHog/ph_types.h>
|
||||
#import <PostHog/ph_utils.h>
|
||||
#import <PostHog/ph_vp8i_enc.h>
|
||||
#import <PostHog/ph_vp8li_enc.h>
|
||||
#import <PostHog/ph_vp8_dec.h>
|
||||
#import <PostHog/ph_vp8i_dec.h>
|
||||
#import <PostHog/ph_vp8li_dec.h>
|
||||
#import <PostHog/ph_webpi_dec.h>
|
||||
#import <PostHog/ph_huffman_utils.h>
|
||||
#import <PostHog/ph_yuv.h>
|
||||
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
Normal file
337
Pods/PostHog/PostHog/PostHogApi.swift
generated
Normal file
@@ -0,0 +1,337 @@
|
||||
//
|
||||
// PostHogApi.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 06.02.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PostHogApi {
|
||||
private let config: PostHogConfig
|
||||
|
||||
// default is 60s but we do 10s
|
||||
private let defaultTimeout: TimeInterval = 10
|
||||
|
||||
init(_ config: PostHogConfig) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
func sessionConfig() -> URLSessionConfiguration {
|
||||
let config = URLSessionConfiguration.default
|
||||
|
||||
config.httpAdditionalHeaders = [
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"User-Agent": "\(postHogSdkName)/\(postHogVersion)",
|
||||
]
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
private func getURLRequest(_ url: URL) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = defaultTimeout
|
||||
return request
|
||||
}
|
||||
|
||||
private func getEndpointURL(
|
||||
_ endpoint: String,
|
||||
queryItems: URLQueryItem...,
|
||||
relativeTo baseUrl: URL
|
||||
) -> URL? {
|
||||
guard var components = URLComponents(
|
||||
url: baseUrl,
|
||||
resolvingAgainstBaseURL: true
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
let path = "\(components.path)/\(endpoint)"
|
||||
.replacingOccurrences(of: "/+", with: "/", options: .regularExpression)
|
||||
components.path = path
|
||||
components.queryItems = queryItems
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func getRemoteConfigRequest() -> URLRequest? {
|
||||
guard let baseUrl: URL = switch config.host.absoluteString {
|
||||
case "https://us.i.posthog.com":
|
||||
URL(string: "https://us-assets.i.posthog.com")
|
||||
case "https://eu.i.posthog.com":
|
||||
URL(string: "https://eu-assets.i.posthog.com")
|
||||
default:
|
||||
config.host
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let url = baseUrl.appendingPathComponent("/array/\(config.apiKey)/config")
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = defaultTimeout
|
||||
return request
|
||||
}
|
||||
|
||||
func batch(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
|
||||
guard let url = getEndpointURL("/batch", relativeTo: config.host) else {
|
||||
hedgeLog("Malformed batch URL error.")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
|
||||
}
|
||||
|
||||
let config = sessionConfig()
|
||||
var headers = config.httpAdditionalHeaders ?? [:]
|
||||
headers["Accept-Encoding"] = "gzip"
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
config.httpAdditionalHeaders = headers
|
||||
|
||||
let request = getURLRequest(url)
|
||||
|
||||
let toSend: [String: Any] = [
|
||||
"api_key": self.config.apiKey,
|
||||
"batch": events.map { $0.toJSON() },
|
||||
"sent_at": toISO8601String(Date()),
|
||||
]
|
||||
|
||||
var data: Data?
|
||||
|
||||
do {
|
||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
||||
} catch {
|
||||
hedgeLog("Error parsing the batch body: \(error)")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
var gzippedPayload: Data?
|
||||
do {
|
||||
gzippedPayload = try data!.gzipped()
|
||||
} catch {
|
||||
hedgeLog("Error gzipping the batch body: \(error).")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
|
||||
if error != nil {
|
||||
hedgeLog("Error calling the batch API: \(String(describing: error)).")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
||||
let errorMessage = "Error sending events to batch API: status: \(jsonBody)."
|
||||
hedgeLog(errorMessage)
|
||||
} else {
|
||||
hedgeLog("Events sent successfully.")
|
||||
}
|
||||
|
||||
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func snapshot(events: [PostHogEvent], completion: @escaping (PostHogBatchUploadInfo) -> Void) {
|
||||
guard let url = getEndpointURL(config.snapshotEndpoint, relativeTo: config.host) else {
|
||||
hedgeLog("Malformed snapshot URL error.")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: nil))
|
||||
}
|
||||
|
||||
for event in events {
|
||||
event.apiKey = self.config.apiKey
|
||||
}
|
||||
|
||||
let config = sessionConfig()
|
||||
var headers = config.httpAdditionalHeaders ?? [:]
|
||||
headers["Accept-Encoding"] = "gzip"
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
config.httpAdditionalHeaders = headers
|
||||
|
||||
let request = getURLRequest(url)
|
||||
|
||||
let toSend = events.map { $0.toJSON() }
|
||||
|
||||
var data: Data?
|
||||
|
||||
do {
|
||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
||||
// remove it only for debugging
|
||||
// if let newData = data {
|
||||
// let convertedString = String(data: newData, encoding: .utf8)
|
||||
// hedgeLog("snapshot body: \(convertedString ?? "")")
|
||||
// }
|
||||
} catch {
|
||||
hedgeLog("Error parsing the snapshot body: \(error)")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
var gzippedPayload: Data?
|
||||
do {
|
||||
gzippedPayload = try data!.gzipped()
|
||||
} catch {
|
||||
hedgeLog("Error gzipping the snapshot body: \(error).")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
URLSession(configuration: config).uploadTask(with: request, from: gzippedPayload!) { data, response, error in
|
||||
if error != nil {
|
||||
hedgeLog("Error calling the snapshot API: \(String(describing: error)).")
|
||||
return completion(PostHogBatchUploadInfo(statusCode: nil, error: error))
|
||||
}
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
||||
let errorMessage = "Error sending events to snapshot API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
||||
hedgeLog(errorMessage)
|
||||
} else {
|
||||
hedgeLog("Snapshots sent successfully.")
|
||||
}
|
||||
|
||||
return completion(PostHogBatchUploadInfo(statusCode: httpResponse.statusCode, error: error))
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func flags(
|
||||
distinctId: String,
|
||||
anonymousId: String?,
|
||||
groups: [String: String],
|
||||
personProperties: [String: Any],
|
||||
groupProperties: [String: [String: Any]]? = nil,
|
||||
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
|
||||
) {
|
||||
let url = getEndpointURL(
|
||||
"/flags",
|
||||
queryItems: URLQueryItem(name: "v", value: "2"), URLQueryItem(name: "config", value: "true"),
|
||||
relativeTo: config.host
|
||||
)
|
||||
|
||||
guard let url else {
|
||||
hedgeLog("Malformed flags URL error.")
|
||||
return completion(nil, nil)
|
||||
}
|
||||
|
||||
let config = sessionConfig()
|
||||
|
||||
let request = getURLRequest(url)
|
||||
|
||||
var toSend: [String: Any] = [
|
||||
"api_key": self.config.apiKey,
|
||||
"distinct_id": distinctId,
|
||||
"$groups": groups,
|
||||
]
|
||||
|
||||
if let anonymousId {
|
||||
toSend["$anon_distinct_id"] = anonymousId
|
||||
}
|
||||
|
||||
if !personProperties.isEmpty {
|
||||
toSend["person_properties"] = personProperties
|
||||
}
|
||||
|
||||
if let groupProperties, !groupProperties.isEmpty {
|
||||
toSend["group_properties"] = groupProperties
|
||||
}
|
||||
|
||||
if let evaluationEnvironments = self.config.evaluationEnvironments, !evaluationEnvironments.isEmpty {
|
||||
toSend["evaluation_environments"] = evaluationEnvironments
|
||||
}
|
||||
|
||||
var data: Data?
|
||||
|
||||
do {
|
||||
data = try JSONSerialization.data(withJSONObject: toSend)
|
||||
} catch {
|
||||
hedgeLog("Error parsing the flags body: \(error)")
|
||||
return completion(nil, error)
|
||||
}
|
||||
|
||||
URLSession(configuration: config).uploadTask(with: request, from: data!) { data, response, error in
|
||||
if error != nil {
|
||||
hedgeLog("Error calling the flags API: \(String(describing: error))")
|
||||
return completion(nil, error)
|
||||
}
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
||||
let errorMessage = "Error calling flags API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
||||
hedgeLog(errorMessage)
|
||||
|
||||
return completion(nil,
|
||||
InternalPostHogError(description: errorMessage))
|
||||
} else {
|
||||
hedgeLog("Flags called successfully.")
|
||||
}
|
||||
|
||||
do {
|
||||
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
|
||||
completion(jsonData, nil)
|
||||
} catch {
|
||||
hedgeLog("Error parsing the flags response: \(error)")
|
||||
completion(nil, error)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func remoteConfig(
|
||||
completion: @escaping ([String: Any]?, _ error: Error?) -> Void
|
||||
) {
|
||||
guard let request = getRemoteConfigRequest() else {
|
||||
hedgeLog("Error calling the remote config API: unable to create request")
|
||||
return
|
||||
}
|
||||
|
||||
let config = sessionConfig()
|
||||
|
||||
let task = URLSession(configuration: config).dataTask(with: request) { data, response, error in
|
||||
if let error {
|
||||
hedgeLog("Error calling the remote config API: \(error.localizedDescription)")
|
||||
return completion(nil, error)
|
||||
}
|
||||
|
||||
let httpResponse = response as! HTTPURLResponse
|
||||
|
||||
if !(200 ... 299 ~= httpResponse.statusCode) {
|
||||
let jsonBody = String(describing: try? JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any])
|
||||
let errorMessage = "Error calling the remote config API: status: \(httpResponse.statusCode), body: \(jsonBody)."
|
||||
hedgeLog(errorMessage)
|
||||
|
||||
return completion(nil,
|
||||
InternalPostHogError(description: errorMessage))
|
||||
} else {
|
||||
hedgeLog("Remote config called successfully.")
|
||||
}
|
||||
|
||||
do {
|
||||
let jsonData = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as? [String: Any]
|
||||
completion(jsonData, nil)
|
||||
} catch {
|
||||
hedgeLog("Error parsing the remote config response: \(error)")
|
||||
completion(nil, error)
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogApi {
|
||||
static var jsonDecoder: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let dateString = try container.decode(String.self)
|
||||
guard let date = apiDateFormatter.date(from: dateString) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "Invalid date format"
|
||||
)
|
||||
}
|
||||
return date
|
||||
}
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
Normal file
13
Pods/PostHog/PostHog/PostHogBatchUploadInfo.swift
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// PostHogBatchUploadInfo.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PostHogBatchUploadInfo {
|
||||
let statusCode: Int?
|
||||
let error: Error?
|
||||
}
|
||||
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
Normal file
276
Pods/PostHog/PostHog/PostHogConfig.swift
generated
Normal file
@@ -0,0 +1,276 @@
|
||||
//
|
||||
// PostHogConfig.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 07.02.23.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent?
|
||||
|
||||
@objc public final class BoxedBeforeSendBlock: NSObject {
|
||||
@objc public let block: BeforeSendBlock
|
||||
|
||||
@objc(block:)
|
||||
public init(block: @escaping BeforeSendBlock) {
|
||||
self.block = block
|
||||
}
|
||||
}
|
||||
|
||||
@objc(PostHogConfig) public class PostHogConfig: NSObject {
|
||||
enum Defaults {
|
||||
#if os(tvOS)
|
||||
static let flushAt: Int = 5
|
||||
static let maxQueueSize: Int = 100
|
||||
#else
|
||||
static let flushAt: Int = 20
|
||||
static let maxQueueSize: Int = 1000
|
||||
#endif
|
||||
static let maxBatchSize: Int = 50
|
||||
static let flushIntervalSeconds: TimeInterval = 30
|
||||
}
|
||||
|
||||
@objc(PostHogDataMode) public enum PostHogDataMode: Int {
|
||||
case wifi
|
||||
case cellular
|
||||
case any
|
||||
}
|
||||
|
||||
@objc public let host: URL
|
||||
@objc public let apiKey: String
|
||||
@objc public var flushAt: Int = Defaults.flushAt
|
||||
@objc public var maxQueueSize: Int = Defaults.maxQueueSize
|
||||
@objc public var maxBatchSize: Int = Defaults.maxBatchSize
|
||||
@objc public var flushIntervalSeconds: TimeInterval = Defaults.flushIntervalSeconds
|
||||
@objc public var dataMode: PostHogDataMode = .any
|
||||
@objc public var sendFeatureFlagEvent: Bool = true
|
||||
@objc public var preloadFeatureFlags: Bool = true
|
||||
|
||||
/// Preload PostHog remote config automatically
|
||||
/// Default: true
|
||||
///
|
||||
/// Note: Surveys rely on remote config. Disabling this will also disable Surveys
|
||||
@objc public var remoteConfig: Bool = true
|
||||
|
||||
@objc public var captureApplicationLifecycleEvents: Bool = true
|
||||
@objc public var captureScreenViews: Bool = true
|
||||
|
||||
/// Enable method swizzling for SDK functionality that depends on it
|
||||
///
|
||||
/// When disabled, functionality that require swizzling (like autocapture, screen views, session replay, surveys) will not be installed.
|
||||
///
|
||||
/// Note: Disabling swizzling will limit session rotation logic to only detect application open and background events.
|
||||
/// Session rotation will still work, just with reduced granularity for detecting user activity.
|
||||
///
|
||||
/// Default: true
|
||||
@objc public var enableSwizzling: Bool = true
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
/// Enable autocapture for iOS
|
||||
/// Default: false
|
||||
@objc public var captureElementInteractions: Bool = false
|
||||
#endif
|
||||
@objc public var debug: Bool = false
|
||||
@objc public var optOut: Bool = false
|
||||
@objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid }
|
||||
|
||||
/// Flag to reuse the anonymous Id between `reset()` and next `identify()` calls
|
||||
///
|
||||
/// If enabled, the anonymous Id will be reused for all anonymous users on this device,
|
||||
/// essentially creating a "Guest user Id" as long as this option is enabled.
|
||||
///
|
||||
/// Note:
|
||||
/// Events captured *before* call to *identify()* won't be linked to the identified user
|
||||
/// Events captured *after* call to *reset()* won't be linked to the identified user
|
||||
///
|
||||
/// Defaults to false.
|
||||
@objc public var reuseAnonymousId: Bool = false
|
||||
|
||||
/// Hook that allows to sanitize the event properties
|
||||
/// The hook is called before the event is cached or sent over the wire
|
||||
@available(*, deprecated, message: "Use beforeSend instead")
|
||||
@objc public var propertiesSanitizer: PostHogPropertiesSanitizer?
|
||||
/// Determines the behavior for processing user profiles.
|
||||
@objc public var personProfiles: PostHogPersonProfiles = .identifiedOnly
|
||||
|
||||
/// Automatically set common device and app properties as person properties for feature flag evaluation.
|
||||
///
|
||||
/// When enabled, the SDK will automatically set the following person properties:
|
||||
/// - $app_version: App version from bundle
|
||||
/// - $app_build: App build number from bundle
|
||||
/// - $os_name: Operating system name (iOS, macOS, etc.)
|
||||
/// - $os_version: Operating system version
|
||||
/// - $device_type: Device type (Mobile, Tablet, Desktop, etc.)
|
||||
/// - $locale: User's current locale
|
||||
///
|
||||
/// This helps ensure feature flags that rely on these properties work correctly
|
||||
/// without waiting for server-side processing of identify() calls.
|
||||
///
|
||||
/// Default: true
|
||||
@objc public var setDefaultPersonProperties: Bool = true
|
||||
|
||||
/// Evaluation environments for feature flags.
|
||||
///
|
||||
/// When configured, only feature flags that have at least one matching evaluation tag
|
||||
/// will be evaluated. Feature flags with no evaluation tags will always be evaluated
|
||||
/// for backward compatibility.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// config.evaluationEnvironments = ["production", "web", "checkout"]
|
||||
/// ```
|
||||
///
|
||||
/// This helps ensure feature flags are only evaluated in the appropriate environments
|
||||
/// for your SDK instance.
|
||||
///
|
||||
/// Default: nil (all flags are evaluated)
|
||||
@objc public var evaluationEnvironments: [String]?
|
||||
|
||||
/// The identifier of the App Group that should be used to store shared analytics data.
|
||||
/// PostHog will try to get the physical location of the App Group’s shared container, otherwise fallback to the default location
|
||||
/// Default: nil
|
||||
@objc public var appGroupIdentifier: String?
|
||||
|
||||
/// Internal
|
||||
/// Do not modify it, this flag is read and updated by the SDK via feature flags
|
||||
@objc public var snapshotEndpoint: String = "/s/"
|
||||
|
||||
/// or EU Host: 'https://eu.i.posthog.com'
|
||||
public static let defaultHost: String = "https://us.i.posthog.com"
|
||||
|
||||
#if os(iOS)
|
||||
/// Enable Recording of Session Replays for iOS
|
||||
/// Default: false
|
||||
@objc public var sessionReplay: Bool = false
|
||||
/// Session Replay configuration
|
||||
@objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init()
|
||||
#endif
|
||||
|
||||
/// Enable mobile surveys
|
||||
///
|
||||
/// Default: true
|
||||
///
|
||||
/// Note: Event triggers will only work with the instance that first enables surveys.
|
||||
/// In case of multiple instances, please make sure you are capturing events on the instance that has config.surveys = true
|
||||
@available(iOS 15.0, *)
|
||||
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@objc public var surveys: Bool {
|
||||
get { _surveys }
|
||||
set { setSurveys(newValue) }
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
@available(watchOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(macOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(tvOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@available(visionOS, unavailable, message: "Surveys are only available on iOS 15+")
|
||||
@objc public var surveysConfig: PostHogSurveysConfig {
|
||||
get { _surveysConfig }
|
||||
set { setSurveysConfig(newValue) }
|
||||
}
|
||||
|
||||
// only internal
|
||||
var disableReachabilityForTesting: Bool = false
|
||||
var disableQueueTimerForTesting: Bool = false
|
||||
// internal
|
||||
public var storageManager: PostHogStorageManager?
|
||||
|
||||
@objc(apiKey:)
|
||||
public init(
|
||||
apiKey: String
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
host = URL(string: PostHogConfig.defaultHost)!
|
||||
}
|
||||
|
||||
@objc(apiKey:host:)
|
||||
public init(
|
||||
apiKey: String,
|
||||
host: String = defaultHost
|
||||
) {
|
||||
self.apiKey = apiKey
|
||||
self.host = URL(string: host) ?? URL(string: PostHogConfig.defaultHost)!
|
||||
}
|
||||
|
||||
/// Returns an array of integrations to be installed based on current configuration
|
||||
func getIntegrations() -> [PostHogIntegration] {
|
||||
var integrations: [PostHogIntegration] = []
|
||||
|
||||
if captureScreenViews {
|
||||
integrations.append(PostHogScreenViewIntegration())
|
||||
}
|
||||
|
||||
if captureApplicationLifecycleEvents {
|
||||
integrations.append(PostHogAppLifeCycleIntegration())
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if sessionReplay {
|
||||
integrations.append(PostHogReplayIntegration())
|
||||
}
|
||||
|
||||
if _surveys {
|
||||
integrations.append(PostHogSurveyIntegration())
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#if os(iOS) || targetEnvironment(macCatalyst)
|
||||
if captureElementInteractions {
|
||||
integrations.append(PostHogAutocaptureIntegration())
|
||||
}
|
||||
#endif
|
||||
|
||||
return integrations
|
||||
}
|
||||
|
||||
var _surveys: Bool = true // swiftlint:disable:this identifier_name
|
||||
private func setSurveys(_ value: Bool) {
|
||||
// protection against objc API availability warning instead of error
|
||||
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
|
||||
if #available(iOS 15.0, *) {
|
||||
_surveys = value
|
||||
}
|
||||
}
|
||||
|
||||
var _surveysConfig: PostHogSurveysConfig = .init() // swiftlint:disable:this identifier_name
|
||||
private func setSurveysConfig(_ value: PostHogSurveysConfig) {
|
||||
// protection against objc API availability warning instead of error
|
||||
// Unlike swift, which enforces stricter safety rules, objc just displays a warning
|
||||
if #available(iOS 15.0, *) {
|
||||
_surveysConfig = value
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook that allows to sanitize the event
|
||||
/// The hook is called before the event is cached or sent over the wire
|
||||
private var beforeSend: BeforeSendBlock = { $0 }
|
||||
|
||||
private static func buildBeforeSendBlock(_ blocks: [BeforeSendBlock]) -> BeforeSendBlock {
|
||||
{ event in
|
||||
blocks.reduce(event) { event, block in
|
||||
event.flatMap(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func setBeforeSend(_ blocks: [BeforeSendBlock]) {
|
||||
beforeSend = Self.buildBeforeSendBlock(blocks)
|
||||
}
|
||||
|
||||
public func setBeforeSend(_ blocks: BeforeSendBlock...) {
|
||||
setBeforeSend(blocks)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead")
|
||||
@objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) {
|
||||
setBeforeSend(blocks.map(\.block))
|
||||
}
|
||||
|
||||
func runBeforeSend(_ event: PostHogEvent) -> PostHogEvent? {
|
||||
beforeSend(event)
|
||||
}
|
||||
}
|
||||
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
Normal file
13
Pods/PostHog/PostHog/PostHogConsumerPayload.swift
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// PostHogConsumerPayload.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PostHogConsumerPayload {
|
||||
let events: [PostHogEvent]
|
||||
let completion: (Bool) -> Void
|
||||
}
|
||||
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
Normal file
411
Pods/PostHog/PostHog/PostHogContext.swift
generated
Normal file
@@ -0,0 +1,411 @@
|
||||
//
|
||||
// PostHogContext.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 16.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#elseif os(watchOS)
|
||||
import WatchKit
|
||||
#endif
|
||||
|
||||
class PostHogContext {
|
||||
@ReadWriteLock
|
||||
private var screenSize: CGSize?
|
||||
|
||||
#if !os(watchOS)
|
||||
private let reachability: Reachability?
|
||||
#endif
|
||||
|
||||
private lazy var theStaticContext: [String: Any] = {
|
||||
// Properties that do not change over the lifecycle of an application
|
||||
var properties: [String: Any] = [:]
|
||||
|
||||
let infoDictionary = Bundle.main.infoDictionary
|
||||
|
||||
if let appName = infoDictionary?[kCFBundleNameKey as String] {
|
||||
properties["$app_name"] = appName
|
||||
} else if let appName = infoDictionary?["CFBundleDisplayName"] {
|
||||
properties["$app_name"] = appName
|
||||
}
|
||||
if let appVersion = infoDictionary?["CFBundleShortVersionString"] {
|
||||
properties["$app_version"] = appVersion
|
||||
}
|
||||
if let appBuild = infoDictionary?["CFBundleVersion"] {
|
||||
properties["$app_build"] = appBuild
|
||||
}
|
||||
|
||||
if Bundle.main.bundleIdentifier != nil {
|
||||
properties["$app_namespace"] = Bundle.main.bundleIdentifier
|
||||
}
|
||||
properties["$device_manufacturer"] = "Apple"
|
||||
properties["$device_model"] = platform()
|
||||
|
||||
if let deviceType = PostHogContext.deviceType {
|
||||
properties["$device_type"] = deviceType
|
||||
}
|
||||
|
||||
properties["$is_emulator"] = PostHogContext.isSimulator
|
||||
|
||||
let isIOSAppOnMac = PostHogContext.isIOSAppOnMac
|
||||
let isMacCatalystApp = PostHogContext.isMacCatalystApp
|
||||
|
||||
properties["$is_ios_running_on_mac"] = isIOSAppOnMac
|
||||
properties["$is_mac_catalyst_app"] = isMacCatalystApp
|
||||
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
let device = UIDevice.current
|
||||
// use https://github.com/devicekit/DeviceKit
|
||||
let processInfo = ProcessInfo.processInfo
|
||||
|
||||
if isMacCatalystApp || isIOSAppOnMac {
|
||||
let underlyingOS = device.systemName
|
||||
let underlyingOSVersion = device.systemVersion
|
||||
let macOSVersion = processInfo.operatingSystemVersionString
|
||||
|
||||
if isMacCatalystApp {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
} else {
|
||||
let osVersionString = processInfo.operatingSystemVersionString
|
||||
if let versionRange = osVersionString.range(of: #"\d+\.\d+\.\d+"#, options: .regularExpression) {
|
||||
properties["$os_version"] = osVersionString[versionRange]
|
||||
} else {
|
||||
// fallback to full version string in case formatting changes
|
||||
properties["$os_version"] = osVersionString
|
||||
}
|
||||
}
|
||||
// device.userInterfaceIdiom reports .pad here, so we use a static value instead
|
||||
// - For an app deployable on iPad, the idiom type is always .pad (instead of .mac)
|
||||
//
|
||||
// Source: https://developer.apple.com/documentation/apple-silicon/adapting-ios-code-to-run-in-the-macos-environment#Handle-unknown-device-types-gracefully
|
||||
properties["$os_name"] = "macOS"
|
||||
properties["$device_name"] = processInfo.hostName
|
||||
} else {
|
||||
// use https://github.com/devicekit/DeviceKit
|
||||
properties["$os_name"] = device.systemName
|
||||
properties["$os_version"] = device.systemVersion
|
||||
properties["$device_name"] = device.model
|
||||
}
|
||||
#elseif os(macOS)
|
||||
let deviceName = Host.current().localizedName
|
||||
if (deviceName?.isEmpty) != nil {
|
||||
properties["$device_name"] = deviceName
|
||||
}
|
||||
let processInfo = ProcessInfo.processInfo
|
||||
properties["$os_name"] = "macOS"
|
||||
let osVersion = processInfo.operatingSystemVersion
|
||||
properties["$os_version"] = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
#endif
|
||||
|
||||
return properties
|
||||
}()
|
||||
|
||||
#if !os(watchOS)
|
||||
init(_ reachability: Reachability?) {
|
||||
self.reachability = reachability
|
||||
registerNotifications()
|
||||
}
|
||||
#else
|
||||
init() {
|
||||
if #available(watchOS 7.0, *) {
|
||||
registerNotifications()
|
||||
} else {
|
||||
onShouldUpdateScreenSize()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
#if !os(watchOS)
|
||||
unregisterNotifications()
|
||||
#else
|
||||
if #available(watchOS 7.0, *) {
|
||||
unregisterNotifications()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private lazy var theSdkInfo: [String: Any] = {
|
||||
var sdkInfo: [String: Any] = [:]
|
||||
sdkInfo["$lib"] = postHogSdkName
|
||||
sdkInfo["$lib_version"] = postHogVersion
|
||||
return sdkInfo
|
||||
}()
|
||||
|
||||
func staticContext() -> [String: Any] {
|
||||
theStaticContext
|
||||
}
|
||||
|
||||
func sdkInfo() -> [String: Any] {
|
||||
theSdkInfo
|
||||
}
|
||||
|
||||
private func platform() -> String {
|
||||
var sysctlName = "hw.machine"
|
||||
|
||||
// In case of mac catalyst or iOS running on mac:
|
||||
// - "hw.machine" returns underlying iPad/iPhone model
|
||||
// - "hw.model" returns mac model
|
||||
#if targetEnvironment(macCatalyst)
|
||||
sysctlName = "hw.model"
|
||||
#elseif os(iOS) || os(visionOS)
|
||||
if #available(iOS 14.0, *) {
|
||||
if ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
sysctlName = "hw.model"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var size = 0
|
||||
sysctlbyname(sysctlName, nil, &size, nil, 0)
|
||||
var machine = [CChar](repeating: 0, count: size)
|
||||
sysctlbyname(sysctlName, &machine, &size, nil, 0)
|
||||
return String(cString: machine)
|
||||
}
|
||||
|
||||
func dynamicContext() -> [String: Any] {
|
||||
var properties: [String: Any] = [:]
|
||||
|
||||
if let screenSize {
|
||||
properties["$screen_width"] = Float(screenSize.width)
|
||||
properties["$screen_height"] = Float(screenSize.height)
|
||||
}
|
||||
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
if let languageCode = Locale.current.language.languageCode {
|
||||
properties["$locale"] = languageCode.identifier
|
||||
}
|
||||
} else {
|
||||
if Locale.current.languageCode != nil {
|
||||
properties["$locale"] = Locale.current.languageCode
|
||||
}
|
||||
}
|
||||
properties["$timezone"] = TimeZone.current.identifier
|
||||
|
||||
#if !os(watchOS)
|
||||
if reachability != nil {
|
||||
properties["$network_wifi"] = reachability?.connection == .wifi
|
||||
properties["$network_cellular"] = reachability?.connection == .cellular
|
||||
}
|
||||
#endif
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/// Returns person properties context by extracting relevant properties from static context.
|
||||
/// This centralizes the logic for determining which properties should be used as person properties.
|
||||
func personPropertiesContext() -> [String: Any] {
|
||||
let staticCtx = staticContext()
|
||||
var personProperties: [String: Any] = [:]
|
||||
|
||||
// App information
|
||||
if let appVersion = staticCtx["$app_version"] {
|
||||
personProperties["$app_version"] = appVersion
|
||||
}
|
||||
if let appBuild = staticCtx["$app_build"] {
|
||||
personProperties["$app_build"] = appBuild
|
||||
}
|
||||
|
||||
// Operating system information
|
||||
if let osName = staticCtx["$os_name"] {
|
||||
personProperties["$os_name"] = osName
|
||||
}
|
||||
if let osVersion = staticCtx["$os_version"] {
|
||||
personProperties["$os_version"] = osVersion
|
||||
}
|
||||
|
||||
// Device information
|
||||
if let deviceType = staticCtx["$device_type"] {
|
||||
personProperties["$device_type"] = deviceType
|
||||
}
|
||||
if let deviceManufacturer = staticCtx["$device_manufacturer"] {
|
||||
personProperties["$device_manufacturer"] = deviceManufacturer
|
||||
}
|
||||
if let deviceModel = staticCtx["$device_model"] {
|
||||
personProperties["$device_model"] = deviceModel
|
||||
}
|
||||
|
||||
// Localization - read directly to avoid expensive dynamicContext call
|
||||
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
|
||||
if let languageCode = Locale.current.language.languageCode {
|
||||
personProperties["$locale"] = languageCode.identifier
|
||||
}
|
||||
} else {
|
||||
if let languageCode = Locale.current.languageCode {
|
||||
personProperties["$locale"] = languageCode
|
||||
}
|
||||
}
|
||||
|
||||
return personProperties
|
||||
}
|
||||
|
||||
private func registerNotifications() {
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onOrientationDidChange),
|
||||
name: UIDevice.orientationDidChangeNotification,
|
||||
object: nil)
|
||||
#endif
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onShouldUpdateScreenSize),
|
||||
name: UIWindow.didBecomeKeyNotification,
|
||||
object: nil)
|
||||
#elseif os(macOS)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onShouldUpdateScreenSize),
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onShouldUpdateScreenSize),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onShouldUpdateScreenSize),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
#elseif os(watchOS)
|
||||
if #available(watchOS 7.0, *) {
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(onShouldUpdateScreenSize),
|
||||
name: WKApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func unregisterNotifications() {
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
#if os(iOS)
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: UIDevice.orientationDidChangeNotification,
|
||||
object: nil)
|
||||
#endif
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: UIWindow.didBecomeKeyNotification,
|
||||
object: nil)
|
||||
|
||||
#elseif os(macOS)
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil)
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
#elseif os(watchOS)
|
||||
if #available(watchOS 7.0, *) {
|
||||
NotificationCenter.default.removeObserver(self,
|
||||
name: WKApplication.didBecomeActiveNotification,
|
||||
object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Retrieves the current screen size of the application window based on platform
|
||||
private func getScreenSize() -> CGSize? {
|
||||
#if os(iOS) || os(tvOS) || os(visionOS)
|
||||
return UIApplication.getCurrentWindow(filterForegrounded: false)?.bounds.size
|
||||
#elseif os(macOS)
|
||||
// NSScreen.frame represents the full screen rectangle and includes any space occupied by menu, dock or camera bezel
|
||||
return NSApplication.shared.windows.first { $0.isKeyWindow }?.screen?.frame.size
|
||||
#elseif os(watchOS)
|
||||
return WKInterfaceDevice.current().screenBounds.size
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Special treatment for `orientationDidChangeNotification` since the notification seems to be _sometimes_ called early, before screen bounds are flipped
|
||||
@objc private func onOrientationDidChange() {
|
||||
updateScreenSize {
|
||||
self.getScreenSize().map { size in
|
||||
// manually set width and height based on device orientation. (Needed for fast orientation changes)
|
||||
if UIDevice.current.orientation.isLandscape {
|
||||
CGSize(width: max(size.width, size.height), height: min(size.height, size.width))
|
||||
} else {
|
||||
CGSize(width: min(size.width, size.height), height: max(size.height, size.width))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc private func onShouldUpdateScreenSize() {
|
||||
updateScreenSize(getScreenSize)
|
||||
}
|
||||
|
||||
private func updateScreenSize(_ getSize: @escaping () -> CGSize?) {
|
||||
let block = {
|
||||
self.screenSize = getSize()
|
||||
}
|
||||
// ensure block is executed on `main` since closure accesses non thread-safe UI objects like UIApplication
|
||||
if Thread.isMainThread {
|
||||
block()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
|
||||
static let deviceType: String? = {
|
||||
#if os(iOS) || os(tvOS)
|
||||
if isMacCatalystApp || isIOSAppOnMac {
|
||||
return "Desktop"
|
||||
} else {
|
||||
switch UIDevice.current.userInterfaceIdiom {
|
||||
case UIUserInterfaceIdiom.phone:
|
||||
return "Mobile"
|
||||
case UIUserInterfaceIdiom.pad:
|
||||
return "Tablet"
|
||||
case UIUserInterfaceIdiom.tv:
|
||||
return "TV"
|
||||
case UIUserInterfaceIdiom.carPlay:
|
||||
return "CarPlay"
|
||||
case UIUserInterfaceIdiom.mac:
|
||||
return "Desktop"
|
||||
case UIUserInterfaceIdiom.vision:
|
||||
return "Vision"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return "Desktop"
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}()
|
||||
|
||||
static let isIOSAppOnMac: Bool = {
|
||||
if #available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) {
|
||||
return ProcessInfo.processInfo.isiOSAppOnMac
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
static let isMacCatalystApp: Bool = {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}()
|
||||
|
||||
static let isSimulator: Bool = {
|
||||
#if targetEnvironment(simulator)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
Normal file
20
Pods/PostHog/PostHog/PostHogExtensions.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// PostHogExtensions.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
# Notifications
|
||||
|
||||
This helper module encapsulates all notifications that we trigger from within the SDK.
|
||||
|
||||
*/
|
||||
|
||||
public extension PostHogSDK {
|
||||
@objc static let didStartNotification = Notification.Name("PostHogDidStart") // object: nil
|
||||
@objc static let didReceiveFeatureFlags = Notification.Name("PostHogDidReceiveFeatureFlags") // object: nil
|
||||
}
|
||||
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
Normal file
114
Pods/PostHog/PostHog/PostHogFileBackedQueue.swift
generated
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// PostHogFileBackedQueue.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PostHogFileBackedQueue {
|
||||
let queue: URL
|
||||
@ReadWriteLock
|
||||
private var items = [String]()
|
||||
|
||||
var depth: Int {
|
||||
items.count
|
||||
}
|
||||
|
||||
init(queue: URL, oldQueue: URL? = nil) {
|
||||
self.queue = queue
|
||||
setup(oldQueue: oldQueue)
|
||||
}
|
||||
|
||||
private func setup(oldQueue: URL?) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(atPath: queue.path, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
hedgeLog("Error trying to create caching folder \(error)")
|
||||
}
|
||||
|
||||
if oldQueue != nil {
|
||||
migrateOldQueue(queue: queue, oldQueue: oldQueue!)
|
||||
}
|
||||
|
||||
do {
|
||||
items = try FileManager.default.contentsOfDirectory(atPath: queue.path)
|
||||
items.sort { Double($0)! < Double($1)! }
|
||||
} catch {
|
||||
hedgeLog("Failed to load files for queue \(error)")
|
||||
// failed to read directory – bad permissions, perhaps?
|
||||
}
|
||||
}
|
||||
|
||||
func peek(_ count: Int) -> [Data] {
|
||||
loadFiles(count)
|
||||
}
|
||||
|
||||
func delete(index: Int) {
|
||||
if items.isEmpty { return }
|
||||
let removed = items.remove(at: index)
|
||||
|
||||
deleteSafely(queue.appendingPathComponent(removed))
|
||||
}
|
||||
|
||||
func pop(_ count: Int) {
|
||||
deleteFiles(count)
|
||||
}
|
||||
|
||||
func add(_ contents: Data) {
|
||||
do {
|
||||
let filename = "\(Date().timeIntervalSince1970)"
|
||||
try contents.write(to: queue.appendingPathComponent(filename))
|
||||
items.append(filename)
|
||||
} catch {
|
||||
hedgeLog("Could not write file \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal, used for testing
|
||||
func clear() {
|
||||
deleteSafely(queue)
|
||||
setup(oldQueue: nil)
|
||||
}
|
||||
|
||||
private func loadFiles(_ count: Int) -> [Data] {
|
||||
var results = [Data]()
|
||||
|
||||
for item in items {
|
||||
let itemURL = queue.appendingPathComponent(item)
|
||||
do {
|
||||
if !FileManager.default.fileExists(atPath: itemURL.path) {
|
||||
hedgeLog("File \(itemURL) does not exist")
|
||||
continue
|
||||
}
|
||||
let contents = try Data(contentsOf: itemURL)
|
||||
|
||||
results.append(contents)
|
||||
} catch {
|
||||
hedgeLog("File \(itemURL) is corrupted \(error)")
|
||||
|
||||
deleteSafely(itemURL)
|
||||
}
|
||||
|
||||
if results.count == count {
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private func deleteFiles(_ count: Int) {
|
||||
for _ in 0 ..< count {
|
||||
if let removed: String = _items.mutate({ items in
|
||||
if items.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return items.remove(at: 0) // We always remove from the top of the queue
|
||||
}) {
|
||||
deleteSafely(queue.appendingPathComponent(removed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
Normal file
59
Pods/PostHog/PostHog/PostHogIntegration.swift
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// PostHogIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 25/02/2025.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
protocol PostHogIntegration {
|
||||
/**
|
||||
* Indicates whether this integration requires method swizzling to function.
|
||||
*
|
||||
* When `enableSwizzling` is set to `false` in PostHogConfig, integrations
|
||||
* that return `true` for this property will be skipped during installation.
|
||||
*/
|
||||
var requiresSwizzling: Bool { get }
|
||||
|
||||
/**
|
||||
* Installs and initializes the integration with a PostHogSDK instance.
|
||||
*
|
||||
* This method should:
|
||||
* 1. Run checks if needed to ensure that the integration is only installed once
|
||||
* 2. Initialize any required resources
|
||||
* 3. Start the integration's functionality
|
||||
*
|
||||
* - Parameter postHog: The PostHogSDK instance to integrate with
|
||||
* - Throws: InternalPostHogError if installation fails (e.g., already installed)
|
||||
*/
|
||||
func install(_ postHog: PostHogSDK) throws
|
||||
|
||||
/**
|
||||
* Uninstalls the integration from a specific PostHogSDK instance.
|
||||
*
|
||||
* This method should:
|
||||
* 1. Stop all integration functionality
|
||||
* 2. Clean up any resources
|
||||
* 3. Remove references to the PostHog instance
|
||||
*
|
||||
* - Parameter postHog: The PostHog SDK instance to uninstall from
|
||||
*/
|
||||
func uninstall(_ postHog: PostHogSDK)
|
||||
|
||||
/**
|
||||
* Starts the integration's functionality.
|
||||
*
|
||||
* Note: This is typically called automatically during installation
|
||||
* but may be called manually to restart a stopped integration.
|
||||
*/
|
||||
func start()
|
||||
|
||||
/**
|
||||
* Stops the integration's functionality without uninstalling.
|
||||
*
|
||||
* Note: This is typically called automatically during uninstallation
|
||||
* but may be called manually to temporarily suspend the integration
|
||||
* while maintaining its installation status (e.g manual start/stop for session recording)
|
||||
*/
|
||||
func stop()
|
||||
}
|
||||
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
Normal file
45
Pods/PostHog/PostHog/PostHogLegacyQueue.swift
generated
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// PostHogLegacyQueue.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 30.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Migrates the Old Queue (v2) to the new Queue (v3)
|
||||
func migrateOldQueue(queue: URL, oldQueue: URL) {
|
||||
if !FileManager.default.fileExists(atPath: oldQueue.path) {
|
||||
return
|
||||
}
|
||||
|
||||
defer {
|
||||
deleteSafely(oldQueue)
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: oldQueue)
|
||||
let array = try JSONSerialization.jsonObject(with: data) as? [Any]
|
||||
|
||||
if array == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for item in array! {
|
||||
guard let event = item as? [String: Any] else {
|
||||
continue
|
||||
}
|
||||
let timestamp = event["timestamp"] as? String ?? toISO8601String(Date())
|
||||
|
||||
let timestampDate = toISO8601Date(timestamp) ?? Date()
|
||||
|
||||
let filename = "\(timestampDate.timeIntervalSince1970)"
|
||||
|
||||
let contents = try JSONSerialization.data(withJSONObject: event)
|
||||
|
||||
try contents.write(to: queue.appendingPathComponent(filename))
|
||||
}
|
||||
} catch {
|
||||
hedgeLog("Failed to migrate queue \(error)")
|
||||
}
|
||||
}
|
||||
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
Normal file
20
Pods/PostHog/PostHog/PostHogPersonProfiles.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// PostHogPersonProfiles.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 09.09.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Determines the behavior for processing user profiles.
|
||||
/// - `never`: We won't process persons for any event. This means that anonymous users will not be merged once
|
||||
/// they sign up or login, so you lose the ability to create funnels that track users from anonymous to identified.
|
||||
/// All events (including `$identify`) will be sent with `$process_person_profile: False`.
|
||||
/// - `always`: We will process persons data for all events.
|
||||
/// - `identifiedOnly`: (default): we will only process persons when you call `identify`, `alias`, and `group`, Anonymous users won't get person profiles.
|
||||
@objc(PostHogPersonProfiles) public enum PostHogPersonProfiles: Int {
|
||||
case never
|
||||
case always
|
||||
case identifiedOnly
|
||||
}
|
||||
34
Pods/PostHog/PostHog/PostHogPropertiesSanitizer.swift
generated
Normal file
34
Pods/PostHog/PostHog/PostHogPropertiesSanitizer.swift
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// PostHogPropertiesSanitizer.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 06.08.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol to sanitize the event properties
|
||||
@objc(PostHogPropertiesSanitizer) public protocol PostHogPropertiesSanitizer {
|
||||
/// Sanitizes the event properties
|
||||
/// - Parameter properties: the event properties to sanitize
|
||||
/// - Returns: the sanitized properties
|
||||
///
|
||||
/// Obs: `inout` cannot be used in Swift protocols, so you need to clone the properties
|
||||
///
|
||||
/// ```swift
|
||||
/// private class ExampleSanitizer: PostHogPropertiesSanitizer {
|
||||
/// public func sanitize(_ properties: [String: Any]) -> [String: Any] {
|
||||
/// var sanitizedProperties = properties
|
||||
/// // Perform sanitization
|
||||
/// // For example, removing keys with empty values
|
||||
/// for (key, value) in properties {
|
||||
/// if let stringValue = value as? String, stringValue.isEmpty {
|
||||
/// sanitizedProperties.removeValue(forKey: key)
|
||||
/// }
|
||||
/// }
|
||||
/// return sanitizedProperties
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
@objc func sanitize(_ properties: [String: Any]) -> [String: Any]
|
||||
}
|
||||
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
Normal file
285
Pods/PostHog/PostHog/PostHogQueue.swift
generated
Normal file
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// PostHogQueue.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 06.02.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
# Queue
|
||||
|
||||
The queue uses File persistence. This allows us to
|
||||
1. Only send events when we have a network connection
|
||||
2. Ensure that we can survive app closing or offline situations
|
||||
3. Not hold too much in memory
|
||||
|
||||
*/
|
||||
|
||||
class PostHogQueue {
|
||||
enum PostHogApiEndpoint: Int {
|
||||
case batch
|
||||
case snapshot
|
||||
}
|
||||
|
||||
private let config: PostHogConfig
|
||||
private let api: PostHogApi
|
||||
private var paused: Bool = false
|
||||
private let pausedLock = NSLock()
|
||||
private var pausedUntil: Date?
|
||||
private var retryCount: TimeInterval = 0
|
||||
#if !os(watchOS)
|
||||
private let reachability: Reachability?
|
||||
#endif
|
||||
|
||||
private var isFlushing = false
|
||||
private let isFlushingLock = NSLock()
|
||||
private var timer: Timer?
|
||||
private let timerLock = NSLock()
|
||||
private let endpoint: PostHogApiEndpoint
|
||||
private let dispatchQueue: DispatchQueue
|
||||
|
||||
/// Internal, used for testing
|
||||
var depth: Int {
|
||||
fileQueue.depth
|
||||
}
|
||||
|
||||
private let fileQueue: PostHogFileBackedQueue
|
||||
|
||||
#if !os(watchOS)
|
||||
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint, _ reachability: Reachability?) {
|
||||
self.config = config
|
||||
self.api = api
|
||||
self.reachability = reachability
|
||||
self.endpoint = endpoint
|
||||
|
||||
switch endpoint {
|
||||
case .batch:
|
||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
|
||||
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
|
||||
case .snapshot:
|
||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
|
||||
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
#else
|
||||
init(_ config: PostHogConfig, _ storage: PostHogStorage, _ api: PostHogApi, _ endpoint: PostHogApiEndpoint) {
|
||||
self.config = config
|
||||
self.api = api
|
||||
self.endpoint = endpoint
|
||||
|
||||
switch endpoint {
|
||||
case .batch:
|
||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .queue), oldQueue: storage.url(forKey: .oldQeueue))
|
||||
dispatchQueue = DispatchQueue(label: "com.posthog.Queue", target: .global(qos: .utility))
|
||||
case .snapshot:
|
||||
fileQueue = PostHogFileBackedQueue(queue: storage.url(forKey: .replayQeueue))
|
||||
dispatchQueue = DispatchQueue(label: "com.posthog.ReplayQueue", target: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func eventHandler(_ payload: PostHogConsumerPayload) {
|
||||
hedgeLog("Sending batch of \(payload.events.count) events to PostHog")
|
||||
|
||||
switch endpoint {
|
||||
case .batch:
|
||||
api.batch(events: payload.events) { result in
|
||||
self.handleResult(result, payload)
|
||||
}
|
||||
case .snapshot:
|
||||
api.snapshot(events: payload.events) { result in
|
||||
self.handleResult(result, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleResult(_ result: PostHogBatchUploadInfo, _ payload: PostHogConsumerPayload) {
|
||||
// -1 means its not anything related to the API but rather network or something else, so we try again
|
||||
let statusCode = result.statusCode ?? -1
|
||||
|
||||
var shouldRetry = false
|
||||
if 300 ... 399 ~= statusCode || statusCode == -1 {
|
||||
shouldRetry = true
|
||||
}
|
||||
|
||||
// TODO: https://github.com/PostHog/posthog-android/pull/130
|
||||
// fix: reduce batch size if API returns 413
|
||||
|
||||
if shouldRetry {
|
||||
retryCount += 1
|
||||
let delay = min(retryCount * retryDelay, maxRetryDelay)
|
||||
pauseFor(seconds: delay)
|
||||
hedgeLog("Pausing queue consumption for \(delay) seconds due to \(retryCount) API failure(s).")
|
||||
} else {
|
||||
retryCount = 0
|
||||
}
|
||||
|
||||
payload.completion(!shouldRetry)
|
||||
}
|
||||
|
||||
func start(disableReachabilityForTesting: Bool,
|
||||
disableQueueTimerForTesting: Bool)
|
||||
{
|
||||
if !disableReachabilityForTesting {
|
||||
// Setup the monitoring of network status for the queue
|
||||
#if !os(watchOS)
|
||||
reachability?.whenReachable = { reachability in
|
||||
self.pausedLock.withLock {
|
||||
if self.config.dataMode == .wifi, reachability.connection != .wifi {
|
||||
hedgeLog("Queue is paused because its not in WiFi mode")
|
||||
self.paused = true
|
||||
} else {
|
||||
self.paused = false
|
||||
}
|
||||
}
|
||||
|
||||
// Always trigger a flush when we are on wifi
|
||||
if reachability.connection == .wifi {
|
||||
if !self.isFlushing {
|
||||
self.flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reachability?.whenUnreachable = { _ in
|
||||
self.pausedLock.withLock {
|
||||
hedgeLog("Queue is paused because network is unreachable")
|
||||
self.paused = true
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try reachability?.startNotifier()
|
||||
} catch {
|
||||
hedgeLog("Error: Unable to monitor network reachability: \(error)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if !disableQueueTimerForTesting {
|
||||
timerLock.withLock {
|
||||
DispatchQueue.main.async {
|
||||
self.timer = Timer.scheduledTimer(withTimeInterval: self.config.flushIntervalSeconds, repeats: true, block: { _ in
|
||||
if !self.isFlushing {
|
||||
self.flush()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal, used for testing
|
||||
func clear() {
|
||||
fileQueue.clear()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timerLock.withLock {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func flush() {
|
||||
if !canFlush() {
|
||||
return
|
||||
}
|
||||
|
||||
take(config.maxBatchSize) { payload in
|
||||
if !payload.events.isEmpty {
|
||||
self.eventHandler(payload)
|
||||
} else {
|
||||
// there's nothing to be sent
|
||||
payload.completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushIfOverThreshold() {
|
||||
if fileQueue.depth >= config.flushAt {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ event: PostHogEvent) {
|
||||
if fileQueue.depth >= config.maxQueueSize {
|
||||
hedgeLog("Queue is full, dropping oldest event")
|
||||
// first is always oldest
|
||||
fileQueue.delete(index: 0)
|
||||
}
|
||||
|
||||
var data: Data?
|
||||
do {
|
||||
data = try JSONSerialization.data(withJSONObject: event.toJSON())
|
||||
} catch {
|
||||
hedgeLog("Tried to queue unserialisable PostHogEvent \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
fileQueue.add(data!)
|
||||
hedgeLog("Queued event '\(event.event)'. Depth: \(fileQueue.depth)")
|
||||
flushIfOverThreshold()
|
||||
}
|
||||
|
||||
private func take(_ count: Int, completion: @escaping (PostHogConsumerPayload) -> Void) {
|
||||
dispatchQueue.async {
|
||||
self.isFlushingLock.withLock {
|
||||
if self.isFlushing {
|
||||
return
|
||||
}
|
||||
self.isFlushing = true
|
||||
}
|
||||
|
||||
let items = self.fileQueue.peek(count)
|
||||
|
||||
var processing = [PostHogEvent]()
|
||||
|
||||
for item in items {
|
||||
// each element is a PostHogEvent if fromJSON succeeds
|
||||
guard let event = PostHogEvent.fromJSON(item) else {
|
||||
continue
|
||||
}
|
||||
processing.append(event)
|
||||
}
|
||||
|
||||
completion(PostHogConsumerPayload(events: processing) { success in
|
||||
if success, items.count > 0 {
|
||||
self.fileQueue.pop(items.count)
|
||||
hedgeLog("Completed!")
|
||||
}
|
||||
|
||||
self.isFlushingLock.withLock {
|
||||
self.isFlushing = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseFor(seconds: TimeInterval) {
|
||||
pausedUntil = Date().addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
private func canFlush() -> Bool {
|
||||
if isFlushing {
|
||||
hedgeLog("Already flushing")
|
||||
return false
|
||||
}
|
||||
|
||||
if paused {
|
||||
// We don't flush data if the queue is paused
|
||||
hedgeLog("The queue is paused due to the reachability check")
|
||||
return false
|
||||
}
|
||||
|
||||
if pausedUntil != nil, pausedUntil! > Date() {
|
||||
// We don't flush data if the queue is temporarily paused
|
||||
hedgeLog("The queue is paused until `\(pausedUntil!)`")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
Normal file
636
Pods/PostHog/PostHog/PostHogRemoteConfig.swift
generated
Normal file
@@ -0,0 +1,636 @@
|
||||
//
|
||||
// PostHogRemoteConfig.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 10.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PostHogRemoteConfig {
|
||||
private let hasFeatureFlagsKey = "hasFeatureFlags"
|
||||
|
||||
private let config: PostHogConfig
|
||||
private let storage: PostHogStorage
|
||||
private let api: PostHogApi
|
||||
private let getDefaultPersonProperties: () -> [String: Any]
|
||||
|
||||
private let loadingFeatureFlagsLock = NSLock()
|
||||
private let featureFlagsLock = NSLock()
|
||||
private var loadingFeatureFlags = false
|
||||
private var sessionReplayFlagActive = false
|
||||
|
||||
private var flags: [String: Any]?
|
||||
private var featureFlags: [String: Any]?
|
||||
|
||||
private var remoteConfigLock = NSLock()
|
||||
private let loadingRemoteConfigLock = NSLock()
|
||||
private var loadingRemoteConfig = false
|
||||
private var remoteConfig: [String: Any]?
|
||||
private var remoteConfigDidFetch: Bool = false
|
||||
private var featureFlagPayloads: [String: Any]?
|
||||
private var requestId: String?
|
||||
|
||||
private let personPropertiesForFlagsLock = NSLock()
|
||||
private var personPropertiesForFlags: [String: Any] = [:]
|
||||
|
||||
private let groupPropertiesForFlagsLock = NSLock()
|
||||
private var groupPropertiesForFlags: [String: [String: Any]] = [:]
|
||||
|
||||
/// Internal, only used for testing
|
||||
var canReloadFlagsForTesting = true
|
||||
|
||||
var onRemoteConfigLoaded: (([String: Any]?) -> Void)?
|
||||
var onFeatureFlagsLoaded: (([String: Any]?) -> Void)?
|
||||
|
||||
private let dispatchQueue = DispatchQueue(label: "com.posthog.RemoteConfig",
|
||||
target: .global(qos: .utility))
|
||||
|
||||
var lastRequestId: String? {
|
||||
featureFlagsLock.withLock {
|
||||
requestId ?? storage.getString(forKey: .requestId)
|
||||
}
|
||||
}
|
||||
|
||||
init(_ config: PostHogConfig,
|
||||
_ storage: PostHogStorage,
|
||||
_ api: PostHogApi,
|
||||
_ getDefaultPersonProperties: @escaping () -> [String: Any])
|
||||
{
|
||||
self.config = config
|
||||
self.storage = storage
|
||||
self.api = api
|
||||
self.getDefaultPersonProperties = getDefaultPersonProperties
|
||||
|
||||
// Load cached person and group properties for flags
|
||||
loadCachedPropertiesForFlags()
|
||||
|
||||
preloadSessionReplayFlag()
|
||||
|
||||
if config.remoteConfig {
|
||||
preloadRemoteConfig()
|
||||
} else if config.preloadFeatureFlags {
|
||||
preloadFeatureFlags()
|
||||
}
|
||||
}
|
||||
|
||||
private func preloadRemoteConfig() {
|
||||
remoteConfigLock.withLock {
|
||||
// load disk cached config to memory
|
||||
_ = getCachedRemoteConfig()
|
||||
}
|
||||
|
||||
// may have already beed fetched from `loadFeatureFlags` call
|
||||
if remoteConfigLock.withLock({
|
||||
self.remoteConfig == nil || !self.remoteConfigDidFetch
|
||||
}) {
|
||||
dispatchQueue.async {
|
||||
self.reloadRemoteConfig { [weak self] remoteConfig in
|
||||
guard let self else { return }
|
||||
|
||||
// if there's no remote config response, skip
|
||||
guard let remoteConfig else {
|
||||
hedgeLog("Remote config response is missing, skipping loading flags")
|
||||
notifyFeatureFlags(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the server explicitly responded with hasFeatureFlags key
|
||||
if let hasFeatureFlagsBoolValue = remoteConfig[self.hasFeatureFlagsKey] as? Bool, !hasFeatureFlagsBoolValue {
|
||||
hedgeLog("hasFeatureFlags is false, clearing flags and skipping loading flags")
|
||||
// Server responded with explicit hasFeatureFlags: false, meaning no active flags on the account
|
||||
clearFeatureFlags()
|
||||
// need to notify cause people may be waiting for flags to load
|
||||
notifyFeatureFlags([:])
|
||||
} else if self.config.preloadFeatureFlags {
|
||||
// If we reach here, hasFeatureFlags is either true, nil or not a boolean value
|
||||
// Note: notifyFeatureFlags() will be eventually called inside preloadFeatureFlags()
|
||||
self.preloadFeatureFlags()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func preloadFeatureFlags() {
|
||||
featureFlagsLock.withLock {
|
||||
// load disk cached config to memory
|
||||
_ = getCachedFeatureFlags()
|
||||
}
|
||||
|
||||
if config.preloadFeatureFlags {
|
||||
dispatchQueue.async {
|
||||
self.reloadFeatureFlags()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadRemoteConfig(
|
||||
callback: (([String: Any]?) -> Void)? = nil
|
||||
) {
|
||||
guard config.remoteConfig else {
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
loadingRemoteConfigLock.withLock {
|
||||
if self.loadingRemoteConfig {
|
||||
return
|
||||
}
|
||||
self.loadingRemoteConfig = true
|
||||
}
|
||||
|
||||
api.remoteConfig { config, _ in
|
||||
if let config {
|
||||
// cache config
|
||||
self.remoteConfigLock.withLock {
|
||||
self.remoteConfig = config
|
||||
self.storage.setDictionary(forKey: .remoteConfig, contents: config)
|
||||
}
|
||||
|
||||
// process session replay config
|
||||
#if os(iOS)
|
||||
let featureFlags = self.featureFlagsLock.withLock { self.featureFlags }
|
||||
self.processSessionRecordingConfig(config, featureFlags: featureFlags ?? [:])
|
||||
#endif
|
||||
|
||||
// notify
|
||||
DispatchQueue.main.async {
|
||||
self.onRemoteConfigLoaded?(config)
|
||||
}
|
||||
}
|
||||
|
||||
self.loadingRemoteConfigLock.withLock {
|
||||
self.remoteConfigDidFetch = true
|
||||
self.loadingRemoteConfig = false
|
||||
}
|
||||
|
||||
callback?(config)
|
||||
}
|
||||
}
|
||||
|
||||
func reloadFeatureFlags(
|
||||
callback: (([String: Any]?) -> Void)? = nil
|
||||
) {
|
||||
guard canReloadFlagsForTesting else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let storageManager = config.storageManager else {
|
||||
hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags")
|
||||
callback?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let groups = featureFlagsLock.withLock { getGroups() }
|
||||
let distinctId = storageManager.getDistinctId()
|
||||
let anonymousId = config.reuseAnonymousId == false ? storageManager.getAnonymousId() : nil
|
||||
|
||||
loadFeatureFlags(
|
||||
distinctId: distinctId,
|
||||
anonymousId: anonymousId,
|
||||
groups: groups,
|
||||
callback: callback ?? { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
private func preloadSessionReplayFlag() {
|
||||
var sessionReplay: [String: Any]?
|
||||
var featureFlags: [String: Any]?
|
||||
featureFlagsLock.withLock {
|
||||
sessionReplay = self.storage.getDictionary(forKey: .sessionReplay) as? [String: Any]
|
||||
featureFlags = self.getCachedFeatureFlags()
|
||||
}
|
||||
|
||||
if let sessionReplay = sessionReplay {
|
||||
sessionReplayFlagActive = isRecordingActive(featureFlags ?? [:], sessionReplay)
|
||||
|
||||
if let endpoint = sessionReplay["endpoint"] as? String {
|
||||
config.snapshotEndpoint = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isRecordingActive(_ featureFlags: [String: Any], _ sessionRecording: [String: Any]) -> Bool {
|
||||
var recordingActive = true
|
||||
|
||||
// check for boolean flags
|
||||
if let linkedFlag = sessionRecording["linkedFlag"] as? String {
|
||||
let value = featureFlags[linkedFlag]
|
||||
|
||||
if let boolValue = value as? Bool {
|
||||
// boolean flag with value
|
||||
recordingActive = boolValue
|
||||
} else if value is String {
|
||||
// its a multi-variant flag linked to "any"
|
||||
recordingActive = true
|
||||
} else {
|
||||
// disable recording if the flag does not exist/quota limited
|
||||
recordingActive = false
|
||||
}
|
||||
// check for specific flag variant
|
||||
} else if let linkedFlag = sessionRecording["linkedFlag"] as? [String: Any] {
|
||||
let flag = linkedFlag["flag"] as? String
|
||||
let variant = linkedFlag["variant"] as? String
|
||||
|
||||
if let flag, let variant {
|
||||
let value = featureFlags[flag] as? String
|
||||
recordingActive = value == variant
|
||||
} else {
|
||||
// disable recording if the flag does not exist/quota limited
|
||||
recordingActive = false
|
||||
}
|
||||
}
|
||||
// check for multi flag variant (any)
|
||||
// if let linkedFlag = sessionRecording["linkedFlag"] as? String,
|
||||
// featureFlags[linkedFlag] != nil
|
||||
// is also a valid check but since we cannot check the value of the flag,
|
||||
// we consider session recording is active
|
||||
|
||||
return recordingActive
|
||||
}
|
||||
|
||||
func loadFeatureFlags(
|
||||
distinctId: String,
|
||||
anonymousId: String?,
|
||||
groups: [String: String],
|
||||
callback: @escaping ([String: Any]?) -> Void
|
||||
) {
|
||||
loadingFeatureFlagsLock.withLock {
|
||||
if self.loadingFeatureFlags {
|
||||
return
|
||||
}
|
||||
self.loadingFeatureFlags = true
|
||||
}
|
||||
|
||||
let personProperties = getPersonPropertiesForFlags()
|
||||
let groupProperties = getGroupPropertiesForFlags()
|
||||
|
||||
api.flags(distinctId: distinctId,
|
||||
anonymousId: anonymousId,
|
||||
groups: groups,
|
||||
personProperties: personProperties,
|
||||
groupProperties: groupProperties.isEmpty ? nil : groupProperties)
|
||||
{ data, _ in
|
||||
self.dispatchQueue.async {
|
||||
// Check for quota limitation first
|
||||
if let quotaLimited = data?["quotaLimited"] as? [String],
|
||||
quotaLimited.contains("feature_flags")
|
||||
{
|
||||
// swiftlint:disable:next line_length
|
||||
hedgeLog("Warning: Feature flags quota limit reached - clearing all feature flags and payloads. See https://posthog.com/docs/billing/limits-alerts for more information.")
|
||||
|
||||
self.clearFeatureFlags()
|
||||
self.notifyFeatureFlagsAndRelease([:])
|
||||
return callback([:])
|
||||
}
|
||||
|
||||
// Safely handle optional data
|
||||
guard var data = data else {
|
||||
hedgeLog("Error: Flags response data is nil")
|
||||
self.notifyFeatureFlagsAndRelease(nil)
|
||||
return callback(nil)
|
||||
}
|
||||
|
||||
self.normalizeResponse(&data)
|
||||
|
||||
let flagsV4 = data["flags"] as? [String: Any]
|
||||
|
||||
guard let featureFlags = data["featureFlags"] as? [String: Any],
|
||||
let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any]
|
||||
else {
|
||||
hedgeLog("Error: Flags response missing correct featureFlags format")
|
||||
self.notifyFeatureFlagsAndRelease(nil)
|
||||
return callback(nil)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
self.processSessionRecordingConfig(data, featureFlags: featureFlags)
|
||||
#endif
|
||||
|
||||
// Grab the request ID from the response
|
||||
let requestId = data["requestId"] as? String
|
||||
let errorsWhileComputingFlags = data["errorsWhileComputingFlags"] as? Bool ?? false
|
||||
var loadedFeatureFlags: [String: Any]?
|
||||
|
||||
self.featureFlagsLock.withLock {
|
||||
if let requestId {
|
||||
// Store the request ID in the storage.
|
||||
self.setCachedRequestId(requestId)
|
||||
}
|
||||
|
||||
if errorsWhileComputingFlags {
|
||||
// v4 cached flags which contains metadata about each flag.
|
||||
let cachedFlags = self.getCachedFlags() ?? [:]
|
||||
|
||||
// The following two aren't necessarily needed for v4, but we'll keep them for now
|
||||
// for back compatibility for existing v3 users who might already have cached flag data.
|
||||
let cachedFeatureFlags = self.getCachedFeatureFlags() ?? [:]
|
||||
let cachedFeatureFlagsPayloads = self.getCachedFeatureFlagPayload() ?? [:]
|
||||
|
||||
let newFeatureFlags = cachedFeatureFlags.merging(featureFlags) { _, new in new }
|
||||
let newFeatureFlagsPayloads = cachedFeatureFlagsPayloads.merging(featureFlagPayloads) { _, new in new }
|
||||
|
||||
// if not all flags were computed, we upsert flags instead of replacing them
|
||||
loadedFeatureFlags = newFeatureFlags
|
||||
if let flagsV4 {
|
||||
let newFlags = cachedFlags.merging(flagsV4) { _, new in new }
|
||||
// if not all flags were computed, we upsert flags instead of replacing them
|
||||
self.setCachedFlags(newFlags)
|
||||
}
|
||||
self.setCachedFeatureFlags(newFeatureFlags)
|
||||
self.setCachedFeatureFlagPayload(newFeatureFlagsPayloads)
|
||||
self.notifyFeatureFlagsAndRelease(newFeatureFlags)
|
||||
} else {
|
||||
loadedFeatureFlags = featureFlags
|
||||
if let flagsV4 {
|
||||
self.setCachedFlags(flagsV4)
|
||||
}
|
||||
self.setCachedFeatureFlags(featureFlags)
|
||||
self.setCachedFeatureFlagPayload(featureFlagPayloads)
|
||||
self.notifyFeatureFlagsAndRelease(featureFlags)
|
||||
}
|
||||
}
|
||||
|
||||
return callback(loadedFeatureFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func processSessionRecordingConfig(_ data: [String: Any]?, featureFlags: [String: Any]) {
|
||||
if let sessionRecording = data?["sessionRecording"] as? Bool {
|
||||
sessionReplayFlagActive = sessionRecording
|
||||
|
||||
// its always false here anyway
|
||||
if !sessionRecording {
|
||||
storage.remove(key: .sessionReplay)
|
||||
}
|
||||
|
||||
} else if let sessionRecording = data?["sessionRecording"] as? [String: Any] {
|
||||
// keeps the value from config.sessionReplay since having sessionRecording
|
||||
// means its enabled on the project settings, but its only enabled
|
||||
// when local replay integration is enabled/active
|
||||
if let endpoint = sessionRecording["endpoint"] as? String {
|
||||
config.snapshotEndpoint = endpoint
|
||||
}
|
||||
sessionReplayFlagActive = isRecordingActive(featureFlags, sessionRecording)
|
||||
storage.setDictionary(forKey: .sessionReplay, contents: sessionRecording)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func notifyFeatureFlags(_ featureFlags: [String: Any]?) {
|
||||
DispatchQueue.main.async {
|
||||
self.onFeatureFlagsLoaded?(featureFlags)
|
||||
NotificationCenter.default.post(name: PostHogSDK.didReceiveFeatureFlags, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) {
|
||||
notifyFeatureFlags(featureFlags)
|
||||
|
||||
loadingFeatureFlagsLock.withLock {
|
||||
self.loadingFeatureFlags = false
|
||||
}
|
||||
}
|
||||
|
||||
func getFeatureFlags() -> [String: Any]? {
|
||||
featureFlagsLock.withLock { getCachedFeatureFlags() }
|
||||
}
|
||||
|
||||
func getFeatureFlag(_ key: String) -> Any? {
|
||||
var flags: [String: Any]?
|
||||
featureFlagsLock.withLock {
|
||||
flags = self.getCachedFeatureFlags()
|
||||
}
|
||||
|
||||
return flags?[key]
|
||||
}
|
||||
|
||||
func getFeatureFlagDetails(_ key: String) -> Any? {
|
||||
var flags: [String: Any]?
|
||||
featureFlagsLock.withLock {
|
||||
flags = self.getCachedFlags()
|
||||
}
|
||||
|
||||
return flags?[key]
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func getCachedFeatureFlagPayload() -> [String: Any]? {
|
||||
if featureFlagPayloads == nil {
|
||||
featureFlagPayloads = storage.getDictionary(forKey: .enabledFeatureFlagPayloads) as? [String: Any]
|
||||
}
|
||||
return featureFlagPayloads
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func setCachedFeatureFlagPayload(_ featureFlagPayloads: [String: Any]) {
|
||||
self.featureFlagPayloads = featureFlagPayloads
|
||||
storage.setDictionary(forKey: .enabledFeatureFlagPayloads, contents: featureFlagPayloads)
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func getCachedFeatureFlags() -> [String: Any]? {
|
||||
if featureFlags == nil {
|
||||
featureFlags = storage.getDictionary(forKey: .enabledFeatureFlags) as? [String: Any]
|
||||
}
|
||||
return featureFlags
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func setCachedFeatureFlags(_ featureFlags: [String: Any]) {
|
||||
self.featureFlags = featureFlags
|
||||
storage.setDictionary(forKey: .enabledFeatureFlags, contents: featureFlags)
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func setCachedFlags(_ flags: [String: Any]) {
|
||||
self.flags = flags
|
||||
storage.setDictionary(forKey: .flags, contents: flags)
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func getCachedFlags() -> [String: Any]? {
|
||||
if flags == nil {
|
||||
flags = storage.getDictionary(forKey: .flags) as? [String: Any]
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func setPersonPropertiesForFlags(_ properties: [String: Any]) {
|
||||
personPropertiesForFlagsLock.withLock {
|
||||
// Merge properties additively, similar to JS SDK behavior
|
||||
personPropertiesForFlags.merge(properties, uniquingKeysWith: { _, new in new })
|
||||
// Persist to disk
|
||||
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func resetPersonPropertiesForFlags() {
|
||||
personPropertiesForFlagsLock.withLock {
|
||||
personPropertiesForFlags.removeAll()
|
||||
// Clear from disk
|
||||
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func setGroupPropertiesForFlags(_ groupType: String, properties: [String: Any]) {
|
||||
groupPropertiesForFlagsLock.withLock {
|
||||
// Merge properties additively for this group type
|
||||
groupPropertiesForFlags[groupType, default: [:]].merge(properties) { _, new in new }
|
||||
// Persist to disk
|
||||
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func resetGroupPropertiesForFlags(_ groupType: String? = nil) {
|
||||
groupPropertiesForFlagsLock.withLock {
|
||||
if let groupType = groupType {
|
||||
groupPropertiesForFlags.removeValue(forKey: groupType)
|
||||
} else {
|
||||
groupPropertiesForFlags.removeAll()
|
||||
}
|
||||
// Persist changes to disk
|
||||
storage.setDictionary(forKey: .groupPropertiesForFlags, contents: groupPropertiesForFlags)
|
||||
}
|
||||
}
|
||||
|
||||
private func getGroupPropertiesForFlags() -> [String: [String: Any]] {
|
||||
groupPropertiesForFlagsLock.withLock {
|
||||
groupPropertiesForFlags
|
||||
}
|
||||
}
|
||||
|
||||
private func getPersonPropertiesForFlags() -> [String: Any] {
|
||||
personPropertiesForFlagsLock.withLock {
|
||||
var properties = personPropertiesForFlags
|
||||
|
||||
// Always include fresh default properties if enabled
|
||||
if config.setDefaultPersonProperties {
|
||||
let defaultProperties = getDefaultPersonProperties()
|
||||
// User-set properties override default properties
|
||||
properties = defaultProperties.merging(properties) { _, userValue in userValue }
|
||||
}
|
||||
|
||||
return properties
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCachedPropertiesForFlags() {
|
||||
personPropertiesForFlagsLock.withLock {
|
||||
if let cachedPersonProperties = storage.getDictionary(forKey: .personPropertiesForFlags) as? [String: Any] {
|
||||
personPropertiesForFlags = cachedPersonProperties
|
||||
}
|
||||
}
|
||||
|
||||
groupPropertiesForFlagsLock.withLock {
|
||||
if let cachedGroupProperties = storage.getDictionary(forKey: .groupPropertiesForFlags) as? [String: [String: Any]] {
|
||||
groupPropertiesForFlags = cachedGroupProperties
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getFeatureFlagPayload(_ key: String) -> Any? {
|
||||
var flags: [String: Any]?
|
||||
featureFlagsLock.withLock {
|
||||
flags = getCachedFeatureFlagPayload()
|
||||
}
|
||||
|
||||
let value = flags?[key]
|
||||
|
||||
guard let stringValue = value as? String else {
|
||||
return value
|
||||
}
|
||||
|
||||
do {
|
||||
// The payload value is stored as a string and is not pre-parsed...
|
||||
// We need to mimic the JSON.parse of JS which is what posthog-js uses
|
||||
return try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed)
|
||||
} catch {
|
||||
hedgeLog("Error parsing the object \(String(describing: value)): \(error)")
|
||||
}
|
||||
|
||||
// fallback to original value if not possible to serialize
|
||||
return value
|
||||
}
|
||||
|
||||
// To be called after acquiring `featureFlagsLock`
|
||||
private func setCachedRequestId(_ value: String?) {
|
||||
requestId = value
|
||||
if let value {
|
||||
storage.setString(forKey: .requestId, contents: value)
|
||||
} else {
|
||||
storage.remove(key: .requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizeResponse(_ data: inout [String: Any]) {
|
||||
if let flagsV4 = data["flags"] as? [String: Any] {
|
||||
var featureFlags = [String: Any]()
|
||||
var featureFlagsPayloads = [String: Any]()
|
||||
for (key, value) in flagsV4 {
|
||||
if let flag = value as? [String: Any] {
|
||||
if let variant = flag["variant"] as? String {
|
||||
featureFlags[key] = variant
|
||||
// If there's a variant, the flag is enabled, so we can store the payload
|
||||
if let metadata = flag["metadata"] as? [String: Any],
|
||||
let payload = metadata["payload"]
|
||||
{
|
||||
featureFlagsPayloads[key] = payload
|
||||
}
|
||||
} else {
|
||||
let enabled = flag["enabled"] as? Bool
|
||||
featureFlags[key] = enabled
|
||||
|
||||
// Only store payload if the flag is enabled
|
||||
if enabled == true,
|
||||
let metadata = flag["metadata"] as? [String: Any],
|
||||
let payload = metadata["payload"]
|
||||
{
|
||||
featureFlagsPayloads[key] = payload
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data["featureFlags"] = featureFlags
|
||||
data["featureFlagPayloads"] = featureFlagsPayloads
|
||||
}
|
||||
}
|
||||
|
||||
private func clearFeatureFlags() {
|
||||
featureFlagsLock.withLock {
|
||||
setCachedFlags([:])
|
||||
setCachedFeatureFlags([:])
|
||||
setCachedFeatureFlagPayload([:])
|
||||
setCachedRequestId(nil) // requestId no longer valid
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func isSessionReplayFlagActive() -> Bool {
|
||||
sessionReplayFlagActive
|
||||
}
|
||||
#endif
|
||||
|
||||
private func getGroups() -> [String: String] {
|
||||
guard let groups = storage.getDictionary(forKey: .groups) as? [String: String] else {
|
||||
return [:]
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// MARK: Remote Config
|
||||
|
||||
func getRemoteConfig() -> [String: Any]? {
|
||||
remoteConfigLock.withLock { getCachedRemoteConfig() }
|
||||
}
|
||||
|
||||
private func getCachedRemoteConfig() -> [String: Any]? {
|
||||
if remoteConfig == nil {
|
||||
remoteConfig = storage.getDictionary(forKey: .remoteConfig) as? [String: Any]
|
||||
}
|
||||
return remoteConfig
|
||||
}
|
||||
}
|
||||
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
Normal file
1499
Pods/PostHog/PostHog/PostHogSDK.swift
generated
Normal file
File diff suppressed because it is too large
Load Diff
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
Normal file
271
Pods/PostHog/PostHog/PostHogSessionManager.swift
generated
Normal file
@@ -0,0 +1,271 @@
|
||||
//
|
||||
// PostHogSessionManager.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 28.08.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// only for internal use
|
||||
// Do we need to expose this as public API? Could be internal static instead?
|
||||
@objc public class PostHogSessionManager: NSObject {
|
||||
enum SessionIDChangeReason: String {
|
||||
case sessionIdEmpty = "Session id was empty"
|
||||
case sessionStart = "Session started"
|
||||
case sessionEnd = "Session ended"
|
||||
case sessionReset = "Session was reset"
|
||||
case sessionTimeout = "Session timed out"
|
||||
case sessionPastMaximumLength = "Session past maximum length"
|
||||
case customSessionId = "Custom session set"
|
||||
}
|
||||
|
||||
@objc public static var shared: PostHogSessionManager {
|
||||
PostHogSDK.shared.sessionManager
|
||||
}
|
||||
|
||||
private var config: PostHogConfig?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setup(config: PostHogConfig) {
|
||||
self.config = config
|
||||
didBecomeActiveToken = nil
|
||||
didEnterBackgroundToken = nil
|
||||
applicationEventToken = nil
|
||||
registerNotifications()
|
||||
registerApplicationSendEvent()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
resetSession()
|
||||
didBecomeActiveToken = nil
|
||||
didEnterBackgroundToken = nil
|
||||
applicationEventToken = nil
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "com.posthog.PostHogSessionManager", target: .global(qos: .utility))
|
||||
private var sessionId: String?
|
||||
private var sessionStartTimestamp: TimeInterval?
|
||||
private var sessionActivityTimestamp: TimeInterval?
|
||||
private let sessionLock = NSLock()
|
||||
private var isAppInBackground = true
|
||||
// 30 minutes in seconds
|
||||
private let sessionActivityThreshold: TimeInterval = 60 * 30
|
||||
// 24 hours in seconds
|
||||
private let sessionMaxLengthThreshold: TimeInterval = 24 * 60 * 60
|
||||
// Called when session id is cleared or changes
|
||||
var onSessionIdChanged: () -> Void = {}
|
||||
|
||||
@objc public func setSessionId(_ sessionId: String) {
|
||||
setSessionIdInternal(sessionId, at: now(), reason: .customSessionId)
|
||||
}
|
||||
|
||||
private func isNotReactNative() -> Bool {
|
||||
// for the RN SDK, the session is handled by the RN SDK itself
|
||||
postHogSdkName != "posthog-react-native"
|
||||
}
|
||||
|
||||
/**
|
||||
Returns the current session id, and manages id rotation logic
|
||||
|
||||
In addition, this method handles core session cycling logic including:
|
||||
- Creates a new session id when none exists (but only if app is foregrounded)
|
||||
- if `readOnly` is false
|
||||
- Rotates session after *30 minutes* of inactivity
|
||||
- Clears session after *30 minutes* of inactivity (when app is backgrounded)
|
||||
- Enforces a maximum session duration of *24 hours*
|
||||
|
||||
- Parameters:
|
||||
- timeNow: Reference timestamp used for evaluating session expiry rules.
|
||||
Defaults to current system time.
|
||||
- readOnly: When true, bypasses all session management logic and returns
|
||||
the current session id without modifications.
|
||||
Defaults to false.
|
||||
|
||||
- Returns: Returns the existing session id, or a new one after performing validity checks
|
||||
*/
|
||||
func getSessionId(
|
||||
at timeNow: Date = now(),
|
||||
readOnly: Bool = false
|
||||
) -> String? {
|
||||
let timestamp = timeNow.timeIntervalSince1970
|
||||
let (currentSessionId, lastActive, sessionStart, isBackgrounded) = sessionLock.withLock {
|
||||
(sessionId, sessionActivityTimestamp, sessionStartTimestamp, isAppInBackground)
|
||||
}
|
||||
|
||||
// RN manages its own session, just return session id
|
||||
guard isNotReactNative(), !readOnly else {
|
||||
return currentSessionId
|
||||
}
|
||||
|
||||
// Create a new session id if empty
|
||||
if currentSessionId.isNilOrEmpty, !isBackgrounded {
|
||||
return rotateSession(force: true, at: timeNow, reason: .sessionIdEmpty)
|
||||
}
|
||||
|
||||
// Check if session has passed maximum inactivity length
|
||||
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
|
||||
return isBackgrounded
|
||||
? clearSession(reason: .sessionTimeout)
|
||||
: rotateSession(at: timeNow, reason: .sessionTimeout)
|
||||
}
|
||||
|
||||
// Check if session has passed maximum session length
|
||||
if let sessionStart, isExpired(timestamp, sessionStart, sessionMaxLengthThreshold) {
|
||||
return isBackgrounded
|
||||
? clearSession(reason: .sessionPastMaximumLength)
|
||||
: rotateSession(at: timeNow, reason: .sessionPastMaximumLength)
|
||||
}
|
||||
|
||||
return currentSessionId
|
||||
}
|
||||
|
||||
func getNextSessionId() -> String? {
|
||||
// if this is RN, return the current session id
|
||||
guard isNotReactNative() else {
|
||||
return sessionLock.withLock { sessionId }
|
||||
}
|
||||
|
||||
return rotateSession(force: true, at: now(), reason: .sessionStart)
|
||||
}
|
||||
|
||||
/// Creates a new session id and sets timestamps
|
||||
func startSession(_ completion: (() -> Void)? = nil) {
|
||||
guard isNotReactNative() else { return }
|
||||
|
||||
rotateSession(force: true, at: now(), reason: .sessionStart)
|
||||
completion?()
|
||||
}
|
||||
|
||||
/// Clears current session id and timestamps
|
||||
func endSession(_ completion: (() -> Void)? = nil) {
|
||||
guard isNotReactNative() else { return }
|
||||
|
||||
clearSession(reason: .sessionEnd)
|
||||
completion?()
|
||||
}
|
||||
|
||||
/// Resets current session id and timestamps
|
||||
func resetSession() {
|
||||
guard isNotReactNative() else { return }
|
||||
|
||||
rotateSession(force: true, at: now(), reason: .sessionReset)
|
||||
}
|
||||
|
||||
/// Call this method to mark any user activity on this session
|
||||
func touchSession() {
|
||||
guard isNotReactNative() else { return }
|
||||
|
||||
let (currentSessionId, lastActive) = sessionLock.withLock {
|
||||
(sessionId, sessionActivityTimestamp)
|
||||
}
|
||||
|
||||
guard currentSessionId != nil else { return }
|
||||
|
||||
let timeNow = now()
|
||||
let timestamp = timeNow.timeIntervalSince1970
|
||||
|
||||
// Check if session has passed maximum inactivity length between user activity marks
|
||||
if let lastActive, isExpired(timestamp, lastActive, sessionActivityThreshold) {
|
||||
rotateSession(at: timeNow, reason: .sessionTimeout)
|
||||
} else {
|
||||
sessionLock.withLock {
|
||||
sessionActivityTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Rotates the current session id
|
||||
|
||||
- Parameters:
|
||||
- force: When true, creates a new session ID if current one is empty
|
||||
- reason: The underlying reason behind this session ID rotation
|
||||
- Returns: a new session id
|
||||
*/
|
||||
@discardableResult private func rotateSession(force: Bool = false, at timestamp: Date, reason: SessionIDChangeReason) -> String? {
|
||||
// only rotate when session is empty
|
||||
if !force {
|
||||
let currentSessionId = sessionLock.withLock { sessionId }
|
||||
if currentSessionId.isNilOrEmpty {
|
||||
return currentSessionId
|
||||
}
|
||||
}
|
||||
|
||||
let newSessionId = UUID.v7().uuidString
|
||||
setSessionIdInternal(newSessionId, at: timestamp, reason: reason)
|
||||
return newSessionId
|
||||
}
|
||||
|
||||
@discardableResult private func clearSession(reason: SessionIDChangeReason) -> String? {
|
||||
setSessionIdInternal(nil, at: nil, reason: reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setSessionIdInternal(_ sessionId: String?, at timestamp: Date?, reason: SessionIDChangeReason) {
|
||||
let timestamp = timestamp?.timeIntervalSince1970
|
||||
|
||||
sessionLock.withLock {
|
||||
self.sessionId = sessionId
|
||||
self.sessionStartTimestamp = timestamp
|
||||
self.sessionActivityTimestamp = timestamp
|
||||
}
|
||||
|
||||
onSessionIdChanged()
|
||||
|
||||
if let sessionId {
|
||||
hedgeLog("New session id created \(sessionId) (\(reason))")
|
||||
} else {
|
||||
hedgeLog("Session id cleared - reason: (\(reason))")
|
||||
}
|
||||
}
|
||||
|
||||
private var didBecomeActiveToken: RegistrationToken?
|
||||
private var didEnterBackgroundToken: RegistrationToken?
|
||||
|
||||
private func registerNotifications() {
|
||||
let lifecyclePublisher = DI.main.appLifecyclePublisher
|
||||
didBecomeActiveToken = lifecyclePublisher.onDidBecomeActive { [weak self] in
|
||||
guard let self, sessionLock.withLock({ self.isAppInBackground }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// we consider foregrounding an app an activity on the current session
|
||||
touchSession()
|
||||
sessionLock.withLock { self.isAppInBackground = false }
|
||||
}
|
||||
didEnterBackgroundToken = lifecyclePublisher.onDidEnterBackground { [weak self] in
|
||||
guard let self, !sessionLock.withLock({ self.isAppInBackground }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// we consider backgrounding the app an activity on the current session
|
||||
touchSession()
|
||||
sessionLock.withLock { self.isAppInBackground = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var applicationEventToken: RegistrationToken?
|
||||
|
||||
private func registerApplicationSendEvent() {
|
||||
#if os(iOS) || os(tvOS)
|
||||
guard let config, config.enableSwizzling else {
|
||||
return
|
||||
}
|
||||
applicationEventToken = DI.main.applicationEventPublisher.onApplicationEvent { [weak self] _, _ in
|
||||
// update "last active" session
|
||||
// we want to keep track of the idle time, so we need to maintain a timestamp on the last interactions of the user with the app. UIEvents are a good place to do so since it means that the user is actively interacting with the app (e.g not just noise background activity)
|
||||
self?.queue.async {
|
||||
self?.touchSession()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func isExpired(_ timeNow: TimeInterval, _ timeThen: TimeInterval, _ threshold: TimeInterval) -> Bool {
|
||||
max(timeNow - timeThen, 0) > threshold
|
||||
}
|
||||
}
|
||||
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
Normal file
419
Pods/PostHog/PostHog/PostHogStorage.swift
generated
Normal file
@@ -0,0 +1,419 @@
|
||||
//
|
||||
// PostHogStorage.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 08.02.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
# Storage
|
||||
|
||||
Note for tvOS:
|
||||
As tvOS restricts access to persisted Application Support directory, we use Library/Caches instead for storage
|
||||
|
||||
If needed, we can use UserDefaults for lightweight data - according to Apple, you can use UserDefaults to persist up to 500KB of data on tvOS
|
||||
see: https://developer.apple.com/forums/thread/16967?answerId=50696022#50696022
|
||||
*/
|
||||
func applicationSupportDirectoryURL() -> URL {
|
||||
#if os(tvOS)
|
||||
// tvOS restricts access to Application Support directory on physical devices
|
||||
// Use Library/Caches directory which may have less frequent eviction behavior than temp (which is purged when the app quits)
|
||||
let searchPath: FileManager.SearchPathDirectory = .cachesDirectory
|
||||
#else
|
||||
let searchPath: FileManager.SearchPathDirectory = .applicationSupportDirectory
|
||||
#endif
|
||||
|
||||
let url = FileManager.default.urls(for: searchPath, in: .userDomainMask).first!
|
||||
let bundleIdentifier = getBundleIdentifier()
|
||||
|
||||
return url.appendingPathComponent(bundleIdentifier)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
From Apple Docs:
|
||||
In iOS, the value is nil when the group identifier is invalid. In macOS, a URL of the expected form is always
|
||||
returned, even if the app group is invalid, so be sure to test that you can access the underlying directory
|
||||
before attempting to use it.
|
||||
|
||||
MacOS: The system also creates the Library/Application Support, Library/Caches, and Library/Preferences
|
||||
subdirectories inside the group directory the first time you use it
|
||||
iOS: The system creates only the Library/Caches subdirectory automatically
|
||||
|
||||
see: https://developer.apple.com/documentation/foundation/filemanager/1412643-containerurl/
|
||||
*/
|
||||
func appGroupContainerUrl(config: PostHogConfig) -> URL? {
|
||||
guard let appGroupIdentifier = config.appGroupIdentifier else { return nil }
|
||||
|
||||
#if os(tvOS)
|
||||
// tvOS: Due to stricter sandbox rules, creating "Application Support" directory is not possible on tvOS
|
||||
let librarySubPath = "Library/Caches/"
|
||||
#else
|
||||
let librarySubPath = "Library/Application Support/"
|
||||
#endif
|
||||
|
||||
let libraryUrl = FileManager.default
|
||||
.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)?
|
||||
.appendingPathComponent(librarySubPath)
|
||||
|
||||
guard let url = libraryUrl?.appendingPathComponent(appGroupIdentifier) else { return nil }
|
||||
|
||||
createDirectoryAtURLIfNeeded(url: url)
|
||||
|
||||
// Merges a legacy container (using bundleIdentifier) into the new container using appGroupIdentifier
|
||||
mergeLegacyContainerIfNeeded(within: libraryUrl, to: url)
|
||||
|
||||
return directoryExists(url) ? url : nil
|
||||
}
|
||||
|
||||
func getBundleIdentifier() -> String {
|
||||
#if TESTING // only visible to test targets
|
||||
return Bundle.main.bundleIdentifier ?? "com.posthog.test"
|
||||
#else
|
||||
return Bundle.main.bundleIdentifier!
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
Merges content from a legacy container directory into the current app group container.
|
||||
|
||||
This function handles the migration of PostHog data from the old storage location (using `bundleIdentifier`)
|
||||
to the new app group shared container location (using `appGroupIdentifier`).
|
||||
|
||||
Migration rules:
|
||||
- Files that already exist at the destination are skipped (no overwrite)
|
||||
- The anonymousId from the first processed container (legacy or current) is preserved to maintain user identity
|
||||
- Successfully migrated files are deleted from the source
|
||||
- Empty directories are cleaned up after migration
|
||||
- The entire folder structure is preserved during migration
|
||||
|
||||
- Parameters:
|
||||
- libraryUrl: The base library URL where both legacy and new containers might exist
|
||||
- destinationUrl: The target app group container URL where files should be migrated
|
||||
*/
|
||||
func mergeLegacyContainerIfNeeded(within libraryUrl: URL?, to destinationUrl: URL) {
|
||||
let bundleIdentifier = getBundleIdentifier()
|
||||
guard let sourceUrl = libraryUrl?.appendingPathComponent(bundleIdentifier), directoryExists(sourceUrl) else {
|
||||
return
|
||||
}
|
||||
|
||||
hedgeLog("Legacy folder found at \(sourceUrl), merging...")
|
||||
|
||||
// Migrate all contents from the legacy container
|
||||
migrateDirectoryContents(from: sourceUrl, to: destinationUrl)
|
||||
|
||||
// Try to remove the source directory if it's empty
|
||||
if removeIfEmpty(sourceUrl) {
|
||||
hedgeLog("Successfully migrated and removed legacy folder at \(sourceUrl)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Removes a directory if it's empty.
|
||||
|
||||
- Parameters:
|
||||
- url: The directory URL to potentially remove
|
||||
- Returns: `true` if the directory was removed, `false` otherwise
|
||||
*/
|
||||
@discardableResult
|
||||
func removeIfEmpty(_ url: URL) -> Bool {
|
||||
let remainingItems = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
|
||||
if remainingItems?.isEmpty == true {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
hedgeLog("Failed to remove empty directory at \(url.path): \(error)")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
Migrates a single file from source to destination.
|
||||
|
||||
Migration rules:
|
||||
- If the file doesn't exist at destination, it's copied and then deleted from source
|
||||
- If the file already exists at destination, only the source file is deleted
|
||||
|
||||
- Parameters:
|
||||
- sourceFile: The source file URL
|
||||
- destinationFile: The destination file URL
|
||||
- Throws: Any errors that occur during file operations
|
||||
*/
|
||||
func migrateFile(from sourceFile: URL, to destinationFile: URL) throws {
|
||||
if !FileManager.default.fileExists(atPath: destinationFile.path) {
|
||||
try FileManager.default.copyItem(at: sourceFile, to: destinationFile)
|
||||
}
|
||||
// Always delete source file after processing (whether copied or skipped)
|
||||
try FileManager.default.removeItem(at: sourceFile)
|
||||
}
|
||||
|
||||
/**
|
||||
Recursively migrates all contents from a source directory to a destination directory.
|
||||
|
||||
- Parameters:
|
||||
- sourceDir: The source directory URL
|
||||
- destinationDir: The destination directory URL
|
||||
*/
|
||||
func migrateDirectoryContents(from sourceDir: URL, to destinationDir: URL) {
|
||||
do {
|
||||
// Create destination directory if it doesn't exist (we need to call this here again as the function is recursive)
|
||||
createDirectoryAtURLIfNeeded(url: destinationDir)
|
||||
|
||||
// Get all items in source directory
|
||||
let items = try FileManager.default.contentsOfDirectory(at: sourceDir, includingPropertiesForKeys: nil, options: [])
|
||||
|
||||
for item in items {
|
||||
let destinationItem = destinationDir.appendingPathComponent(item.lastPathComponent)
|
||||
|
||||
// Check if it's a directory
|
||||
var isDirectory: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDirectory) {
|
||||
if isDirectory.boolValue {
|
||||
// Recursively migrate subdirectory (preserving the folder structure)
|
||||
migrateDirectoryContents(from: item, to: destinationItem)
|
||||
// Remove empty directory after migration
|
||||
removeIfEmpty(item)
|
||||
} else {
|
||||
// Migrate file
|
||||
do {
|
||||
try migrateFile(from: item, to: destinationItem)
|
||||
} catch {
|
||||
hedgeLog("Failed to migrate file from \(item.path) to \(destinationItem.path): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
hedgeLog("Error reading directory contents at \(sourceDir.path): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
class PostHogStorage {
|
||||
// when adding or removing items here, make sure to update the reset method
|
||||
enum StorageKey: String, CaseIterable {
|
||||
case distinctId = "posthog.distinctId"
|
||||
case anonymousId = "posthog.anonymousId"
|
||||
case queue = "posthog.queueFolder" // NOTE: This is different to posthog-ios v2
|
||||
case oldQeueue = "posthog.queue.plist"
|
||||
case replayQeueue = "posthog.replayFolder"
|
||||
case enabledFeatureFlags = "posthog.enabledFeatureFlags"
|
||||
case enabledFeatureFlagPayloads = "posthog.enabledFeatureFlagPayloads"
|
||||
case flags = "posthog.flags"
|
||||
case groups = "posthog.groups"
|
||||
case registerProperties = "posthog.registerProperties"
|
||||
case optOut = "posthog.optOut"
|
||||
case sessionReplay = "posthog.sessionReplay"
|
||||
case isIdentified = "posthog.isIdentified"
|
||||
case personProcessingEnabled = "posthog.enabledPersonProcessing"
|
||||
case remoteConfig = "posthog.remoteConfig"
|
||||
case surveySeen = "posthog.surveySeen"
|
||||
case requestId = "posthog.requestId"
|
||||
case personPropertiesForFlags = "posthog.personPropertiesForFlags"
|
||||
case groupPropertiesForFlags = "posthog.groupPropertiesForFlags"
|
||||
}
|
||||
|
||||
// The location for storing data that we always want to keep
|
||||
let appFolderUrl: URL
|
||||
|
||||
init(_ config: PostHogConfig) {
|
||||
appFolderUrl = Self.getAppFolderUrl(from: config)
|
||||
|
||||
// migrate legacy storage if needed
|
||||
Self.migrateLegacyStorage(from: config, to: appFolderUrl)
|
||||
}
|
||||
|
||||
func url(forKey key: StorageKey) -> URL {
|
||||
appFolderUrl.appendingPathComponent(key.rawValue)
|
||||
}
|
||||
|
||||
// The "data" methods are the core for storing data and differ between Modes
|
||||
// All other typed storage methods call these
|
||||
private func getData(forKey: StorageKey) -> Data? {
|
||||
let url = url(forKey: forKey)
|
||||
|
||||
do {
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
} catch {
|
||||
hedgeLog("Error reading data from key \(forKey): \(error)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setData(forKey: StorageKey, contents: Data?) {
|
||||
var url = url(forKey: forKey)
|
||||
|
||||
do {
|
||||
if contents == nil {
|
||||
deleteSafely(url)
|
||||
return
|
||||
}
|
||||
|
||||
try contents?.write(to: url)
|
||||
|
||||
var resourceValues = URLResourceValues()
|
||||
resourceValues.isExcludedFromBackup = true
|
||||
try url.setResourceValues(resourceValues)
|
||||
} catch {
|
||||
hedgeLog("Failed to write data for key '\(forKey)' error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func getJson(forKey key: StorageKey) -> Any? {
|
||||
guard let data = getData(forKey: key) else { return nil }
|
||||
|
||||
do {
|
||||
return try JSONSerialization.jsonObject(with: data)
|
||||
} catch {
|
||||
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setJson(forKey key: StorageKey, json: Any) {
|
||||
var jsonObject: Any?
|
||||
|
||||
if let dictionary = json as? [AnyHashable: Any] {
|
||||
jsonObject = dictionary
|
||||
} else if let array = json as? [Any] {
|
||||
jsonObject = array
|
||||
} else {
|
||||
// TRICKY: This is weird legacy behaviour storing the data as a dictionary
|
||||
jsonObject = [key.rawValue: json]
|
||||
}
|
||||
|
||||
var data: Data?
|
||||
do {
|
||||
data = try JSONSerialization.data(withJSONObject: jsonObject!)
|
||||
} catch {
|
||||
hedgeLog("Failed to serialize key '\(key)' error: \(error)")
|
||||
}
|
||||
setData(forKey: key, contents: data)
|
||||
}
|
||||
|
||||
/**
|
||||
There are cases where applications using posthog-ios want to share analytics data between host app and
|
||||
an app extension, Widget or App Clip. If there's a defined `appGroupIdentifier` in configuration,
|
||||
we want to use a shared container for storing data so that extensions correctly identify a user (and batch process events)
|
||||
*/
|
||||
private static func getBaseAppFolderUrl(from configuration: PostHogConfig) -> URL {
|
||||
appGroupContainerUrl(config: configuration) ?? applicationSupportDirectoryURL()
|
||||
}
|
||||
|
||||
private static func migrateItem(at sourceUrl: URL, to destinationUrl: URL, fileManager: FileManager) throws {
|
||||
guard fileManager.fileExists(atPath: sourceUrl.path) else { return }
|
||||
// Copy file or directory over (if it doesn't exist)
|
||||
if !fileManager.fileExists(atPath: destinationUrl.path) {
|
||||
try fileManager.copyItem(at: sourceUrl, to: destinationUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyStorage(from configuration: PostHogConfig, to apiDir: URL) {
|
||||
let legacyUrl = getBaseAppFolderUrl(from: configuration)
|
||||
if directoryExists(legacyUrl) {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
// Migrate old files that correspond to StorageKey values
|
||||
for storageKey in StorageKey.allCases {
|
||||
let legacyFileUrl = legacyUrl.appendingPathComponent(storageKey.rawValue)
|
||||
let newFileUrl = apiDir.appendingPathComponent(storageKey.rawValue)
|
||||
|
||||
do {
|
||||
// Migrate the item and its contents if it exists
|
||||
try migrateItem(at: legacyFileUrl, to: newFileUrl, fileManager: fileManager)
|
||||
} catch {
|
||||
hedgeLog("Error during storage migration for file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
|
||||
}
|
||||
|
||||
// Remove the legacy item after successful migration
|
||||
if fileManager.fileExists(atPath: legacyFileUrl.path) {
|
||||
do {
|
||||
try fileManager.removeItem(at: legacyFileUrl)
|
||||
} catch {
|
||||
hedgeLog("Could not delete file \(storageKey.rawValue) at path \(legacyFileUrl.path): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func getAppFolderUrl(from configuration: PostHogConfig) -> URL {
|
||||
let apiDir = getBaseAppFolderUrl(from: configuration)
|
||||
.appendingPathComponent(configuration.apiKey)
|
||||
|
||||
createDirectoryAtURLIfNeeded(url: apiDir)
|
||||
|
||||
return apiDir
|
||||
}
|
||||
|
||||
func reset(keepAnonymousId: Bool = false) {
|
||||
// sadly the StorageKey.allCases does not work here
|
||||
deleteSafely(url(forKey: .distinctId))
|
||||
if !keepAnonymousId {
|
||||
deleteSafely(url(forKey: .anonymousId))
|
||||
}
|
||||
// .queue, .replayQeueue not needed since it'll be deleted by the queue.clear()
|
||||
deleteSafely(url(forKey: .oldQeueue))
|
||||
deleteSafely(url(forKey: .flags))
|
||||
deleteSafely(url(forKey: .enabledFeatureFlags))
|
||||
deleteSafely(url(forKey: .enabledFeatureFlagPayloads))
|
||||
deleteSafely(url(forKey: .groups))
|
||||
deleteSafely(url(forKey: .registerProperties))
|
||||
deleteSafely(url(forKey: .optOut))
|
||||
deleteSafely(url(forKey: .sessionReplay))
|
||||
deleteSafely(url(forKey: .isIdentified))
|
||||
deleteSafely(url(forKey: .personProcessingEnabled))
|
||||
deleteSafely(url(forKey: .remoteConfig))
|
||||
deleteSafely(url(forKey: .surveySeen))
|
||||
deleteSafely(url(forKey: .requestId))
|
||||
deleteSafely(url(forKey: .personPropertiesForFlags))
|
||||
deleteSafely(url(forKey: .groupPropertiesForFlags))
|
||||
}
|
||||
|
||||
func remove(key: StorageKey) {
|
||||
let url = url(forKey: key)
|
||||
|
||||
deleteSafely(url)
|
||||
}
|
||||
|
||||
func getString(forKey key: StorageKey) -> String? {
|
||||
let value = getJson(forKey: key)
|
||||
if let stringValue = value as? String {
|
||||
return stringValue
|
||||
} else if let dictValue = value as? [String: String] {
|
||||
return dictValue[key.rawValue]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setString(forKey key: StorageKey, contents: String) {
|
||||
setJson(forKey: key, json: contents)
|
||||
}
|
||||
|
||||
func getDictionary(forKey key: StorageKey) -> [AnyHashable: Any]? {
|
||||
getJson(forKey: key) as? [AnyHashable: Any]
|
||||
}
|
||||
|
||||
func setDictionary(forKey key: StorageKey, contents: [AnyHashable: Any]) {
|
||||
setJson(forKey: key, json: contents)
|
||||
}
|
||||
|
||||
func getBool(forKey key: StorageKey) -> Bool? {
|
||||
let value = getJson(forKey: key)
|
||||
if let boolValue = value as? Bool {
|
||||
return boolValue
|
||||
} else if let dictValue = value as? [String: Bool] {
|
||||
return dictValue[key.rawValue]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setBool(forKey key: StorageKey, contents: Bool) {
|
||||
setJson(forKey: key, json: contents)
|
||||
}
|
||||
}
|
||||
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
Normal file
163
Pods/PostHog/PostHog/PostHogStorageManager.swift
generated
Normal file
@@ -0,0 +1,163 @@
|
||||
//
|
||||
// PostHogStorageManager.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ben White on 08.02.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Internal class to manage the storage metadata of the PostHog SDK
|
||||
public class PostHogStorageManager {
|
||||
private let storage: PostHogStorage!
|
||||
|
||||
private let anonLock = NSLock()
|
||||
private let distinctLock = NSLock()
|
||||
private let identifiedLock = NSLock()
|
||||
private let personProcessingLock = NSLock()
|
||||
private let idGen: (UUID) -> UUID
|
||||
|
||||
private var distinctId: String?
|
||||
private var cachedDistinctId = false
|
||||
private var anonymousId: String?
|
||||
private var isIdentifiedValue: Bool?
|
||||
private var personProcessingEnabled: Bool?
|
||||
|
||||
init(_ config: PostHogConfig) {
|
||||
storage = PostHogStorage(config)
|
||||
idGen = config.getAnonymousId
|
||||
}
|
||||
|
||||
public func getAnonymousId() -> String {
|
||||
anonLock.withLock {
|
||||
if anonymousId == nil {
|
||||
var anonymousId = storage.getString(forKey: .anonymousId)
|
||||
|
||||
if anonymousId == nil {
|
||||
let uuid = UUID.v7()
|
||||
anonymousId = idGen(uuid).uuidString
|
||||
setAnonId(anonymousId ?? "")
|
||||
} else {
|
||||
// update the memory value
|
||||
self.anonymousId = anonymousId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return anonymousId ?? ""
|
||||
}
|
||||
|
||||
public func setAnonymousId(_ id: String) {
|
||||
anonLock.withLock {
|
||||
setAnonId(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func setAnonId(_ id: String) {
|
||||
anonymousId = id
|
||||
storage.setString(forKey: .anonymousId, contents: id)
|
||||
}
|
||||
|
||||
public func getDistinctId() -> String {
|
||||
var distinctId: String?
|
||||
distinctLock.withLock {
|
||||
if self.distinctId == nil {
|
||||
// since distinctId is nil until its identified, no need to read from
|
||||
// cache every single time, otherwise anon users will never used the
|
||||
// cached values
|
||||
if !cachedDistinctId {
|
||||
distinctId = storage.getString(forKey: .distinctId)
|
||||
cachedDistinctId = true
|
||||
}
|
||||
|
||||
// do this to not assign the AnonymousId to the DistinctId, its just a fallback
|
||||
if distinctId == nil {
|
||||
distinctId = getAnonymousId()
|
||||
} else {
|
||||
// update the memory value
|
||||
self.distinctId = distinctId
|
||||
}
|
||||
} else {
|
||||
// read from memory
|
||||
distinctId = self.distinctId
|
||||
}
|
||||
}
|
||||
return distinctId ?? ""
|
||||
}
|
||||
|
||||
public func setDistinctId(_ id: String) {
|
||||
distinctLock.withLock {
|
||||
distinctId = id
|
||||
storage.setString(forKey: .distinctId, contents: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func isIdentified() -> Bool {
|
||||
identifiedLock.withLock {
|
||||
if isIdentifiedValue == nil {
|
||||
isIdentifiedValue = storage.getBool(forKey: .isIdentified) ?? (getDistinctId() != getAnonymousId())
|
||||
}
|
||||
}
|
||||
return isIdentifiedValue ?? false
|
||||
}
|
||||
|
||||
public func setIdentified(_ isIdentified: Bool) {
|
||||
identifiedLock.withLock {
|
||||
isIdentifiedValue = isIdentified
|
||||
storage.setBool(forKey: .isIdentified, contents: isIdentified)
|
||||
}
|
||||
}
|
||||
|
||||
public func isPersonProcessing() -> Bool {
|
||||
personProcessingLock.withLock {
|
||||
if personProcessingEnabled == nil {
|
||||
personProcessingEnabled = storage.getBool(forKey: .personProcessingEnabled) ?? false
|
||||
}
|
||||
}
|
||||
return personProcessingEnabled ?? false
|
||||
}
|
||||
|
||||
public func setPersonProcessing(_ enable: Bool) {
|
||||
personProcessingLock.withLock {
|
||||
// only set if its different to avoid IO since this is called more often
|
||||
if self.personProcessingEnabled != enable {
|
||||
self.personProcessingEnabled = enable
|
||||
storage.setBool(forKey: .personProcessingEnabled, contents: enable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func reset(keepAnonymousId: Bool = false, _ resetStorage: Bool = false) {
|
||||
// resetStorage is only used for testing, when the reset method is called,
|
||||
// the storage is also cleared, so we don't do here to not do it twice.
|
||||
distinctLock.withLock {
|
||||
distinctId = nil
|
||||
cachedDistinctId = false
|
||||
if resetStorage {
|
||||
storage.remove(key: .distinctId)
|
||||
}
|
||||
}
|
||||
|
||||
if !keepAnonymousId {
|
||||
anonLock.withLock {
|
||||
anonymousId = nil
|
||||
if resetStorage {
|
||||
storage.remove(key: .anonymousId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
identifiedLock.withLock {
|
||||
isIdentifiedValue = nil
|
||||
if resetStorage {
|
||||
storage.remove(key: .isIdentified)
|
||||
}
|
||||
}
|
||||
personProcessingLock.withLock {
|
||||
personProcessingEnabled = nil
|
||||
if resetStorage {
|
||||
storage.remove(key: .personProcessingEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
Normal file
14
Pods/PostHog/PostHog/PostHogSwizzler.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// PostHogSwizzler.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 26.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
func swizzle(forClass: AnyClass, original: Selector, new: Selector) {
|
||||
guard let originalMethod = class_getInstanceMethod(forClass, original) else { return }
|
||||
guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return }
|
||||
method_exchangeImplementations(originalMethod, swizzledMethod)
|
||||
}
|
||||
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
Normal file
16
Pods/PostHog/PostHog/PostHogVersion.swift
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// PostHogVersion.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 13.10.23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// if you change this, make sure to also change it in the podspec and check if the script scripts/bump-version.sh still works
|
||||
// This property is internal only
|
||||
public var postHogVersion = "3.34.0"
|
||||
|
||||
public let postHogiOSSdkName = "posthog-ios"
|
||||
// This property is internal only
|
||||
public var postHogSdkName = postHogiOSSdkName
|
||||
130
Pods/PostHog/PostHog/Replay/ApplicationEventPublisher.swift
generated
Normal file
130
Pods/PostHog/PostHog/Replay/ApplicationEventPublisher.swift
generated
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// ApplicationEventPublisher.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 24/02/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
|
||||
typealias ApplicationEventHandler = (_ event: UIEvent, _ date: Date) -> Void
|
||||
|
||||
protocol ApplicationEventPublishing: AnyObject {
|
||||
/// Registers a callback for a `UIApplication.sendEvent`
|
||||
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken
|
||||
}
|
||||
|
||||
final class ApplicationEventPublisher: BaseApplicationEventPublisher {
|
||||
static let shared = ApplicationEventPublisher()
|
||||
|
||||
private var hasSwizzled: Bool = false
|
||||
|
||||
func start() {
|
||||
swizzleSendEvent()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unswizzleSendEvent()
|
||||
}
|
||||
|
||||
func swizzleSendEvent() {
|
||||
guard !hasSwizzled else { return }
|
||||
hasSwizzled = true
|
||||
|
||||
swizzle(
|
||||
forClass: UIApplication.self,
|
||||
original: #selector(UIApplication.sendEvent(_:)),
|
||||
new: #selector(UIApplication.sendEventOverride)
|
||||
)
|
||||
}
|
||||
|
||||
func unswizzleSendEvent() {
|
||||
guard hasSwizzled else { return }
|
||||
hasSwizzled = false
|
||||
|
||||
// swizzling twice will exchange implementations back to original
|
||||
swizzle(
|
||||
forClass: UIApplication.self,
|
||||
original: #selector(UIApplication.sendEvent(_:)),
|
||||
new: #selector(UIApplication.sendEventOverride)
|
||||
)
|
||||
}
|
||||
|
||||
override func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onApplicationEventCallbacks[id] = callback
|
||||
}
|
||||
|
||||
// start on first callback registration
|
||||
if !hasSwizzled {
|
||||
start()
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
let handlerCount = self.registrationLock.withLock {
|
||||
self.onApplicationEventCallbacks[id] = nil
|
||||
return self.onApplicationEventCallbacks.values.count
|
||||
}
|
||||
|
||||
// stop when there are no more callbacks
|
||||
if handlerCount <= 0 {
|
||||
self.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called from swizzled `UIApplication.sendEvent`
|
||||
fileprivate func sendEvent(event: UIEvent, date: Date) {
|
||||
notifyHandlers(uiEvent: event, date: date)
|
||||
}
|
||||
}
|
||||
|
||||
class BaseApplicationEventPublisher: ApplicationEventPublishing {
|
||||
fileprivate let registrationLock = NSLock()
|
||||
|
||||
var onApplicationEventCallbacks: [UUID: ApplicationEventHandler] = [:]
|
||||
|
||||
func onApplicationEvent(_ callback: @escaping ApplicationEventHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onApplicationEventCallbacks[id] = callback
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
self.registrationLock.withLock {
|
||||
self.onApplicationEventCallbacks[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notifyHandlers(uiEvent: UIEvent, date: Date) {
|
||||
let handlers = registrationLock.withLock { onApplicationEventCallbacks.values }
|
||||
for handler in handlers {
|
||||
notifyHander(handler, uiEvent: uiEvent, date: date)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyHander(_ handler: @escaping ApplicationEventHandler, uiEvent: UIEvent, date: Date) {
|
||||
if Thread.isMainThread {
|
||||
handler(uiEvent, date)
|
||||
} else {
|
||||
DispatchQueue.main.async { handler(uiEvent, date) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIApplication {
|
||||
@objc func sendEventOverride(_ event: UIEvent) {
|
||||
sendEventOverride(event)
|
||||
ApplicationEventPublisher.shared.sendEvent(event: event, date: Date())
|
||||
}
|
||||
}
|
||||
#endif
|
||||
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
Normal file
33
Pods/PostHog/PostHog/Replay/CGColor+Util.swift
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// CGColor+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension CGColor {
|
||||
func toRGBString() -> String? {
|
||||
// see dicussion: https://github.com/PostHog/posthog-ios/issues/226
|
||||
// Allow only CGColors with an intiialized value of `numberOfComponents` with a value in 3...4 range
|
||||
// Loading dynamic colors from storyboard sometimes leads to some random values for numberOfComponents like `105553118884896` which crashes the app
|
||||
guard
|
||||
3 ... 4 ~= numberOfComponents, // check range
|
||||
let components = components, // we now assume it's safe to access `components`
|
||||
components.count >= 3
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let red = Int(components[0] * 255)
|
||||
let green = Int(components[1] * 255)
|
||||
let blue = Int(components[2] * 255)
|
||||
|
||||
return String(format: "#%02X%02X%02X", red, green, blue)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
Normal file
19
Pods/PostHog/PostHog/Replay/CGSize+Util.swift
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// CGSize+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 24.07.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
extension CGSize {
|
||||
func hasSize() -> Bool {
|
||||
if width == 0 || height == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
Normal file
18
Pods/PostHog/PostHog/Replay/Date+Util.swift
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Date+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Date {
|
||||
func toMillis() -> Int64 {
|
||||
Int64(timeIntervalSince1970 * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
public func dateToMillis(_ date: Date) -> Int64 {
|
||||
date.toMillis()
|
||||
}
|
||||
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
Normal file
20
Pods/PostHog/PostHog/Replay/Float+Util.swift
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Float+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 07/02/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CGFloat {
|
||||
func toInt() -> Int {
|
||||
NSNumber(value: rounded()).intValue
|
||||
}
|
||||
}
|
||||
|
||||
extension Double {
|
||||
func toInt() -> Int {
|
||||
NSNumber(value: rounded()).intValue
|
||||
}
|
||||
}
|
||||
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
Normal file
120
Pods/PostHog/PostHog/Replay/MethodSwizzler.swift
generated
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
||||
* Copyright 2019-Present Datadog, Inc.
|
||||
*/
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
class MethodSwizzler<TypedIMP, TypedBlockIMP> {
|
||||
struct FoundMethod: Hashable {
|
||||
let method: Method
|
||||
private let klass: AnyClass
|
||||
|
||||
fileprivate init(method: Method, klass: AnyClass) {
|
||||
self.method = method
|
||||
self.klass = klass
|
||||
}
|
||||
|
||||
static func == (lhs: FoundMethod, rhs: FoundMethod) -> Bool {
|
||||
let methodParity = (lhs.method == rhs.method)
|
||||
let classParity = (NSStringFromClass(lhs.klass) == NSStringFromClass(rhs.klass))
|
||||
return methodParity && classParity
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
let methodName = NSStringFromSelector(method_getName(method))
|
||||
let klassName = NSStringFromClass(klass)
|
||||
let identifier = "\(methodName)|||\(klassName)"
|
||||
hasher.combine(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
private var implementationCache: [FoundMethod: IMP] = [:]
|
||||
var swizzledMethods: [FoundMethod] {
|
||||
Array(implementationCache.keys)
|
||||
}
|
||||
|
||||
static func findMethod(with selector: Selector, in klass: AnyClass) throws -> FoundMethod {
|
||||
/// NOTE: RUMM-452 as we never add/remove methods/classes at runtime,
|
||||
/// search operation doesn't have to wrapped in sync {...} although it's visible in the interface
|
||||
var headKlass: AnyClass? = klass
|
||||
while let someKlass = headKlass {
|
||||
if let foundMethod = findMethod(with: selector, in: someKlass) {
|
||||
return FoundMethod(method: foundMethod, klass: someKlass)
|
||||
}
|
||||
headKlass = class_getSuperclass(headKlass)
|
||||
}
|
||||
throw InternalPostHogError(description: "\(NSStringFromSelector(selector)) is not found in \(NSStringFromClass(klass))")
|
||||
}
|
||||
|
||||
func originalImplementation(of found: FoundMethod) -> TypedIMP {
|
||||
sync {
|
||||
let originalImp: IMP = implementationCache[found] ?? method_getImplementation(found.method)
|
||||
return unsafeBitCast(originalImp, to: TypedIMP.self)
|
||||
}
|
||||
}
|
||||
|
||||
func swizzle(
|
||||
_ foundMethod: FoundMethod,
|
||||
impProvider: (TypedIMP) -> TypedBlockIMP
|
||||
) {
|
||||
sync {
|
||||
let currentIMP = method_getImplementation(foundMethod.method)
|
||||
let currentTypedIMP = unsafeBitCast(currentIMP, to: TypedIMP.self)
|
||||
let newImpBlock: TypedBlockIMP = impProvider(currentTypedIMP)
|
||||
let newImp: IMP = imp_implementationWithBlock(newImpBlock)
|
||||
|
||||
set(newIMP: newImp, for: foundMethod)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes swizzling and resets the method to its original implementation.
|
||||
func unswizzle() {
|
||||
for foundMethod in swizzledMethods {
|
||||
let originalTypedIMP = originalImplementation(of: foundMethod)
|
||||
let originalIMP: IMP = unsafeBitCast(originalTypedIMP, to: IMP.self)
|
||||
method_setImplementation(foundMethod.method, originalIMP)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private methods
|
||||
|
||||
@discardableResult
|
||||
private func sync<T>(block: () -> T) -> T {
|
||||
objc_sync_enter(self)
|
||||
defer { objc_sync_exit(self) }
|
||||
return block()
|
||||
}
|
||||
|
||||
private static func findMethod(with selector: Selector, in klass: AnyClass) -> Method? {
|
||||
var methodsCount: UInt32 = 0
|
||||
let methodsCountPtr = withUnsafeMutablePointer(to: &methodsCount) { $0 }
|
||||
guard let methods: UnsafeMutablePointer<Method> = class_copyMethodList(klass, methodsCountPtr) else {
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
free(methods)
|
||||
}
|
||||
for index in 0 ..< Int(methodsCount) {
|
||||
let method = methods.advanced(by: index).pointee
|
||||
if method_getName(method) == selector {
|
||||
return method
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func set(newIMP: IMP, for found: FoundMethod) {
|
||||
if implementationCache[found] == nil {
|
||||
implementationCache[found] = method_getImplementation(found.method)
|
||||
}
|
||||
method_setImplementation(found.method, newIMP)
|
||||
}
|
||||
}
|
||||
|
||||
extension MethodSwizzler.FoundMethod {
|
||||
var swizzlingName: String { "\(klass).\(method_getName(method))" }
|
||||
}
|
||||
#endif
|
||||
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
Normal file
58
Pods/PostHog/PostHog/Replay/NetworkSample.swift
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// NetworkSample.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 26.03.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
struct NetworkSample {
|
||||
let sessionId: String
|
||||
let timeOrigin: Date
|
||||
let entryType = "resource"
|
||||
var name: String?
|
||||
var responseStatus: Int?
|
||||
var initiatorType = "fetch"
|
||||
var httpMethod: String?
|
||||
var duration: Int64?
|
||||
var decodedBodySize: Int64?
|
||||
|
||||
init(sessionId: String, timeOrigin: Date, url: String? = nil) {
|
||||
self.timeOrigin = timeOrigin
|
||||
self.sessionId = sessionId
|
||||
name = url
|
||||
}
|
||||
|
||||
func toDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"timestamp": timeOrigin.toMillis(),
|
||||
"entryType": entryType,
|
||||
"initiatorType": initiatorType,
|
||||
]
|
||||
|
||||
if let name = name {
|
||||
dict["name"] = name
|
||||
}
|
||||
|
||||
if let responseStatus = responseStatus {
|
||||
dict["responseStatus"] = responseStatus
|
||||
}
|
||||
|
||||
if let httpMethod = httpMethod {
|
||||
dict["method"] = httpMethod
|
||||
}
|
||||
|
||||
if let duration = duration {
|
||||
dict["duration"] = duration
|
||||
}
|
||||
|
||||
if let decodedBodySize = decodedBodySize {
|
||||
dict["transferSize"] = decodedBodySize
|
||||
}
|
||||
|
||||
return dict
|
||||
}
|
||||
}
|
||||
#endif
|
||||
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
Normal file
12
Pods/PostHog/PostHog/Replay/Optional+Util.swift
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// Optional+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Yiannis Josephides on 20/01/2025.
|
||||
//
|
||||
|
||||
extension Optional where Wrapped: Collection {
|
||||
var isNilOrEmpty: Bool {
|
||||
self?.isEmpty ?? true
|
||||
}
|
||||
}
|
||||
136
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift
generated
Normal file
136
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift
generated
Normal file
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// PostHogConsoleLogInterceptor.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 05/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
final class PostHogConsoleLogInterceptor {
|
||||
private let maxLogStringSize = 2000 // Maximum number of characters allowed in a string
|
||||
|
||||
struct ConsoleOutput {
|
||||
let timestamp: Date
|
||||
let text: String
|
||||
let level: PostHogLogLevel
|
||||
}
|
||||
|
||||
static let shared = PostHogConsoleLogInterceptor()
|
||||
|
||||
// Pipe redirection properties
|
||||
private var stdoutPipe: Pipe?
|
||||
private var stderrPipe: Pipe?
|
||||
private var originalStdout: Int32 = -1
|
||||
private var originalStderr: Int32 = -1
|
||||
|
||||
private init() { /* Singleton */ }
|
||||
|
||||
func startCapturing(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
||||
stopCapturing() // cleanup
|
||||
setupPipeRedirection(config: config, callback: callback)
|
||||
}
|
||||
|
||||
private func setupPipeRedirection(config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
||||
// Set stdout/stderr to unbuffered mode (_IONBF) to ensure real-time output capture.
|
||||
// Without this, output might be buffered and only flushed when the buffer is full or
|
||||
// when explicitly flushed, which is especially problematic without an attached debugger
|
||||
setvbuf(stdout, nil, _IONBF, 0)
|
||||
setvbuf(stderr, nil, _IONBF, 0)
|
||||
|
||||
// Save original file descriptors
|
||||
originalStdout = dup(STDOUT_FILENO)
|
||||
originalStderr = dup(STDERR_FILENO)
|
||||
|
||||
stdoutPipe = Pipe()
|
||||
stderrPipe = Pipe()
|
||||
|
||||
guard let stdoutPipe = stdoutPipe, let stderrPipe = stderrPipe else { return }
|
||||
|
||||
// Redirect stdout and stderr to our pipes
|
||||
dup2(stdoutPipe.fileHandleForWriting.fileDescriptor, STDOUT_FILENO)
|
||||
dup2(stderrPipe.fileHandleForWriting.fileDescriptor, STDERR_FILENO)
|
||||
|
||||
// Setup and handle pipe output
|
||||
setupPipeSource(for: originalStdout, fileHandle: stdoutPipe.fileHandleForReading, config: config, callback: callback)
|
||||
setupPipeSource(for: originalStderr, fileHandle: stderrPipe.fileHandleForReading, config: config, callback: callback)
|
||||
}
|
||||
|
||||
private func setupPipeSource(for originalFd: Int32, fileHandle: FileHandle, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
||||
fileHandle.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty,
|
||||
let output = String(data: data, encoding: .utf8),
|
||||
let self = self else { return }
|
||||
|
||||
// Write to original file descriptor, so logs appear normally
|
||||
if originalFd != -1 {
|
||||
if let data = output.data(using: .utf8) {
|
||||
_ = data.withUnsafeBytes { ptr in
|
||||
write(originalFd, ptr.baseAddress, ptr.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.processOutput(output, config: config, callback: callback)
|
||||
}
|
||||
}
|
||||
|
||||
private func processOutput(_ output: String, config: PostHogConfig, callback: @escaping (ConsoleOutput) -> Void) {
|
||||
// Skip internal logs and empty lines
|
||||
// Note: Need to skip internal logs because `config.debug` may be enabled. If that's the case, then
|
||||
// the process of capturing logs, will generate more logs, leading to an infinite loop. This relies on hedgeLog() format which should
|
||||
// be okay, even not ideal
|
||||
guard !output.contains("[PostHog]"), !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
// Process log entries from config
|
||||
let entries = output
|
||||
.components(separatedBy: CharacterSet.newlines) // split by line
|
||||
.lazy
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } // Skip empty strings and new lines
|
||||
.compactMap(config.sessionReplayConfig.captureLogsConfig.logSanitizer)
|
||||
|
||||
for entry in entries where shouldCaptureLog(entry: entry, config: config) {
|
||||
callback(ConsoleOutput(timestamp: Date(), text: truncatedOutput(entry.message), level: entry.level))
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines if the log message should be captured, based on config
|
||||
private func shouldCaptureLog(entry: PostHogLogEntry, config: PostHogConfig) -> Bool {
|
||||
entry.level.rawValue >= config.sessionReplayConfig.captureLogsConfig.minLogLevel.rawValue
|
||||
}
|
||||
|
||||
/// Console logs can be really large.
|
||||
/// This function returns a truncated version of the console output if it exceeds `maxLogStringSize`
|
||||
private func truncatedOutput(_ output: String) -> String {
|
||||
guard output.count > maxLogStringSize else { return output }
|
||||
return "\(output.prefix(maxLogStringSize))...[truncated]"
|
||||
}
|
||||
|
||||
func stopCapturing() {
|
||||
// Restore original file descriptors
|
||||
if originalStdout != -1 {
|
||||
dup2(originalStdout, STDOUT_FILENO)
|
||||
close(originalStdout)
|
||||
originalStdout = -1
|
||||
}
|
||||
|
||||
if originalStderr != -1 {
|
||||
dup2(originalStderr, STDERR_FILENO)
|
||||
close(originalStderr)
|
||||
originalStderr = -1
|
||||
}
|
||||
|
||||
// remove pipes
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
stderrPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
stdoutPipe?.fileHandleForReading.closeFile()
|
||||
stderrPipe?.fileHandleForReading.closeFile()
|
||||
stdoutPipe = nil
|
||||
stderrPipe = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
37
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift
generated
Normal file
37
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift
generated
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// PostHogLogEntry.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 09/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A model representing a processed console log entry for session replay.
|
||||
|
||||
Describes a single console log entry after it has been processed by `PostHogSessionReplayConsoleLogConfig.logSanitizer`.
|
||||
Each instance contains the log message content and its determined severity level.
|
||||
*/
|
||||
@objc public class PostHogLogEntry: NSObject {
|
||||
/// The severity level of the log entry.
|
||||
/// This determines how the log will be displayed in the session replay and
|
||||
/// whether it will be captured based on `minLogLevel` setting.
|
||||
@objc public let level: PostHogLogLevel
|
||||
|
||||
/// The actual content of the log message.
|
||||
/// This is the processed and sanitized log message
|
||||
@objc public let message: String
|
||||
|
||||
/// Creates a new console log result.
|
||||
/// - Parameters:
|
||||
/// - level: The severity level of the log entry
|
||||
/// - message: The processed log message content
|
||||
@objc public init(level: PostHogLogLevel, message: String) {
|
||||
self.level = level
|
||||
self.message = message
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
22
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift
generated
Normal file
22
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// PostHogLogLevel.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 09/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
/// The severity level of a console log entry.
|
||||
///
|
||||
/// Used to categorize logs by their severity in session replay.
|
||||
@objc public enum PostHogLogLevel: Int {
|
||||
/// Informational messages, debugging output, and general logs
|
||||
case info
|
||||
/// Warning messages indicating potential issues or deprecation notices
|
||||
case warn
|
||||
/// Error messages indicating failures or critical issues
|
||||
case error
|
||||
}
|
||||
#endif
|
||||
86
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift
generated
Normal file
86
Pods/PostHog/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift
generated
Normal file
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// PostHogSessionReplayConsoleLogsPlugin.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 09/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
final class PostHogSessionReplayConsoleLogsPlugin: PostHogSessionReplayPlugin {
|
||||
private weak var postHog: PostHogSDK?
|
||||
private var isActive = false
|
||||
|
||||
func start(postHog: PostHogSDK) {
|
||||
self.postHog = postHog
|
||||
isActive = true
|
||||
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
|
||||
self?.handleConsoleLog(output)
|
||||
}
|
||||
hedgeLog("[Session Replay] Console logs plugin started")
|
||||
}
|
||||
|
||||
func stop() {
|
||||
postHog = nil
|
||||
isActive = false
|
||||
PostHogConsoleLogInterceptor.shared.stopCapturing()
|
||||
hedgeLog("[Session Replay] Console logs plugin stopped")
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard !isActive, let postHog else { return }
|
||||
isActive = true
|
||||
PostHogConsoleLogInterceptor.shared.startCapturing(config: postHog.config) { [weak self] output in
|
||||
self?.handleConsoleLog(output)
|
||||
}
|
||||
hedgeLog("[Session Replay] Console logs plugin resumed")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard isActive else { return }
|
||||
isActive = false
|
||||
PostHogConsoleLogInterceptor.shared.stopCapturing()
|
||||
hedgeLog("[Session Replay] Console logs plugin paused")
|
||||
}
|
||||
|
||||
private func handleConsoleLog(_ output: PostHogConsoleLogInterceptor.ConsoleOutput) {
|
||||
guard
|
||||
isActive,
|
||||
let postHog,
|
||||
postHog.isSessionReplayActive(),
|
||||
let sessionId = postHog.sessionManager.getSessionId(at: output.timestamp)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
// `PostHogLogLevel`` needs to be an Int enum for objc interop
|
||||
// So we need to convert this to a String before sending upstream
|
||||
let level = switch output.level {
|
||||
case .error: "error"
|
||||
case .info: "info"
|
||||
case .warn: "warn"
|
||||
}
|
||||
|
||||
var snapshotsData: [Any] = []
|
||||
let payloadData: [String: Any] = ["level": level, "payload": output.text]
|
||||
let pluginData: [String: Any] = ["plugin": "rrweb/console@1", "payload": payloadData]
|
||||
|
||||
snapshotsData.append([
|
||||
"type": 6,
|
||||
"data": pluginData,
|
||||
"timestamp": output.timestamp.toMillis(),
|
||||
])
|
||||
|
||||
postHog.capture(
|
||||
"$snapshot",
|
||||
properties: [
|
||||
"$snapshot_source": "mobile",
|
||||
"$snapshot_data": snapshotsData,
|
||||
"$session_id": sessionId,
|
||||
],
|
||||
timestamp: output.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
89
Pods/PostHog/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift
generated
Normal file
89
Pods/PostHog/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift
generated
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// PostHogSessionReplayNetworkPlugin.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 28/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
/// Session replay plugin that captures network requests using URLSession swizzling.
|
||||
class PostHogSessionReplayNetworkPlugin: PostHogSessionReplayPlugin {
|
||||
private var sessionSwizzler: URLSessionSwizzler?
|
||||
private var postHog: PostHogSDK?
|
||||
private var isActive = false
|
||||
|
||||
func start(postHog: PostHogSDK) {
|
||||
self.postHog = postHog
|
||||
do {
|
||||
sessionSwizzler = try URLSessionSwizzler(
|
||||
shouldCapture: shouldCaptureNetworkSample,
|
||||
onCapture: handleNetworkSample,
|
||||
getSessionId: { [weak self] date in
|
||||
self?.postHog?.sessionManager.getSessionId(at: date)
|
||||
}
|
||||
)
|
||||
sessionSwizzler?.swizzle()
|
||||
hedgeLog("[Session Replay] Network telemetry plugin started")
|
||||
isActive = true
|
||||
} catch {
|
||||
hedgeLog("[Session Replay] Failed to initialize network telemetry: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
sessionSwizzler?.unswizzle()
|
||||
sessionSwizzler = nil
|
||||
postHog = nil
|
||||
isActive = false
|
||||
hedgeLog("[Session Replay] Network telemetry plugin stopped")
|
||||
}
|
||||
|
||||
func resume() {
|
||||
guard !isActive else { return }
|
||||
isActive = true
|
||||
hedgeLog("[Session Replay] Network telemetry plugin resumed")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
guard isActive else { return }
|
||||
isActive = false
|
||||
hedgeLog("[Session Replay] Network telemetry plugin paused")
|
||||
}
|
||||
|
||||
private func shouldCaptureNetworkSample() -> Bool {
|
||||
guard let postHog else { return false }
|
||||
return isActive && postHog.config.sessionReplayConfig.captureNetworkTelemetry && postHog.isSessionReplayActive()
|
||||
}
|
||||
|
||||
private func handleNetworkSample(sample: NetworkSample) {
|
||||
guard let postHog else { return }
|
||||
|
||||
let timestamp = sample.timeOrigin
|
||||
|
||||
var snapshotsData: [Any] = []
|
||||
|
||||
let requestsData = [sample.toDict()]
|
||||
let payloadData: [String: Any] = ["requests": requestsData]
|
||||
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
|
||||
|
||||
let data: [String: Any] = [
|
||||
"type": 6,
|
||||
"data": pluginData,
|
||||
"timestamp": timestamp.toMillis(),
|
||||
]
|
||||
snapshotsData.append(data)
|
||||
|
||||
postHog.capture(
|
||||
"$snapshot",
|
||||
properties: [
|
||||
"$snapshot_source": "mobile",
|
||||
"$snapshot_data": snapshotsData,
|
||||
"$session_id": sample.sessionId,
|
||||
],
|
||||
timestamp: sample.timeOrigin
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
231
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionExtension.swift
generated
Normal file
231
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionExtension.swift
generated
Normal file
@@ -0,0 +1,231 @@
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
public extension URLSession {
|
||||
private func getMonotonicTimeInMilliseconds() -> UInt64 {
|
||||
// Get the raw mach time
|
||||
let machTime = mach_absolute_time()
|
||||
|
||||
// Get timebase info to convert to nanoseconds
|
||||
var timebaseInfo = mach_timebase_info_data_t()
|
||||
mach_timebase_info(&timebaseInfo)
|
||||
|
||||
// Convert mach time to nanoseconds
|
||||
let nanoTime = machTime * UInt64(timebaseInfo.numer) / UInt64(timebaseInfo.denom)
|
||||
|
||||
// Convert nanoseconds to milliseconds
|
||||
let milliTime = nanoTime / 1_000_000
|
||||
|
||||
return milliTime
|
||||
}
|
||||
|
||||
private func executeRequest(request: URLRequest? = nil,
|
||||
action: () async throws -> (Data, URLResponse),
|
||||
postHog: PostHogSDK?) async throws -> (Data, URLResponse)
|
||||
{
|
||||
let timestamp = Date()
|
||||
let startMillis = getMonotonicTimeInMilliseconds()
|
||||
var endMillis: UInt64?
|
||||
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
|
||||
do {
|
||||
let (data, response) = try await action()
|
||||
endMillis = getMonotonicTimeInMilliseconds()
|
||||
captureData(request: request,
|
||||
response: response,
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
start: startMillis,
|
||||
end: endMillis,
|
||||
postHog: postHog)
|
||||
return (data, response)
|
||||
} catch {
|
||||
captureData(request: request,
|
||||
response: nil,
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
start: startMillis,
|
||||
end: endMillis,
|
||||
postHog: postHog)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func executeRequest(request: URLRequest? = nil,
|
||||
action: () async throws -> (URL, URLResponse),
|
||||
postHog: PostHogSDK?) async throws -> (URL, URLResponse)
|
||||
{
|
||||
let timestamp = Date()
|
||||
let startMillis = getMonotonicTimeInMilliseconds()
|
||||
var endMillis: UInt64?
|
||||
let sessionId = postHog?.sessionManager.getSessionId(at: timestamp)
|
||||
do {
|
||||
let (url, response) = try await action()
|
||||
endMillis = getMonotonicTimeInMilliseconds()
|
||||
captureData(request: request,
|
||||
response: response,
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
start: startMillis,
|
||||
end: endMillis,
|
||||
postHog: postHog)
|
||||
return (url, response)
|
||||
} catch {
|
||||
captureData(request: request,
|
||||
response: nil,
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
start: startMillis,
|
||||
end: endMillis,
|
||||
postHog: postHog)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await data(for: request) }, postHog: postHog)
|
||||
}
|
||||
|
||||
func postHogData(from url: URL, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(action: { try await data(from: url) }, postHog: postHog)
|
||||
}
|
||||
|
||||
func postHogUpload(
|
||||
for request: URLRequest,
|
||||
fromFile fileURL: URL,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL) }, postHog: postHog)
|
||||
}
|
||||
|
||||
func postHogUpload(
|
||||
for request: URLRequest,
|
||||
from bodyData: Data,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogData(
|
||||
for request: URLRequest,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await data(for: request, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogData(
|
||||
from url: URL,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(action: { try await data(from: url, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogUpload(
|
||||
for request: URLRequest,
|
||||
fromFile fileURL: URL,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await upload(for: request, fromFile: fileURL, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogUpload(
|
||||
for request: URLRequest,
|
||||
from bodyData: Data,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (Data, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await upload(for: request, from: bodyData, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogDownload(
|
||||
for request: URLRequest,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (URL, URLResponse) {
|
||||
try await executeRequest(request: request, action: { try await download(for: request, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogDownload(
|
||||
from url: URL,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (URL, URLResponse) {
|
||||
try await executeRequest(action: { try await download(from: url, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
func postHogDownload(
|
||||
resumeFrom resumeData: Data,
|
||||
delegate: (any URLSessionTaskDelegate)? = nil,
|
||||
postHog: PostHogSDK? = nil
|
||||
) async throws -> (URL, URLResponse) {
|
||||
try await executeRequest(action: { try await download(resumeFrom: resumeData, delegate: delegate) }, postHog: postHog)
|
||||
}
|
||||
|
||||
// MARK: Private methods
|
||||
|
||||
private func captureData(
|
||||
request: URLRequest? = nil,
|
||||
response: URLResponse? = nil,
|
||||
sessionId: String?,
|
||||
timestamp: Date,
|
||||
start: UInt64,
|
||||
end: UInt64? = nil,
|
||||
postHog: PostHogSDK?
|
||||
) {
|
||||
let instance = postHog ?? PostHogSDK.shared
|
||||
|
||||
// we don't check config.sessionReplayConfig.captureNetworkTelemetry here since this extension
|
||||
// has to be called manually anyway
|
||||
guard let sessionId, instance.isSessionReplayActive() else {
|
||||
return
|
||||
}
|
||||
let currentEnd = end ?? getMonotonicTimeInMilliseconds()
|
||||
|
||||
PostHogReplayIntegration.dispatchQueue.async {
|
||||
var snapshotsData: [Any] = []
|
||||
|
||||
var requestsData: [String: Any] = ["duration": currentEnd - start,
|
||||
"method": request?.httpMethod ?? "GET",
|
||||
"name": request?.url?.absoluteString ?? (response?.url?.absoluteString ?? ""),
|
||||
"initiatorType": "fetch",
|
||||
"entryType": "resource",
|
||||
"timestamp": timestamp.toMillis()]
|
||||
|
||||
// the UI special case if the transferSize is 0 as coming from cache
|
||||
let transferSize = Int64(request?.httpBody?.count ?? 0) + (response?.expectedContentLength ?? 0)
|
||||
if transferSize > 0 {
|
||||
requestsData["transferSize"] = transferSize
|
||||
}
|
||||
|
||||
if let urlResponse = response as? HTTPURLResponse {
|
||||
requestsData["responseStatus"] = urlResponse.statusCode
|
||||
}
|
||||
|
||||
let payloadData: [String: Any] = ["requests": [requestsData]]
|
||||
let pluginData: [String: Any] = ["plugin": "rrweb/network@1", "payload": payloadData]
|
||||
|
||||
let recordingData: [String: Any] = ["type": 6, "data": pluginData, "timestamp": timestamp.toMillis()]
|
||||
snapshotsData.append(recordingData)
|
||||
|
||||
instance.capture(
|
||||
"$snapshot",
|
||||
properties: [
|
||||
"$snapshot_source": "mobile",
|
||||
"$snapshot_data": snapshotsData,
|
||||
"$session_id": sessionId,
|
||||
],
|
||||
timestamp: timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
163
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift
generated
Normal file
163
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift
generated
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
||||
* Copyright 2019-Present Datadog, Inc.
|
||||
*/
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
|
||||
class URLSessionInterceptor {
|
||||
private let tasksLock = NSLock()
|
||||
private let shouldCapture: () -> Bool
|
||||
private let onCapture: (NetworkSample) -> Void
|
||||
private let getSessionId: (Date) -> String?
|
||||
|
||||
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) {
|
||||
self.shouldCapture = shouldCapture
|
||||
self.onCapture = onCapture
|
||||
self.getSessionId = getSessionId
|
||||
}
|
||||
|
||||
/// An internal queue for synchronising the access to `samplesByTask`.
|
||||
private let queue = DispatchQueue(label: "com.posthog.URLSessionInterceptor", target: .global(qos: .utility))
|
||||
private var samplesByTask: [URLSessionTask: NetworkSample] = [:]
|
||||
|
||||
// MARK: - Interception Flow
|
||||
|
||||
/// Notifies the `URLSessionTask` creation.
|
||||
/// This method should be called as soon as the task was created.
|
||||
/// - Parameter task: the task object obtained from `URLSession`.
|
||||
func taskCreated(task: URLSessionTask, session _: URLSession? = nil) {
|
||||
guard shouldCapture() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let request = task.originalRequest else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = request.url else {
|
||||
return
|
||||
}
|
||||
|
||||
let date = now()
|
||||
|
||||
guard let sessionId = getSessionId(date) else {
|
||||
return
|
||||
}
|
||||
|
||||
queue.async {
|
||||
let sample = NetworkSample(
|
||||
sessionId: sessionId,
|
||||
timeOrigin: date,
|
||||
url: url.absoluteString
|
||||
)
|
||||
|
||||
self.tasksLock.withLock {
|
||||
self.samplesByTask[task] = sample
|
||||
}
|
||||
|
||||
self.finishAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Notifies the `URLSessionTask` completion.
|
||||
/// This method should be called as soon as the task was completed.
|
||||
/// - Parameter task: the task object obtained from `URLSession`.
|
||||
/// - Parameter error: optional `Error` if the task completed with error.
|
||||
func taskCompleted(task: URLSessionTask, error _: Error?) {
|
||||
guard shouldCapture() else {
|
||||
return
|
||||
}
|
||||
|
||||
let date = Date()
|
||||
|
||||
queue.async {
|
||||
var sampleTask: NetworkSample?
|
||||
self.tasksLock.withLock {
|
||||
sampleTask = self.samplesByTask[task]
|
||||
}
|
||||
|
||||
guard var sample = sampleTask else {
|
||||
return
|
||||
}
|
||||
|
||||
self.finish(task: task, sample: &sample, date: date)
|
||||
|
||||
self.finishAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(task: URLSessionTask, sample: inout NetworkSample, date: Date? = nil) {
|
||||
// only safe guard, should not happen
|
||||
guard let request = task.originalRequest else {
|
||||
tasksLock.withLock {
|
||||
_ = samplesByTask.removeValue(forKey: task)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let responseStatusCode = urlResponseStatusCode(response: task.response)
|
||||
|
||||
if responseStatusCode != -1 {
|
||||
sample.responseStatus = responseStatusCode
|
||||
}
|
||||
|
||||
sample.httpMethod = request.httpMethod
|
||||
sample.initiatorType = "fetch"
|
||||
// instrumented requests that dont use the completion handler wont have the duration set
|
||||
if let date = date {
|
||||
sample.duration = (date.toMillis() - sample.timeOrigin.toMillis())
|
||||
}
|
||||
|
||||
// the UI special case if the transferSize is 0 as coming from cache
|
||||
let transferSize = Int64(request.httpBody?.count ?? 0) + (task.response?.expectedContentLength ?? 0)
|
||||
if transferSize > 0 {
|
||||
sample.decodedBodySize = transferSize
|
||||
}
|
||||
|
||||
finish(task: task, sample: sample)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func urlResponseStatusCode(response: URLResponse?) -> Int {
|
||||
if let urlResponse = response as? HTTPURLResponse {
|
||||
return urlResponse.statusCode
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private func finish(task: URLSessionTask, sample: NetworkSample) {
|
||||
if shouldCapture() {
|
||||
onCapture(sample)
|
||||
}
|
||||
|
||||
tasksLock.withLock {
|
||||
_ = samplesByTask.removeValue(forKey: task)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishAll() {
|
||||
var completedTasks: [URLSessionTask: NetworkSample] = [:]
|
||||
tasksLock.withLock {
|
||||
for item in samplesByTask where item.key.state == .completed {
|
||||
completedTasks[item.key] = item.value
|
||||
}
|
||||
}
|
||||
|
||||
for item in completedTasks {
|
||||
var value = item.value
|
||||
finish(task: item.key, sample: &value)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
tasksLock.withLock {
|
||||
samplesByTask.removeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
251
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift
generated
Normal file
251
Pods/PostHog/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift
generated
Normal file
@@ -0,0 +1,251 @@
|
||||
// swiftlint:disable nesting
|
||||
|
||||
/*
|
||||
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
|
||||
* This product includes software developed at Datadog (https://www.datadoghq.com/).
|
||||
* Copyright 2019-Present Datadog, Inc.
|
||||
*/
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
|
||||
class URLSessionSwizzler {
|
||||
/// `URLSession.dataTask(with:completionHandler:)` (for `URLRequest`) swizzling.
|
||||
private let dataTaskWithURLRequestAndCompletion: DataTaskWithURLRequestAndCompletion
|
||||
/// `URLSession.dataTask(with:)` (for `URLRequest`) swizzling.
|
||||
private let dataTaskWithURLRequest: DataTaskWithURLRequest
|
||||
|
||||
/// `URLSession.dataTask(with:completionHandler:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
|
||||
private let dataTaskWithURLAndCompletion: DataTaskWithURLAndCompletion?
|
||||
/// `URLSession.dataTask(with:)` (for `URL`) swizzling. Only applied on iOS 13 and above.
|
||||
private let dataTaskWithURL: DataTaskWithURL?
|
||||
|
||||
private let interceptor: URLSessionInterceptor
|
||||
|
||||
private var hasSwizzled = false
|
||||
|
||||
init(shouldCapture: @escaping () -> Bool, onCapture: @escaping (NetworkSample) -> Void, getSessionId: @escaping (Date) -> String?) throws {
|
||||
interceptor = URLSessionInterceptor(
|
||||
shouldCapture: shouldCapture,
|
||||
onCapture: onCapture,
|
||||
getSessionId: getSessionId
|
||||
)
|
||||
|
||||
dataTaskWithURLAndCompletion = try DataTaskWithURLAndCompletion.build(interceptor: interceptor)
|
||||
dataTaskWithURL = try DataTaskWithURL.build(interceptor: interceptor)
|
||||
|
||||
dataTaskWithURLRequestAndCompletion = try DataTaskWithURLRequestAndCompletion.build(interceptor: interceptor)
|
||||
dataTaskWithURLRequest = try DataTaskWithURLRequest.build(interceptor: interceptor)
|
||||
}
|
||||
|
||||
func swizzle() {
|
||||
dataTaskWithURLRequestAndCompletion.swizzle()
|
||||
dataTaskWithURLAndCompletion?.swizzle()
|
||||
dataTaskWithURLRequest.swizzle()
|
||||
dataTaskWithURL?.swizzle()
|
||||
hasSwizzled = true
|
||||
}
|
||||
|
||||
func unswizzle() {
|
||||
if !hasSwizzled {
|
||||
return
|
||||
}
|
||||
dataTaskWithURLRequestAndCompletion.unswizzle()
|
||||
dataTaskWithURLRequest.unswizzle()
|
||||
dataTaskWithURLAndCompletion?.unswizzle()
|
||||
dataTaskWithURL?.unswizzle()
|
||||
hasSwizzled = false
|
||||
}
|
||||
|
||||
// MARK: - Swizzlings
|
||||
|
||||
typealias CompletionHandler = (Data?, URLResponse?, Error?) -> Void
|
||||
|
||||
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URLRequest`.
|
||||
class DataTaskWithURLRequestAndCompletion: MethodSwizzler<
|
||||
@convention(c) (URLSession, Selector, URLRequest, CompletionHandler?) -> URLSessionDataTask,
|
||||
@convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
|
||||
> {
|
||||
private static let selector = #selector(
|
||||
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URLRequest, @escaping CompletionHandler) -> URLSessionDataTask
|
||||
)
|
||||
|
||||
private let method: FoundMethod
|
||||
private let interceptor: URLSessionInterceptor
|
||||
|
||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequestAndCompletion {
|
||||
try DataTaskWithURLRequestAndCompletion(
|
||||
selector: selector,
|
||||
klass: URLSession.self,
|
||||
interceptor: interceptor
|
||||
)
|
||||
}
|
||||
|
||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
||||
method = try Self.findMethod(with: selector, in: klass)
|
||||
self.interceptor = interceptor
|
||||
super.init()
|
||||
}
|
||||
|
||||
func swizzle() {
|
||||
typealias Signature = @convention(block) (URLSession, URLRequest, CompletionHandler?) -> URLSessionDataTask
|
||||
swizzle(method) { previousImplementation -> Signature in { session, urlRequest, completionHandler -> URLSessionDataTask in
|
||||
let task: URLSessionDataTask
|
||||
if completionHandler != nil {
|
||||
var taskReference: URLSessionDataTask?
|
||||
let newCompletionHandler: CompletionHandler = { data, response, error in
|
||||
if let task = taskReference { // sanity check, should always succeed
|
||||
self.interceptor.taskCompleted(task: task, error: error)
|
||||
}
|
||||
completionHandler?(data, response, error)
|
||||
}
|
||||
|
||||
task = previousImplementation(session, Self.selector, urlRequest, newCompletionHandler)
|
||||
taskReference = task
|
||||
} else {
|
||||
// The `completionHandler` can be `nil` in two cases:
|
||||
// - on iOS 11 or 12, where `dataTask(with:)` (for `URL` and `URLRequest`) calls
|
||||
// the `dataTask(with:completionHandler:)` (for `URLRequest`) internally by nullifying the completion block.
|
||||
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
|
||||
// `nil` as the `completionHandler` (it produces a warning, but compiles).
|
||||
task = previousImplementation(session, Self.selector, urlRequest, completionHandler)
|
||||
}
|
||||
self.interceptor.taskCreated(task: task, session: session)
|
||||
return task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swizzles the `URLSession.dataTask(with:completionHandler:)` for `URL`.
|
||||
class DataTaskWithURLAndCompletion: MethodSwizzler<
|
||||
@convention(c) (URLSession, Selector, URL, CompletionHandler?) -> URLSessionDataTask,
|
||||
@convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
|
||||
> {
|
||||
private static let selector = #selector(
|
||||
URLSession.dataTask(with:completionHandler:) as (URLSession) -> (URL, @escaping CompletionHandler) -> URLSessionDataTask
|
||||
)
|
||||
|
||||
private let method: FoundMethod
|
||||
private let interceptor: URLSessionInterceptor
|
||||
|
||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLAndCompletion {
|
||||
try DataTaskWithURLAndCompletion(
|
||||
selector: selector,
|
||||
klass: URLSession.self,
|
||||
interceptor: interceptor
|
||||
)
|
||||
}
|
||||
|
||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
||||
method = try Self.findMethod(with: selector, in: klass)
|
||||
self.interceptor = interceptor
|
||||
super.init()
|
||||
}
|
||||
|
||||
func swizzle() {
|
||||
typealias Signature = @convention(block) (URLSession, URL, CompletionHandler?) -> URLSessionDataTask
|
||||
swizzle(method) { previousImplementation -> Signature in { session, url, completionHandler -> URLSessionDataTask in
|
||||
let task: URLSessionDataTask
|
||||
if completionHandler != nil {
|
||||
var taskReference: URLSessionDataTask?
|
||||
let newCompletionHandler: CompletionHandler = { data, response, error in
|
||||
if let task = taskReference { // sanity check, should always succeed
|
||||
self.interceptor.taskCompleted(task: task, error: error)
|
||||
}
|
||||
completionHandler?(data, response, error)
|
||||
}
|
||||
task = previousImplementation(session, Self.selector, url, newCompletionHandler)
|
||||
taskReference = task
|
||||
} else {
|
||||
// The `completionHandler` can be `nil` in one case:
|
||||
// - when `[session dataTaskWithURL:completionHandler:]` is called in Objective-C with explicitly passing
|
||||
// `nil` as the `completionHandler` (it produces a warning, but compiles).
|
||||
task = previousImplementation(session, Self.selector, url, completionHandler)
|
||||
}
|
||||
self.interceptor.taskCreated(task: task, session: session)
|
||||
return task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swizzles the `URLSession.dataTask(with:)` for `URLRequest`.
|
||||
class DataTaskWithURLRequest: MethodSwizzler<
|
||||
@convention(c) (URLSession, Selector, URLRequest) -> URLSessionDataTask,
|
||||
@convention(block) (URLSession, URLRequest) -> URLSessionDataTask
|
||||
> {
|
||||
private static let selector = #selector(
|
||||
URLSession.dataTask(with:) as (URLSession) -> (URLRequest) -> URLSessionDataTask
|
||||
)
|
||||
|
||||
private let method: FoundMethod
|
||||
private let interceptor: URLSessionInterceptor
|
||||
|
||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURLRequest {
|
||||
try DataTaskWithURLRequest(
|
||||
selector: selector,
|
||||
klass: URLSession.self,
|
||||
interceptor: interceptor
|
||||
)
|
||||
}
|
||||
|
||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
||||
method = try Self.findMethod(with: selector, in: klass)
|
||||
self.interceptor = interceptor
|
||||
super.init()
|
||||
}
|
||||
|
||||
func swizzle() {
|
||||
typealias Signature = @convention(block) (URLSession, URLRequest) -> URLSessionDataTask
|
||||
swizzle(method) { previousImplementation -> Signature in { session, urlRequest -> URLSessionDataTask in
|
||||
let task = previousImplementation(session, Self.selector, urlRequest)
|
||||
self.interceptor.taskCreated(task: task, session: session)
|
||||
return task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swizzles the `URLSession.dataTask(with:)` for `URL`.
|
||||
class DataTaskWithURL: MethodSwizzler<
|
||||
@convention(c) (URLSession, Selector, URL) -> URLSessionDataTask,
|
||||
@convention(block) (URLSession, URL) -> URLSessionDataTask
|
||||
> {
|
||||
private static let selector = #selector(
|
||||
URLSession.dataTask(with:) as (URLSession) -> (URL) -> URLSessionDataTask
|
||||
)
|
||||
|
||||
private let method: FoundMethod
|
||||
private let interceptor: URLSessionInterceptor
|
||||
|
||||
static func build(interceptor: URLSessionInterceptor) throws -> DataTaskWithURL {
|
||||
try DataTaskWithURL(
|
||||
selector: selector,
|
||||
klass: URLSession.self,
|
||||
interceptor: interceptor
|
||||
)
|
||||
}
|
||||
|
||||
private init(selector: Selector, klass: AnyClass, interceptor: URLSessionInterceptor) throws {
|
||||
method = try Self.findMethod(with: selector, in: klass)
|
||||
self.interceptor = interceptor
|
||||
super.init()
|
||||
}
|
||||
|
||||
func swizzle() {
|
||||
typealias Signature = @convention(block) (URLSession, URL) -> URLSessionDataTask
|
||||
swizzle(method) { previousImplementation -> Signature in { session, url -> URLSessionDataTask in
|
||||
let task = previousImplementation(session, Self.selector, url)
|
||||
self.interceptor.taskCreated(task: task, session: session)
|
||||
return task
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// swiftlint:enable nesting
|
||||
43
Pods/PostHog/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift
generated
Normal file
43
Pods/PostHog/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift
generated
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// PostHogSessionReplayPlugin.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 12/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
/// Session replay plugins are used to capture specific types of meta data during a session,
|
||||
/// such as console logs, network requests and user interactions. Each plugin is responsible
|
||||
/// for managing its own capture lifecycle and sending data to PostHog.
|
||||
///
|
||||
/// Plugins are installed automatically based on the session replay configuration.
|
||||
protocol PostHogSessionReplayPlugin {
|
||||
/// Starts the plugin and begins data capture.
|
||||
///
|
||||
/// Called when session replay is started. The plugin should set up any required
|
||||
/// resources and begin capturing data.
|
||||
///
|
||||
/// - Parameter postHog: The PostHog SDK instance to use for sending data
|
||||
func start(postHog: PostHogSDK)
|
||||
|
||||
/// Stops the plugin and cleans up resources.
|
||||
///
|
||||
/// Called when session replay is stopped. The plugin should clean up any resources
|
||||
/// and stop capturing data.
|
||||
func stop()
|
||||
|
||||
/// Temporarily pauses data capture.
|
||||
///
|
||||
/// Called by session replay integration when plugin is requested to temporarily pause capturing data
|
||||
/// The plugin should pause data capture but maintain its state.
|
||||
func pause()
|
||||
|
||||
/// Resumes data capture after being paused.
|
||||
///
|
||||
/// Called by session replay integration when plugin is requested to resume normal capturing data
|
||||
/// The plugin should resume data capture from its previous state.
|
||||
func resume()
|
||||
}
|
||||
#endif
|
||||
865
Pods/PostHog/PostHog/Replay/PostHogReplayIntegration.swift
generated
Normal file
865
Pods/PostHog/PostHog/Replay/PostHogReplayIntegration.swift
generated
Normal file
@@ -0,0 +1,865 @@
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
|
||||
//
|
||||
// PostHogReplayIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 19.03.24.
|
||||
//
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
class PostHogReplayIntegration: PostHogIntegration {
|
||||
var requiresSwizzling: Bool { true }
|
||||
|
||||
private static var integrationInstalledLock = NSLock()
|
||||
private static var integrationInstalled = false
|
||||
|
||||
private var config: PostHogConfig? {
|
||||
postHog?.config
|
||||
}
|
||||
|
||||
private weak var postHog: PostHogSDK?
|
||||
|
||||
private var isEnabled: Bool = false
|
||||
|
||||
private let windowViewsLock = NSLock()
|
||||
private let windowViews = NSMapTable<UIWindow, ViewTreeSnapshotStatus>.weakToStrongObjects()
|
||||
private var applicationEventToken: RegistrationToken?
|
||||
private var applicationBackgroundedToken: RegistrationToken?
|
||||
private var applicationForegroundedToken: RegistrationToken?
|
||||
private var viewLayoutToken: RegistrationToken?
|
||||
private var installedPlugins: [PostHogSessionReplayPlugin] = []
|
||||
|
||||
/**
|
||||
### Mapping of SwiftUI Views to UIKit
|
||||
|
||||
This section summarizes findings on how SwiftUI views map to UIKit components
|
||||
|
||||
#### Image-Based Views
|
||||
- **`AsyncImage` and `Image`**
|
||||
- Both views have a `CALayer` of type `SwiftUI.ImageLayer`.
|
||||
- The associated `UIView` is of type `SwiftUI._UIGraphicsView`.
|
||||
|
||||
#### Graphic-based Views
|
||||
- **`Color`, `Divider`, `Gradient` etc
|
||||
- These are backed by `SwiftUI._UIGraphicsView` but have a different layer type than images
|
||||
|
||||
#### Text-Based Views
|
||||
- **`Text`, `Button`, and `TextEditor`**
|
||||
- These views are backed by a `UIView` of type `SwiftUI.CGDrawingView`, which is a subclass of `SwiftUI._UIGraphicsView`.
|
||||
- CoreGraphics (`CG`) is used for rendering text content directly, making it challenging to access the value programmatically.
|
||||
|
||||
#### UIKit-Mapped Views
|
||||
- **Views Hosted by `UIViewRepresentable`**
|
||||
- Some SwiftUI views map directly to UIKit classes or to a subclass:
|
||||
- **Control Images** (e.g., in `Picker` drop-downs) may map to `UIImageView`.
|
||||
- **Buttons** map to `SwiftUI.UIKitIconPreferringButton` (a subclass of `UIButton`).
|
||||
- **Toggle** maps to `UISwitch` (the toggle itself, excluding its label).
|
||||
- **Picker** with wheel style maps to `UIPickerView`. Other styles use combinations of image-based and text-based views.
|
||||
|
||||
#### Layout and Structure Views
|
||||
- **`Spacer`, `VStack`, `HStack`, `ZStack`, and Lazy Stacks**
|
||||
- These views do not correspond to specific a `UIView`. Instead, they translate directly into layout constraints.
|
||||
|
||||
#### List-Based Views
|
||||
- **`List` and Scrollable Container Views**
|
||||
- Backed by a subclass of `UICollectionView`
|
||||
|
||||
#### Other SwiftUI Views
|
||||
- Most other SwiftUI views are *compositions* of the views described above
|
||||
|
||||
SwiftUI Image Types:
|
||||
- [StackOverflow: Subviews of a Window or View in SwiftUI](https://stackoverflow.com/questions/57554590/how-to-get-all-the-subviews-of-a-window-or-view-in-latest-swiftui-app)
|
||||
- [StackOverflow: Detect SwiftUI Usage Programmatically](https://stackoverflow.com/questions/58336045/how-to-detect-swiftui-usage-programmatically-in-an-ios-application)
|
||||
*/
|
||||
|
||||
/// `AsyncImage` and `Image`
|
||||
private let swiftUIImageLayerTypes = [
|
||||
"SwiftUI.ImageLayer",
|
||||
].compactMap(NSClassFromString)
|
||||
|
||||
/// `Text`, `Button`, `TextEditor` views
|
||||
private let swiftUITextBasedViewTypes = [
|
||||
"SwiftUI.CGDrawingView", // Text, Button
|
||||
"SwiftUI.TextEditorTextView", // TextEditor
|
||||
"SwiftUI.VerticalTextView", // TextField, vertical axis
|
||||
].compactMap(NSClassFromString)
|
||||
|
||||
private let swiftUIGenericTypes = [
|
||||
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView",
|
||||
].compactMap(NSClassFromString)
|
||||
|
||||
private let reactNativeTextView: AnyClass? = NSClassFromString("RCTTextView")
|
||||
private let reactNativeImageView: AnyClass? = NSClassFromString("RCTImageView")
|
||||
// These are usually views that don't belong to the current process and are most likely sensitive
|
||||
private let systemSandboxedView: AnyClass? = NSClassFromString("_UIRemoteView")
|
||||
|
||||
// These layer types should be safe to ignore while masking
|
||||
private let swiftUISafeLayerTypes: [AnyClass] = [
|
||||
"SwiftUI.GradientLayer", // Views like LinearGradient, RadialGradient, or AngularGradient
|
||||
].compactMap(NSClassFromString)
|
||||
|
||||
static let dispatchQueue = DispatchQueue(label: "com.posthog.PostHogReplayIntegration",
|
||||
target: .global(qos: .utility))
|
||||
|
||||
private func isNotFlutter() -> Bool {
|
||||
// for the Flutter SDK, screen recordings are managed by Flutter SDK itself
|
||||
postHogSdkName != "posthog-flutter"
|
||||
}
|
||||
|
||||
func install(_ postHog: PostHogSDK) throws {
|
||||
try PostHogReplayIntegration.integrationInstalledLock.withLock {
|
||||
if PostHogReplayIntegration.integrationInstalled {
|
||||
throw InternalPostHogError(description: "Replay integration already installed to another PostHogSDK instance.")
|
||||
}
|
||||
PostHogReplayIntegration.integrationInstalled = true
|
||||
}
|
||||
|
||||
self.postHog = postHog
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func uninstall(_ postHog: PostHogSDK) {
|
||||
if self.postHog === postHog || self.postHog == nil {
|
||||
stop()
|
||||
self.postHog = nil
|
||||
PostHogReplayIntegration.integrationInstalledLock.withLock {
|
||||
PostHogReplayIntegration.integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard let postHog, !isEnabled else {
|
||||
return
|
||||
}
|
||||
|
||||
isEnabled = true
|
||||
// reset views when session id changes (or is cleared) so we can re-send new metadata (or full snapshot in the future)
|
||||
postHog.sessionManager.onSessionIdChanged = { [weak self] in
|
||||
self?.resetViews()
|
||||
}
|
||||
|
||||
// flutter captures snapshots, so we don't need to capture them here
|
||||
if isNotFlutter() {
|
||||
let interval = postHog.config.sessionReplayConfig.throttleDelay
|
||||
viewLayoutToken = DI.main.viewLayoutPublisher.onViewLayout(throttle: interval) { [weak self] in
|
||||
// called on main thread
|
||||
self?.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
// start listening to `UIApplication.sendEvent`
|
||||
let applicationEventPublisher = DI.main.applicationEventPublisher
|
||||
applicationEventToken = applicationEventPublisher.onApplicationEvent { [weak self] event, date in
|
||||
self?.handleApplicationEvent(event: event, date: date)
|
||||
}
|
||||
|
||||
// Install plugins
|
||||
let plugins = postHog.config.sessionReplayConfig.getPlugins()
|
||||
installedPlugins = []
|
||||
for plugin in plugins {
|
||||
plugin.start(postHog: postHog)
|
||||
installedPlugins.append(plugin)
|
||||
}
|
||||
|
||||
// Start listening to application background events and pause all plugins
|
||||
let applicationLifecyclePublisher = DI.main.appLifecyclePublisher
|
||||
applicationBackgroundedToken = applicationLifecyclePublisher.onDidEnterBackground { [weak self] in
|
||||
self?.pauseAllPlugins()
|
||||
}
|
||||
|
||||
// Start listening to application foreground events and resume all plugins
|
||||
applicationForegroundedToken = applicationLifecyclePublisher.onDidBecomeActive { [weak self] in
|
||||
self?.resumeAllPlugins()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isEnabled else { return }
|
||||
isEnabled = false
|
||||
resetViews()
|
||||
postHog?.sessionManager.onSessionIdChanged = {}
|
||||
|
||||
// stop listening to `UIApplication.sendEvent`
|
||||
applicationEventToken = nil
|
||||
// stop listening to Application lifecycle events
|
||||
applicationBackgroundedToken = nil
|
||||
applicationForegroundedToken = nil
|
||||
// stop listening to `UIView.layoutSubviews` events
|
||||
viewLayoutToken = nil
|
||||
|
||||
// stop plugins
|
||||
for plugin in installedPlugins {
|
||||
plugin.stop()
|
||||
}
|
||||
installedPlugins = []
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
isEnabled
|
||||
}
|
||||
|
||||
private func resetViews() {
|
||||
// Ensure thread-safe access to windowViews
|
||||
windowViewsLock.withLock {
|
||||
windowViews.removeAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseAllPlugins() {
|
||||
for plugin in installedPlugins {
|
||||
plugin.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeAllPlugins() {
|
||||
for plugin in installedPlugins {
|
||||
plugin.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleApplicationEvent(event: UIEvent, date: Date) {
|
||||
guard let postHog, postHog.isSessionReplayActive() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard event.type == .touches else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = UIApplication.getCurrentWindow() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let touches = event.touches(for: window) else {
|
||||
return
|
||||
}
|
||||
|
||||
// capture necessary touch information on the main thread before performing any asynchronous operations
|
||||
// - this ensures that UITouch associated objects like UIView, UIWindow, or [UIGestureRecognizer] are still valid.
|
||||
// - these objects may be released or erased by the system if accessed asynchronously, resulting in invalid/zeroed-out touch coordinates
|
||||
let touchInfo = touches.map {
|
||||
(phase: $0.phase, location: $0.location(in: window))
|
||||
}
|
||||
|
||||
PostHogReplayIntegration.dispatchQueue.async { [touchInfo, weak postHog = postHog] in
|
||||
// always make sure we have a fresh session id as early as possible
|
||||
guard let sessionId = postHog?.sessionManager.getSessionId(at: date) else {
|
||||
return
|
||||
}
|
||||
|
||||
// captured weakly since integration may have uninstalled by now
|
||||
guard let postHog else { return }
|
||||
|
||||
var snapshotsData: [Any] = []
|
||||
for touch in touchInfo {
|
||||
let phase = touch.phase
|
||||
|
||||
let type: Int
|
||||
if phase == .began {
|
||||
type = 7
|
||||
} else if phase == .ended {
|
||||
type = 9
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
// we keep a failsafe here just in case, but this will likely never be triggered
|
||||
guard touch.location != .zero else {
|
||||
continue
|
||||
}
|
||||
|
||||
let posX = touch.location.x.toInt()
|
||||
let posY = touch.location.y.toInt()
|
||||
|
||||
// if the id is 0, BE transformer will set it to the virtual bodyId
|
||||
let touchData: [String: Any] = ["id": 0, "pointerType": 2, "source": 2, "type": type, "x": posX, "y": posY]
|
||||
|
||||
let data: [String: Any] = ["type": 3, "data": touchData, "timestamp": date.toMillis()]
|
||||
snapshotsData.append(data)
|
||||
}
|
||||
if !snapshotsData.isEmpty {
|
||||
postHog.capture(
|
||||
"$snapshot",
|
||||
properties: [
|
||||
"$snapshot_source": "mobile",
|
||||
"$snapshot_data": snapshotsData,
|
||||
"$session_id": sessionId,
|
||||
],
|
||||
timestamp: date
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil, postHog: PostHogSDK) {
|
||||
var hasChanges = false
|
||||
|
||||
guard let wireframe = postHog.config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(window) : toWireframe(window) else {
|
||||
return
|
||||
}
|
||||
|
||||
// capture timestamp after snapshot was taken
|
||||
let timestampDate = Date()
|
||||
let timestamp = timestampDate.toMillis()
|
||||
|
||||
let snapshotStatus = windowViewsLock.withLock {
|
||||
windowViews.object(forKey: window) ?? ViewTreeSnapshotStatus()
|
||||
}
|
||||
|
||||
var snapshotsData: [Any] = []
|
||||
|
||||
if !snapshotStatus.sentMetaEvent {
|
||||
let size = window.bounds.size
|
||||
let width = size.width.toInt()
|
||||
let height = size.height.toInt()
|
||||
|
||||
var data: [String: Any] = ["width": width, "height": height]
|
||||
|
||||
if let screenName = screenName {
|
||||
data["href"] = screenName
|
||||
}
|
||||
|
||||
let snapshotData: [String: Any] = ["type": 4, "data": data, "timestamp": timestamp]
|
||||
snapshotsData.append(snapshotData)
|
||||
snapshotStatus.sentMetaEvent = true
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
windowViewsLock.withLock {
|
||||
windowViews.setObject(snapshotStatus, forKey: window)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: IncrementalSnapshot, type=2
|
||||
|
||||
PostHogReplayIntegration.dispatchQueue.async {
|
||||
// always make sure we have a fresh session id at correct timestamp
|
||||
guard let sessionId = postHog.sessionManager.getSessionId(at: timestampDate) else {
|
||||
return
|
||||
}
|
||||
|
||||
var wireframes: [Any] = []
|
||||
wireframes.append(wireframe.toDict())
|
||||
let initialOffset = ["top": 0, "left": 0]
|
||||
let data: [String: Any] = ["initialOffset": initialOffset, "wireframes": wireframes]
|
||||
let snapshotData: [String: Any] = ["type": 2, "data": data, "timestamp": timestamp]
|
||||
snapshotsData.append(snapshotData)
|
||||
|
||||
postHog.capture(
|
||||
"$snapshot",
|
||||
properties: [
|
||||
"$snapshot_source": "mobile",
|
||||
"$snapshot_data": snapshotsData,
|
||||
"$session_id": sessionId,
|
||||
],
|
||||
timestamp: timestampDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func setAlignment(_ alignment: NSTextAlignment, _ style: RRStyle) {
|
||||
if alignment == .center {
|
||||
style.verticalAlign = "center"
|
||||
style.horizontalAlign = "center"
|
||||
} else if alignment == .right {
|
||||
style.horizontalAlign = "right"
|
||||
} else if alignment == .left {
|
||||
style.horizontalAlign = "left"
|
||||
}
|
||||
}
|
||||
|
||||
private func setPadding(_ insets: UIEdgeInsets, _ style: RRStyle) {
|
||||
style.paddingTop = insets.top.toInt()
|
||||
style.paddingRight = insets.right.toInt()
|
||||
style.paddingBottom = insets.bottom.toInt()
|
||||
style.paddingLeft = insets.left.toInt()
|
||||
}
|
||||
|
||||
private func createBasicWireframe(_ view: UIView) -> RRWireframe {
|
||||
let wireframe = RRWireframe()
|
||||
|
||||
// since FE will render each node of the wireframe with position: fixed
|
||||
// we need to convert bounds to global screen coordinates
|
||||
// otherwise each view of depth > 1 will likely have an origin of 0,0 (which is the local origin)
|
||||
let frame = view.toAbsoluteRect(view.window)
|
||||
|
||||
wireframe.id = view.hash
|
||||
wireframe.posX = frame.origin.x.toInt()
|
||||
wireframe.posY = frame.origin.y.toInt()
|
||||
wireframe.width = frame.size.width.toInt()
|
||||
wireframe.height = frame.size.height.toInt()
|
||||
|
||||
return wireframe
|
||||
}
|
||||
|
||||
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))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: `TextField`, `SecureField` will land here
|
||||
if let textField = view as? UITextField {
|
||||
if isTextFieldSensitive(textField) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let reactNativeTextView = reactNativeTextView {
|
||||
if view.isKind(of: reactNativeTextView), config?.sessionReplayConfig.maskAllTextInputs == true {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: Some control images like the ones in `Picker` view may land here
|
||||
if let image = view as? UIImageView {
|
||||
if isImageViewSensitive(image) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let reactNativeImageView = reactNativeImageView {
|
||||
if view.isKind(of: reactNativeImageView), config?.sessionReplayConfig.maskAllImages == true {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
|
||||
if isLabelSensitive(label) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let webView = view as? WKWebView { // Link, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead
|
||||
// since we cannot mask the webview content, if masking texts or images are enabled
|
||||
// we mask the whole webview as well
|
||||
if isAnyInputSensitive(webView) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: `SwiftUI.UIKitIconPreferringButton` and other subclasses will land here
|
||||
if let button = view as? UIButton {
|
||||
if isButtonSensitive(button) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: `Toggle` (no text, labels are just rendered to Text (swiftUIImageTypes))
|
||||
if let theSwitch = view as? UISwitch {
|
||||
if isSwitchSensitive(theSwitch) {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// detect any views that don't belong to the current process (likely system views)
|
||||
if config?.sessionReplayConfig.maskAllSandboxedViews == true,
|
||||
let systemSandboxedView,
|
||||
view.isKind(of: systemSandboxedView)
|
||||
{
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
|
||||
// if its a generic type and has subviews, subviews have to be checked first
|
||||
let hasSubViews = !view.subviews.isEmpty
|
||||
|
||||
/// SwiftUI: `Picker` with .pickerStyle(.wheel) will land here
|
||||
if let picker = view as? UIPickerView {
|
||||
if isTextInputSensitive(picker), !hasSubViews {
|
||||
maskableWidgets.append(picker.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: Text based views like `Text`, `Button`, `TextEditor`
|
||||
if swiftUITextBasedViewTypes.contains(where: view.isKind(of:)) {
|
||||
if isTextInputSensitive(view), !hasSubViews {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/// SwiftUI: Image based views like `Image`, `AsyncImage`. (Note: We check the layer type here)
|
||||
if swiftUIImageLayerTypes.contains(where: view.layer.isKind(of:)) {
|
||||
if isSwiftUIImageSensitive(view), !hasSubViews {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// this can be anything, so better to be conservative
|
||||
if swiftUIGenericTypes.contains(where: { view.isKind(of: $0) }), !isSwiftUILayerSafe(view.layer) {
|
||||
if isTextInputSensitive(view), !hasSubViews {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// manually masked views through `.postHogMask()` view modifier
|
||||
if view.postHogNoCapture {
|
||||
maskableWidgets.append(view.toAbsoluteRect(window))
|
||||
return
|
||||
}
|
||||
|
||||
// on RN, lots get converted to RCTRootContentView, RCTRootView, RCTView and sometimes its just the whole screen, we dont want to mask
|
||||
// in such cases
|
||||
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(view.toAbsoluteRect(window))
|
||||
} else {
|
||||
maskChildren = true
|
||||
}
|
||||
}
|
||||
|
||||
if !view.subviews.isEmpty {
|
||||
for child in view.subviews {
|
||||
if !child.isVisible() {
|
||||
continue
|
||||
}
|
||||
|
||||
findMaskableWidgets(child, window, &maskableWidgets, &maskChildren)
|
||||
}
|
||||
}
|
||||
maskChildren = false
|
||||
}
|
||||
|
||||
private func toScreenshotWireframe(_ window: UIWindow) -> RRWireframe? {
|
||||
// this will bail on view controller animations (interactive or not)
|
||||
if !window.isVisible() || isAnimatingTransition(window) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var maskableWidgets: [CGRect] = []
|
||||
var maskChildren = false
|
||||
findMaskableWidgets(window, window, &maskableWidgets, &maskChildren)
|
||||
|
||||
let wireframe = createBasicWireframe(window)
|
||||
|
||||
if let image = window.toImage() {
|
||||
if !image.size.hasSize() {
|
||||
return nil
|
||||
}
|
||||
|
||||
wireframe.maskableWidgets = maskableWidgets
|
||||
|
||||
wireframe.image = image
|
||||
}
|
||||
wireframe.type = "screenshot"
|
||||
return wireframe
|
||||
}
|
||||
|
||||
/// Check if any view controller in the hierarchy is animating a transition
|
||||
private func isAnimatingTransition(_ window: UIWindow) -> Bool {
|
||||
guard let rootViewController = window.rootViewController else { return false }
|
||||
return isAnimatingTransition(rootViewController)
|
||||
}
|
||||
|
||||
private func isAnimatingTransition(_ viewController: UIViewController) -> Bool {
|
||||
// Check if this view controller is animating
|
||||
if viewController.transitionCoordinator?.isAnimated ?? false {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if presented view controller is animating
|
||||
if let presented = viewController.presentedViewController, isAnimatingTransition(presented) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if any of the child view controllers is animating
|
||||
if viewController.children.first(where: isAnimatingTransition) != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isAssetsImage(_ image: UIImage) -> Bool {
|
||||
// https://github.com/daydreamboy/lldb_scripts#9-pimage
|
||||
// do not mask if its an asset image, likely not PII anyway
|
||||
image.imageAsset?.value(forKey: "_containingBundle") != nil
|
||||
}
|
||||
|
||||
private func isAnyInputSensitive(_ view: UIView) -> Bool {
|
||||
isTextInputSensitive(view) || config?.sessionReplayConfig.maskAllImages == true
|
||||
}
|
||||
|
||||
private func isTextInputSensitive(_ view: UIView) -> Bool {
|
||||
config?.sessionReplayConfig.maskAllTextInputs == true || view.isNoCapture()
|
||||
}
|
||||
|
||||
private func isLabelSensitive(_ view: UILabel) -> Bool {
|
||||
isTextInputSensitive(view) && hasText(view.text)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func isSwitchSensitive(_ view: UISwitch) -> Bool {
|
||||
var containsText = true
|
||||
if #available(iOS 14.0, *) {
|
||||
containsText = hasText(view.title)
|
||||
}
|
||||
|
||||
return isTextInputSensitive(view) && containsText
|
||||
}
|
||||
|
||||
private func isTextFieldSensitive(_ view: UITextField) -> Bool {
|
||||
(isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder))
|
||||
}
|
||||
|
||||
private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool {
|
||||
swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) })
|
||||
}
|
||||
|
||||
private func hasText(_ text: String?) -> Bool {
|
||||
if let text = text, !text.isEmpty {
|
||||
return true
|
||||
} else {
|
||||
// if there's no text, there's nothing to mask
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func isSwiftUIImageSensitive(_ view: UIView) -> Bool {
|
||||
// No way of checking if this is an asset image or not
|
||||
// No way of checking if there's actual content in the image or not
|
||||
config?.sessionReplayConfig.maskAllImages == true || view.isNoCapture()
|
||||
}
|
||||
|
||||
private func isImageViewSensitive(_ view: UIImageView) -> Bool {
|
||||
// if there's no image, there's nothing to mask
|
||||
guard let image = view.image else { return false }
|
||||
|
||||
// sensitive, regardless
|
||||
if view.isNoCapture() {
|
||||
return true
|
||||
}
|
||||
|
||||
// asset images are probably not sensitive
|
||||
if isAssetsImage(image) {
|
||||
return false
|
||||
}
|
||||
|
||||
// symbols are probably not sensitive
|
||||
if image.isSymbolImage {
|
||||
return false
|
||||
}
|
||||
|
||||
return config?.sessionReplayConfig.maskAllImages == true
|
||||
}
|
||||
|
||||
private func toWireframe(_ view: UIView) -> RRWireframe? {
|
||||
if !view.isVisible() {
|
||||
return nil
|
||||
}
|
||||
|
||||
let wireframe = createBasicWireframe(view)
|
||||
|
||||
let style = RRStyle()
|
||||
|
||||
if let textView = view as? UITextView {
|
||||
wireframe.type = "text"
|
||||
wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text
|
||||
wireframe.disabled = !textView.isEditable
|
||||
style.color = textView.textColor?.toRGBString()
|
||||
style.fontFamily = textView.font?.familyName
|
||||
if let fontSize = textView.font?.pointSize.toInt() {
|
||||
style.fontSize = fontSize
|
||||
}
|
||||
setAlignment(textView.textAlignment, style)
|
||||
setPadding(textView.textContainerInset, style)
|
||||
}
|
||||
|
||||
if let textField = view as? UITextField {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "text_area"
|
||||
let isSensitive = isTextFieldSensitive(textField)
|
||||
if let text = textField.text {
|
||||
wireframe.value = isSensitive ? text.mask() : text
|
||||
} else {
|
||||
if let text = textField.placeholder {
|
||||
wireframe.value = isSensitive ? text.mask() : text
|
||||
}
|
||||
}
|
||||
wireframe.disabled = !textField.isEnabled
|
||||
style.color = textField.textColor?.toRGBString()
|
||||
style.fontFamily = textField.font?.familyName
|
||||
if let fontSize = textField.font?.pointSize.toInt() {
|
||||
style.fontSize = fontSize
|
||||
}
|
||||
setAlignment(textField.textAlignment, style)
|
||||
}
|
||||
|
||||
if view is UIPickerView {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "select"
|
||||
// set wireframe.value from selected row
|
||||
}
|
||||
|
||||
if let theSwitch = view as? UISwitch {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "toggle"
|
||||
wireframe.checked = theSwitch.isOn
|
||||
if #available(iOS 14.0, *) {
|
||||
if let text = theSwitch.title {
|
||||
wireframe.label = isSwitchSensitive(theSwitch) ? text.mask() : text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let imageView = view as? UIImageView {
|
||||
wireframe.type = "image"
|
||||
if let image = imageView.image {
|
||||
if !isImageViewSensitive(imageView) {
|
||||
wireframe.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let button = view as? UIButton {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "button"
|
||||
wireframe.disabled = !button.isEnabled
|
||||
|
||||
if let text = button.titleLabel?.text {
|
||||
// NOTE: this will create a ghosting effect since text will also be captured in child UILabel
|
||||
// We also may be masking this UIButton but child UILabel may remain unmasked
|
||||
wireframe.value = isButtonSensitive(button) ? text.mask() : text
|
||||
}
|
||||
}
|
||||
|
||||
if let label = view as? UILabel {
|
||||
wireframe.type = "text"
|
||||
if let text = label.text {
|
||||
wireframe.text = isLabelSensitive(label) ? text.mask() : text
|
||||
}
|
||||
wireframe.disabled = !label.isEnabled
|
||||
style.color = label.textColor?.toRGBString()
|
||||
style.fontFamily = label.font?.familyName
|
||||
if let fontSize = label.font?.pointSize.toInt() {
|
||||
style.fontSize = fontSize
|
||||
}
|
||||
setAlignment(label.textAlignment, style)
|
||||
}
|
||||
|
||||
if view is WKWebView {
|
||||
wireframe.type = "web_view"
|
||||
}
|
||||
|
||||
if let progressView = view as? UIProgressView {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "progress"
|
||||
wireframe.value = progressView.progress
|
||||
wireframe.max = 1
|
||||
// UIProgressView theres not circular format, only custom view or swiftui
|
||||
style.bar = "horizontal"
|
||||
}
|
||||
|
||||
if view is UIActivityIndicatorView {
|
||||
wireframe.type = "input"
|
||||
wireframe.inputType = "progress"
|
||||
style.bar = "circular"
|
||||
}
|
||||
|
||||
// TODO: props: backgroundImage (probably not needed)
|
||||
// TODO: componenets: UITabBar, UINavigationBar, UISlider, UIStepper, UIDatePicker
|
||||
|
||||
style.backgroundColor = view.backgroundColor?.toRGBString()
|
||||
let layer = view.layer
|
||||
style.borderWidth = layer.borderWidth.toInt()
|
||||
style.borderRadius = layer.cornerRadius.toInt()
|
||||
style.borderColor = layer.borderColor?.toRGBString()
|
||||
|
||||
wireframe.style = style
|
||||
|
||||
if !view.subviews.isEmpty {
|
||||
var childWireframes: [RRWireframe] = []
|
||||
for subview in view.subviews {
|
||||
if let child = toWireframe(subview) {
|
||||
childWireframes.append(child)
|
||||
}
|
||||
}
|
||||
wireframe.childWireframes = childWireframes
|
||||
}
|
||||
|
||||
return wireframe
|
||||
}
|
||||
|
||||
@objc private func snapshot() {
|
||||
guard let postHog, postHog.isSessionReplayActive() else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = UIApplication.getCurrentWindow() else {
|
||||
return
|
||||
}
|
||||
|
||||
var screenName: String?
|
||||
if let controller = window.rootViewController {
|
||||
// SwiftUI only supported with screenshotMode
|
||||
if controller is AnyObjectUIHostingViewController, !postHog.config.sessionReplayConfig.screenshotMode {
|
||||
hedgeLog("SwiftUI snapshot not supported, enable screenshotMode.")
|
||||
return
|
||||
// screen name only makes sense if we are not using SwiftUI
|
||||
} else if !postHog.config.sessionReplayConfig.screenshotMode {
|
||||
screenName = UIViewController.getViewControllerName(controller)
|
||||
}
|
||||
}
|
||||
|
||||
// this cannot run off of the main thread because most properties require to be called within the main thread
|
||||
// this method has to be fast and do as little as possible
|
||||
generateSnapshot(window, screenName, postHog: postHog)
|
||||
}
|
||||
}
|
||||
|
||||
private protocol AnyObjectUIHostingViewController: AnyObject {}
|
||||
|
||||
extension UIHostingController: AnyObjectUIHostingViewController {}
|
||||
|
||||
#if TESTING
|
||||
extension PostHogReplayIntegration {
|
||||
static func clearInstalls() {
|
||||
integrationInstalledLock.withLock {
|
||||
integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
93
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConfig.swift
generated
Normal file
93
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConfig.swift
generated
Normal file
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// PostHogSessionReplayConfig.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 19.03.24.
|
||||
//
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
@objc(PostHogSessionReplayConfig) public class PostHogSessionReplayConfig: NSObject {
|
||||
/// Enable masking of all text and text input fields
|
||||
/// Default: true
|
||||
@objc public var maskAllTextInputs: Bool = true
|
||||
|
||||
/// Enable masking of all images to a placeholder
|
||||
/// Default: true
|
||||
@objc public var maskAllImages: Bool = true
|
||||
|
||||
/// Enable masking of all sandboxed system views
|
||||
/// These may include UIImagePickerController, PHPickerViewController and CNContactPickerViewController
|
||||
/// Default: true
|
||||
@objc public var maskAllSandboxedViews: Bool = true
|
||||
|
||||
/// Enable masking of images that likely originated from user's photo library (UIKit only)
|
||||
/// Default: false
|
||||
///
|
||||
/// - Note: Deprecated
|
||||
@available(*, deprecated, message: "This property has no effect and will be removed in the next major release. To learn how to manually mask user photos please see our Privacy controls documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS")
|
||||
@objc public var maskPhotoLibraryImages: Bool = false
|
||||
|
||||
/// Enable capturing network telemetry
|
||||
/// Default: true
|
||||
@objc public var captureNetworkTelemetry: Bool = true
|
||||
|
||||
/// By default Session replay will capture all the views on the screen as a wireframe,
|
||||
/// By enabling this option, PostHog will capture the screenshot of the screen.
|
||||
/// The screenshot may contain sensitive information, use with caution.
|
||||
/// Default: false
|
||||
@objc public var screenshotMode: Bool = false
|
||||
|
||||
/// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact
|
||||
/// This is used for capturing the view as a wireframe or screenshot
|
||||
/// The lower the number more snapshots will be captured but higher the performance impact
|
||||
/// Defaults to 1s
|
||||
@available(*, deprecated, message: "Deprecated in favor of 'throttleDelay' which provides identical functionality. Will be removed in the next major release.")
|
||||
@objc public var debouncerDelay: TimeInterval {
|
||||
get { throttleDelay }
|
||||
set { throttleDelay = newValue }
|
||||
}
|
||||
|
||||
/// Throttle delay used to reduce the number of snapshots captured and reduce performance impact
|
||||
/// This is used for capturing the view as a wireframe or screenshot
|
||||
/// The lower the number more snapshots will be captured but higher the performance impact
|
||||
/// Defaults to 1s
|
||||
///
|
||||
/// Note: Previously `debouncerDelay`
|
||||
@objc public var throttleDelay: TimeInterval = 1
|
||||
|
||||
/// Enable capturing console output for session replay.
|
||||
///
|
||||
/// When enabled, logs from the following sources will be captured:
|
||||
/// - Standard output (stdout)
|
||||
/// - Standard error (stderr)
|
||||
/// - OSLog messages
|
||||
/// - NSLog messages
|
||||
///
|
||||
/// Each log entry will be tagged with a level (info/warning/error) based on the message content
|
||||
/// and the source.
|
||||
///
|
||||
/// Defaults to `false`
|
||||
@objc public var captureLogs: Bool = false
|
||||
|
||||
/// Further configuration for capturing console output
|
||||
@objc public var captureLogsConfig: PostHogSessionReplayConsoleLogConfig = .init()
|
||||
|
||||
// TODO: sessionRecording config such as networkPayloadCapture, sampleRate, etc
|
||||
|
||||
/// Returns an array of plugins to be installed based on current configuration
|
||||
func getPlugins() -> [PostHogSessionReplayPlugin] {
|
||||
var plugins: [PostHogSessionReplayPlugin] = []
|
||||
|
||||
if captureLogs {
|
||||
plugins.append(PostHogSessionReplayConsoleLogsPlugin())
|
||||
}
|
||||
|
||||
if captureNetworkTelemetry {
|
||||
plugins.append(PostHogSessionReplayNetworkPlugin())
|
||||
}
|
||||
|
||||
return plugins
|
||||
}
|
||||
}
|
||||
#endif
|
||||
73
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift
generated
Normal file
73
Pods/PostHog/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift
generated
Normal file
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// PostHogSessionReplayConsoleLogConfig.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 09/05/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
|
||||
@objc public class PostHogSessionReplayConsoleLogConfig: NSObject {
|
||||
/// Block to process and format captured console output for session replay.
|
||||
///
|
||||
/// This block is called whenever console output is captured. It allows you to:
|
||||
/// 1. Filter or modify log messages before they are sent to session replay
|
||||
/// 2. Determine the appropriate log level (info/warn/error) for each message
|
||||
/// 3. Format, sanitize or skip a log messages (e.g. remove sensitive data or PII)
|
||||
///
|
||||
/// The default implementation:
|
||||
/// - Detect log level (best effort)
|
||||
/// - Process OSLog messages to remove metadata
|
||||
///
|
||||
/// - Parameter output: The raw console output to process
|
||||
/// - Returns: Array of `PostHogConsoleLogResult` objects, one for each processed log entry. Return an empty array to skip a log output
|
||||
@objc public var logSanitizer: ((String) -> PostHogLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer
|
||||
|
||||
/// The minimum log level to capture in session replay.
|
||||
/// Only log messages with this level or higher will be captured.
|
||||
/// For example, if set to `.warn`:
|
||||
/// - `.error` messages will be captured
|
||||
/// - `.warn` messages will be captured
|
||||
/// - `.info` messages will be skipped
|
||||
///
|
||||
/// Defaults to `.error` to minimize noise in session replays.
|
||||
@objc public var minLogLevel: PostHogLogLevel = .error
|
||||
|
||||
/// Default implementation for processing console output.
|
||||
static func defaultLogSanitizer(_ message: String) -> PostHogLogEntry? {
|
||||
let message = String(message)
|
||||
// Determine console log level
|
||||
let level: PostHogLogLevel = {
|
||||
if message.range(of: logMessageWarningPattern, options: .regularExpression) != nil { return .warn }
|
||||
if message.range(of: logMessageErrorPattern, options: .regularExpression) != nil { return .error }
|
||||
return .info
|
||||
}()
|
||||
|
||||
// For OSLog messages, extract just the log message part
|
||||
let sanitizedMessage = message.contains("OSLOG-") ? {
|
||||
if let tabIndex = message.lastIndex(of: "\t") {
|
||||
return String(message[message.index(after: tabIndex)...])
|
||||
}
|
||||
return message
|
||||
}() : message
|
||||
|
||||
return PostHogLogEntry(level: level, message: sanitizedMessage)
|
||||
}
|
||||
|
||||
/// Default regular expression pattern used to identify error-level log messages.
|
||||
///
|
||||
/// By default, it matches common error indicators such as:
|
||||
/// - The word "error", "exception", "fail" or "failed"
|
||||
/// - OSLog messages with type "Error" or "Fault"
|
||||
private static let logMessageErrorPattern = "(error|exception|fail(ed)?|OSLOG-.*type:\"Error\"|OSLOG-.*type:\"Fault\")"
|
||||
|
||||
/// Default regular expression pattern used to identify warning-level log messages.
|
||||
///
|
||||
/// By default, it matches common warning indicators such as:
|
||||
/// - The words "warning", "warn", "caution", or "deprecated"
|
||||
/// - OSLog messages with type "Warning"
|
||||
///
|
||||
private static let logMessageWarningPattern = "(warn(ing)?|caution|deprecated|OSLOG-.*type:\"Warning\")"
|
||||
}
|
||||
#endif
|
||||
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
Normal file
96
Pods/PostHog/PostHog/Replay/RRStyle.swift
generated
Normal file
@@ -0,0 +1,96 @@
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
|
||||
//
|
||||
// RRStyle.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class RRStyle {
|
||||
var color: String?
|
||||
var backgroundColor: String?
|
||||
var backgroundImage: String?
|
||||
var borderWidth: Int?
|
||||
var borderRadius: Int?
|
||||
var borderColor: String?
|
||||
var fontSize: Int?
|
||||
var fontFamily: String?
|
||||
var horizontalAlign: String?
|
||||
var verticalAlign: String?
|
||||
var paddingTop: Int?
|
||||
var paddingBottom: Int?
|
||||
var paddingLeft: Int?
|
||||
var paddingRight: Int?
|
||||
var bar: String?
|
||||
|
||||
func toDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [:]
|
||||
|
||||
if let color = color {
|
||||
dict["color"] = color
|
||||
}
|
||||
|
||||
if let backgroundColor = backgroundColor {
|
||||
dict["backgroundColor"] = backgroundColor
|
||||
}
|
||||
|
||||
if let backgroundImage = backgroundImage {
|
||||
dict["backgroundImage"] = backgroundImage
|
||||
}
|
||||
|
||||
if let borderWidth = borderWidth {
|
||||
dict["borderWidth"] = borderWidth
|
||||
}
|
||||
|
||||
if let borderRadius = borderRadius {
|
||||
dict["borderRadius"] = borderRadius
|
||||
}
|
||||
|
||||
if let borderColor = borderColor {
|
||||
dict["borderColor"] = borderColor
|
||||
}
|
||||
|
||||
if let fontSize = fontSize {
|
||||
dict["fontSize"] = fontSize
|
||||
}
|
||||
|
||||
if let fontFamily = fontFamily {
|
||||
dict["fontFamily"] = fontFamily
|
||||
}
|
||||
|
||||
if let horizontalAlign = horizontalAlign {
|
||||
dict["horizontalAlign"] = horizontalAlign
|
||||
}
|
||||
|
||||
if let verticalAlign = verticalAlign {
|
||||
dict["verticalAlign"] = verticalAlign
|
||||
}
|
||||
|
||||
if let paddingTop = paddingTop {
|
||||
dict["paddingTop"] = paddingTop
|
||||
}
|
||||
|
||||
if let paddingBottom = paddingBottom {
|
||||
dict["paddingBottom"] = paddingBottom
|
||||
}
|
||||
|
||||
if let paddingLeft = paddingLeft {
|
||||
dict["paddingLeft"] = paddingLeft
|
||||
}
|
||||
|
||||
if let paddingRight = paddingRight {
|
||||
dict["paddingRight"] = paddingRight
|
||||
}
|
||||
|
||||
if let bar = bar {
|
||||
dict["bar"] = bar
|
||||
}
|
||||
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
Normal file
133
Pods/PostHog/PostHog/Replay/RRWireframe.swift
generated
Normal file
@@ -0,0 +1,133 @@
|
||||
// swiftlint:disable cyclomatic_complexity
|
||||
|
||||
//
|
||||
// RRWireframe.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
class RRWireframe {
|
||||
var id: Int = 0
|
||||
var posX: Int = 0
|
||||
var posY: Int = 0
|
||||
var width: Int = 0
|
||||
var height: Int = 0
|
||||
var childWireframes: [RRWireframe]?
|
||||
var type: String? // text|image|rectangle|input|div|screenshot
|
||||
var inputType: String?
|
||||
var text: String?
|
||||
var label: String?
|
||||
var value: Any? // string or number
|
||||
#if os(iOS)
|
||||
var image: UIImage?
|
||||
var maskableWidgets: [CGRect]?
|
||||
#endif
|
||||
var base64: String?
|
||||
var style: RRStyle?
|
||||
var disabled: Bool?
|
||||
var checked: Bool?
|
||||
var options: [String]?
|
||||
var max: Int?
|
||||
// internal
|
||||
var parentId: Int?
|
||||
|
||||
#if os(iOS)
|
||||
private func maskImage() -> UIImage? {
|
||||
if let image = image {
|
||||
// the scale also affects the image size/resolution, from usually 100kb to 15kb each
|
||||
let redactedImage = UIGraphicsImageRenderer(size: image.size, format: .init(for: .init(displayScale: 1))).image { context in
|
||||
context.cgContext.interpolationQuality = .none
|
||||
image.draw(at: .zero)
|
||||
|
||||
if let maskableWidgets = maskableWidgets {
|
||||
for rect in maskableWidgets {
|
||||
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10)
|
||||
UIColor.black.setFill()
|
||||
path.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
return redactedImage
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
func toDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"id": id,
|
||||
"x": posX,
|
||||
"y": posY,
|
||||
"width": width,
|
||||
"height": height,
|
||||
]
|
||||
|
||||
if let childWireframes = childWireframes {
|
||||
dict["childWireframes"] = childWireframes.map { $0.toDict() }
|
||||
}
|
||||
|
||||
if let type = type {
|
||||
dict["type"] = type
|
||||
}
|
||||
|
||||
if let inputType = inputType {
|
||||
dict["inputType"] = inputType
|
||||
}
|
||||
|
||||
if let text = text {
|
||||
dict["text"] = text
|
||||
}
|
||||
|
||||
if let label = label {
|
||||
dict["label"] = label
|
||||
}
|
||||
|
||||
if let value = value {
|
||||
dict["value"] = value
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let image = image {
|
||||
if let maskedImage = maskImage() {
|
||||
base64 = maskedImage.toBase64()
|
||||
} else {
|
||||
base64 = image.toBase64()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if let base64 = base64 {
|
||||
dict["base64"] = base64
|
||||
}
|
||||
|
||||
if let style = style {
|
||||
dict["style"] = style.toDict()
|
||||
}
|
||||
|
||||
if let disabled = disabled {
|
||||
dict["disabled"] = disabled
|
||||
}
|
||||
|
||||
if let checked = checked {
|
||||
dict["checked"] = checked
|
||||
}
|
||||
|
||||
if let options = options {
|
||||
dict["options"] = options
|
||||
}
|
||||
|
||||
if let max = max {
|
||||
dict["max"] = max
|
||||
}
|
||||
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable cyclomatic_complexity
|
||||
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
Normal file
14
Pods/PostHog/PostHog/Replay/String+Util.swift
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// String+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
func mask() -> String {
|
||||
String(repeating: "*", count: count)
|
||||
}
|
||||
}
|
||||
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
Normal file
17
Pods/PostHog/PostHog/Replay/UIColor+Util.swift
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// UIColor+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UIColor {
|
||||
func toRGBString() -> String? {
|
||||
cgColor.toRGBString()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
33
Pods/PostHog/PostHog/Replay/UIImage+Util.swift
generated
Normal file
33
Pods/PostHog/PostHog/Replay/UIImage+Util.swift
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// UIImage+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 27.11.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UIImage {
|
||||
func toBase64(_ compressionQuality: CGFloat = 0.3) -> String? {
|
||||
toWebPBase64(compressionQuality) ?? toJpegBase64(compressionQuality)
|
||||
}
|
||||
|
||||
private func toWebPBase64(_ compressionQuality: CGFloat) -> String? {
|
||||
webpData(compressionQuality: compressionQuality).map { data in
|
||||
"data:image/webp;base64,\(data.base64EncodedString())"
|
||||
}
|
||||
}
|
||||
|
||||
private func toJpegBase64(_ compressionQuality: CGFloat) -> String? {
|
||||
jpegData(compressionQuality: compressionQuality).map { data in
|
||||
"data:image/jpeg;base64,\(data.base64EncodedString())"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? {
|
||||
image.toBase64(compressionQuality)
|
||||
}
|
||||
#endif
|
||||
36
Pods/PostHog/PostHog/Replay/UITextInputTraits+Util.swift
generated
Normal file
36
Pods/PostHog/PostHog/Replay/UITextInputTraits+Util.swift
generated
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// UITextInputTraits+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
private let sensibleTypes: [UITextContentType] = [
|
||||
.newPassword, .oneTimeCode, .creditCardNumber,
|
||||
.telephoneNumber, .emailAddress, .password,
|
||||
.username, .URL, .name, .nickname,
|
||||
.middleName, .familyName, .nameSuffix,
|
||||
.namePrefix, .organizationName, .location,
|
||||
.fullStreetAddress, .streetAddressLine1,
|
||||
.streetAddressLine2, .addressCity, .addressState,
|
||||
.addressCityAndState, .postalCode,
|
||||
]
|
||||
|
||||
extension UITextInputTraits {
|
||||
func isSensitiveText() -> Bool {
|
||||
if isSecureTextEntry ?? false {
|
||||
return true
|
||||
}
|
||||
|
||||
if let contentType = textContentType, let contentType = contentType {
|
||||
return sensibleTypes.contains(contentType)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
70
Pods/PostHog/PostHog/Replay/UIView+Util.swift
generated
Normal file
70
Pods/PostHog/PostHog/Replay/UIView+Util.swift
generated
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// UIView+Util.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 21.03.24.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
func isVisible() -> Bool {
|
||||
if isHidden || alpha == 0 || frame == .zero {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isNoCapture() -> Bool {
|
||||
var isNoCapture = false
|
||||
if let identifier = accessibilityIdentifier {
|
||||
isNoCapture = checkLabel(identifier)
|
||||
}
|
||||
// read accessibilityLabel from the parent's view to skip the RCTRecursiveAccessibilityLabel on RN which is slow and may cause an endless loop
|
||||
// see https://github.com/facebook/react-native/issues/33084
|
||||
if let label = super.accessibilityLabel, !isNoCapture {
|
||||
isNoCapture = checkLabel(label)
|
||||
}
|
||||
|
||||
return isNoCapture
|
||||
}
|
||||
|
||||
private func checkLabel(_ label: String) -> Bool {
|
||||
label.lowercased().contains("ph-no-capture")
|
||||
}
|
||||
|
||||
func toImage() -> UIImage? {
|
||||
// Avoid Rendering Offscreen Views
|
||||
let bounds = superview?.bounds ?? bounds
|
||||
let size = bounds.intersection(bounds).size
|
||||
|
||||
if !size.hasSize() {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rendererFormat = UIGraphicsImageRendererFormat.default()
|
||||
|
||||
// This can significantly improve rendering performance because the renderer won't need to
|
||||
// process transparency.
|
||||
rendererFormat.opaque = isOpaque
|
||||
// Another way to improve rendering performance is to scale the renderer's content.
|
||||
// rendererFormat.scale = 0.5
|
||||
let renderer = UIGraphicsImageRenderer(size: size, format: rendererFormat)
|
||||
|
||||
let image = renderer.image { _ in
|
||||
/// Note: Always `false` for `afterScreenUpdates` since this will cause the screen to flicker when a sensitive text field is visible on screen
|
||||
/// This can potentially affect capturing a snapshot during a screen transition but we want the lesser of the two evils here
|
||||
drawHierarchy(in: bounds, afterScreenUpdates: false)
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
// you need this because of SwiftUI otherwise the coordinates always zeroed for some reason
|
||||
func toAbsoluteRect(_ window: UIWindow?) -> CGRect {
|
||||
convert(bounds, to: window)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
15
Pods/PostHog/PostHog/Replay/ViewTreeSnapshotStatus.swift
generated
Normal file
15
Pods/PostHog/PostHog/Replay/ViewTreeSnapshotStatus.swift
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// ViewTreeSnapshotStatus.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Manoel Aranda Neto on 20.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class ViewTreeSnapshotStatus {
|
||||
var sentFullSnapshot: Bool = false
|
||||
var sentMetaEvent: Bool = false
|
||||
var keyboardVisible: Bool = false
|
||||
var lastSnapshot: Bool = false
|
||||
}
|
||||
44
Pods/PostHog/PostHog/Resources/PrivacyInfo.xcprivacy
generated
Normal file
44
Pods/PostHog/PostHog/Resources/PrivacyInfo.xcprivacy
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeProductInteraction</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeOtherUsageData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
172
Pods/PostHog/PostHog/Screen Views/ApplicationScreenViewPublisher.swift
generated
Normal file
172
Pods/PostHog/PostHog/Screen Views/ApplicationScreenViewPublisher.swift
generated
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// ApplicationScreenViewPublisher.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 20/02/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
typealias ScreenViewHandler = (String) -> Void
|
||||
|
||||
protocol ScreenViewPublishing: AnyObject {
|
||||
/// Registers a callback for a view appeared event
|
||||
func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken
|
||||
}
|
||||
|
||||
final class ApplicationScreenViewPublisher: BaseScreenViewPublisher {
|
||||
static let shared = ApplicationScreenViewPublisher()
|
||||
|
||||
private var hasSwizzled: Bool = false
|
||||
|
||||
func start() {
|
||||
// no-op if not UIKit
|
||||
#if os(iOS) || os(tvOS)
|
||||
swizzleViewDidAppear()
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
// no-op if not UIKit
|
||||
#if os(iOS) || os(tvOS)
|
||||
unswizzleViewDidAppear()
|
||||
#endif
|
||||
}
|
||||
|
||||
override func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onScreenViewCallbacks[id] = callback
|
||||
}
|
||||
|
||||
// start on first callback registration
|
||||
if !hasSwizzled {
|
||||
start()
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
let handlerCount = self.registrationLock.withLock {
|
||||
self.onScreenViewCallbacks[id] = nil
|
||||
return self.onScreenViewCallbacks.values.count
|
||||
}
|
||||
// stop when there are no more callbacks
|
||||
if handlerCount <= 0 {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
func swizzleViewDidAppear() {
|
||||
guard !hasSwizzled else { return }
|
||||
hasSwizzled = true
|
||||
swizzle(
|
||||
forClass: UIViewController.self,
|
||||
original: #selector(UIViewController.viewDidAppear(_:)),
|
||||
new: #selector(UIViewController.viewDidAppearOverride)
|
||||
)
|
||||
}
|
||||
|
||||
func unswizzleViewDidAppear() {
|
||||
guard hasSwizzled else { return }
|
||||
hasSwizzled = false
|
||||
swizzle(
|
||||
forClass: UIViewController.self,
|
||||
original: #selector(UIViewController.viewDidAppearOverride),
|
||||
new: #selector(UIViewController.viewDidAppear(_:))
|
||||
)
|
||||
}
|
||||
|
||||
// Called from swizzled `viewDidAppearOverride`
|
||||
fileprivate func viewDidAppear(in viewController: UIViewController?) {
|
||||
// ignore views from keyboard window
|
||||
guard let window = viewController?.viewIfLoaded?.window, !window.isKeyboardWindow else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let top = findVisibleViewController(viewController) else { return }
|
||||
|
||||
if let name = UIViewController.getViewControllerName(top) {
|
||||
notifyHandlers(screen: name)
|
||||
}
|
||||
}
|
||||
|
||||
private func findVisibleViewController(_ controller: UIViewController?) -> UIViewController? {
|
||||
if let navigationController = controller as? UINavigationController {
|
||||
return findVisibleViewController(navigationController.visibleViewController)
|
||||
}
|
||||
if let tabController = controller as? UITabBarController {
|
||||
if let selected = tabController.selectedViewController {
|
||||
return findVisibleViewController(selected)
|
||||
}
|
||||
}
|
||||
if let presented = controller?.presentedViewController {
|
||||
return findVisibleViewController(presented)
|
||||
}
|
||||
return controller
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class BaseScreenViewPublisher: ScreenViewPublishing {
|
||||
fileprivate let registrationLock = NSLock()
|
||||
|
||||
var onScreenViewCallbacks: [UUID: ScreenViewHandler] = [:]
|
||||
|
||||
func onScreenView(_ callback: @escaping ScreenViewHandler) -> RegistrationToken {
|
||||
let id = UUID()
|
||||
registrationLock.withLock {
|
||||
self.onScreenViewCallbacks[id] = callback
|
||||
}
|
||||
|
||||
return RegistrationToken { [weak self] in
|
||||
// Registration token deallocated here
|
||||
guard let self else { return }
|
||||
self.registrationLock.withLock {
|
||||
self.onScreenViewCallbacks[id] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notifyHandlers(screen: String) {
|
||||
let handlers = registrationLock.withLock { onScreenViewCallbacks.values }
|
||||
for handler in handlers {
|
||||
notifyHander(handler, screen: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyHander(_ handler: @escaping ScreenViewHandler, screen: String) {
|
||||
if Thread.isMainThread {
|
||||
handler(screen)
|
||||
} else {
|
||||
DispatchQueue.main.async { handler(screen) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
private extension UIViewController {
|
||||
@objc func viewDidAppearOverride(animated: Bool) {
|
||||
ApplicationScreenViewPublisher.shared.viewDidAppear(in: activeController)
|
||||
|
||||
// it looks like we're calling ourselves, but we're actually
|
||||
// calling the original implementation of viewDidAppear since it's been swizzled.
|
||||
viewDidAppearOverride(animated: animated)
|
||||
}
|
||||
|
||||
private var activeController: UIViewController? {
|
||||
// if a view is being dismissed, this will return nil
|
||||
if let root = viewIfLoaded?.window?.rootViewController {
|
||||
return root
|
||||
}
|
||||
// TODO: handle container controllers (see ph_topViewController)
|
||||
return UIApplication.getCurrentWindow()?.rootViewController
|
||||
}
|
||||
}
|
||||
#endif
|
||||
79
Pods/PostHog/PostHog/Screen Views/PostHogScreenViewIntegration.swift
generated
Normal file
79
Pods/PostHog/PostHog/Screen Views/PostHogScreenViewIntegration.swift
generated
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// PostHogScreenViewIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 20/02/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class PostHogScreenViewIntegration: PostHogIntegration {
|
||||
var requiresSwizzling: Bool { true }
|
||||
|
||||
private static var integrationInstalledLock = NSLock()
|
||||
private static var integrationInstalled = false
|
||||
|
||||
private weak var postHog: PostHogSDK?
|
||||
private var screenViewToken: RegistrationToken?
|
||||
|
||||
func install(_ postHog: PostHogSDK) throws {
|
||||
try PostHogScreenViewIntegration.integrationInstalledLock.withLock {
|
||||
if PostHogScreenViewIntegration.integrationInstalled {
|
||||
throw InternalPostHogError(description: "Autocapture integration already installed to another PostHogSDK instance.")
|
||||
}
|
||||
PostHogScreenViewIntegration.integrationInstalled = true
|
||||
}
|
||||
|
||||
self.postHog = postHog
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
func uninstall(_ postHog: PostHogSDK) {
|
||||
// uninstall only for integration instance
|
||||
if self.postHog === postHog || self.postHog == nil {
|
||||
stop()
|
||||
self.postHog = nil
|
||||
PostHogScreenViewIntegration.integrationInstalledLock.withLock {
|
||||
PostHogScreenViewIntegration.integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Start capturing screen view events
|
||||
*/
|
||||
func start() {
|
||||
let screenViewPublisher = DI.main.screenViewPublisher
|
||||
screenViewToken = screenViewPublisher.onScreenView { [weak self] screen in
|
||||
self?.captureScreenView(screen: screen)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Stop capturing screen view events
|
||||
*/
|
||||
func stop() {
|
||||
screenViewToken = nil
|
||||
}
|
||||
|
||||
private func captureScreenView(screen screenName: String) {
|
||||
guard let postHog else { return }
|
||||
|
||||
if postHog.config.captureScreenViews {
|
||||
postHog.screen(screenName)
|
||||
} else {
|
||||
hedgeLog("Skipping $screen event - captureScreenViews is disabled in configuration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if TESTING
|
||||
extension PostHogScreenViewIntegration {
|
||||
static func clearInstalls() {
|
||||
integrationInstalledLock.withLock {
|
||||
integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
23
Pods/PostHog/PostHog/Surveys/BottomSection.swift
generated
Normal file
23
Pods/PostHog/PostHog/Surveys/BottomSection.swift
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// BottomSection.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 18/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct BottomSection: View {
|
||||
let label: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(label, action: action)
|
||||
.buttonStyle(SurveyButtonStyle())
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
42
Pods/PostHog/PostHog/Surveys/ConfirmationMessage.swift
generated
Normal file
42
Pods/PostHog/PostHog/Surveys/ConfirmationMessage.swift
generated
Normal file
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// ConfirmationMessage.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 13/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct ConfirmationMessage: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(appearance.thankYouMessageHeader)
|
||||
.font(.body.bold())
|
||||
.foregroundStyle(foregroundTextColor)
|
||||
if let description = appearance.thankYouMessageDescription, appearance.thankYouMessageDescriptionContentType == .text {
|
||||
Text(description)
|
||||
.font(.body)
|
||||
.foregroundStyle(foregroundTextColor)
|
||||
}
|
||||
|
||||
BottomSection(label: appearance.thankYouMessageCloseButtonText, action: onClose)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
|
||||
private var foregroundTextColor: Color {
|
||||
appearance.backgroundColor.getContrastingTextColor()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
#Preview {
|
||||
ConfirmationMessage {}
|
||||
}
|
||||
#endif
|
||||
57
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurvey.swift
generated
Normal file
57
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurvey.swift
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// PostHogDisplaySurvey.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 18/06/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A model representing a PostHog survey to be displayed to users
|
||||
@objc public class PostHogDisplaySurvey: NSObject, Identifiable {
|
||||
/// Unique identifier for the survey
|
||||
public let id: String
|
||||
/// Name of the survey
|
||||
public let name: String
|
||||
/// Array of questions to be presented in the survey
|
||||
public let questions: [PostHogDisplaySurveyQuestion]
|
||||
/// Optional appearance configuration for customizing the survey's look and feel
|
||||
public let appearance: PostHogDisplaySurveyAppearance?
|
||||
/// Optional date indicating when the survey should start being shown
|
||||
public let startDate: Date?
|
||||
/// Optional date indicating when the survey should stop being shown
|
||||
public let endDate: Date?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
questions: [PostHogDisplaySurveyQuestion],
|
||||
appearance: PostHogDisplaySurveyAppearance?,
|
||||
startDate: Date?,
|
||||
endDate: Date?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.questions = questions
|
||||
self.appearance = appearance
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of rating display for survey rating questions
|
||||
@objc public enum PostHogDisplaySurveyRatingType: Int {
|
||||
/// Display numeric rating options
|
||||
case number
|
||||
/// Display emoji rating options
|
||||
case emoji
|
||||
}
|
||||
|
||||
/// Content type for text-based survey elements
|
||||
@objc public enum PostHogDisplaySurveyTextContentType: Int {
|
||||
/// Content should be rendered as HTML
|
||||
case html
|
||||
/// Content should be rendered as plain text
|
||||
case text
|
||||
}
|
||||
88
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift
generated
Normal file
88
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift
generated
Normal file
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// PostHogDisplaySurveyAppearance.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/06/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Model that describes the appearance customization of a PostHog survey
|
||||
@objc public class PostHogDisplaySurveyAppearance: NSObject {
|
||||
// General
|
||||
/// Optional font family to use throughout the survey
|
||||
public let fontFamily: String?
|
||||
/// Optional background color as web color (e.g. "#FFFFFF" or "white")
|
||||
public let backgroundColor: String?
|
||||
/// Optional border color as web color
|
||||
public let borderColor: String?
|
||||
|
||||
// Submit button
|
||||
/// Optional background color for the submit button as web color
|
||||
public let submitButtonColor: String?
|
||||
/// Optional custom text for the submit button
|
||||
public let submitButtonText: String?
|
||||
/// Optional text color for the submit button as web color
|
||||
public let submitButtonTextColor: String?
|
||||
|
||||
// Text colors
|
||||
/// Optional color for description text as web color
|
||||
public let descriptionTextColor: String?
|
||||
|
||||
// Rating buttons
|
||||
/// Optional color for rating buttons as web color
|
||||
public let ratingButtonColor: String?
|
||||
/// Optional color for active/selected rating buttons as web color
|
||||
public let ratingButtonActiveColor: String?
|
||||
|
||||
// Input
|
||||
/// Optional placeholder text for input fields
|
||||
public let placeholder: String?
|
||||
|
||||
// Thank you message
|
||||
/// Whether to show a thank you message after survey completion
|
||||
public let displayThankYouMessage: Bool
|
||||
/// Optional header text for the thank you message
|
||||
public let thankYouMessageHeader: String?
|
||||
/// Optional description text for the thank you message
|
||||
public let thankYouMessageDescription: String?
|
||||
/// Optional content type for the thank you message description
|
||||
public let thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType?
|
||||
/// Optional text for the close button in the thank you message
|
||||
public let thankYouMessageCloseButtonText: String?
|
||||
|
||||
init(
|
||||
fontFamily: String?,
|
||||
backgroundColor: String?,
|
||||
borderColor: String?,
|
||||
submitButtonColor: String?,
|
||||
submitButtonText: String?,
|
||||
submitButtonTextColor: String?,
|
||||
descriptionTextColor: String?,
|
||||
ratingButtonColor: String?,
|
||||
ratingButtonActiveColor: String?,
|
||||
placeholder: String?,
|
||||
displayThankYouMessage: Bool,
|
||||
thankYouMessageHeader: String?,
|
||||
thankYouMessageDescription: String?,
|
||||
thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType?,
|
||||
thankYouMessageCloseButtonText: String?
|
||||
) {
|
||||
self.fontFamily = fontFamily
|
||||
self.backgroundColor = backgroundColor
|
||||
self.borderColor = borderColor
|
||||
self.submitButtonColor = submitButtonColor
|
||||
self.submitButtonText = submitButtonText
|
||||
self.submitButtonTextColor = submitButtonTextColor
|
||||
self.descriptionTextColor = descriptionTextColor
|
||||
self.ratingButtonColor = ratingButtonColor
|
||||
self.ratingButtonActiveColor = ratingButtonActiveColor
|
||||
self.placeholder = placeholder
|
||||
self.displayThankYouMessage = displayThankYouMessage
|
||||
self.thankYouMessageHeader = thankYouMessageHeader
|
||||
self.thankYouMessageDescription = thankYouMessageDescription
|
||||
self.thankYouMessageDescriptionContentType = thankYouMessageDescriptionContentType
|
||||
self.thankYouMessageCloseButtonText = thankYouMessageCloseButtonText
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
150
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift
generated
Normal file
150
Pods/PostHog/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift
generated
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// PostHogDisplaySurveyQuestion.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/06/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Base class for all survey question types
|
||||
@objc public class PostHogDisplaySurveyQuestion: NSObject {
|
||||
/// The question ID, empty if none
|
||||
@objc public let id: String
|
||||
/// The main question text to display
|
||||
@objc public let question: String
|
||||
/// Optional additional description or context for the question
|
||||
@objc public let questionDescription: String?
|
||||
/// Content type for the question description (HTML or plain text)
|
||||
@objc public let questionDescriptionContentType: PostHogDisplaySurveyTextContentType
|
||||
/// Whether the question can be skipped
|
||||
@objc public let isOptional: Bool
|
||||
/// Optional custom text for the question's action button
|
||||
@objc public let buttonText: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
question: String,
|
||||
questionDescription: String?,
|
||||
questionDescriptionContentType: PostHogDisplaySurveyTextContentType?,
|
||||
isOptional: Bool,
|
||||
buttonText: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.question = question
|
||||
self.questionDescription = questionDescription
|
||||
self.questionDescriptionContentType = questionDescriptionContentType ?? .text
|
||||
self.isOptional = isOptional
|
||||
self.buttonText = buttonText
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an open-ended question where users can input free-form text
|
||||
@objc public class PostHogDisplayOpenQuestion: PostHogDisplaySurveyQuestion { /**/ }
|
||||
|
||||
/// Represents a question with a clickable link
|
||||
@objc public class PostHogDisplayLinkQuestion: PostHogDisplaySurveyQuestion {
|
||||
/// The URL that will be opened when the link is clicked
|
||||
public let link: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
question: String,
|
||||
questionDescription: String?,
|
||||
questionDescriptionContentType: PostHogDisplaySurveyTextContentType?,
|
||||
isOptional: Bool,
|
||||
buttonText: String?,
|
||||
link: String?
|
||||
) {
|
||||
self.link = link
|
||||
super.init(
|
||||
id: id,
|
||||
question: question,
|
||||
questionDescription: questionDescription,
|
||||
questionDescriptionContentType: questionDescriptionContentType,
|
||||
isOptional: isOptional,
|
||||
buttonText: buttonText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a rating question where users can select a rating from a scale
|
||||
@objc public class PostHogDisplayRatingQuestion: PostHogDisplaySurveyQuestion {
|
||||
/// The type of rating scale (numbers, emoji)
|
||||
public let ratingType: PostHogDisplaySurveyRatingType
|
||||
/// The lower bound of the rating scale
|
||||
public let scaleLowerBound: Int
|
||||
/// The upper bound of the rating scale
|
||||
public let scaleUpperBound: Int
|
||||
/// The label for the lower bound of the rating scale
|
||||
public let lowerBoundLabel: String
|
||||
/// The label for the upper bound of the rating scale
|
||||
public let upperBoundLabel: String
|
||||
|
||||
init(
|
||||
id: String,
|
||||
question: String,
|
||||
questionDescription: String?,
|
||||
questionDescriptionContentType: PostHogDisplaySurveyTextContentType?,
|
||||
isOptional: Bool,
|
||||
buttonText: String?,
|
||||
ratingType: PostHogDisplaySurveyRatingType,
|
||||
scaleLowerBound: Int,
|
||||
scaleUpperBound: Int,
|
||||
lowerBoundLabel: String,
|
||||
upperBoundLabel: String
|
||||
) {
|
||||
self.ratingType = ratingType
|
||||
self.scaleLowerBound = scaleLowerBound
|
||||
self.scaleUpperBound = scaleUpperBound
|
||||
self.lowerBoundLabel = lowerBoundLabel
|
||||
self.upperBoundLabel = upperBoundLabel
|
||||
super.init(
|
||||
id: id,
|
||||
question: question,
|
||||
questionDescription: questionDescription,
|
||||
questionDescriptionContentType: questionDescriptionContentType,
|
||||
isOptional: isOptional,
|
||||
buttonText: buttonText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a multiple or single choice question where users can select one or more options
|
||||
@objc public class PostHogDisplayChoiceQuestion: PostHogDisplaySurveyQuestion {
|
||||
/// The list of options for the user to choose from
|
||||
public let choices: [String]
|
||||
/// Whether the question includes an "other" option for users to input free-form text
|
||||
public let hasOpenChoice: Bool
|
||||
/// Whether the options should be shuffled to randomize the order
|
||||
public let shuffleOptions: Bool
|
||||
/// Whether the user can select multiple options
|
||||
public let isMultipleChoice: Bool
|
||||
|
||||
init(
|
||||
id: String,
|
||||
question: String,
|
||||
questionDescription: String?,
|
||||
questionDescriptionContentType: PostHogDisplaySurveyTextContentType?,
|
||||
isOptional: Bool,
|
||||
buttonText: String?,
|
||||
choices: [String],
|
||||
hasOpenChoice: Bool,
|
||||
shuffleOptions: Bool,
|
||||
isMultipleChoice: Bool
|
||||
) {
|
||||
self.choices = choices
|
||||
self.hasOpenChoice = hasOpenChoice
|
||||
self.shuffleOptions = shuffleOptions
|
||||
self.isMultipleChoice = isMultipleChoice
|
||||
super.init(
|
||||
id: id,
|
||||
question: question,
|
||||
questionDescription: questionDescription,
|
||||
questionDescriptionContentType: questionDescriptionContentType,
|
||||
isOptional: isOptional,
|
||||
buttonText: buttonText
|
||||
)
|
||||
}
|
||||
}
|
||||
23
Pods/PostHog/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift
generated
Normal file
23
Pods/PostHog/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// PostHogNextSurveyQuestion.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/06/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A model representing the next state of the survey progression.
|
||||
@objc public class PostHogNextSurveyQuestion: NSObject {
|
||||
/// The index of the next question to be displayed (0-based)
|
||||
public let questionIndex: Int
|
||||
/// Whether all questions have been answered and the survey is complete
|
||||
/// Depending on the survey appearance configuration, you may want to show the "Thank you" message or dismiss the survey at this point
|
||||
public let isSurveyCompleted: Bool
|
||||
|
||||
init(questionIndex: Int, isSurveyCompleted: Bool) {
|
||||
self.questionIndex = questionIndex
|
||||
self.isSurveyCompleted = isSurveyCompleted
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
107
Pods/PostHog/PostHog/Surveys/Models/PostHogSurveyResponse.swift
generated
Normal file
107
Pods/PostHog/PostHog/Surveys/Models/PostHogSurveyResponse.swift
generated
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// PostHogSurveyResponse.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 19/06/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A model representing a user's response to a survey question
|
||||
@objc @objcMembers
|
||||
public class PostHogSurveyResponse: NSObject {
|
||||
/// The type of response (link, rating, text, or multiple choice)
|
||||
public let type: PostHogSurveyResponseType
|
||||
/// Whether a link was clicked (for link questions)
|
||||
public let linkClicked: Bool?
|
||||
/// The numeric rating value (for rating questions)
|
||||
public let ratingValue: Int?
|
||||
/// The text response (for open questions)
|
||||
public let textValue: String?
|
||||
/// The selected options (for multiple or single choice questions)
|
||||
public let selectedOptions: [String]?
|
||||
|
||||
private init(
|
||||
type: PostHogSurveyResponseType,
|
||||
linkClicked: Bool? = nil,
|
||||
ratingValue: Int? = nil,
|
||||
textValue: String? = nil,
|
||||
multipleChoiceValues: [String]? = nil
|
||||
) {
|
||||
self.type = type
|
||||
self.linkClicked = linkClicked
|
||||
self.ratingValue = ratingValue
|
||||
self.textValue = textValue
|
||||
selectedOptions = multipleChoiceValues
|
||||
}
|
||||
|
||||
/// Creates a response for a link question
|
||||
/// - Parameter clicked: Whether the link was clicked
|
||||
public static func link(_ clicked: Bool) -> PostHogSurveyResponse {
|
||||
PostHogSurveyResponse(
|
||||
type: .link,
|
||||
linkClicked: clicked,
|
||||
ratingValue: nil,
|
||||
textValue: nil,
|
||||
multipleChoiceValues: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a response for a rating question
|
||||
/// - Parameter rating: The selected rating value
|
||||
public static func rating(_ rating: Int?) -> PostHogSurveyResponse {
|
||||
PostHogSurveyResponse(
|
||||
type: .rating,
|
||||
linkClicked: nil,
|
||||
ratingValue: rating,
|
||||
textValue: nil,
|
||||
multipleChoiceValues: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a response for an open-ended question
|
||||
/// - Parameter openEnded: The text response
|
||||
public static func openEnded(_ openEnded: String?) -> PostHogSurveyResponse {
|
||||
PostHogSurveyResponse(
|
||||
type: .openEnded,
|
||||
linkClicked: nil,
|
||||
ratingValue: nil,
|
||||
textValue: openEnded,
|
||||
multipleChoiceValues: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a response for a single-choice question
|
||||
/// - Parameter singleChoice: The selected option
|
||||
public static func singleChoice(_ singleChoice: String?) -> PostHogSurveyResponse {
|
||||
PostHogSurveyResponse(
|
||||
type: .singleChoice,
|
||||
linkClicked: nil,
|
||||
ratingValue: nil,
|
||||
textValue: nil,
|
||||
multipleChoiceValues: {
|
||||
if let singleChoice { [singleChoice] } else { nil }
|
||||
}()
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a response for a multiple-choice question
|
||||
/// - Parameter multipleChoice: The selected options
|
||||
public static func multipleChoice(_ multipleChoice: [String]?) -> PostHogSurveyResponse {
|
||||
PostHogSurveyResponse(
|
||||
type: .multipleChoice,
|
||||
linkClicked: nil,
|
||||
ratingValue: nil,
|
||||
textValue: nil,
|
||||
multipleChoiceValues: multipleChoice
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public enum PostHogSurveyResponseType: Int {
|
||||
case link
|
||||
case rating
|
||||
case openEnded
|
||||
case singleChoice
|
||||
case multipleChoice
|
||||
}
|
||||
937
Pods/PostHog/PostHog/Surveys/PostHogSurveyIntegration.swift
generated
Normal file
937
Pods/PostHog/PostHog/Surveys/PostHogSurveyIntegration.swift
generated
Normal file
@@ -0,0 +1,937 @@
|
||||
//
|
||||
// PostHogSurveyIntegration.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 20/02/2025.
|
||||
//
|
||||
|
||||
#if os(iOS) || TESTING
|
||||
|
||||
import Foundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
final class PostHogSurveyIntegration: PostHogIntegration {
|
||||
var requiresSwizzling: Bool { true }
|
||||
|
||||
private static var integrationInstalledLock = NSLock()
|
||||
private static var integrationInstalled = false
|
||||
|
||||
typealias SurveyCallback = (_ surveys: [PostHogSurvey]) -> Void
|
||||
|
||||
private let kSurveySeenKeyPrefix = "seenSurvey_"
|
||||
private let kSurveyResponseKey = "$survey_response"
|
||||
|
||||
private var postHog: PostHogSDK?
|
||||
private var config: PostHogConfig? { postHog?.config }
|
||||
private var storage: PostHogStorage? { postHog?.storage }
|
||||
private var remoteConfig: PostHogRemoteConfig? { postHog?.remoteConfig }
|
||||
|
||||
private var allSurveysLock = NSLock()
|
||||
private var allSurveys: [PostHogSurvey]?
|
||||
|
||||
private var eventsToSurveysLock = NSLock()
|
||||
private var eventsToSurveys: [String: [String]] = [:]
|
||||
|
||||
private var seenSurveyKeysLock = NSLock()
|
||||
private var seenSurveyKeys: [AnyHashable: Any]?
|
||||
|
||||
private var eventActivatedSurveysLock = NSLock()
|
||||
private var eventActivatedSurveys: Set<String> = []
|
||||
|
||||
private var didBecomeActiveToken: RegistrationToken?
|
||||
private var didLayoutViewToken: RegistrationToken?
|
||||
|
||||
private var activeSurveyLock = NSLock()
|
||||
private var activeSurvey: PostHogSurvey?
|
||||
private var activeSurveyResponses: [String: PostHogSurveyResponse] = [:] // keyed by question identifier
|
||||
private var activeSurveyCompleted: Bool = false
|
||||
private var activeSurveyQuestionIndex: Int = 0
|
||||
|
||||
func install(_ postHog: PostHogSDK) throws {
|
||||
try PostHogSurveyIntegration.integrationInstalledLock.withLock {
|
||||
if PostHogSurveyIntegration.integrationInstalled {
|
||||
throw InternalPostHogError(description: "Replay integration already installed to another PostHogSDK instance.")
|
||||
}
|
||||
PostHogSurveyIntegration.integrationInstalled = true
|
||||
}
|
||||
|
||||
self.postHog = postHog
|
||||
start()
|
||||
}
|
||||
|
||||
func uninstall(_ postHog: PostHogSDK) {
|
||||
if self.postHog === postHog || self.postHog == nil {
|
||||
stop()
|
||||
self.postHog = nil
|
||||
PostHogSurveyIntegration.integrationInstalledLock.withLock {
|
||||
PostHogSurveyIntegration.integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
#if os(iOS)
|
||||
// TODO: listen to screen view events
|
||||
didLayoutViewToken = DI.main.viewLayoutPublisher.onViewLayout(throttle: 5) { [weak self] in
|
||||
self?.showNextSurvey()
|
||||
}
|
||||
|
||||
didBecomeActiveToken = DI.main.appLifecyclePublisher.onDidBecomeActive { [weak self] in
|
||||
self?.showNextSurvey()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
didBecomeActiveToken = nil
|
||||
didLayoutViewToken = nil
|
||||
#if os(iOS)
|
||||
if #available(iOS 15.0, *) {
|
||||
config?.surveysConfig.surveysDelegate.cleanupSurveys()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Get surveys enabled for the current user
|
||||
func getActiveMatchingSurveys(
|
||||
forceReload: Bool = false,
|
||||
callback: @escaping SurveyCallback
|
||||
) {
|
||||
getSurveys(forceReload: forceReload) { [weak self] surveys in
|
||||
guard let self else { return }
|
||||
|
||||
let matchingSurveys = surveys
|
||||
.lazy
|
||||
.filter { // 1. unseen surveys,
|
||||
!self.getSurveySeen(survey: $0)
|
||||
}
|
||||
.filter(\.isActive) // 2. that are active,
|
||||
.filter { survey in // 3. and match display conditions,
|
||||
// TODO: Check screen conditions
|
||||
// TODO: Check event conditions
|
||||
let deviceTypeCheck = self.doesSurveyDeviceTypesMatch(survey: survey)
|
||||
return deviceTypeCheck
|
||||
}
|
||||
.filter { survey in // 4. and match linked flags
|
||||
let allKeys: [String?] = [
|
||||
[survey.linkedFlagKey],
|
||||
[survey.targetingFlagKey],
|
||||
// we check internal targeting flags only if this survey cannot be activated repeatedly
|
||||
[survey.canActivateRepeatedly ? nil : survey.internalTargetingFlagKey],
|
||||
survey.featureFlagKeys?.compactMap { kvp in
|
||||
kvp.key.isEmpty ? nil : kvp.value
|
||||
} ?? [],
|
||||
]
|
||||
.joined()
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
// all keys must be enabled
|
||||
return Set(allKeys)
|
||||
.allSatisfy(self.isSurveyFeatureFlagEnabled)
|
||||
}
|
||||
.filter { survey in // 5. and if event-based, have been activated by that event
|
||||
survey.hasEvents ? self.isSurveyEventActivated(survey: survey) : true
|
||||
}
|
||||
|
||||
callback(Array(matchingSurveys))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Decouple PostHogSDK and use registration handlers instead
|
||||
/// Called from PostHogSDK instance when an event is captured
|
||||
func onEvent(event: String) {
|
||||
let activatedSurveys = eventsToSurveysLock.withLock { eventsToSurveys[event] } ?? []
|
||||
guard !activatedSurveys.isEmpty else { return }
|
||||
|
||||
eventActivatedSurveysLock.withLock {
|
||||
for survey in activatedSurveys {
|
||||
eventActivatedSurveys.insert(survey)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.showNextSurvey()
|
||||
}
|
||||
}
|
||||
|
||||
private func getSurveys(forceReload: Bool = false, callback: @escaping SurveyCallback) {
|
||||
guard let remoteConfig else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let config = config, config._surveys else {
|
||||
hedgeLog("Surveys disabled. Not loading surveys.")
|
||||
return callback([])
|
||||
}
|
||||
|
||||
// mem cache
|
||||
let allSurveys = allSurveysLock.withLock { self.allSurveys }
|
||||
|
||||
if let allSurveys, !forceReload {
|
||||
callback(allSurveys)
|
||||
} else {
|
||||
// first or force load
|
||||
getRemoteConfig(remoteConfig, forceReload: forceReload) { [weak self] config in
|
||||
self?.getFeatureFlags(remoteConfig, forceReload: forceReload) { [weak self] _ in
|
||||
self?.decodeAndSetSurveys(remoteConfig: config, callback: callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getRemoteConfig(
|
||||
_ remoteConfig: PostHogRemoteConfig,
|
||||
forceReload: Bool = false,
|
||||
callback: (([String: Any]?) -> Void)? = nil
|
||||
) {
|
||||
let cached = remoteConfig.getRemoteConfig()
|
||||
if cached == nil || forceReload {
|
||||
remoteConfig.reloadRemoteConfig(callback: callback)
|
||||
} else {
|
||||
callback?(cached)
|
||||
}
|
||||
}
|
||||
|
||||
private func getFeatureFlags(
|
||||
_ remoteConfig: PostHogRemoteConfig,
|
||||
forceReload: Bool = false,
|
||||
callback: (([String: Any]?) -> Void)? = nil
|
||||
) {
|
||||
let cached = remoteConfig.getFeatureFlags()
|
||||
if cached == nil || forceReload {
|
||||
remoteConfig.reloadFeatureFlags(callback: callback)
|
||||
} else {
|
||||
callback?(cached)
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeAndSetSurveys(remoteConfig: [String: Any]?, callback: @escaping SurveyCallback) {
|
||||
let loadedSurveys: [PostHogSurvey] = decodeSurveys(from: remoteConfig ?? [:])
|
||||
|
||||
let eventMap = loadedSurveys.reduce(into: [String: [String]]()) { result, current in
|
||||
if let surveyEvents = current.conditions?.events?.values.map(\.name) {
|
||||
for event in surveyEvents {
|
||||
result[event, default: []].append(current.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allSurveysLock.withLock {
|
||||
self.allSurveys = loadedSurveys
|
||||
}
|
||||
eventsToSurveysLock.withLock {
|
||||
self.eventsToSurveys = eventMap
|
||||
}
|
||||
|
||||
callback(loadedSurveys)
|
||||
}
|
||||
|
||||
private func decodeSurveys(from remoteConfig: [String: Any]) -> [PostHogSurvey] {
|
||||
guard let surveysJSON = remoteConfig["surveys"] as? [[String: Any]] else {
|
||||
// surveys not json, disabled
|
||||
return []
|
||||
}
|
||||
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: surveysJSON)
|
||||
return try PostHogApi.jsonDecoder.decode([PostHogSurvey].self, from: jsonData)
|
||||
} catch {
|
||||
hedgeLog("Error decoding Surveys: \(error)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func isSurveyFeatureFlagEnabled(flagKey: String?) -> Bool {
|
||||
guard let flagKey, let postHog else {
|
||||
return false
|
||||
}
|
||||
|
||||
return postHog.isFeatureEnabled(flagKey)
|
||||
}
|
||||
|
||||
private func canRenderSurvey(survey: PostHogSurvey) -> Bool {
|
||||
// only render popover surveys for now
|
||||
survey.type == .popover
|
||||
}
|
||||
|
||||
/// Shows next survey in queue. No-op if a survey is already being shown
|
||||
private func showNextSurvey() {
|
||||
#if os(iOS)
|
||||
guard #available(iOS 15.0, *) else {
|
||||
hedgeLog("[Surveys] Surveys can be rendered only on iOS 15+")
|
||||
return
|
||||
}
|
||||
|
||||
guard canShowNextSurvey() else { return }
|
||||
|
||||
// Check if there is a new popover surveys to be displayed
|
||||
getActiveMatchingSurveys { activeSurveys in
|
||||
if let survey = activeSurveys.first(where: self.canRenderSurvey) {
|
||||
// set survey as active
|
||||
self.setActiveSurvey(survey: survey)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let self {
|
||||
// render the survey
|
||||
self.postHog?.config.surveysConfig.surveysDelegate.renderSurvey(
|
||||
survey.toDisplaySurvey(),
|
||||
onSurveyShown: self.handleSurveyShown,
|
||||
onSurveyResponse: self.handleSurveyResponse,
|
||||
onSurveyClosed: self.handleSurveyClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Returns the computed storage key for a given survey
|
||||
private func getSurveySeenKey(_ survey: PostHogSurvey) -> String {
|
||||
let surveySeenKey = "\(kSurveySeenKeyPrefix)\(survey.id)"
|
||||
if let currentIteration = survey.currentIteration, currentIteration > 0 {
|
||||
return "\(surveySeenKey)_\(currentIteration)"
|
||||
}
|
||||
return surveySeenKey
|
||||
}
|
||||
|
||||
/// Checks storage for seenSurvey_ key and returns its value
|
||||
///
|
||||
/// Note: if the survey can be repeatedly activated by its events, or if the key is missing, this value will default to false
|
||||
private func getSurveySeen(survey: PostHogSurvey) -> Bool {
|
||||
if survey.canActivateRepeatedly {
|
||||
// if this survey can activate repeatedly, we override this return value
|
||||
return false
|
||||
}
|
||||
|
||||
let key = getSurveySeenKey(survey)
|
||||
let surveysSeen = getSeenSurveyKeys()
|
||||
let surveySeen = surveysSeen[key] as? Bool ?? false
|
||||
|
||||
return surveySeen
|
||||
}
|
||||
|
||||
/// Mark a survey as seen
|
||||
private func setSurveySeen(survey: PostHogSurvey) {
|
||||
let key = getSurveySeenKey(survey)
|
||||
let seenKeys = seenSurveyKeysLock.withLock {
|
||||
seenSurveyKeys?[key] = true
|
||||
return seenSurveyKeys
|
||||
}
|
||||
|
||||
storage?.setDictionary(forKey: .surveySeen, contents: seenKeys ?? [:])
|
||||
}
|
||||
|
||||
/// Returns survey seen list (and mem-cache from disk if needed)
|
||||
private func getSeenSurveyKeys() -> [AnyHashable: Any] {
|
||||
seenSurveyKeysLock.withLock {
|
||||
if seenSurveyKeys == nil {
|
||||
seenSurveyKeys = storage?.getDictionary(forKey: .surveySeen) ?? [:]
|
||||
}
|
||||
return seenSurveyKeys ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns given match type or default value if nil
|
||||
private func getMatchTypeOrDefault(_ matchType: PostHogSurveyMatchType?) -> PostHogSurveyMatchType {
|
||||
matchType ?? .iContains
|
||||
}
|
||||
|
||||
/// Checks if a survey with a device type condition matches the current device type
|
||||
private func doesSurveyDeviceTypesMatch(survey: PostHogSurvey) -> Bool {
|
||||
guard
|
||||
let conditions = survey.conditions,
|
||||
let deviceTypes = conditions.deviceTypes, deviceTypes.count > 0
|
||||
else {
|
||||
// not device type restrictions, assume true
|
||||
return true
|
||||
}
|
||||
|
||||
guard
|
||||
let deviceType = PostHogContext.deviceType
|
||||
else {
|
||||
// if we don't know the current device type, we assume it is not a match
|
||||
return false
|
||||
}
|
||||
|
||||
let matchType = getMatchTypeOrDefault(conditions.deviceTypesMatchType)
|
||||
|
||||
return matchType.matches(targets: deviceTypes, value: deviceType)
|
||||
}
|
||||
|
||||
/// Checks if a survey has been previously activated by an associated event
|
||||
private func isSurveyEventActivated(survey: PostHogSurvey) -> Bool {
|
||||
eventActivatedSurveysLock.withLock {
|
||||
eventActivatedSurveys.contains(survey.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a survey that is shown
|
||||
private func handleSurveyShown(survey: PostHogDisplaySurvey) {
|
||||
let activeSurvey = activeSurveyLock.withLock { self.activeSurvey }
|
||||
|
||||
guard let activeSurvey, survey.id == activeSurvey.id else {
|
||||
hedgeLog("[Surveys] Received a show event for a non-active survey")
|
||||
return
|
||||
}
|
||||
|
||||
sendSurveyShownEvent(survey: activeSurvey)
|
||||
|
||||
// clear up event-activated surveys
|
||||
if activeSurvey.hasEvents {
|
||||
eventActivatedSurveysLock.withLock {
|
||||
_ = eventActivatedSurveys.remove(activeSurvey.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a survey response
|
||||
/// Processes a user's response to a survey question and determines the next question to display
|
||||
/// - Parameters:
|
||||
/// - survey: The currently displayed survey
|
||||
/// - index: The index of the current question being answered
|
||||
/// - response: The user's response to the current question
|
||||
/// - Returns: The next question to display based on branching logic, or nil if there was an error
|
||||
private func handleSurveyResponse(survey: PostHogDisplaySurvey, index: Int, response: PostHogSurveyResponse) -> PostHogNextSurveyQuestion? {
|
||||
let (activeSurvey, activeSurveyQuestionIndex) = activeSurveyLock.withLock { (self.activeSurvey, self.activeSurveyQuestionIndex) }
|
||||
|
||||
guard let activeSurvey, survey.id == activeSurvey.id else {
|
||||
hedgeLog("[Surveys] Received a response event for a non-active survey")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: ideally the handleSurveyResponse should pass the question ID as param but it would break the Flutter SDK for older versions
|
||||
let questionId: String
|
||||
if index < survey.questions.count {
|
||||
let question = survey.questions[index]
|
||||
questionId = question.id
|
||||
} else {
|
||||
// this should not happen, its only for back compatibility
|
||||
questionId = ""
|
||||
}
|
||||
|
||||
// 2. Get next step
|
||||
let nextStep = getNextSurveyStep(
|
||||
survey: activeSurvey,
|
||||
questionIndex: activeSurveyQuestionIndex,
|
||||
response: response
|
||||
)
|
||||
|
||||
let (isCompleted, nextIndex) = switch nextStep {
|
||||
case let .index(nextIndex): (false, nextIndex)
|
||||
case .end: (true, activeSurveyQuestionIndex)
|
||||
}
|
||||
|
||||
let nextSurveyQuestion = PostHogNextSurveyQuestion(
|
||||
questionIndex: nextIndex,
|
||||
isSurveyCompleted: isCompleted
|
||||
)
|
||||
|
||||
// update response, next question index and survey completion
|
||||
let allResponses = setActiveSurveyResponse(id: questionId, index: index, response: response, nextQuestion: nextSurveyQuestion)
|
||||
|
||||
// send event if needed
|
||||
// TODO: Partial responses
|
||||
if isCompleted {
|
||||
sendSurveySentEvent(survey: activeSurvey, responses: allResponses)
|
||||
}
|
||||
|
||||
return nextSurveyQuestion
|
||||
}
|
||||
|
||||
/// Handle a survey dismiss
|
||||
private func handleSurveyClosed(survey: PostHogDisplaySurvey) {
|
||||
let (activeSurvey, activeSurveyCompleted) = activeSurveyLock.withLock { (self.activeSurvey, self.activeSurveyCompleted) }
|
||||
|
||||
guard let activeSurvey, survey.id == activeSurvey.id else {
|
||||
hedgeLog("Received a close event for a non-active survey")
|
||||
return
|
||||
}
|
||||
|
||||
// send survey dismissed event if needed
|
||||
if !activeSurveyCompleted {
|
||||
sendSurveyDismissedEvent(survey: activeSurvey)
|
||||
}
|
||||
|
||||
// mark as seen
|
||||
setSurveySeen(survey: activeSurvey)
|
||||
|
||||
// clear active survey
|
||||
clearActiveSurvey()
|
||||
|
||||
// show next survey in queue, if any, after a short delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
self.showNextSurvey()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a `survey shown` event to PostHog instance
|
||||
private func sendSurveyShownEvent(survey: PostHogSurvey) {
|
||||
sendSurveyEvent(
|
||||
event: "survey shown",
|
||||
survey: survey
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends a `survey sent` event to PostHog instance
|
||||
/// Sends a survey completion event to PostHog with all collected responses
|
||||
/// - Parameters:
|
||||
/// - survey: The completed survey
|
||||
/// - responses: Dictionary of collected responses for each question
|
||||
private func sendSurveySentEvent(survey: PostHogSurvey, responses: [String: PostHogSurveyResponse]) {
|
||||
let responsesProperties: [String: Any] = responses.compactMapValues { resp in
|
||||
switch resp.type {
|
||||
case .link: resp.linkClicked == true ? "link clicked" : nil
|
||||
case .multipleChoice: resp.selectedOptions
|
||||
case .singleChoice: resp.selectedOptions?.first
|
||||
case .openEnded: resp.textValue
|
||||
case .rating: resp.ratingValue.map { "\($0)" }
|
||||
}
|
||||
}
|
||||
|
||||
let surveyQuestions = survey.questions.enumerated().map { index, question in
|
||||
let responseKey = question.id.isEmpty ? getOldResponseKey(for: index) : getNewResponseKey(for: question.id)
|
||||
var questionData: [String: Any] = [
|
||||
"id": question.id,
|
||||
"question": question.question,
|
||||
]
|
||||
|
||||
if let response = responsesProperties[responseKey] {
|
||||
questionData["response"] = response
|
||||
}
|
||||
|
||||
return questionData
|
||||
}
|
||||
|
||||
let questionProperties: [String: Any] = [
|
||||
"$survey_questions": surveyQuestions,
|
||||
"$set": [getSurveyInteractionProperty(survey: survey, property: "responded"): true],
|
||||
]
|
||||
|
||||
// TODO: Should be doing some validation before sending the event?
|
||||
|
||||
let additionalProperties = questionProperties.merging(responsesProperties, uniquingKeysWith: { _, new in new })
|
||||
|
||||
sendSurveyEvent(
|
||||
event: "survey sent",
|
||||
survey: survey,
|
||||
additionalProperties: additionalProperties
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends a `survey dismissed` event to PostHog instance
|
||||
private func sendSurveyDismissedEvent(survey: PostHogSurvey) {
|
||||
let additionalProperties: [String: Any] = [
|
||||
"$set": [
|
||||
getSurveyInteractionProperty(survey: survey, property: "dismissed"): true,
|
||||
],
|
||||
]
|
||||
|
||||
sendSurveyEvent(
|
||||
event: "survey dismissed",
|
||||
survey: survey,
|
||||
additionalProperties: additionalProperties
|
||||
)
|
||||
}
|
||||
|
||||
private func sendSurveyEvent(event: String, survey: PostHogSurvey, additionalProperties: [String: Any] = [:]) {
|
||||
guard let postHog else {
|
||||
hedgeLog("[\(event)] event not captured, PostHog instance not found.")
|
||||
return
|
||||
}
|
||||
|
||||
var properties = getBaseSurveyEventProperties(for: survey)
|
||||
properties.merge(additionalProperties) { _, new in new }
|
||||
|
||||
postHog.capture(event, properties: properties)
|
||||
}
|
||||
|
||||
private func getBaseSurveyEventProperties(for survey: PostHogSurvey) -> [String: Any] {
|
||||
// TODO: Add session replay screen name
|
||||
let props: [String: Any?] = [
|
||||
"$survey_name": survey.name,
|
||||
"$survey_id": survey.id,
|
||||
"$survey_iteration": survey.currentIteration,
|
||||
"$survey_iteration_start_date": survey.currentIterationStartDate.map(toISO8601String),
|
||||
]
|
||||
return props.compactMapValues { $0 }
|
||||
}
|
||||
|
||||
private func getSurveyInteractionProperty(survey: PostHogSurvey, property: String) -> String {
|
||||
var surveyProperty = "$survey_\(property)/\(survey.id)"
|
||||
|
||||
if let currentIteration = survey.currentIteration, currentIteration > 0 {
|
||||
surveyProperty = "$survey_\(property)/\(survey.id)/\(currentIteration)"
|
||||
}
|
||||
|
||||
return surveyProperty
|
||||
}
|
||||
|
||||
private func setActiveSurvey(survey: PostHogSurvey) {
|
||||
activeSurveyLock.withLock {
|
||||
if activeSurvey == nil {
|
||||
activeSurvey = survey
|
||||
activeSurveyCompleted = false
|
||||
activeSurveyResponses = [:]
|
||||
activeSurveyQuestionIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearActiveSurvey() {
|
||||
activeSurveyLock.withLock {
|
||||
activeSurvey = nil
|
||||
activeSurveyCompleted = false
|
||||
activeSurveyResponses = [:]
|
||||
activeSurveyQuestionIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a response for the current question in the active survey, and returns updated responses
|
||||
/// - Parameters:
|
||||
/// - id: The question ID, empty if none
|
||||
/// - index: The index of the question being answered
|
||||
/// - response: The user's response to store
|
||||
/// - nextQuestion: The next question index and completion info
|
||||
private func setActiveSurveyResponse(
|
||||
id: String,
|
||||
index: Int,
|
||||
response: PostHogSurveyResponse,
|
||||
nextQuestion: PostHogNextSurveyQuestion
|
||||
) -> [String: PostHogSurveyResponse] {
|
||||
activeSurveyLock.withLock {
|
||||
// keeping the old response key format for back compatibility
|
||||
activeSurveyResponses[getOldResponseKey(for: index)] = response
|
||||
if !id.isEmpty {
|
||||
// setting the new response key format
|
||||
activeSurveyResponses[getNewResponseKey(for: id)] = response
|
||||
}
|
||||
activeSurveyQuestionIndex = nextQuestion.questionIndex
|
||||
activeSurveyCompleted = nextQuestion.isSurveyCompleted
|
||||
return activeSurveyResponses
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns next question index
|
||||
/// - Parameters:
|
||||
/// - survey: The survey which contains the question
|
||||
/// - questionIndex: The current question index
|
||||
/// - response: The current question response
|
||||
/// - Returns: The next question `.index()` if found, or `.end` survey reach the end
|
||||
private func getNextSurveyStep(
|
||||
survey: PostHogSurvey,
|
||||
questionIndex: Int,
|
||||
response: PostHogSurveyResponse
|
||||
) -> NextSurveyQuestion {
|
||||
let question = survey.questions[questionIndex]
|
||||
let nextQuestionIndex = min(questionIndex + 1, survey.questions.count - 1)
|
||||
|
||||
guard let branching = question.branching else {
|
||||
return questionIndex == survey.questions.count - 1 ? .end : .index(nextQuestionIndex)
|
||||
}
|
||||
|
||||
switch branching {
|
||||
case .end:
|
||||
return .end
|
||||
|
||||
case let .specificQuestion(index):
|
||||
return .index(min(index, survey.questions.count - 1))
|
||||
|
||||
case let .responseBased(responseValues):
|
||||
return getResponseBasedNextQuestionIndex(
|
||||
survey: survey,
|
||||
question: question,
|
||||
response: response,
|
||||
responseValues: responseValues
|
||||
) ?? .index(nextQuestionIndex)
|
||||
|
||||
case .next, .unknown:
|
||||
return .index(nextQuestionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns next question index based on response value (from responseValues dictionary)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - survey: The survey which contains the question
|
||||
/// - question: The current question
|
||||
/// - response: The response to the current question
|
||||
/// - responseValues: The response values dictionary
|
||||
/// - Returns: The next index if found in the `responseValues`
|
||||
private func getResponseBasedNextQuestionIndex(
|
||||
survey: PostHogSurvey,
|
||||
question: PostHogSurveyQuestion,
|
||||
response: PostHogSurveyResponse?,
|
||||
responseValues: [String: Any]
|
||||
) -> NextSurveyQuestion? {
|
||||
guard let response else {
|
||||
hedgeLog("[Surveys] Got response based branching, but missing the actual response.")
|
||||
return nil
|
||||
}
|
||||
|
||||
switch (question, response.type) {
|
||||
case let (.singleChoice(singleChoiceQuestion), .singleChoice):
|
||||
let singleChoiceResponse = response.selectedOptions?.first
|
||||
var responseIndex = singleChoiceQuestion.choices.firstIndex(of: singleChoiceResponse ?? "")
|
||||
|
||||
if responseIndex == nil, singleChoiceQuestion.hasOpenChoice == true {
|
||||
// if the response is not found in the choices, it must be the open choice, which is always the last choice
|
||||
responseIndex = singleChoiceQuestion.choices.count - 1
|
||||
}
|
||||
|
||||
if let responseIndex, let nextIndex = responseValues["\(responseIndex)"] {
|
||||
return processBranchingStep(nextIndex: nextIndex, totalQuestions: survey.questions.count)
|
||||
}
|
||||
|
||||
hedgeLog("[Surveys] Could not find response index for specific question.")
|
||||
return nil
|
||||
|
||||
case let (.rating(ratingQuestion), .rating):
|
||||
if let responseInt = response.ratingValue,
|
||||
let ratingBucket = getRatingBucketForResponseValue(scale: ratingQuestion.scale, value: responseInt),
|
||||
let nextIndex = responseValues[ratingBucket]
|
||||
{
|
||||
return processBranchingStep(nextIndex: nextIndex, totalQuestions: survey.questions.count)
|
||||
}
|
||||
hedgeLog("[Surveys] Could not get response bucket for rating question.")
|
||||
return nil
|
||||
|
||||
default:
|
||||
hedgeLog("[Surveys] Got response based branching for an unsupported question type.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns next question index based on a branching step result
|
||||
/// - Parameters:
|
||||
/// - nextIndex: The next index to process
|
||||
/// - totalQuestions: The total number of questions in the survey
|
||||
/// - Returns: The next question index if found, or nil if not
|
||||
private func processBranchingStep(nextIndex: Any, totalQuestions: Int) -> NextSurveyQuestion? {
|
||||
if let nextIndex = nextIndex as? Int {
|
||||
return .index(min(nextIndex, totalQuestions - 1))
|
||||
}
|
||||
if let nextIndex = nextIndex as? String, nextIndex.lowercased() == "end" {
|
||||
return .end
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets the response bucket for a given rating response value, given the scale.
|
||||
// For example, for a scale of 3, the buckets are "negative", "neutral" and "positive".
|
||||
private func getRatingBucketForResponseValue(scale: PostHogSurveyRatingScale, value: Int) -> String? {
|
||||
// swiftlint:disable:previous cyclomatic_complexity
|
||||
// Validate input ranges
|
||||
switch scale {
|
||||
case .threePoint where RatingBucket.threePointRange.contains(value):
|
||||
switch value {
|
||||
case BucketThresholds.ThreePoint.negatives: return RatingBucket.negative
|
||||
case BucketThresholds.ThreePoint.neutrals: return RatingBucket.neutral
|
||||
default: return RatingBucket.positive
|
||||
}
|
||||
|
||||
case .fivePoint where RatingBucket.fivePointRange.contains(value):
|
||||
switch value {
|
||||
case BucketThresholds.FivePoint.negatives: return RatingBucket.negative
|
||||
case BucketThresholds.FivePoint.neutrals: return RatingBucket.neutral
|
||||
default: return RatingBucket.positive
|
||||
}
|
||||
|
||||
case .sevenPoint where RatingBucket.sevenPointRange.contains(value):
|
||||
switch value {
|
||||
case BucketThresholds.SevenPoint.negatives: return RatingBucket.negative
|
||||
case BucketThresholds.SevenPoint.neutrals: return RatingBucket.neutral
|
||||
default: return RatingBucket.positive
|
||||
}
|
||||
|
||||
case .tenPoint where RatingBucket.tenPointRange.contains(value):
|
||||
switch value {
|
||||
case BucketThresholds.TenPoint.detractors: return RatingBucket.detractors
|
||||
case BucketThresholds.TenPoint.passives: return RatingBucket.passives
|
||||
default: return RatingBucket.promoters
|
||||
}
|
||||
|
||||
default:
|
||||
hedgeLog("[Surveys] Cannot get rating bucket for invalid scale: \(scale). The scale must be one of: 3 (1-3), 5 (1-5), 7 (1-7), 10 (0-10).")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the old survey response key for a specific question index
|
||||
private func getOldResponseKey(for index: Int) -> String {
|
||||
index == 0 ? kSurveyResponseKey : "\(kSurveyResponseKey)_\(index)"
|
||||
}
|
||||
|
||||
// Returns the new survey response key for a specific question id
|
||||
private func getNewResponseKey(for questionId: String) -> String {
|
||||
"\(kSurveyResponseKey)_\(questionId)"
|
||||
}
|
||||
|
||||
func canShowNextSurvey() -> Bool {
|
||||
activeSurveyLock.withLock { activeSurvey == nil }
|
||||
}
|
||||
}
|
||||
|
||||
enum NextSurveyQuestion {
|
||||
case index(Int)
|
||||
case end
|
||||
}
|
||||
|
||||
extension PostHogSurvey: CustomStringConvertible {
|
||||
var description: String {
|
||||
"\(name) [\(id)]"
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurvey {
|
||||
var isActive: Bool {
|
||||
startDate != nil && endDate == nil
|
||||
}
|
||||
|
||||
var hasEvents: Bool {
|
||||
conditions?.events?.values.count ?? 0 > 0
|
||||
}
|
||||
|
||||
var canActivateRepeatedly: Bool {
|
||||
conditions?.events?.repeatedActivation == true && hasEvents
|
||||
}
|
||||
}
|
||||
|
||||
private extension PostHogSurveyMatchType {
|
||||
func matches(targets: [String], value: String) -> Bool {
|
||||
switch self {
|
||||
// any of the targets contain the value (matched lowercase)
|
||||
case .iContains:
|
||||
targets.contains { target in
|
||||
target.lowercased().contains(value.lowercased())
|
||||
}
|
||||
// *none* of the targets contain the value (matched lowercase)
|
||||
case .notIContains:
|
||||
targets.allSatisfy { target in
|
||||
!target.lowercased().contains(value.lowercased())
|
||||
}
|
||||
// any of the targets match with regex
|
||||
case .regex:
|
||||
targets.contains { target in
|
||||
target.range(of: value, options: .regularExpression) != nil
|
||||
}
|
||||
// *none* if the targets match with regex
|
||||
case .notRegex:
|
||||
targets.allSatisfy { target in
|
||||
target.range(of: value, options: .regularExpression) == nil
|
||||
}
|
||||
// any of the targets is an exact match
|
||||
case .exact:
|
||||
targets.contains { target in
|
||||
target == value
|
||||
}
|
||||
// *none* of the targets is an exact match
|
||||
case .isNot:
|
||||
targets.allSatisfy { target in
|
||||
target != value
|
||||
}
|
||||
case .unknown:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum RatingBucket {
|
||||
// Bucket names
|
||||
static let negative = "negative"
|
||||
static let neutral = "neutral"
|
||||
static let positive = "positive"
|
||||
static let detractors = "detractors"
|
||||
static let passives = "passives"
|
||||
static let promoters = "promoters"
|
||||
|
||||
// Scale ranges
|
||||
static let threePointRange = 1 ... 3
|
||||
static let fivePointRange = 1 ... 5
|
||||
static let sevenPointRange = 1 ... 7
|
||||
static let tenPointRange = 0 ... 10
|
||||
}
|
||||
|
||||
private enum BucketThresholds {
|
||||
enum ThreePoint {
|
||||
static let negatives = 1 ... 1
|
||||
static let neutrals = 2 ... 2
|
||||
}
|
||||
|
||||
enum FivePoint {
|
||||
static let negatives = 1 ... 2
|
||||
static let neutrals = 3 ... 3
|
||||
}
|
||||
|
||||
enum SevenPoint {
|
||||
static let negatives = 1 ... 3
|
||||
static let neutrals = 4 ... 4
|
||||
}
|
||||
|
||||
enum TenPoint {
|
||||
static let detractors = 0 ... 6
|
||||
static let passives = 7 ... 8
|
||||
}
|
||||
}
|
||||
|
||||
#if TESTING
|
||||
extension PostHogSurveyMatchType {
|
||||
var matchFunction: (_ targets: [String], _ value: String) -> Bool {
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
extension PostHogSurveyIntegration {
|
||||
func setSurveys(_ surveys: [PostHogSurvey]) {
|
||||
allSurveys = surveys
|
||||
}
|
||||
|
||||
func setShownSurvey(_ survey: PostHogSurvey) {
|
||||
clearActiveSurvey()
|
||||
setActiveSurvey(survey: survey)
|
||||
}
|
||||
|
||||
func getNextQuestion(index: Int, response: PostHogSurveyResponse) -> (Int, Bool)? {
|
||||
guard let activeSurvey else { return nil }
|
||||
activeSurveyQuestionIndex = index
|
||||
if let next = handleSurveyResponse(survey: activeSurvey.toDisplaySurvey(), index: index, response: response) {
|
||||
return (next.questionIndex, next.isSurveyCompleted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testSendSurveyShownEvent(survey: PostHogSurvey) {
|
||||
sendSurveyShownEvent(survey: survey)
|
||||
}
|
||||
|
||||
func testSendSurveySentEvent(survey: PostHogSurvey, responses: [String: PostHogSurveyResponse]) {
|
||||
sendSurveySentEvent(survey: survey, responses: responses)
|
||||
}
|
||||
|
||||
func testSendSurveyDismissedEvent(survey: PostHogSurvey) {
|
||||
sendSurveyDismissedEvent(survey: survey)
|
||||
}
|
||||
|
||||
func testGetBaseSurveyEventProperties(for survey: PostHogSurvey) -> [String: Any] {
|
||||
getBaseSurveyEventProperties(for: survey)
|
||||
}
|
||||
|
||||
func testGetSurveyInteractionProperty(survey: PostHogSurvey, property: String) -> String {
|
||||
getSurveyInteractionProperty(survey: survey, property: property)
|
||||
}
|
||||
|
||||
func testGetResponseKey(questionId: String) -> String {
|
||||
getNewResponseKey(for: questionId)
|
||||
}
|
||||
|
||||
static func clearInstalls() {
|
||||
integrationInstalledLock.withLock {
|
||||
integrationInstalled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
53
Pods/PostHog/PostHog/Surveys/PostHogSurveysConfig.swift
generated
Normal file
53
Pods/PostHog/PostHog/Surveys/PostHogSurveysConfig.swift
generated
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// PostHogSurveysConfig.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 24/04/2025.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc public class PostHogSurveysConfig: NSObject {
|
||||
/// Delegate responsible for managing survey presentation in your app.
|
||||
/// Handles survey rendering, response collection, and lifecycle events.
|
||||
/// You can provide your own delegate for a custom survey presentation.
|
||||
///
|
||||
/// Defaults to `PostHogSurveysDefaultDelegate` which provides a standard survey UI.
|
||||
public var surveysDelegate: PostHogSurveysDelegate = PostHogSurveysDefaultDelegate()
|
||||
}
|
||||
|
||||
/// To be called when a survey is successfully shown to the user
|
||||
/// - Parameter survey: The survey that was displayed
|
||||
public typealias OnPostHogSurveyShown = (_ survey: PostHogDisplaySurvey) -> Void
|
||||
|
||||
/// To be called when a user responds to a survey question
|
||||
/// - Parameters:
|
||||
/// - survey: The current survey being displayed
|
||||
/// - index: The index of the question being answered
|
||||
/// - response: The user's response to the question
|
||||
/// - Returns: The next question state (next question index and completion flag)
|
||||
public typealias OnPostHogSurveyResponse = (_ survey: PostHogDisplaySurvey, _ index: Int, _ response: PostHogSurveyResponse) -> PostHogNextSurveyQuestion?
|
||||
|
||||
/// To be called when a survey is dismissed
|
||||
/// - Parameter survey: The survey that was closed
|
||||
public typealias OnPostHogSurveyClosed = (_ survey: PostHogDisplaySurvey) -> Void
|
||||
|
||||
@objc public protocol PostHogSurveysDelegate {
|
||||
/// Called when an activated PostHog survey needs to be rendered on the app's UI
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - survey: The survey to be displayed to the user
|
||||
/// - onSurveyShown: To be called when the survey is successfully displayed to the user.
|
||||
/// - onSurveyResponse: To be called the user submits a response to a question.
|
||||
/// - onSurveyClosed: To be called when the survey is dismissed
|
||||
@objc func renderSurvey(
|
||||
_ survey: PostHogDisplaySurvey,
|
||||
onSurveyShown: @escaping OnPostHogSurveyShown,
|
||||
onSurveyResponse: @escaping OnPostHogSurveyResponse,
|
||||
onSurveyClosed: @escaping OnPostHogSurveyClosed
|
||||
)
|
||||
|
||||
/// Called when surveys are stopped to clean up any UI elements and reset the survey display state.
|
||||
/// This method should handle the dismissal of any active surveys and cleanup of associated resources.
|
||||
@objc func cleanupSurveys()
|
||||
}
|
||||
70
Pods/PostHog/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift
generated
Normal file
70
Pods/PostHog/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift
generated
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// PostHogSurveysDefaultDelegate.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 18/06/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate {
|
||||
#if os(iOS)
|
||||
private var surveysWindow: UIWindow?
|
||||
private var displayController: SurveyDisplayController?
|
||||
#endif
|
||||
|
||||
func renderSurvey(
|
||||
_ survey: PostHogDisplaySurvey,
|
||||
onSurveyShown: @escaping OnPostHogSurveyShown,
|
||||
onSurveyResponse: @escaping OnPostHogSurveyResponse,
|
||||
onSurveyClosed: @escaping OnPostHogSurveyClosed
|
||||
) {
|
||||
#if os(iOS)
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
|
||||
if surveysWindow == nil {
|
||||
// setup window for first-time display
|
||||
setupWindow()
|
||||
}
|
||||
|
||||
// Setup handlers
|
||||
displayController?.onSurveyShown = onSurveyShown
|
||||
displayController?.onSurveyResponse = onSurveyResponse
|
||||
displayController?.onSurveyClosed = onSurveyClosed
|
||||
|
||||
// Display survey
|
||||
displayController?.showSurvey(survey)
|
||||
#endif
|
||||
}
|
||||
|
||||
func cleanupSurveys() {
|
||||
#if os(iOS)
|
||||
displayController?.dismissSurvey() // dismiss any active surveys
|
||||
surveysWindow?.rootViewController?.dismiss(animated: true) {
|
||||
self.surveysWindow?.isHidden = true
|
||||
self.surveysWindow = nil
|
||||
self.displayController = nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@available(iOS 15.0, *)
|
||||
private func setupWindow() {
|
||||
if let activeWindow = UIApplication.getCurrentWindow(), let activeScene = activeWindow.windowScene {
|
||||
let controller = SurveyDisplayController()
|
||||
displayController = controller
|
||||
surveysWindow = SurveysWindow(
|
||||
controller: controller,
|
||||
scene: activeScene
|
||||
)
|
||||
surveysWindow?.isHidden = false
|
||||
surveysWindow?.windowLevel = activeWindow.windowLevel + 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
49
Pods/PostHog/PostHog/Surveys/QuestionHeader.swift
generated
Normal file
49
Pods/PostHog/PostHog/Surveys/QuestionHeader.swift
generated
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// QuestionHeader.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 13/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct QuestionHeader: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: String
|
||||
let description: String?
|
||||
let contentType: PostHogDisplaySurveyTextContentType
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(question)
|
||||
.font(.body.bold())
|
||||
.foregroundColor(foregroundTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
if let description, !description.isEmpty, contentType == .text {
|
||||
Text(description)
|
||||
.font(.callout)
|
||||
.foregroundColor(foregroundTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var foregroundTextColor: Color {
|
||||
appearance.backgroundColor.getContrastingTextColor()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
#Preview {
|
||||
QuestionHeader(
|
||||
question: "What can we do to improve our product?",
|
||||
description: "Any feedback will be helpful!",
|
||||
contentType: .text
|
||||
)
|
||||
}
|
||||
#endif
|
||||
243
Pods/PostHog/PostHog/Surveys/QuestionTypes.swift
generated
Normal file
243
Pods/PostHog/PostHog/Surveys/QuestionTypes.swift
generated
Normal file
@@ -0,0 +1,243 @@
|
||||
//
|
||||
// QuestionTypes.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 13/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct OpenTextQuestionView: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: PostHogDisplayOpenQuestion
|
||||
let onNextQuestion: (String?) -> Void
|
||||
|
||||
@State private var text: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
QuestionHeader(
|
||||
question: question.question,
|
||||
description: question.questionDescription,
|
||||
contentType: question.questionDescriptionContentType
|
||||
)
|
||||
|
||||
TextEditor(text: $text)
|
||||
.frame(height: 80)
|
||||
.overlay(
|
||||
Group {
|
||||
if text.isEmpty {
|
||||
Text(appearance.placeholder ?? "Start typing...")
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 5, y: 8)
|
||||
}
|
||||
},
|
||||
alignment: .topLeading
|
||||
)
|
||||
.padding(8)
|
||||
.tint(.black)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color(uiColor: .secondaryLabel), lineWidth: 1)
|
||||
.background(Color.white)
|
||||
)
|
||||
|
||||
BottomSection(label: question.buttonText ?? appearance.submitButtonText) {
|
||||
let resp = text.trimmingCharacters(in: .whitespaces)
|
||||
onNextQuestion(resp.isEmpty ? nil : text)
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if question.isOptional { return true }
|
||||
return !text.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct LinkQuestionView: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: PostHogDisplayLinkQuestion
|
||||
let onNextQuestion: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
QuestionHeader(
|
||||
question: question.question,
|
||||
description: question.questionDescription,
|
||||
contentType: question.questionDescriptionContentType
|
||||
)
|
||||
|
||||
BottomSection(label: question.buttonText ?? appearance.submitButtonText) {
|
||||
onNextQuestion(true)
|
||||
if let link, UIApplication.shared.canOpenURL(link) {
|
||||
UIApplication.shared.open(link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var link: URL? {
|
||||
if let link = question.link {
|
||||
return URL(string: link)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct RatingQuestionView: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: PostHogDisplayRatingQuestion
|
||||
let onNextQuestion: (Int?) -> Void
|
||||
@State var rating: Int?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
QuestionHeader(
|
||||
question: question.question,
|
||||
description: question.questionDescription,
|
||||
contentType: question.questionDescriptionContentType
|
||||
)
|
||||
|
||||
if question.ratingType == .emoji {
|
||||
EmojiRating(
|
||||
selectedValue: $rating,
|
||||
scale: scale,
|
||||
lowerBoundLabel: question.lowerBoundLabel,
|
||||
upperBoundLabel: question.upperBoundLabel
|
||||
)
|
||||
} else {
|
||||
NumberRating(
|
||||
selectedValue: $rating,
|
||||
scale: scale,
|
||||
lowerBoundLabel: question.lowerBoundLabel,
|
||||
upperBoundLabel: question.upperBoundLabel
|
||||
)
|
||||
}
|
||||
|
||||
BottomSection(label: question.buttonText ?? appearance.submitButtonText) {
|
||||
onNextQuestion(rating)
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if question.isOptional { return true }
|
||||
return rating != nil
|
||||
}
|
||||
|
||||
private var scale: PostHogSurveyRatingScale {
|
||||
PostHogSurveyRatingScale(range: question.scaleLowerBound ... question.scaleUpperBound)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct SingleChoiceQuestionView: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: PostHogDisplayChoiceQuestion
|
||||
let onNextQuestion: (String?) -> Void
|
||||
|
||||
@State private var selectedChoices: Set<String> = []
|
||||
@State private var openChoiceInput: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
QuestionHeader(
|
||||
question: question.question,
|
||||
description: question.questionDescription,
|
||||
contentType: question.questionDescriptionContentType
|
||||
)
|
||||
|
||||
MultipleChoiceOptions(
|
||||
allowsMultipleSelection: false,
|
||||
hasOpenChoiceQuestion: question.hasOpenChoice,
|
||||
options: question.choices,
|
||||
selectedOptions: $selectedChoices,
|
||||
openChoiceInput: $openChoiceInput
|
||||
)
|
||||
|
||||
BottomSection(label: question.buttonText ?? appearance.submitButtonText) {
|
||||
let response = selectedChoices.first
|
||||
let openChoiceInput = openChoiceInput.trimmingCharacters(in: .whitespaces)
|
||||
onNextQuestion(response == openChoice ? openChoiceInput : response)
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if question.isOptional { return true }
|
||||
return selectedChoices.count == 1 && (hasOpenChoiceSelected ? !openChoiceInput.isEmpty : true)
|
||||
}
|
||||
|
||||
private var hasOpenChoiceSelected: Bool {
|
||||
guard let openChoice else { return false }
|
||||
return selectedChoices.contains(openChoice)
|
||||
}
|
||||
|
||||
private var openChoice: String? {
|
||||
guard question.hasOpenChoice == true else { return nil }
|
||||
return question.choices.last
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct MultipleChoiceQuestionView: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
let question: PostHogDisplayChoiceQuestion
|
||||
let onNextQuestion: ([String]?) -> Void
|
||||
|
||||
@State private var selectedChoices: Set<String> = []
|
||||
@State private var openChoiceInput: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
QuestionHeader(
|
||||
question: question.question,
|
||||
description: question.questionDescription,
|
||||
contentType: question.questionDescriptionContentType
|
||||
)
|
||||
|
||||
MultipleChoiceOptions(
|
||||
allowsMultipleSelection: true,
|
||||
hasOpenChoiceQuestion: question.hasOpenChoice,
|
||||
options: question.choices,
|
||||
selectedOptions: $selectedChoices,
|
||||
openChoiceInput: $openChoiceInput
|
||||
)
|
||||
|
||||
BottomSection(label: question.buttonText ?? appearance.submitButtonText) {
|
||||
let resp = selectedChoices.map { $0 == openChoice ? openChoiceInput : $0 }
|
||||
onNextQuestion(resp.isEmpty ? nil : resp)
|
||||
}
|
||||
.disabled(!canSubmit)
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if question.isOptional { return true }
|
||||
return !selectedChoices.isEmpty && (hasOpenChoiceSelected ? !openChoiceInput.isEmpty : true)
|
||||
}
|
||||
|
||||
private var hasOpenChoiceSelected: Bool {
|
||||
guard let openChoice else { return false }
|
||||
return selectedChoices.contains(openChoice)
|
||||
}
|
||||
|
||||
private var openChoice: String? {
|
||||
guard question.hasOpenChoice == true else { return nil }
|
||||
return question.choices.last
|
||||
}
|
||||
}
|
||||
#endif
|
||||
56
Pods/PostHog/PostHog/Surveys/SurveyDisplayController.swift
generated
Normal file
56
Pods/PostHog/PostHog/Surveys/SurveyDisplayController.swift
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
//
|
||||
// SurveyDisplayController.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 07/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS) || Testing
|
||||
import SwiftUI
|
||||
|
||||
final class SurveyDisplayController: ObservableObject {
|
||||
@Published var displayedSurvey: PostHogDisplaySurvey?
|
||||
@Published var isSurveyCompleted: Bool = false
|
||||
@Published var currentQuestionIndex: Int = 0
|
||||
|
||||
var onSurveyShown: OnPostHogSurveyShown?
|
||||
var onSurveyResponse: OnPostHogSurveyResponse?
|
||||
var onSurveyClosed: OnPostHogSurveyClosed?
|
||||
|
||||
func showSurvey(_ survey: PostHogDisplaySurvey) {
|
||||
guard displayedSurvey == nil else {
|
||||
hedgeLog("[Surveys] Already displaying a survey. Skipping")
|
||||
return
|
||||
}
|
||||
|
||||
displayedSurvey = survey
|
||||
isSurveyCompleted = false
|
||||
currentQuestionIndex = 0
|
||||
onSurveyShown?(survey)
|
||||
}
|
||||
|
||||
func onNextQuestion(index: Int, response: PostHogSurveyResponse) {
|
||||
guard let displayedSurvey else { return }
|
||||
guard let next = onSurveyResponse?(displayedSurvey, index, response) else { return }
|
||||
|
||||
currentQuestionIndex = next.questionIndex
|
||||
isSurveyCompleted = next.isSurveyCompleted
|
||||
|
||||
// auto-dismiss survey when completed
|
||||
if isSurveyCompleted, displayedSurvey.appearance?.displayThankYouMessage == false {
|
||||
dismissSurvey()
|
||||
}
|
||||
}
|
||||
|
||||
// User dismissed survey
|
||||
func dismissSurvey() {
|
||||
if let survey = displayedSurvey {
|
||||
onSurveyClosed?(survey)
|
||||
}
|
||||
displayedSurvey = nil
|
||||
isSurveyCompleted = false
|
||||
currentQuestionIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
249
Pods/PostHog/PostHog/Surveys/SurveySheet.swift
generated
Normal file
249
Pods/PostHog/PostHog/Surveys/SurveySheet.swift
generated
Normal file
@@ -0,0 +1,249 @@
|
||||
//
|
||||
// SurveySheet.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 12/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15, *)
|
||||
struct SurveySheet: View {
|
||||
let survey: PostHogDisplaySurvey
|
||||
let isSurveyCompleted: Bool
|
||||
let currentQuestionIndex: Int
|
||||
let onClose: () -> Void
|
||||
let onNextQuestionClicked: (_ index: Int, _ response: PostHogSurveyResponse) -> Void
|
||||
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
|
||||
var body: some View {
|
||||
surveyContent
|
||||
.animation(.linear(duration: 0.25), value: currentQuestionIndex)
|
||||
.readFrame(in: .named("survey-scroll-view")) { frame in
|
||||
sheetHeight = frame.height
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
SurveyDismissButton(action: onClose)
|
||||
}
|
||||
}
|
||||
.surveyBottomSheet(height: sheetHeight)
|
||||
.environment(\.surveyAppearance, appearance)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var surveyContent: some View {
|
||||
if isSurveyCompleted, appearance.displayThankYouMessage {
|
||||
ConfirmationMessage(onClose: onClose)
|
||||
} else if let currentQuestion {
|
||||
switch currentQuestion {
|
||||
case let currentQuestion as PostHogDisplayOpenQuestion:
|
||||
OpenTextQuestionView(question: currentQuestion) { resp in
|
||||
onNextQuestionClicked(currentQuestionIndex, .openEnded(resp))
|
||||
}
|
||||
case let currentQuestion as PostHogDisplayLinkQuestion:
|
||||
LinkQuestionView(question: currentQuestion) { resp in
|
||||
onNextQuestionClicked(currentQuestionIndex, .link(resp))
|
||||
}
|
||||
case let currentQuestion as PostHogDisplayRatingQuestion:
|
||||
RatingQuestionView(question: currentQuestion) { resp in
|
||||
onNextQuestionClicked(currentQuestionIndex, .rating(resp))
|
||||
}
|
||||
case let currentQuestion as PostHogDisplayChoiceQuestion:
|
||||
if currentQuestion.isMultipleChoice {
|
||||
MultipleChoiceQuestionView(question: currentQuestion) { resp in
|
||||
onNextQuestionClicked(currentQuestionIndex, .multipleChoice(resp))
|
||||
}
|
||||
} else {
|
||||
SingleChoiceQuestionView(question: currentQuestion) { resp in
|
||||
onNextQuestionClicked(currentQuestionIndex, .singleChoice(resp))
|
||||
}
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentQuestion: PostHogDisplaySurveyQuestion? {
|
||||
guard currentQuestionIndex <= survey.questions.count - 1 else {
|
||||
return nil
|
||||
}
|
||||
return survey.questions[currentQuestionIndex]
|
||||
}
|
||||
|
||||
private var appearance: SwiftUISurveyAppearance {
|
||||
.getAppearanceWithDefaults(survey.appearance)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15, *)
|
||||
private struct SurveyDismissButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.body)
|
||||
.foregroundColor(Color(uiColor: .label))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@available(iOS 15, *)
|
||||
func surveyBottomSheet(height: CGFloat) -> some View {
|
||||
modifier(
|
||||
SurveyBottomSheetWithWithDetents(height: height)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
private struct SurveyBottomSheetWithWithDetents: ViewModifier {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
@State private var sheetHeight: CGFloat = .zero
|
||||
@State private var safeAreaInsetsTop: CGFloat = .zero
|
||||
|
||||
let height: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
NavigationView {
|
||||
scrolledContent(with: content)
|
||||
.background(appearance.backgroundColor)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.readSafeAreaInsets { insets in
|
||||
DispatchQueue.main.async {
|
||||
if safeAreaInsetsTop == .zero {
|
||||
safeAreaInsetsTop = insets.top
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
.background(
|
||||
SurveyPresentationDetentsRepresentable(detents: sheetDetents)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func scrolledContent(with content: Content) -> some View {
|
||||
if #available(iOS 16.4, *) {
|
||||
ScrollView {
|
||||
content
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.coordinateSpace(name: "survey-scroll-view")
|
||||
.scrollBounceBehavior(.basedOnSize)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
ScrollView {
|
||||
content
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.coordinateSpace(name: "survey-scroll-view")
|
||||
}
|
||||
}
|
||||
|
||||
private var sheetDetents: [SurveyPresentationDetentsRepresentable.Detent] {
|
||||
if adjustedSheetHeight >= UIScreen.main.bounds.height {
|
||||
return [.medium, .large]
|
||||
}
|
||||
return [.height(adjustedSheetHeight)]
|
||||
}
|
||||
|
||||
var adjustedSheetHeight: CGFloat {
|
||||
height + safeAreaInsetsTop
|
||||
}
|
||||
}
|
||||
|
||||
struct SwiftUISurveyAppearance {
|
||||
var fontFamily: Font
|
||||
var backgroundColor: Color
|
||||
var submitButtonColor: Color
|
||||
var submitButtonText: String
|
||||
var submitButtonTextColor: Color
|
||||
var descriptionTextColor: Color
|
||||
var ratingButtonColor: Color?
|
||||
var ratingButtonActiveColor: Color?
|
||||
var displayThankYouMessage: Bool
|
||||
var thankYouMessageHeader: String
|
||||
var thankYouMessageDescription: String?
|
||||
var thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType = .text
|
||||
var thankYouMessageCloseButtonText: String
|
||||
var borderColor: Color
|
||||
var placeholder: String?
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
private struct SurveyAppearanceEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue: SwiftUISurveyAppearance = .getAppearanceWithDefaults()
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@available(iOS 15.0, *)
|
||||
var surveyAppearance: SwiftUISurveyAppearance {
|
||||
get { self[SurveyAppearanceEnvironmentKey.self] }
|
||||
set { self[SurveyAppearanceEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension SwiftUISurveyAppearance {
|
||||
@available(iOS 15.0, *)
|
||||
static func getAppearanceWithDefaults(_ appearance: PostHogDisplaySurveyAppearance? = nil) -> SwiftUISurveyAppearance {
|
||||
SwiftUISurveyAppearance(
|
||||
fontFamily: Font.customFont(family: appearance?.fontFamily ?? "") ?? Font.body,
|
||||
backgroundColor: colorFrom(css: appearance?.backgroundColor, defaultColor: .tertiarySystemBackground),
|
||||
submitButtonColor: colorFrom(css: appearance?.submitButtonColor, defaultColor: .black),
|
||||
submitButtonText: appearance?.submitButtonText ?? "Submit",
|
||||
submitButtonTextColor: colorFrom(css: appearance?.submitButtonTextColor, defaultColor: .white),
|
||||
descriptionTextColor: colorFrom(css: appearance?.descriptionTextColor, defaultColor: .secondaryLabel),
|
||||
ratingButtonColor: colorFrom(css: appearance?.ratingButtonColor),
|
||||
ratingButtonActiveColor: colorFrom(css: appearance?.ratingButtonActiveColor),
|
||||
displayThankYouMessage: appearance?.displayThankYouMessage ?? true,
|
||||
thankYouMessageHeader: appearance?.thankYouMessageHeader ?? "Thank you for your feedback!",
|
||||
thankYouMessageDescriptionContentType: appearance?.thankYouMessageDescriptionContentType ?? .text,
|
||||
thankYouMessageCloseButtonText: appearance?.thankYouMessageCloseButtonText ?? "Close",
|
||||
borderColor: colorFrom(css: appearance?.borderColor, defaultColor: .systemFill)
|
||||
)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
private static func colorFrom(css hex: String?, defaultColor: UIColor) -> Color {
|
||||
hex.map { Color(uiColor: UIColor(hex: $0)) } ?? Color(uiColor: defaultColor)
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
private static func colorFrom(css hex: String?) -> Color? {
|
||||
hex.map { Color(uiColor: UIColor(hex: $0)) }
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension PresentationDetent {
|
||||
/// Same as .large detent but without shrinking the source view
|
||||
static let almostLarge = Self.custom(AlmostLarge.self)
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct AlmostLarge: CustomPresentationDetent {
|
||||
static func height(in context: Context) -> CGFloat? {
|
||||
context.maxDetentValue - 0.5
|
||||
}
|
||||
}
|
||||
|
||||
extension Font {
|
||||
static func customFont(family: String) -> Font? {
|
||||
if let uiFont = UIFont(name: family, size: UIFont.systemFontSize) {
|
||||
return Font(uiFont)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
44
Pods/PostHog/PostHog/Surveys/SurveysRootView.swift
generated
Normal file
44
Pods/PostHog/PostHog/Surveys/SurveysRootView.swift
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// SurveysRootView.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 07/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct SurveysRootView: View {
|
||||
@EnvironmentObject private var displayManager: SurveyDisplayController
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.allowsHitTesting(false)
|
||||
.sheet(item: displayBinding) { survey in
|
||||
SurveySheet(
|
||||
survey: survey,
|
||||
isSurveyCompleted: displayManager.isSurveyCompleted,
|
||||
currentQuestionIndex: displayManager.currentQuestionIndex,
|
||||
onClose: displayManager.dismissSurvey,
|
||||
onNextQuestionClicked: displayManager.onNextQuestion
|
||||
)
|
||||
.environment(\.colorScheme, .light) // enforce light theme for now
|
||||
}
|
||||
}
|
||||
|
||||
private var displayBinding: Binding<PostHogDisplaySurvey?> {
|
||||
.init(
|
||||
get: {
|
||||
displayManager.displayedSurvey
|
||||
},
|
||||
set: { newValue in
|
||||
// in case interactive dismiss is allowed
|
||||
if newValue == nil {
|
||||
displayManager.dismissSurvey()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
41
Pods/PostHog/PostHog/Surveys/SurveysWindow.swift
generated
Normal file
41
Pods/PostHog/PostHog/Surveys/SurveysWindow.swift
generated
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// SurveysWindow.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 06/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
final class SurveysWindow: PassthroughWindow {
|
||||
init(controller: SurveyDisplayController, scene: UIWindowScene) {
|
||||
super.init(windowScene: scene)
|
||||
let rootView = SurveysRootView().environmentObject(controller)
|
||||
let hostingController = UIHostingController(rootView: rootView)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
rootViewController = hostingController
|
||||
}
|
||||
|
||||
required init?(coder _: NSCoder) {
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
}
|
||||
|
||||
class PassthroughWindow: UIWindow {
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
guard
|
||||
let hitView = super.hitTest(point, with: event),
|
||||
let rootView = rootViewController?.view
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if test comes back as our own view, ignore (this is the passthrough part)
|
||||
return hitView == rootView ? nil : hitView
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
26
Pods/PostHog/PostHog/Surveys/Utils/EdgeBorder.swift
generated
Normal file
26
Pods/PostHog/PostHog/Surveys/Utils/EdgeBorder.swift
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// EdgeBorder.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 22/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct EdgeBorder: Shape {
|
||||
var lineWidth: CGFloat
|
||||
var edges: [Edge]
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
edges.map { edge -> Path in
|
||||
switch edge {
|
||||
case .top: return Path(.init(x: rect.minX, y: rect.minY, width: rect.width, height: lineWidth))
|
||||
case .bottom: return Path(.init(x: rect.minX, y: rect.maxY - lineWidth, width: rect.width, height: lineWidth))
|
||||
case .leading: return Path(.init(x: rect.minX, y: rect.minY, width: lineWidth, height: rect.height))
|
||||
case .trailing: return Path(.init(x: rect.maxX - lineWidth, y: rect.minY, width: lineWidth, height: rect.height))
|
||||
}
|
||||
}.reduce(into: Path()) { $0.addPath($1) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
115
Pods/PostHog/PostHog/Surveys/Utils/EmojiRating.swift
generated
Normal file
115
Pods/PostHog/PostHog/Surveys/Utils/EmojiRating.swift
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// EmojiRating.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 11/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct EmojiRating: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
@Binding var selectedValue: Int?
|
||||
|
||||
let scale: PostHogSurveyRatingScale
|
||||
let lowerBoundLabel: String
|
||||
let upperBoundLabel: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
ForEach(scale.range, id: \.self) { value in
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.25)) {
|
||||
selectedValue = selectedValue == value ? nil : value
|
||||
}
|
||||
} label: {
|
||||
let isSelected = selectedValue == value
|
||||
emoji(for: value)
|
||||
.frame(width: 48, height: 48)
|
||||
.font(.body.bold())
|
||||
.foregroundColor(foregroundColor(selected: isSelected))
|
||||
|
||||
if value != scale.range.upperBound {
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(lowerBoundLabel)
|
||||
.foregroundStyle(appearance.descriptionTextColor)
|
||||
.frame(alignment: .leading)
|
||||
Spacer()
|
||||
Text(upperBoundLabel)
|
||||
.foregroundStyle(appearance.descriptionTextColor)
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
@ViewBuilder private func emoji(for value: Int) -> some View {
|
||||
switch scale {
|
||||
case .threePoint:
|
||||
switch value {
|
||||
case 1: DissatisfiedEmoji()
|
||||
case 2: NeutralEmoji()
|
||||
case 3: SatisfiedEmoji()
|
||||
default: EmptyView()
|
||||
}
|
||||
case .fivePoint:
|
||||
switch value {
|
||||
case 1: VeryDissatisfiedEmoji()
|
||||
case 2: DissatisfiedEmoji()
|
||||
case 3: NeutralEmoji()
|
||||
case 4: SatisfiedEmoji()
|
||||
case 5: VerySatisfiedEmoji()
|
||||
default: EmptyView()
|
||||
}
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func foregroundColor(selected: Bool) -> Color {
|
||||
selected ? Color(uiColor: .label) : Color(uiColor: .tertiaryLabel)
|
||||
}
|
||||
|
||||
private var ratingButtonActiveColor: Color {
|
||||
appearance.ratingButtonActiveColor ?? .black
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 18.0, *)
|
||||
private struct TestView: View {
|
||||
@State var selectedValue: Int?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 40) {
|
||||
EmojiRating(
|
||||
selectedValue: $selectedValue,
|
||||
scale: .fivePoint,
|
||||
lowerBoundLabel: "Unlikely",
|
||||
upperBoundLabel: "Very likely"
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(Text("Emoji Rating"))
|
||||
.environment(\.surveyAppearance.ratingButtonColor, .green.opacity(0.3))
|
||||
.environment(\.surveyAppearance.ratingButtonActiveColor, .green)
|
||||
.environment(\.surveyAppearance.descriptionTextColor, .orange)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
#Preview {
|
||||
TestView()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
160
Pods/PostHog/PostHog/Surveys/Utils/MultipleChoiceOptions.swift
generated
Normal file
160
Pods/PostHog/PostHog/Surveys/Utils/MultipleChoiceOptions.swift
generated
Normal file
@@ -0,0 +1,160 @@
|
||||
//
|
||||
// MultipleChoiceOptions.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 11/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct MultipleChoiceOptions: View {
|
||||
let allowsMultipleSelection: Bool
|
||||
let hasOpenChoiceQuestion: Bool
|
||||
let options: [String]
|
||||
|
||||
@Binding var selectedOptions: Set<String>
|
||||
@Binding var openChoiceInput: String
|
||||
@State private var textFieldRect: CGRect = .zero
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ForEach(options, id: \.self) { option in
|
||||
let isSelected = isSelected(option)
|
||||
|
||||
Button {
|
||||
withAnimation(.linear(duration: 0.15)) {
|
||||
setSelected(!isSelected, option: option)
|
||||
}
|
||||
} label: {
|
||||
if isOpenChoice(option) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(option):")
|
||||
.multilineTextAlignment(.leading)
|
||||
// Invisible text for calculating TextField placement
|
||||
Text("text-field-placeholder")
|
||||
.opacity(0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
.readFrame(in: .named("SurveyButton")) { frame in
|
||||
textFieldRect = frame
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.modifier(SurveyOptionStyle(isChecked: isSelected))
|
||||
.coordinateSpace(name: "SurveyButton")
|
||||
} else {
|
||||
Text(option)
|
||||
.modifier(SurveyOptionStyle(isChecked: isSelected))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
// text field needs to overlay the Button so it can receive touches first when enabled
|
||||
.overlay(openChoiceField(option), alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isOpenChoice(_ option: String) -> Bool {
|
||||
hasOpenChoiceQuestion && options.last == option
|
||||
}
|
||||
|
||||
private func isSelected(_ option: String) -> Bool {
|
||||
selectedOptions.contains(option)
|
||||
}
|
||||
|
||||
private func setSelected(_ selected: Bool, option: String) {
|
||||
if selected {
|
||||
if allowsMultipleSelection {
|
||||
selectedOptions.insert(option)
|
||||
} else {
|
||||
selectedOptions = [option]
|
||||
}
|
||||
|
||||
let isOpenChoice = self.isOpenChoice(option)
|
||||
// requires a small delay since textfield is enabled/disabled based on `selectedOptions` state update
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
isTextFieldFocused = isOpenChoice
|
||||
}
|
||||
} else {
|
||||
selectedOptions.remove(option)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func openChoiceField(_ option: String) -> some View {
|
||||
if isOpenChoice(option) {
|
||||
TextField("", text: $openChoiceInput)
|
||||
.focused($isTextFieldFocused)
|
||||
.foregroundColor(isSelected(option) ? Color.black : Color.black.opacity(0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(maxWidth: textFieldRect.size.width)
|
||||
.disabled(!isSelected(option))
|
||||
.offset(
|
||||
x: textFieldRect.origin.x,
|
||||
y: textFieldRect.origin.y
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
private struct SurveyOptionStyle: ViewModifier {
|
||||
let isChecked: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(isChecked ? .body.bold() : .body)
|
||||
.animation(.linear(duration: 0.15), value: isChecked)
|
||||
|
||||
if isChecked {
|
||||
CheckIcon()
|
||||
.frame(width: 16, height: 12)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(10)
|
||||
.frame(minHeight: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(isChecked ? Color.black : Color.black.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
.foregroundColor(isChecked ? Color.black : Color.black.opacity(0.5))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 18.0, *)
|
||||
private struct TestView: View {
|
||||
@State var selectedOptions: Set<String> = []
|
||||
@State var openChoiceInput = ""
|
||||
|
||||
var body: some View {
|
||||
MultipleChoiceOptions(
|
||||
allowsMultipleSelection: true,
|
||||
hasOpenChoiceQuestion: true,
|
||||
options: [
|
||||
"Tutorials",
|
||||
"Customer case studies",
|
||||
"Product announcements",
|
||||
"Other",
|
||||
],
|
||||
selectedOptions: $selectedOptions,
|
||||
openChoiceInput: $openChoiceInput
|
||||
)
|
||||
.colorScheme(.dark)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
#Preview {
|
||||
TestView()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
115
Pods/PostHog/PostHog/Surveys/Utils/NumberRating.swift
generated
Normal file
115
Pods/PostHog/PostHog/Surveys/Utils/NumberRating.swift
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// NumberRating.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 11/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 15.0, *)
|
||||
struct NumberRating: View {
|
||||
@Environment(\.surveyAppearance) private var appearance
|
||||
|
||||
@Binding var selectedValue: Int?
|
||||
let scale: PostHogSurveyRatingScale
|
||||
let lowerBoundLabel: String
|
||||
let upperBoundLabel: String
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
SegmentedControl(
|
||||
range: scale.range,
|
||||
height: 45,
|
||||
selectedValue: $selectedValue
|
||||
) { value, selected in
|
||||
Text("\(value)")
|
||||
.font(.body.bold())
|
||||
.foregroundColor(
|
||||
foregroundTextColor(selected: selected)
|
||||
)
|
||||
} separatorView: { value, _ in
|
||||
if value != scale.range.upperBound {
|
||||
EdgeBorder(lineWidth: 1, edges: [.trailing])
|
||||
.foregroundStyle(appearance.borderColor)
|
||||
}
|
||||
} indicatorView: { size in
|
||||
Rectangle()
|
||||
.fill(ratingButtonActiveColor)
|
||||
.frame(height: size.height)
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
.background(ratingButtonColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(appearance.borderColor, lineWidth: 2)
|
||||
)
|
||||
HStack {
|
||||
Text(lowerBoundLabel)
|
||||
.font(.callout)
|
||||
.foregroundColor(appearance.descriptionTextColor)
|
||||
.frame(alignment: .leading)
|
||||
Spacer()
|
||||
Text(upperBoundLabel)
|
||||
.font(.callout)
|
||||
.foregroundColor(appearance.descriptionTextColor)
|
||||
.frame(alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
.padding(2)
|
||||
}
|
||||
|
||||
private func foregroundTextColor(selected: Bool) -> Color {
|
||||
backgroundColor(selected: selected)
|
||||
.getContrastingTextColor()
|
||||
.opacity(foregroundTextOpacity(selected: selected))
|
||||
}
|
||||
|
||||
private func foregroundTextOpacity(selected: Bool) -> Double {
|
||||
selected ? 1 : 0.5
|
||||
}
|
||||
|
||||
private func backgroundColor(selected: Bool) -> Color {
|
||||
selected ? ratingButtonActiveColor : ratingButtonColor
|
||||
}
|
||||
|
||||
private var ratingButtonColor: Color {
|
||||
appearance.ratingButtonColor ?? Color(uiColor: .secondarySystemBackground)
|
||||
}
|
||||
|
||||
private var ratingButtonActiveColor: Color {
|
||||
appearance.ratingButtonActiveColor ?? .black
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@available(iOS 18.0, *)
|
||||
private struct TestView: View {
|
||||
@State var selectedValue: Int?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(spacing: 15) {
|
||||
NumberRating(
|
||||
selectedValue: $selectedValue,
|
||||
scale: .tenPoint,
|
||||
lowerBoundLabel: "Unlikely",
|
||||
upperBoundLabel: "Very Likely"
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationBarTitle(Text("Number Rating"))
|
||||
.environment(\.colorScheme, .light)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
#Preview {
|
||||
TestView()
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
431
Pods/PostHog/PostHog/Surveys/Utils/Resources.swift
generated
Normal file
431
Pods/PostHog/PostHog/Surveys/Utils/Resources.swift
generated
Normal file
@@ -0,0 +1,431 @@
|
||||
//
|
||||
// Resources.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 10/03/2025.
|
||||
//
|
||||
|
||||
// see: https://github.com/bring-shrubbery/SVG-to-SwiftUI
|
||||
|
||||
// swiftlint:disable line_length
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct VeryDissatisfiedEmoji: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.43438 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.37344 * width, y: -0.39531 * height), control: CGPoint(x: 0.43021 * width, y: -0.43438 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.28958 * width, y: -0.29167 * height), control: CGPoint(x: 0.31667 * width, y: -0.35625 * height))
|
||||
path.addLine(to: CGPoint(x: 0.71042 * width, y: -0.29167 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.62708 * width, y: -0.39583 * height), control: CGPoint(x: 0.68437 * width, y: -0.35729 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.43438 * height), control: CGPoint(x: 0.56979 * width, y: -0.43438 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.30938 * width, y: -0.50938 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36146 * width, y: -0.55625 * height))
|
||||
path.addLine(to: CGPoint(x: 0.40833 * width, y: -0.50938 * height))
|
||||
path.addLine(to: CGPoint(x: 0.44062 * width, y: -0.54688 * height))
|
||||
path.addLine(to: CGPoint(x: 0.39375 * width, y: -0.59375 * height))
|
||||
path.addLine(to: CGPoint(x: 0.44062 * width, y: -0.64063 * height))
|
||||
path.addLine(to: CGPoint(x: 0.40833 * width, y: -0.67812 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36146 * width, y: -0.63125 * height))
|
||||
path.addLine(to: CGPoint(x: 0.30938 * width, y: -0.67812 * height))
|
||||
path.addLine(to: CGPoint(x: 0.27708 * width, y: -0.64063 * height))
|
||||
path.addLine(to: CGPoint(x: 0.32396 * width, y: -0.59375 * height))
|
||||
path.addLine(to: CGPoint(x: 0.27708 * width, y: -0.54688 * height))
|
||||
path.addLine(to: CGPoint(x: 0.30938 * width, y: -0.50938 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.59271 * width, y: -0.50938 * height))
|
||||
path.addLine(to: CGPoint(x: 0.63854 * width, y: -0.55625 * height))
|
||||
path.addLine(to: CGPoint(x: 0.69167 * width, y: -0.50938 * height))
|
||||
path.addLine(to: CGPoint(x: 0.72396 * width, y: -0.54688 * height))
|
||||
path.addLine(to: CGPoint(x: 0.67708 * width, y: -0.59375 * height))
|
||||
path.addLine(to: CGPoint(x: 0.72396 * width, y: -0.64063 * height))
|
||||
path.addLine(to: CGPoint(x: 0.69167 * width, y: -0.67812 * height))
|
||||
path.addLine(to: CGPoint(x: 0.63854 * width, y: -0.63125 * height))
|
||||
path.addLine(to: CGPoint(x: 0.59271 * width, y: -0.67812 * height))
|
||||
path.addLine(to: CGPoint(x: 0.56042 * width, y: -0.64063 * height))
|
||||
path.addLine(to: CGPoint(x: 0.60625 * width, y: -0.59375 * height))
|
||||
path.addLine(to: CGPoint(x: 0.56042 * width, y: -0.54688 * height))
|
||||
path.addLine(to: CGPoint(x: 0.59271 * width, y: -0.50938 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.11615 * height), control: CGPoint(x: 0.41354 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.20521 * height), control: CGPoint(x: 0.26146 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.3375 * height), control: CGPoint(x: 0.14896 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.08333 * width, y: -0.5 * height), control: CGPoint(x: 0.08333 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.6625 * height), control: CGPoint(x: 0.08333 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.79479 * height), control: CGPoint(x: 0.14896 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.88385 * height), control: CGPoint(x: 0.26146 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.91667 * height), control: CGPoint(x: 0.41354 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.88385 * height), control: CGPoint(x: 0.58646 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.79479 * height), control: CGPoint(x: 0.73854 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.6625 * height), control: CGPoint(x: 0.85104 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.91667 * width, y: -0.5 * height), control: CGPoint(x: 0.91667 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.3375 * height), control: CGPoint(x: 0.91667 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.20521 * height), control: CGPoint(x: 0.85104 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.11615 * height), control: CGPoint(x: 0.73854 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.08333 * height), control: CGPoint(x: 0.58646 * width, y: -0.08333 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.5 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75104 * width, y: -0.24896 * height), control: CGPoint(x: 0.64792 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.85417 * width, y: -0.5 * height), control: CGPoint(x: 0.85417 * width, y: -0.35208 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75104 * width, y: -0.75104 * height), control: CGPoint(x: 0.85417 * width, y: -0.64792 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.85417 * height), control: CGPoint(x: 0.64792 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24896 * width, y: -0.75104 * height), control: CGPoint(x: 0.35208 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.14583 * width, y: -0.5 * height), control: CGPoint(x: 0.14583 * width, y: -0.64792 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24896 * width, y: -0.24896 * height), control: CGPoint(x: 0.14583 * width, y: -0.35208 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.14583 * height), control: CGPoint(x: 0.35208 * width, y: -0.14583 * height))
|
||||
path.closeSubpath()
|
||||
return path.offsetBy(dx: 0, dy: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct VerySatisfiedEmoji: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.49948 * width, y: -0.27187 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6099 * width, y: -0.29896 * height), control: CGPoint(x: 0.55937 * width, y: -0.27187 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69167 * width, y: -0.37437 * height), control: CGPoint(x: 0.66042 * width, y: -0.32604 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69089 * width, y: -0.39792 * height), control: CGPoint(x: 0.69792 * width, y: -0.38646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.66979 * width, y: -0.40937 * height), control: CGPoint(x: 0.68385 * width, y: -0.40937 * height))
|
||||
path.addLine(to: CGPoint(x: 0.33012 * width, y: -0.40937 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30885 * width, y: -0.39792 * height), control: CGPoint(x: 0.31562 * width, y: -0.40937 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30833 * width, y: -0.37437 * height), control: CGPoint(x: 0.30208 * width, y: -0.38646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3901 * width, y: -0.29896 * height), control: CGPoint(x: 0.33958 * width, y: -0.32604 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.49948 * width, y: -0.27187 * height), control: CGPoint(x: 0.44062 * width, y: -0.27187 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.36146 * width, y: -0.60208 * height))
|
||||
path.addLine(to: CGPoint(x: 0.38958 * width, y: -0.57396 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.40814 * width, y: -0.56563 * height), control: CGPoint(x: 0.39754 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.42708 * width, y: -0.57396 * height), control: CGPoint(x: 0.41875 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.43542 * width, y: -0.59271 * height), control: CGPoint(x: 0.43542 * width, y: -0.58229 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.42708 * width, y: -0.61146 * height), control: CGPoint(x: 0.43542 * width, y: -0.60313 * height))
|
||||
path.addLine(to: CGPoint(x: 0.38333 * width, y: -0.65521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.36156 * width, y: -0.66458 * height), control: CGPoint(x: 0.37417 * width, y: -0.66458 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.33958 * width, y: -0.65521 * height), control: CGPoint(x: 0.34896 * width, y: -0.66458 * height))
|
||||
path.addLine(to: CGPoint(x: 0.29583 * width, y: -0.61146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.2875 * width, y: -0.5929 * height), control: CGPoint(x: 0.2875 * width, y: -0.6035 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.29583 * width, y: -0.57396 * height), control: CGPoint(x: 0.2875 * width, y: -0.58229 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.31458 * width, y: -0.56563 * height), control: CGPoint(x: 0.30417 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.33333 * width, y: -0.57396 * height), control: CGPoint(x: 0.325 * width, y: -0.56563 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36146 * width, y: -0.60208 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.63958 * width, y: -0.60208 * height))
|
||||
path.addLine(to: CGPoint(x: 0.66771 * width, y: -0.57396 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.68646 * width, y: -0.56563 * height), control: CGPoint(x: 0.67574 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.70521 * width, y: -0.57396 * height), control: CGPoint(x: 0.69717 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.71354 * width, y: -0.59252 * height), control: CGPoint(x: 0.71354 * width, y: -0.58191 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.70521 * width, y: -0.61146 * height), control: CGPoint(x: 0.71354 * width, y: -0.60313 * height))
|
||||
path.addLine(to: CGPoint(x: 0.66146 * width, y: -0.65521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.63969 * width, y: -0.66458 * height), control: CGPoint(x: 0.65229 * width, y: -0.66458 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61771 * width, y: -0.65521 * height), control: CGPoint(x: 0.62708 * width, y: -0.66458 * height))
|
||||
path.addLine(to: CGPoint(x: 0.57396 * width, y: -0.61146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.56563 * width, y: -0.59271 * height), control: CGPoint(x: 0.56563 * width, y: -0.60342 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.57396 * width, y: -0.57396 * height), control: CGPoint(x: 0.56563 * width, y: -0.58199 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59252 * width, y: -0.56563 * height), control: CGPoint(x: 0.58191 * width, y: -0.56563 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61146 * width, y: -0.57396 * height), control: CGPoint(x: 0.60313 * width, y: -0.56563 * height))
|
||||
path.addLine(to: CGPoint(x: 0.63958 * width, y: -0.60208 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.11615 * height), control: CGPoint(x: 0.41354 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.20521 * height), control: CGPoint(x: 0.26146 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.3375 * height), control: CGPoint(x: 0.14896 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.08333 * width, y: -0.5 * height), control: CGPoint(x: 0.08333 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.6625 * height), control: CGPoint(x: 0.08333 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.79479 * height), control: CGPoint(x: 0.14896 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.88385 * height), control: CGPoint(x: 0.26146 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.91667 * height), control: CGPoint(x: 0.41354 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.88385 * height), control: CGPoint(x: 0.58646 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.79479 * height), control: CGPoint(x: 0.73854 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.6625 * height), control: CGPoint(x: 0.85104 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.91667 * width, y: -0.5 * height), control: CGPoint(x: 0.91667 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.3375 * height), control: CGPoint(x: 0.91667 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.20521 * height), control: CGPoint(x: 0.85104 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.11615 * height), control: CGPoint(x: 0.73854 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.08333 * height), control: CGPoint(x: 0.58646 * width, y: -0.08333 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.5 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.24876 * height), control: CGPoint(x: 0.64831 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.85417 * width, y: -0.5 * height), control: CGPoint(x: 0.85417 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.75124 * height), control: CGPoint(x: 0.85417 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.85417 * height), control: CGPoint(x: 0.64831 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.75124 * height), control: CGPoint(x: 0.35169 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.14583 * width, y: -0.5 * height), control: CGPoint(x: 0.14583 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.24876 * height), control: CGPoint(x: 0.14583 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.14583 * height), control: CGPoint(x: 0.35169 * width, y: -0.14583 * height))
|
||||
path.closeSubpath()
|
||||
return path.offsetBy(dx: 0, dy: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct DissatisfiedEmoji: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.57161 * height), control: CGPoint(x: 0.67552 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.70833 * width, y: -0.61146 * height), control: CGPoint(x: 0.70833 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.6513 * height), control: CGPoint(x: 0.70833 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.66771 * height), control: CGPoint(x: 0.67552 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.6513 * height), control: CGPoint(x: 0.62865 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59583 * width, y: -0.61146 * height), control: CGPoint(x: 0.59583 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.57161 * height), control: CGPoint(x: 0.59583 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height), control: CGPoint(x: 0.62865 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.57161 * height), control: CGPoint(x: 0.37135 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.40417 * width, y: -0.61146 * height), control: CGPoint(x: 0.40417 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.6513 * height), control: CGPoint(x: 0.40417 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.66771 * height), control: CGPoint(x: 0.37135 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.6513 * height), control: CGPoint(x: 0.32448 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.29167 * width, y: -0.61146 * height), control: CGPoint(x: 0.29167 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.57161 * height), control: CGPoint(x: 0.29167 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height), control: CGPoint(x: 0.32448 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.50018 * width, y: -0.43438 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.37344 * width, y: -0.39531 * height), control: CGPoint(x: 0.43021 * width, y: -0.43438 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.28958 * width, y: -0.29167 * height), control: CGPoint(x: 0.31667 * width, y: -0.35625 * height))
|
||||
path.addLine(to: CGPoint(x: 0.34479 * width, y: -0.29167 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.40956 * width, y: -0.35938 * height), control: CGPoint(x: 0.36771 * width, y: -0.33542 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5007 * width, y: -0.38333 * height), control: CGPoint(x: 0.4514 * width, y: -0.38333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59115 * width, y: -0.35885 * height), control: CGPoint(x: 0.55 * width, y: -0.38333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65625 * width, y: -0.29167 * height), control: CGPoint(x: 0.63229 * width, y: -0.33437 * height))
|
||||
path.addLine(to: CGPoint(x: 0.71042 * width, y: -0.29167 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.62726 * width, y: -0.39583 * height), control: CGPoint(x: 0.68437 * width, y: -0.35729 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.50018 * width, y: -0.43438 * height), control: CGPoint(x: 0.57015 * width, y: -0.43438 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.11615 * height), control: CGPoint(x: 0.41354 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.20521 * height), control: CGPoint(x: 0.26146 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.3375 * height), control: CGPoint(x: 0.14896 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.08333 * width, y: -0.5 * height), control: CGPoint(x: 0.08333 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.6625 * height), control: CGPoint(x: 0.08333 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.79479 * height), control: CGPoint(x: 0.14896 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.88385 * height), control: CGPoint(x: 0.26146 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.91667 * height), control: CGPoint(x: 0.41354 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.88385 * height), control: CGPoint(x: 0.58646 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.79479 * height), control: CGPoint(x: 0.73854 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.6625 * height), control: CGPoint(x: 0.85104 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.91667 * width, y: -0.5 * height), control: CGPoint(x: 0.91667 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.3375 * height), control: CGPoint(x: 0.91667 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.20521 * height), control: CGPoint(x: 0.85104 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.11615 * height), control: CGPoint(x: 0.73854 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.08333 * height), control: CGPoint(x: 0.58646 * width, y: -0.08333 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.5 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.24876 * height), control: CGPoint(x: 0.64831 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.85417 * width, y: -0.5 * height), control: CGPoint(x: 0.85417 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.75124 * height), control: CGPoint(x: 0.85417 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.85417 * height), control: CGPoint(x: 0.64831 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.75124 * height), control: CGPoint(x: 0.35169 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.14583 * width, y: -0.5 * height), control: CGPoint(x: 0.14583 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.24876 * height), control: CGPoint(x: 0.14583 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.14583 * height), control: CGPoint(x: 0.35169 * width, y: -0.14583 * height))
|
||||
path.closeSubpath()
|
||||
return path.offsetBy(dx: 0, dy: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct NeutralEmoji: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.57161 * height), control: CGPoint(x: 0.67552 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.70833 * width, y: -0.61146 * height), control: CGPoint(x: 0.70833 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.6513 * height), control: CGPoint(x: 0.70833 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.66771 * height), control: CGPoint(x: 0.67552 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.6513 * height), control: CGPoint(x: 0.62865 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59583 * width, y: -0.61146 * height), control: CGPoint(x: 0.59583 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.57161 * height), control: CGPoint(x: 0.59583 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height), control: CGPoint(x: 0.62865 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.57161 * height), control: CGPoint(x: 0.37135 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.40417 * width, y: -0.61146 * height), control: CGPoint(x: 0.40417 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.6513 * height), control: CGPoint(x: 0.40417 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.66771 * height), control: CGPoint(x: 0.37135 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.6513 * height), control: CGPoint(x: 0.32448 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.29167 * width, y: -0.61146 * height), control: CGPoint(x: 0.29167 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.57161 * height), control: CGPoint(x: 0.29167 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height), control: CGPoint(x: 0.32448 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.36875 * width, y: -0.35313 * height))
|
||||
path.addLine(to: CGPoint(x: 0.63229 * width, y: -0.35313 * height))
|
||||
path.addLine(to: CGPoint(x: 0.63229 * width, y: -0.40417 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36875 * width, y: -0.40417 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36875 * width, y: -0.35313 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.11615 * height), control: CGPoint(x: 0.41354 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.20521 * height), control: CGPoint(x: 0.26146 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.3375 * height), control: CGPoint(x: 0.14896 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.08333 * width, y: -0.5 * height), control: CGPoint(x: 0.08333 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.6625 * height), control: CGPoint(x: 0.08333 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.79479 * height), control: CGPoint(x: 0.14896 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.88385 * height), control: CGPoint(x: 0.26146 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.91667 * height), control: CGPoint(x: 0.41354 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.88385 * height), control: CGPoint(x: 0.58646 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.79479 * height), control: CGPoint(x: 0.73854 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.6625 * height), control: CGPoint(x: 0.85104 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.91667 * width, y: -0.5 * height), control: CGPoint(x: 0.91667 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.3375 * height), control: CGPoint(x: 0.91667 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.20521 * height), control: CGPoint(x: 0.85104 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.11615 * height), control: CGPoint(x: 0.73854 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.08333 * height), control: CGPoint(x: 0.58646 * width, y: -0.08333 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.5 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.24876 * height), control: CGPoint(x: 0.64831 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.85417 * width, y: -0.5 * height), control: CGPoint(x: 0.85417 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.75124 * height), control: CGPoint(x: 0.85417 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.85417 * height), control: CGPoint(x: 0.64831 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.75124 * height), control: CGPoint(x: 0.35169 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.14583 * width, y: -0.5 * height), control: CGPoint(x: 0.14583 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.24876 * height), control: CGPoint(x: 0.14583 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.14583 * height), control: CGPoint(x: 0.35169 * width, y: -0.14583 * height))
|
||||
path.closeSubpath()
|
||||
return path.offsetBy(dx: 0, dy: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct SatisfiedEmoji: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.57161 * height), control: CGPoint(x: 0.67552 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.70833 * width, y: -0.61146 * height), control: CGPoint(x: 0.70833 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.69193 * width, y: -0.6513 * height), control: CGPoint(x: 0.70833 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.66771 * height), control: CGPoint(x: 0.67552 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.6513 * height), control: CGPoint(x: 0.62865 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59583 * width, y: -0.61146 * height), control: CGPoint(x: 0.59583 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.61224 * width, y: -0.57161 * height), control: CGPoint(x: 0.59583 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.65208 * width, y: -0.55521 * height), control: CGPoint(x: 0.62865 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.57161 * height), control: CGPoint(x: 0.37135 * width, y: -0.55521 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.40417 * width, y: -0.61146 * height), control: CGPoint(x: 0.40417 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.38776 * width, y: -0.6513 * height), control: CGPoint(x: 0.40417 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.66771 * height), control: CGPoint(x: 0.37135 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.6513 * height), control: CGPoint(x: 0.32448 * width, y: -0.66771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.29167 * width, y: -0.61146 * height), control: CGPoint(x: 0.29167 * width, y: -0.6349 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.30807 * width, y: -0.57161 * height), control: CGPoint(x: 0.29167 * width, y: -0.58802 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34792 * width, y: -0.55521 * height), control: CGPoint(x: 0.32448 * width, y: -0.55521 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.27187 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.62656 * width, y: -0.30885 * height), control: CGPoint(x: 0.56875 * width, y: -0.27187 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.71042 * width, y: -0.40937 * height), control: CGPoint(x: 0.68437 * width, y: -0.34583 * height))
|
||||
path.addLine(to: CGPoint(x: 0.65625 * width, y: -0.40937 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.59062 * width, y: -0.34531 * height), control: CGPoint(x: 0.63229 * width, y: -0.36771 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.50052 * width, y: -0.32292 * height), control: CGPoint(x: 0.54896 * width, y: -0.32292 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.4099 * width, y: -0.34479 * height), control: CGPoint(x: 0.45208 * width, y: -0.32292 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.34479 * width, y: -0.40937 * height), control: CGPoint(x: 0.36771 * width, y: -0.36667 * height))
|
||||
path.addLine(to: CGPoint(x: 0.28958 * width, y: -0.40937 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.37396 * width, y: -0.30885 * height), control: CGPoint(x: 0.31667 * width, y: -0.34583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.27187 * height), control: CGPoint(x: 0.43125 * width, y: -0.27187 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.11615 * height), control: CGPoint(x: 0.41354 * width, y: -0.08333 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.20521 * height), control: CGPoint(x: 0.26146 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.3375 * height), control: CGPoint(x: 0.14896 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.08333 * width, y: -0.5 * height), control: CGPoint(x: 0.08333 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.11615 * width, y: -0.6625 * height), control: CGPoint(x: 0.08333 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.20521 * width, y: -0.79479 * height), control: CGPoint(x: 0.14896 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.3375 * width, y: -0.88385 * height), control: CGPoint(x: 0.26146 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.91667 * height), control: CGPoint(x: 0.41354 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.88385 * height), control: CGPoint(x: 0.58646 * width, y: -0.91667 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.79479 * height), control: CGPoint(x: 0.73854 * width, y: -0.85104 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.6625 * height), control: CGPoint(x: 0.85104 * width, y: -0.73854 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.91667 * width, y: -0.5 * height), control: CGPoint(x: 0.91667 * width, y: -0.58646 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.88385 * width, y: -0.3375 * height), control: CGPoint(x: 0.91667 * width, y: -0.41354 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.79479 * width, y: -0.20521 * height), control: CGPoint(x: 0.85104 * width, y: -0.26146 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.6625 * width, y: -0.11615 * height), control: CGPoint(x: 0.73854 * width, y: -0.14896 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.08333 * height), control: CGPoint(x: 0.58646 * width, y: -0.08333 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.5 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.5 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.24876 * height), control: CGPoint(x: 0.64831 * width, y: -0.14583 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.85417 * width, y: -0.5 * height), control: CGPoint(x: 0.85417 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.75124 * width, y: -0.75124 * height), control: CGPoint(x: 0.85417 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.85417 * height), control: CGPoint(x: 0.64831 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.75124 * height), control: CGPoint(x: 0.35169 * width, y: -0.85417 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.14583 * width, y: -0.5 * height), control: CGPoint(x: 0.14583 * width, y: -0.64831 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.24876 * width, y: -0.24876 * height), control: CGPoint(x: 0.14583 * width, y: -0.35169 * height))
|
||||
path.addQuadCurve(to: CGPoint(x: 0.5 * width, y: -0.14583 * height), control: CGPoint(x: 0.35169 * width, y: -0.14583 * height))
|
||||
path.closeSubpath()
|
||||
return path.offsetBy(dx: 0, dy: height)
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckIcon: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.33173 * width, y: 0.89102 * height))
|
||||
path.addLine(to: CGPoint(x: 0.29858 * width, y: 0.93522 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.33173 * width, y: 0.95352 * height), control1: CGPoint(x: 0.30738 * width, y: 0.94694 * height), control2: CGPoint(x: 0.3193 * width, y: 0.95352 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.36488 * width, y: 0.93522 * height), control1: CGPoint(x: 0.34416 * width, y: 0.95352 * height), control2: CGPoint(x: 0.35609 * width, y: 0.94694 * height))
|
||||
path.addLine(to: CGPoint(x: 0.33173 * width, y: 0.89102 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.97064 * width, y: 0.12753 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.97064 * width, y: 0.03914 * height), control1: CGPoint(x: 0.98895 * width, y: 0.10312 * height), control2: CGPoint(x: 0.98895 * width, y: 0.06355 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.90436 * width, y: 0.03914 * height), control1: CGPoint(x: 0.95234 * width, y: 0.01473 * height), control2: CGPoint(x: 0.92266 * width, y: 0.01473 * height))
|
||||
path.addLine(to: CGPoint(x: 0.97064 * width, y: 0.12753 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.09565 * width, y: 0.48786 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.02935 * width, y: 0.48786 * height), control1: CGPoint(x: 0.07734 * width, y: 0.46345 * height), control2: CGPoint(x: 0.04766 * width, y: 0.46345 * height))
|
||||
path.addCurve(to: CGPoint(x: 0.02935 * width, y: 0.57625 * height), control1: CGPoint(x: 0.01105 * width, y: 0.51226 * height), control2: CGPoint(x: 0.01105 * width, y: 0.55184 * height))
|
||||
path.addLine(to: CGPoint(x: 0.09565 * width, y: 0.48786 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.36488 * width, y: 0.93522 * height))
|
||||
path.addLine(to: CGPoint(x: 0.97064 * width, y: 0.12753 * height))
|
||||
path.addLine(to: CGPoint(x: 0.90436 * width, y: 0.03914 * height))
|
||||
path.addLine(to: CGPoint(x: 0.29858 * width, y: 0.84683 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36488 * width, y: 0.93522 * height))
|
||||
path.closeSubpath()
|
||||
path.move(to: CGPoint(x: 0.02935 * width, y: 0.57625 * height))
|
||||
path.addLine(to: CGPoint(x: 0.29858 * width, y: 0.93522 * height))
|
||||
path.addLine(to: CGPoint(x: 0.36488 * width, y: 0.84683 * height))
|
||||
path.addLine(to: CGPoint(x: 0.09565 * width, y: 0.48786 * height))
|
||||
path.addLine(to: CGPoint(x: 0.02935 * width, y: 0.57625 * height))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
HStack {
|
||||
VeryDissatisfiedEmoji()
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(.blue)
|
||||
DissatisfiedEmoji().frame(width: 48, height: 48)
|
||||
NeutralEmoji().frame(width: 48, height: 48)
|
||||
SatisfiedEmoji().frame(width: 48, height: 48)
|
||||
VerySatisfiedEmoji().frame(width: 48, height: 48)
|
||||
}
|
||||
HStack {
|
||||
CheckIcon().frame(width: 16, height: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable line_length
|
||||
95
Pods/PostHog/PostHog/Surveys/Utils/SegmentedControl.swift
generated
Normal file
95
Pods/PostHog/PostHog/Surveys/Utils/SegmentedControl.swift
generated
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// SegmentedControl.swift
|
||||
// PostHog
|
||||
//
|
||||
// Created by Ioannis Josephides on 11/03/2025.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct SegmentedControl<Indicator: View, Segment: View, Separator: View>: View {
|
||||
var range: ClosedRange<Int>
|
||||
var height: CGFloat = 45
|
||||
|
||||
@Binding var selectedValue: Int?
|
||||
@ViewBuilder var segmentView: (_ value: Int, _ selected: Bool) -> Segment
|
||||
@ViewBuilder var separatorView: (_ value: Int, _ selected: Bool) -> Separator
|
||||
@ViewBuilder var indicatorView: (CGSize) -> Indicator
|
||||
|
||||
@State private var minX: CGFloat = .zero
|
||||
|
||||
var body: some View {
|
||||
GeometryReader {
|
||||
let size = $0.size
|
||||
let containerWidthForEachTab = size.width / CGFloat(range.count)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ForEach(range, id: \.self) { value in
|
||||
let isSelected = selectedValue == value
|
||||
Button {
|
||||
if selectedValue == value {
|
||||
withAnimation(.snappy(duration: 0.25, extraBounce: 0)) {
|
||||
selectedValue = nil
|
||||
}
|
||||
|
||||
} else {
|
||||
let index = value - range.lowerBound
|
||||
if selectedValue == nil {
|
||||
minX = containerWidthForEachTab * CGFloat(index)
|
||||
withAnimation(.snappy(duration: 0.25, extraBounce: 0)) {
|
||||
selectedValue = value
|
||||
}
|
||||
} else {
|
||||
selectedValue = selectedValue == value ? nil : value
|
||||
withAnimation(.snappy(duration: 0.25, extraBounce: 0)) {
|
||||
minX = containerWidthForEachTab * CGFloat(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} label: {
|
||||
segmentView(value, isSelected)
|
||||
.contentShape(.rect)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.animation(.snappy, value: selectedValue)
|
||||
.background(
|
||||
Group {
|
||||
if value == range.lowerBound, selectedValue != nil {
|
||||
GeometryReader {
|
||||
let size = $0.size
|
||||
indicatorView(size)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
.offset(x: minX)
|
||||
}
|
||||
}
|
||||
},
|
||||
alignment: .leading
|
||||
)
|
||||
.overlay(
|
||||
separatorView(value, isSelected)
|
||||
)
|
||||
}
|
||||
}
|
||||
.preference(key: SizeKey.self, value: size)
|
||||
.onPreferenceChange(SizeKey.self) { _ in
|
||||
if let selectedValue {
|
||||
let index = selectedValue - range.lowerBound
|
||||
minX = containerWidthForEachTab * CGFloat(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SizeKey: PreferenceKey {
|
||||
static var defaultValue: CGSize = .zero
|
||||
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user