ads tracking

This commit is contained in:
Stefan Lange-Hegermann
2025-11-05 11:13:40 +01:00
parent 5fcc33529a
commit ced06f9eb6
198 changed files with 21205 additions and 262 deletions

21
Pods/PostHog/LICENSE generated Normal file
View 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.

View 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()
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
}
}

View 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
}
}

View 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

View 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?
}

View 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?
}

View 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
}

View 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)
}
}
}

View 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
View 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
View 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
}()
}

View 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
View 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 Groups 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)
}
}

View 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
View 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
}()
}

View 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
}

View 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))
}
}
}
}

View 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()
}

View 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)")
}
}

View 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
}

View 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
View 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
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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)
}
}

View 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)
}
}
}
}

View 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)
}

View 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

View 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

View 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

View 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

View 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()
}

View 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
}
}

View 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

View 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

View 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
}
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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)
}
}

View 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

View 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

View 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

View 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

View 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
}

View 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>

View 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

View 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

View 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

View 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

View 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
}

View 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()
}
}

View 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
)
}
}

View 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()
}
}

View 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
}

View 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

View 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()
}

View 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
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,306 @@
// https://gist.github.com/nbasham/3b2de0566d5f716894fc
//
// Survey+Util.swift
// previously Color+HexAndCSSColorNames.swift
//
// Created by Norman Basham on 12/8/15.
// Copyright ©2018 Black Labs. All rights reserved.
//
// 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.
#if os(iOS)
import SwiftUI
import UIKit
// swiftlint:disable identifier_name
public extension UIColor {
/**
Creates an immuatble UIColor instance specified by a hex string, CSS color name, or nil.
- parameter hexString: A case insensitive String? representing a hex or CSS value e.g.
- **"abc"**
- **"abc7"**
- **"#abc7"**
- **"00FFFF"**
- **"#00FFFF"**
- **"00FFFF77"**
- **"Orange", "Azure", "Tomato"** Modern browsers support 140 color names (<http://www.w3schools.com/cssref/css_colornames.asp>)
- **"Clear"** [UIColor clearColor]
- **"Transparent"** [UIColor clearColor]
- **nil** [UIColor clearColor]
- **empty string** [UIColor clearColor]
*/
convenience init(hex: String?) {
let normalizedHexString: String = UIColor.normalize(hex)
var c: CUnsignedLongLong = 0
Scanner(string: normalizedHexString).scanHexInt64(&c)
self.init(
red: UIColorMasks.redValue(CUnsignedInt(c)),
green: UIColorMasks.greenValue(CUnsignedInt(c)),
blue: UIColorMasks.blueValue(CUnsignedInt(c)),
alpha: UIColorMasks.alphaValue(CUnsignedInt(c))
)
}
/**
Returns a hex equivalent of this UIColor.
- Parameter includeAlpha: Optional parameter to include the alpha hex.
color.hexDescription() -> "ff0000"
color.hexDescription(true) -> "ff0000aa"
- Returns: A new string with `String` with the color's hexidecimal value.
*/
func hexDescription(_ includeAlpha: Bool = false) -> String {
guard cgColor.numberOfComponents == 4 else {
return "Color not RGB."
}
let a = cgColor.components!.map { Int($0 * CGFloat(255)) }
let color = String(format: "%02x%02x%02x", a[0], a[1], a[2])
if includeAlpha {
let alpha = String(format: "%02x", a[3])
return "\(color)\(alpha)"
}
return color
}
fileprivate enum UIColorMasks: CUnsignedInt {
case redMask = 0xFF000000
case greenMask = 0x00FF0000
case blueMask = 0x0000FF00
case alphaMask = 0x000000FF
static func redValue(_ value: CUnsignedInt) -> CGFloat {
CGFloat((value & redMask.rawValue) >> 24) / 255.0
}
static func greenValue(_ value: CUnsignedInt) -> CGFloat {
CGFloat((value & greenMask.rawValue) >> 16) / 255.0
}
static func blueValue(_ value: CUnsignedInt) -> CGFloat {
CGFloat((value & blueMask.rawValue) >> 8) / 255.0
}
static func alphaValue(_ value: CUnsignedInt) -> CGFloat {
CGFloat(value & alphaMask.rawValue) / 255.0
}
}
fileprivate static func normalize(_ hex: String?) -> String {
guard var hexString = hex else {
return "00000000"
}
if let cssColor = cssToHexDictionary[hexString.uppercased()] {
return cssColor.count == 8 ? cssColor : cssColor + "ff"
}
if hexString.hasPrefix("#") {
hexString = String(hexString.dropFirst())
}
if hexString.count == 3 || hexString.count == 4 {
hexString = hexString.map { "\($0)\($0)" }.joined()
}
let hasAlpha = hexString.count > 7
if !hasAlpha {
hexString += "ff"
}
return hexString
}
/**
All modern browsers support the following 140 color names (see http://www.w3schools.com/cssref/css_colornames.asp)
*/
fileprivate static func hexFromCssName(_ cssName: String) -> String {
let key = cssName.uppercased()
if let hex = cssToHexDictionary[key] {
return hex
}
return cssName
}
fileprivate static let cssToHexDictionary: [String: String] = [
"CLEAR": "00000000",
"TRANSPARENT": "00000000",
"": "00000000",
"ALICEBLUE": "F0F8FF",
"ANTIQUEWHITE": "FAEBD7",
"AQUA": "00FFFF",
"AQUAMARINE": "7FFFD4",
"AZURE": "F0FFFF",
"BEIGE": "F5F5DC",
"BISQUE": "FFE4C4",
"BLACK": "000000",
"BLANCHEDALMOND": "FFEBCD",
"BLUE": "0000FF",
"BLUEVIOLET": "8A2BE2",
"BROWN": "A52A2A",
"BURLYWOOD": "DEB887",
"CADETBLUE": "5F9EA0",
"CHARTREUSE": "7FFF00",
"CHOCOLATE": "D2691E",
"CORAL": "FF7F50",
"CORNFLOWERBLUE": "6495ED",
"CORNSILK": "FFF8DC",
"CRIMSON": "DC143C",
"CYAN": "00FFFF",
"DARKBLUE": "00008B",
"DARKCYAN": "008B8B",
"DARKGOLDENROD": "B8860B",
"DARKGRAY": "A9A9A9",
"DARKGREY": "A9A9A9",
"DARKGREEN": "006400",
"DARKKHAKI": "BDB76B",
"DARKMAGENTA": "8B008B",
"DARKOLIVEGREEN": "556B2F",
"DARKORANGE": "FF8C00",
"DARKORCHID": "9932CC",
"DARKRED": "8B0000",
"DARKSALMON": "E9967A",
"DARKSEAGREEN": "8FBC8F",
"DARKSLATEBLUE": "483D8B",
"DARKSLATEGRAY": "2F4F4F",
"DARKSLATEGREY": "2F4F4F",
"DARKTURQUOISE": "00CED1",
"DARKVIOLET": "9400D3",
"DEEPPINK": "FF1493",
"DEEPSKYBLUE": "00BFFF",
"DIMGRAY": "696969",
"DIMGREY": "696969",
"DODGERBLUE": "1E90FF",
"FIREBRICK": "B22222",
"FLORALWHITE": "FFFAF0",
"FORESTGREEN": "228B22",
"FUCHSIA": "FF00FF",
"GAINSBORO": "DCDCDC",
"GHOSTWHITE": "F8F8FF",
"GOLD": "FFD700",
"GOLDENROD": "DAA520",
"GRAY": "808080",
"GREY": "808080",
"GREEN": "008000",
"GREENYELLOW": "ADFF2F",
"HONEYDEW": "F0FFF0",
"HOTPINK": "FF69B4",
"INDIANRED": "CD5C5C",
"INDIGO": "4B0082",
"IVORY": "FFFFF0",
"KHAKI": "F0E68C",
"LAVENDER": "E6E6FA",
"LAVENDERBLUSH": "FFF0F5",
"LAWNGREEN": "7CFC00",
"LEMONCHIFFON": "FFFACD",
"LIGHTBLUE": "ADD8E6",
"LIGHTCORAL": "F08080",
"LIGHTCYAN": "E0FFFF",
"LIGHTGOLDENRODYELLOW": "FAFAD2",
"LIGHTGRAY": "D3D3D3",
"LIGHTGREY": "D3D3D3",
"LIGHTGREEN": "90EE90",
"LIGHTPINK": "FFB6C1",
"LIGHTSALMON": "FFA07A",
"LIGHTSEAGREEN": "20B2AA",
"LIGHTSKYBLUE": "87CEFA",
"LIGHTSLATEGRAY": "778899",
"LIGHTSLATEGREY": "778899",
"LIGHTSTEELBLUE": "B0C4DE",
"LIGHTYELLOW": "FFFFE0",
"LIME": "00FF00",
"LIMEGREEN": "32CD32",
"LINEN": "FAF0E6",
"MAGENTA": "FF00FF",
"MAROON": "800000",
"MEDIUMAQUAMARINE": "66CDAA",
"MEDIUMBLUE": "0000CD",
"MEDIUMORCHID": "BA55D3",
"MEDIUMPURPLE": "9370DB",
"MEDIUMSEAGREEN": "3CB371",
"MEDIUMSLATEBLUE": "7B68EE",
"MEDIUMSPRINGGREEN": "00FA9A",
"MEDIUMTURQUOISE": "48D1CC",
"MEDIUMVIOLETRED": "C71585",
"MIDNIGHTBLUE": "191970",
"MINTCREAM": "F5FFFA",
"MISTYROSE": "FFE4E1",
"MOCCASIN": "FFE4B5",
"NAVAJOWHITE": "FFDEAD",
"NAVY": "000080",
"OLDLACE": "FDF5E6",
"OLIVE": "808000",
"OLIVEDRAB": "6B8E23",
"ORANGE": "FFA500",
"ORANGERED": "FF4500",
"ORCHID": "DA70D6",
"PALEGOLDENROD": "EEE8AA",
"PALEGREEN": "98FB98",
"PALETURQUOISE": "AFEEEE",
"PALEVIOLETRED": "DB7093",
"PAPAYAWHIP": "FFEFD5",
"PEACHPUFF": "FFDAB9",
"PERU": "CD853F",
"PINK": "FFC0CB",
"PLUM": "DDA0DD",
"POWDERBLUE": "B0E0E6",
"PURPLE": "800080",
"RED": "FF0000",
"ROSYBROWN": "BC8F8F",
"ROYALBLUE": "4169E1",
"SADDLEBROWN": "8B4513",
"SALMON": "FA8072",
"SANDYBROWN": "F4A460",
"SEAGREEN": "2E8B57",
"SEASHELL": "FFF5EE",
"SIENNA": "A0522D",
"SILVER": "C0C0C0",
"SKYBLUE": "87CEEB",
"SLATEBLUE": "6A5ACD",
"SLATEGRAY": "708090",
"SLATEGREY": "708090",
"SNOW": "FFFAFA",
"SPRINGGREEN": "00FF7F",
"STEELBLUE": "4682B4",
"TAN": "D2B48C",
"TEAL": "008080",
"THISTLE": "D8BFD8",
"TOMATO": "FF6347",
"TURQUOISE": "40E0D0",
"VIOLET": "EE82EE",
"WHEAT": "F5DEB3",
"WHITE": "FFFFFF",
"WHITESMOKE": "F5F5F5",
"YELLOW": "FFFF00",
"YELLOWGREEN": "9ACD32",
]
}
extension Color {
@available(iOS 15.0, *)
func getContrastingTextColor() -> Color {
var r, g, b, a: CGFloat
(r, g, b, a) = (0, 0, 0, 0)
UIColor(self).getRed(&r, green: &g, blue: &b, alpha: &a)
let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance < 0.6 ? .white : .black
}
}
// swiftlint:enable identifier_name
#endif

View File

@@ -0,0 +1,37 @@
//
// SurveyButton.swift
// PostHog
//
// Created by Ioannis Josephides on 11/03/2025.
//
#if os(iOS)
import SwiftUI
@available(iOS 15.0, *)
struct SurveyButtonStyle: ButtonStyle {
@Environment(\.surveyAppearance) private var appearance
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.body.bold())
.frame(maxWidth: .infinity)
.shadow(color: Color.black.opacity(0.12), radius: 0, x: 0, y: -1) // Text shadow
.padding(12)
.foregroundStyle(appearance.submitButtonTextColor)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(appearance.submitButtonColor)
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 2) // Box shadow
)
.contentShape(Rectangle())
.opacity(configuration.isPressed ? 0.80 : opacity)
}
private var opacity: Double {
isEnabled ? 1.0 : 0.5
}
}
#endif

View File

@@ -0,0 +1,122 @@
//
// SurveyPresentationDetentsRepresentable.swift
// PostHog
//
// Created by Ioannis Josephides on 22/03/2025.
//
#if os(iOS)
import SwiftUI
@available(iOS 15.0, *)
struct SurveyPresentationDetentsRepresentable: UIViewControllerRepresentable {
enum Detent: Hashable, Identifiable, Comparable {
case medium
case large
case height(_ value: CGFloat)
var toPresentationDetents: UISheetPresentationController.Detent {
switch self {
case .medium: .medium()
case .large:
if #available(iOS 16.0, *) {
// almost large detent, so that background view is not scaled
.custom(identifier: id, resolver: { context in context.maximumDetentValue - 0.5 })
} else {
.large()
}
case let .height(value):
if #available(iOS 16.0, *) {
if value > 0 {
.custom(identifier: id, resolver: { _ in value })
} else {
.medium()
}
} else {
.medium()
}
}
}
var id: UISheetPresentationController.Detent.Identifier {
switch self {
case .medium: .init("com.apple.UIKit.medium")
case .large:
if #available(iOS 16.0, *) {
.init("posthog.detent.almostLarge")
} else {
.init("com.apple.UIKit.large")
}
case let .height(value):
if #available(iOS 16.0, *) {
if value > 0 {
.init("posthog.detent.customHeight.\(value)")
} else {
.init("com.apple.UIKit.medium")
}
} else {
.init("com.apple.UIKit.medium")
}
}
}
}
let detents: [Detent]
func makeUIViewController(context _: Context) -> Controller {
Controller(detents: detents)
}
func updateUIViewController(_ controller: Controller, context _: Context) {
controller.detents = detents
DispatchQueue.main.async(execute: controller.update)
}
final class Controller: UIViewController, UISheetPresentationControllerDelegate {
var detents: [Detent]
init(detents: [Detent]) {
self.detents = detents
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
detents = []
super.init(nibName: nil, bundle: nil)
}
func update() {
let newDetents = detents.map(\.toPresentationDetents)
if let controller = sheetPresentationController {
controller.detents = newDetents
// present as bottom sheet on compact-size (e.g landscape)
if #available(iOS 16.0, *) {
controller.prefersEdgeAttachedInCompactHeight = true
controller.widthFollowsPreferredContentSizeWhenEdgeAttached = true
} else {
// Getting some weird crash on iOS 15.5 when setting this to true. Disable for now
// This means that on iOS 15.0 landscape mode presentation will be full screen
controller.prefersEdgeAttachedInCompactHeight = false
controller.widthFollowsPreferredContentSizeWhenEdgeAttached = false
}
// scrolling with expand the bottom sheet if needed
controller.prefersScrollingExpandsWhenScrolledToEdge = true
// show drag indicator if bottom sheet is expandable
controller.prefersGrabberVisible = detents.count > 1
// always dim background
controller.presentingViewController.view?.tintAdjustmentMode = .dimmed
}
}
override func viewWillTransition(to size: CGSize, with coordinator: any UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
DispatchQueue.main.async(execute: update)
}
}
}
#endif

View File

@@ -0,0 +1,105 @@
//
// SwiftUI+Util.swift
// PostHog
//
// Created by Ioannis Josephides on 10/03/2025.
//
#if os(iOS)
import SwiftUI
extension View {
/// Reads frame changes of current view in a coordinate space (default global)
@available(iOS 14.0, *)
func readFrame(
in coordinateSpace: CoordinateSpace = .global,
onFrame: @escaping (CGRect) -> Void
) -> some View {
modifier(
ReadFrameModifier(
coordinateSpace: coordinateSpace,
onFrame: onFrame
)
)
}
/// Reads current view's safe area insets
@available(iOS 14.0, *)
func readSafeAreaInsets(
onSafeAreaInsets: @escaping (EdgeInsets) -> Void
) -> some View {
modifier(
ReadSafeAreaInsetsModifier(
onSafeAreaInsets: onSafeAreaInsets
)
)
}
/// Type-erases a View
var erasedToAnyView: AnyView {
AnyView(self)
}
}
@available(iOS 14.0, *)
private struct ReadFrameModifier: ViewModifier {
/// Helper for notifying parents for child view frame changes
struct FramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value _: inout CGRect, nextValue _: () -> CGRect) {
// nothing
}
}
let coordinateSpace: CoordinateSpace
let onFrame: (CGRect) -> Void
func body(content: Content) -> some View {
content
.background(
GeometryReader { proxy in
Color.clear
.preference(
key: FramePreferenceKey.self,
value: proxy.frame(in: coordinateSpace)
)
}
)
.onPreferenceChange(FramePreferenceKey.self, perform: onFrame)
}
}
@available(iOS 14.0, *)
private struct ReadSafeAreaInsetsModifier: ViewModifier {
/// Helper for notifying parents for child view's safe area insets
struct SafeAreaInsetsPreferenceKey: PreferenceKey {
static var defaultValue: EdgeInsets = .init()
static func reduce(value _: inout EdgeInsets, nextValue _: () -> EdgeInsets) {
// nothing
}
}
let onSafeAreaInsets: (EdgeInsets) -> Void
@State private var safeAreaInsets: EdgeInsets = .init()
func body(content: Content) -> some View {
ZStack {
content
.background(
GeometryReader { proxy in
Color.clear
.onChange(of: proxy.safeAreaInsets) { size in
safeAreaInsets = size
}
.preference(
key: SafeAreaInsetsPreferenceKey.self,
value: safeAreaInsets
)
}
)
}
.onPreferenceChange(SafeAreaInsetsPreferenceKey.self, perform: onSafeAreaInsets)
}
}
#endif

View File

@@ -0,0 +1,53 @@
//
// PostHogMaskViewModifier.swift
// PostHog
//
// Created by Yiannis Josephides on 09/10/2024.
//
#if os(iOS) && canImport(SwiftUI)
import SwiftUI
public extension View {
/**
Marks a SwiftUI View to be masked in PostHog session replay recordings.
Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit),
we can't always be 100% confident that a view should be masked and may accidentally mark a
sensitive view as non-sensitive instead.
Use this modifier to explicitly mask sensitive views in session replay recordings.
For example:
```swift
// This view will be masked in recordings
SensitiveDataView()
.postHogMask()
// Conditionally mask based on a flag
SensitiveDataView()
.postHogMask(shouldMask)
```
- Parameter isEnabled: Whether masking should be enabled. Defaults to true.
- Returns: A modified view that will be masked in session replay recordings when enabled
*/
func postHogMask(_ isEnabled: Bool = true) -> some View {
modifier(
PostHogTagViewModifier { uiViews in
uiViews.forEach { $0.postHogNoCapture = isEnabled }
} onRemove: { uiViews in
uiViews.forEach { $0.postHogNoCapture = false }
}
)
}
}
extension UIView {
var postHogNoCapture: Bool {
get { objc_getAssociatedObject(self, &AssociatedKeys.phNoCapture) as? Bool ?? false }
set { objc_setAssociatedObject(self, &AssociatedKeys.phNoCapture, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
#endif

View File

@@ -0,0 +1,54 @@
//
// PostHogNoMaskViewModifier.swift
// PostHog
//
// Created by Yiannis Josephides on 09/10/2024.
//
#if os(iOS) && canImport(SwiftUI)
import SwiftUI
public extension View {
/**
Marks a SwiftUI View to be excluded from masking in PostHog session replay recordings.
There are cases where PostHog SDK will unintentionally mask some SwiftUI views.
Because of the nature of how we intercept SwiftUI view hierarchy (and how it maps to UIKit),
we can't always be 100% confident that a view should be masked. For that reason, we prefer to
take a proactive and prefer to mask views if we're not sure.
Use this modifier to prevent views from being masked in session replay recordings.
For example:
```swift
// This view may be accidentally masked by PostHog SDK
SomeSafeView()
// This custom view (and all its subviews) will not be masked in recordings
SomeSafeView()
.postHogNoMask()
```
- Returns: A modified view that will not be masked in session replay recordings
*/
func postHogNoMask() -> some View {
modifier(
PostHogTagViewModifier { uiViews in
uiViews.forEach { $0.postHogNoMask = true }
} onRemove: { uiViews in
uiViews.forEach { $0.postHogNoMask = false }
}
)
}
}
extension UIView {
var postHogNoMask: Bool {
get { objc_getAssociatedObject(self, &AssociatedKeys.phNoMask) as? Bool ?? false }
set { objc_setAssociatedObject(self, &AssociatedKeys.phNoMask, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
}
#endif

View File

@@ -0,0 +1,68 @@
//
// PostHogSwiftUIViewModifiers.swift
// PostHog
//
// Created by Manoel Aranda Neto on 05.09.24.
//
#if canImport(SwiftUI)
import Foundation
import SwiftUI
public extension View {
/**
Marks a SwiftUI View to be tracked as a $screen event in PostHog when onAppear is called.
- Parameters:
- screenName: The name of the screen. Defaults to the type of the view.
- properties: Additional properties to be tracked with the screen.
- postHog: The instance to be used when sending the $screen event
- Returns: A modified view that will be tracked as a screen in PostHog.
*/
func postHogScreenView(_ screenName: String? = nil,
_ properties: [String: Any]? = nil,
postHog: PostHogSDK? = nil) -> some View
{
let viewEventName = screenName ?? "\(type(of: self))"
return modifier(PostHogSwiftUIViewModifier(viewEventName: viewEventName,
screenEvent: true,
properties: properties,
postHog: postHog))
}
func postHogViewSeen(_ event: String,
_ properties: [String: Any]? = nil,
postHog: PostHogSDK? = nil) -> some View
{
modifier(PostHogSwiftUIViewModifier(viewEventName: event,
screenEvent: false,
properties: properties,
postHog: postHog))
}
}
private struct PostHogSwiftUIViewModifier: ViewModifier {
let viewEventName: String
let screenEvent: Bool
let properties: [String: Any]?
let postHog: PostHogSDK?
func body(content: Content) -> some View {
content.onAppear {
if screenEvent {
instance.screen(viewEventName, properties: properties)
} else {
instance.capture(viewEventName, properties: properties)
}
}
}
private var instance: PostHogSDK {
postHog ?? PostHogSDK.shared
}
}
#endif

View File

@@ -0,0 +1,371 @@
//
// PostHogTagViewModifier.swift
// PostHog
//
// Created by Yiannis Josephides on 19/12/2024.
//
// Inspired from: https://github.com/siteline/swiftui-introspect
#if os(iOS) && canImport(SwiftUI)
import SwiftUI
typealias PostHogTagViewHandler = ([UIView]) -> Void
/**
This is a helper view modifier for retrieving a list of underlying UIKit views for the current SwiftUI view.
This implementation injects two hidden views into the SwiftUI view hierarchy, with the purpose of using them to retrieve the generated UIKit views for this SwiftUI view.
The two injected views basically sandwich the current SwiftUI view:
- The first view is an anchor view, which defines how far **down** we need to traverse the view hierarchy (added as a background view).
- The second view is a tagger view, which defines how far **up** we traverse the view hierarchy (added as an overlay view).
- Any view in between the two should be the generated UIKit views that correspond to the current View
```
View Hierarchy Tree:
UIHostingController
_UIHostingView (Common ancestor)
UnrelatedView |
PostHogTagView
(overlay)
_UIGeneratedView (e.g generated views in an HStack)
_UIGeneratedView (e.g generated views in an HStack)
PostHogTagAnchorView
(background)
The general approach is:
1. PostHogTagAnchorView injected as background (bottom boundary)
2. PostHogTagView injected as overlay (top boundary)
3. System renders SwiftUI view hierarchy in UIKit
4. Find the common ancestor of the PostHogTagAnchorView and PostHogTagView (e.g _UIHostingView)
5. Retrieve all of the descendants of common ancestor that are between PostHogTagView and PostHogTagAnchorView (excluding tagged views)
This logic is implemented in the `getTargetViews` function, which is called from PostHogTagView.
```
*/
struct PostHogTagViewModifier: ViewModifier {
private let id = UUID()
let onChange: PostHogTagViewHandler
let onRemove: PostHogTagViewHandler
/**
This is a helper view modifier for retrieving a list of underlying UIKit views for the current SwiftUI view.
If, for example, this modifier is applied on an instance of an HStack, the returned list will contain the underlying UIKit views embedded in the HStack.
For single views, the returned list will contain a single element, the view itself.
- Parameters:
- onChange: called when the underlying UIKit views are detected, or when they are layed out.
- onRemove: called when the underlying UIKit views are removed from the view hierarchy, for cleanup.
*/
init(onChange: @escaping PostHogTagViewHandler, onRemove: @escaping PostHogTagViewHandler) {
self.onChange = onChange
self.onRemove = onRemove
}
func body(content: Content) -> some View {
content
.background(
PostHogTagAnchorView(id: id)
.accessibility(hidden: true)
.frame(width: 0, height: 0)
)
.overlay(
PostHogTagView(id: id, onChange: onChange, onRemove: onRemove)
.accessibility(hidden: true)
.frame(width: 0, height: 0)
)
}
}
struct PostHogTagView: UIViewRepresentable {
final class Coordinator {
var onChangeHandler: PostHogTagViewHandler?
var onRemoveHandler: PostHogTagViewHandler?
private var _targets: [Weak<UIView>]
var cachedTargets: [UIView] {
get { _targets.compactMap(\.value) }
set { _targets = newValue.map(Weak.init) }
}
init(
onRemove: PostHogTagViewHandler?
) {
_targets = []
onRemoveHandler = onRemove
}
}
@Binding
private var observed: Void // workaround for state changes not triggering view updates
private let id: UUID
private let onChangeHandler: PostHogTagViewHandler?
private let onRemoveHandler: PostHogTagViewHandler?
init(
id: UUID,
onChange: PostHogTagViewHandler?,
onRemove: PostHogTagViewHandler?
) {
_observed = .constant(())
self.id = id
onChangeHandler = onChange
onRemoveHandler = onRemove
}
func makeCoordinator() -> Coordinator {
// dismantleUIView is Static, so we need to store the onRemoveHandler
// somewhere where we can access it during view distruction
Coordinator(onRemove: onRemoveHandler)
}
func makeUIView(context: Context) -> PostHogTagUIView {
let view = PostHogTagUIView(id: id) { controller in
let targets = getTargetViews(from: controller)
if !targets.isEmpty {
context.coordinator.cachedTargets = targets
onChangeHandler?(targets)
}
}
return view
}
func updateUIView(_: PostHogTagUIView, context _: Context) {
//
}
static func dismantleUIView(_ uiView: PostHogTagUIView, coordinator: Coordinator) {
// using cached targets should be good here
let targets = coordinator.cachedTargets.isEmpty
? getTargetViews(from: uiView)
: coordinator.cachedTargets
if !targets.isEmpty {
coordinator.onRemoveHandler?(targets)
}
uiView.postHogTagView = nil
uiView.handler = nil
}
}
private let swiftUIIgnoreTypes: [AnyClass] = [
// .clipShape or .clipped SwiftUI modifiers will add this to view hierarchy
// Not sure of its functionality, but it seems to be just a wrapper view with no visual impact
//
// We can safely ignore from list of descendant views, since it's sometimes being tagged
// for replay masking unintentionally
"SwiftUI._UIInheritedView",
].compactMap(NSClassFromString)
func getTargetViews(from taggerView: UIView) -> [UIView] {
guard
let anchorView = taggerView.postHogAnchor,
let commonAncestor = anchorView.nearestCommonAncestor(with: taggerView)
else {
return []
}
return commonAncestor
.allDescendants(between: anchorView, and: taggerView)
.lazy
.filter {
// ignore some system SwiftUI views
!swiftUIIgnoreTypes.contains(where: $0.isKind(of:))
}
.filter {
// exclude injected views
!$0.postHogView
}
}
private struct PostHogTagAnchorView: UIViewRepresentable {
var id: UUID
func makeUIView(context _: Context) -> some UIView {
PostHogTagAnchorUIView(id: id)
}
func updateUIView(_: UIViewType, context _: Context) {
//
}
}
private class PostHogTagAnchorUIView: UIView {
let id: UUID
init(id: UUID) {
self.id = id
super.init(frame: .zero)
TaggingStore.shared[id, default: .init()].anchor = self
postHogView = true
}
required init?(coder _: NSCoder) {
id = UUID()
super.init(frame: .zero)
}
}
final class PostHogTagUIView: UIView {
let id: UUID
var handler: (() -> Void)?
init(
id: UUID,
handler: ((PostHogTagUIView) -> Void)?
) {
self.id = id
super.init(frame: .zero)
self.handler = { [weak self] in
guard let self else {
return
}
handler?(self)
}
TaggingStore.shared[id, default: .init()].tagger = self
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
id = UUID()
super.init(frame: .zero)
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
postHogTagView = self
postHogView = true
handler?()
}
override func didMoveToWindow() {
super.didMoveToWindow()
handler?()
}
override func layoutSubviews() {
super.layoutSubviews()
handler?()
}
}
private extension UIView {
var postHogTagView: PostHogTagUIView? {
get { objc_getAssociatedObject(self, &AssociatedKeys.phTagView) as? PostHogTagUIView }
set { objc_setAssociatedObject(self, &AssociatedKeys.phTagView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
var postHogView: Bool {
get { objc_getAssociatedObject(self, &AssociatedKeys.phView) as? Bool ?? false }
set { objc_setAssociatedObject(self, &AssociatedKeys.phView, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}
func allDescendants(between bottomEntity: UIView, and topEntity: UIView) -> some Sequence<UIView> {
descendants
.lazy
.drop(while: { $0 !== bottomEntity })
.prefix(while: { $0 !== topEntity })
}
var ancestors: some Sequence<UIView> {
sequence(first: self, next: { $0.superview }).dropFirst()
}
var descendants: some Sequence<UIView> {
recursiveSequence([self], children: { $0.subviews }).dropFirst()
}
func isDescendant(of other: UIView) -> Bool {
ancestors.contains(other)
}
func nearestCommonAncestor(with other: UIView) -> UIView? {
var nearestAncestor: UIView? = self
while let currentEntity = nearestAncestor, !other.isDescendant(of: currentEntity) {
nearestAncestor = currentEntity.superview
}
return nearestAncestor
}
var postHogAnchor: UIView? {
if let tagView = postHogTagView {
return TaggingStore.shared[tagView.id]?.anchor
}
return nil
}
}
/**
A helper store for storing reference pairs between anchor and tagger views
*/
@MainActor private enum TaggingStore {
static var shared: [UUID: Pair] = [:]
struct Pair {
weak var anchor: PostHogTagAnchorUIView?
weak var tagger: PostHogTagUIView?
}
}
/**
Recursively iterates over a sequence of elements, applying a function to each element to get its children.
- Parameters:
- sequence: The sequence of elements to iterate over.
- children: A function that takes an element and returns a sequence of its children.
- Returns: An AnySequence that iterates over all elements and their children.
*/
private func recursiveSequence<S: Sequence>(_ sequence: S, children: @escaping (S.Element) -> S) -> AnySequence<S.Element> {
AnySequence {
var mainIterator = sequence.makeIterator()
// Current iterator, or `nil` if all sequences are exhausted:
var iterator: AnyIterator<S.Element>?
return AnyIterator {
guard let iterator, let element = iterator.next() else {
if let element = mainIterator.next() {
iterator = recursiveSequence(children(element), children: children).makeIterator()
return element
}
return nil
}
return element
}
}
}
/**
Boxing a weak reference to a reference type.
*/
final class Weak<T: AnyObject> {
weak var value: T?
init(_ wrappedValue: T? = nil) {
value = wrappedValue
}
}
#endif

View File

@@ -0,0 +1,26 @@
//
// UIViewController.swift
// PostHog
//
// Inspired by
// https://raw.githubusercontent.com/segmentio/analytics-swift/e613e09aa1b97144126a923ec408374f914a6f2e/Examples/other_plugins/UIKitScreenTracking.swift
//
// Created by Manoel Aranda Neto on 23.10.23.
//
#if os(iOS) || os(tvOS)
import Foundation
import UIKit
extension UIViewController {
static func getViewControllerName(_ viewController: UIViewController) -> String? {
var title: String? = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "")
if title?.isEmpty == true {
title = viewController.title ?? nil
}
return title
}
}
#endif

Some files were not shown because too many files have changed in this diff Show More