diff --git a/.changeset/v4-major-release.md b/.changeset/v4-major-release.md new file mode 100644 index 0000000000..3c53f83d4d --- /dev/null +++ b/.changeset/v4-major-release.md @@ -0,0 +1,32 @@ +--- +'posthog-ios': major +--- + +PostHog iOS 4.0: see the [migration guide](https://github.com/PostHog/posthog-ios/blob/main/MIGRATION.md) to upgrade from 3.x. + +- **Breaking:** stop publishing to CocoaPods — install with Swift Package Manager (3.x versions stay on CocoaPods) +- **Breaking:** raise minimum deployment targets to iOS and Mac Catalyst 15.0, macOS 11.0, tvOS 15.0 and watchOS 10.0 +- **Breaking:** require Xcode 26 or later (`swift-tools-version` 6.2) +- **Breaking:** always record session replay as masked screenshots, which shows custom-drawn content and uses more bandwidth and CPU than wireframes — remove `screenshotMode` and `screenshotModeBackgroundCapture` assignments and mask sensitive content with `postHogMask()` or `ph-no-capture` +- **Breaking:** pass a `PostHogFeatureFlagsLoaded` to the `reloadFeatureFlags(_:)` callback — use `{ result in ... }` or `{ _ in ... }` +- **Breaking:** mark `reloadFeatureFlags(_:)`, `setBeforeSend(_:)`, `getAnonymousId`, `pushIdentityProvider` and `logSanitizer` callbacks `@Sendable`, fixing a crash in Swift 6 apps +- **Breaking:** rename `PostHogLogLevel` to `PostHogConsoleLogLevel` and `PostHogLogEntry` to `PostHogConsoleLogEntry` +- **Breaking:** return `nil` instead of the raw string for a malformed, empty or whitespace-only feature flag payload +- **Breaking:** stop decoding string payloads in `PostHogBootstrapConfig.featureFlagPayloads` a second time, so a bootstrapped `"123"` stays a `String` +- **Breaking:** delete events still queued on disk by a 2.x SDK instead of sending them, and ignore a top-level `$set` and `message_id` in queued events +- **Breaking:** remove `version` and `build` from `Application Installed`, `Application Updated` and `Application Opened` events — use `$app_version` and `$app_build` +- **Breaking:** remove `PostHogConfig.apiKey`, `PostHogConfig(apiKey:)` and `PostHogConfig(apiKey:host:)` — use `projectToken`, `PostHogConfig(projectToken:)` and `PostHogConfig(projectToken:host:)` +- **Breaking:** remove `PostHogConfig.propertiesSanitizer` and `PostHogPropertiesSanitizer` — use `setBeforeSend(_:)` +- **Breaking:** remove `PostHogConfig.evaluationEnvironments` — use `evaluationContexts` +- **Breaking:** remove the no-op `PostHogConfig.remoteConfig` and `PostHogSessionReplayConfig.maskPhotoLibraryImages` options +- **Breaking:** remove `PostHogSDK.getFeatureFlagPayload(_:)` — use `getFeatureFlagResult(_:sendFeatureFlagEvent:)?.payload` +- **Breaking:** remove `PostHogSessionReplayConfig.debouncerDelay` — use `throttleDelay` +- **Breaking:** remove the `PostHogSDK.didReceiveFeatureFlags` notification — use `onFeatureFlags(_:)` +- **Breaking:** remove `PostHogDataMode.cellular` — use `.any` +- **Breaking:** move `postHogSdkName`, `postHogVersion`, `PostHogConfig.snapshotEndpoint`, `PostHogSessionManager`, `PostHogStorageManager` and `PostHogConfig.storageManager` behind `@_spi(PostHogInternal)` — use `PostHogSDK` session and identity methods +- **Breaking:** remove `dateToMillis(_:)`, `imageToBase64(_:_:)`, `toISO8601String(_:)`, `toISO8601Date(_:)`, `sanitizeDictionary(_:)`, `deleteSafely(_:)`, `postHogiOSSdkName`, `Gzip`, `GzipError`, `CompressionLevel`, `ReachabilityError`, `ReachabilityChangedNotification`, `UIColor.init(hex:)` and `UIColor.hexDescription(_:)` from the public API +- Add `SessionReplay`, `CrashReporting` and `Surveys` Swift package traits (on by default) that remove the feature and its public API when turned off +- Change network reachability detection to `NWPathMonitor`, so a path needing user action counts as reachable, queues pause while cellular data is off for the app, and early events may omit `$network_wifi` and `$network_cellular` +- Change the replay screen name to the visible screen, reporting the view name for SwiftUI screens and no longer using a plain `UIViewController`'s `title` +- Fix session replay recording nothing for apps whose window root is a SwiftUI view +- Fix secure-entry text fields being unmasked inside a `postHogNoMask()` or `ph-no-mask` subtree diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index 3c38b7f5a3..c87a83d75e 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -58,34 +58,3 @@ jobs: xcode-version: latest-stable - name: Build XCFramework Example run: make buildExampleXCFramework - - build-pods: - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - name: Static Lib - target: buildExamplePodsStaticLib - runner: macos-15 - xcode-version: latest-stable - - name: Static Lib (Xcode 16.2) - target: buildExamplePodsStaticLib - runner: macos-14-xlarge - xcode-version: '16.2' - - name: Static Framework - target: buildExamplePodsStaticFramework - runner: macos-15 - xcode-version: latest-stable - - name: Dynamic Framework - target: buildExamplePodsDynamicFramework - runner: macos-15 - xcode-version: latest-stable - name: Example Pods (${{ matrix.name }}) - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: ${{ matrix.xcode-version }} - - name: Build Pods Example (${{ matrix.name }}) - run: make ${{ matrix.target }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe91b43554..fd6f7b91d9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,8 @@ jobs: command: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=ios | xcpretty - platform: macos-spm command: set -o pipefail && xcrun swift build --arch arm64 + - platform: spm-no-traits + command: make buildSdkSpmNoTraits buildTestsSpmTraitsIOS - platform: macos command: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=macos | xcpretty - platform: tvos @@ -62,13 +64,13 @@ jobs: - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 with: xcode-version: latest-stable - - name: Check public API snapshot + - name: Check public API snapshots (default build and package traits off) run: make apiCheck - build-sdk-swift53-compat: + build-sdk-min-toolchain: needs: detect-markdown-only - runs-on: macos-14-xlarge - name: Build SDK (Swift 5 compatibility on Xcode 15.4 with CocoaPods) + runs-on: macos-15-xlarge + name: Build SDK (minimum toolchain, Xcode 26.0.1) steps: - name: Complete markdown-only PR check if: needs.detect-markdown-only.outputs.markdown_only == 'true' @@ -78,42 +80,15 @@ jobs: - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 if: needs.detect-markdown-only.outputs.markdown_only != 'true' with: - xcode-version: '15.4' + xcode-version: '26.0.1' - name: Print toolchain versions if: needs.detect-markdown-only.outputs.markdown_only != 'true' run: | xcodebuild -version xcrun swift --version - - name: Install CocoaPods - if: needs.detect-markdown-only.outputs.markdown_only != 'true' - run: gem install cocoapods - - name: Install Pods + - name: Build SDK via Swift Package Manager if: needs.detect-markdown-only.outputs.markdown_only != 'true' - run: | - cd PostHogExampleWithPods - USE_FRAMEWORKS=static pod install - - name: Build SDK with Swift 5 language mode via CocoaPods + run: make buildSdkSpm + - name: Build SDK via Swift Package Manager with package traits off if: needs.detect-markdown-only.outputs.markdown_only != 'true' - run: | - set -o pipefail - cd PostHogExampleWithPods - xcrun xcodebuild clean build \ - -workspace PostHogExampleWithPods.xcworkspace \ - -scheme PostHogExampleWithPods \ - -destination generic/platform=ios \ - SWIFT_VERSION=5 | xcpretty - - podspec-lint: - needs: detect-markdown-only - if: needs.detect-markdown-only.outputs.markdown_only != 'true' - runs-on: macos-15 - name: CocoaPods package lint - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: latest-stable - - name: Install CocoaPods - run: gem install cocoapods - - name: Lint podspec - run: pod lib lint PostHog.podspec --allow-warnings --skip-tests + run: make buildSdkSpmNoTraits diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 882139fee7..2474145144 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,7 +9,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - runs-on: macos-14-xlarge + runs-on: macos-15-xlarge permissions: security-events: write # for uploading CodeQL results packages: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9380d5a763..a79e8edb01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,8 +231,6 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "Version to release: $NEW_VERSION" - # Tag must be pushed to remote before CocoaPods publish, because - # `pod trunk push` validates by cloning the repo at this tag from GitHub. - name: Tag and push to GitHub env: RELEASE_VERSION: ${{ steps.get-version.outputs.version }} @@ -294,99 +292,11 @@ jobs: message: '❌ Failed to create the GitHub release for `posthog-ios@v${{ steps.get-version.outputs.version }}`! ' emoji_reaction: 'x' - publish-cocoapods: - name: Publish to CocoaPods - needs: [create-github-release, notify-approval-needed] - runs-on: macos-14 - if: always() && needs.create-github-release.result == 'success' - permissions: - contents: read - env: - COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} # Using @ioannisj token - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.create-github-release.outputs.version }} - fetch-depth: 0 - - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 - with: - xcode-version: '16.2' - - - name: Publish to CocoaPods - env: - RELEASE_VERSION: ${{ needs.create-github-release.outputs.version }} - run: | - set -euo pipefail - - on_trunk() { - pod trunk info PostHog --no-ansi 2>/dev/null \ - | grep -E -- "^[[:space:]]*- ${RELEASE_VERSION//./\\.} \(" - } - - if make releaseCocoaPods; then - exit 0 - fi - - # `pod trunk push` regularly exits non-zero *after* the spec is already - # accepted (its final CDN/response verification times out). Trunk - # registration and the `pod trunk info` endpoint can lag a bit and the - # info call itself is occasionally flaky, so poll rather than check once. - echo "::warning::pod trunk push failed. Polling pod trunk info to confirm the version landed..." - for attempt in $(seq 1 10); do - if on_trunk; then - echo "PostHog $RELEASE_VERSION is on trunk (attempt $attempt/10). Treating as success." - exit 0 - fi - if [ "$attempt" -lt 10 ]; then - echo "Not visible yet (attempt $attempt/10); retrying in 30s..." - sleep 30 - fi - done - - echo "::error::PostHog $RELEASE_VERSION is not on trunk after polling for ~5 minutes." - exit 1 - - # Notify in case of failure - - name: Send failure event to PostHog - if: ${{ failure() }} - continue-on-error: true - uses: PostHog/posthog-github-action@9a6a19d360820ab4d95ecc4a5a7564062c895f84 # v1.3.0 - with: - posthog-token: '${{ secrets.POSTHOG_PROJECT_API_KEY }}' - event: 'posthog-sdk-github-release-workflow-failure' - properties: >- - { - "commitSha": "${{ github.sha }}", - "jobStatus": "${{ job.status }}", - "ref": "${{ github.ref }}", - "repository": "${{ github.repository }}", - "sdk": "posthog-ios", - "jobName": "publish-cocoapods", - "packageName": "posthog-ios", - "packageVersion": "${{ needs.create-github-release.outputs.version || 'unknown' }}", - "actionUrl": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "alertText": ":rotating_light: Failed to publish posthog-ios@v${{ needs.create-github-release.outputs.version }} to CocoaPods :rotating_light:", - "alertContext": "The CocoaPods publish step failed." - } - - - name: Notify Slack - Failed - continue-on-error: true - if: ${{ failure() && needs.notify-approval-needed.outputs.slack_ts != '' }} - uses: PostHog/.github/.github/actions/slack-thread-reply@cb0979b67dcd585828b61ac45927eef4da8f6287 # main - with: - slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} - slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} - thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} - message: '❌ Failed to publish `posthog-ios@v${{ needs.create-github-release.outputs.version }}` to CocoaPods! ' - emoji_reaction: 'x' - notify-released: name: Notify Slack - Released - needs: [notify-approval-needed, publish-cocoapods] + needs: [notify-approval-needed, create-github-release] runs-on: ubuntu-latest - if: always() && needs.publish-cocoapods.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' + if: always() && needs.create-github-release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' steps: - name: Notify Slack - Released continue-on-error: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb67436e76..4464bf76e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,35 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: make checkTestRegistration + # Package traits off: libwebp and PLCrashReporter are not linked, so the crash-reporting stub + # and the trait-gated tests run. + test-no-traits: + needs: detect-markdown-only + runs-on: macos-15 + steps: + - name: Complete markdown-only PR check + if: needs.detect-markdown-only.outputs.markdown_only == 'true' + run: echo "Only Markdown files changed; no additional work is required for this check." + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + with: + xcode-version: latest-stable + - name: Cache SPM dependencies + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: ${{ runner.os }}-spm-no-traits-${{ hashFiles('Package.swift', 'Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm-no-traits- + - name: Test SDK (package traits off) + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: make testNoTraits + # `make test` runs `swift test` on macOS, which compiles out every `#if os(iOS)` block, so the # iOS-only suites (session replay, autocapture, tracing headers, crash reporting, etc.) never run # there. Run the iOS test target on a simulator so those suites are actually exercised in CI. diff --git a/.swiftlint.yml b/.swiftlint.yml index 639bbe95da..a8a2bcd104 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -9,7 +9,6 @@ excluded: # case-sensitive paths to ignore during linting. Takes precedence over - PostHogExampleWatchOS - PostHogExampleWidget - PostHogExampleWidgetAppGroup - - PostHogExampleWithPods - PostHogExampleWithSPM - PostHogTests - PostHogSurveyUITests diff --git a/AGENTS.md b/AGENTS.md index 164343b5a8..eb66b51f8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,11 @@ - SDK entry point: [PostHog/PostHogSDK.swift](PostHog/PostHogSDK.swift); tests: `PostHogTests/`; replay/privacy: `PostHog/Replay/` and `PostHog/Resources/`. ## Repository invariants -- Maintain Swift 5 SDK language compatibility. Tests require Swift 6+ with Swift Testing (Xcode 16+); prefer Swift Testing for new tests. Use `PostHogTests/TestUtils/MockPostHogServer.swift` for HTTP stubbing. -- Preserve deployment minima: iOS/tvOS 13, macOS 10.15, watchOS 6, visionOS 1. Keep platform guards and graceful degradation, especially for session replay. Availability annotations/checks do not require adding platform support or raising targets. Intentional support changes require checking both `Package.swift` and `Package@swift-5.9.swift`, plus CocoaPods/Xcode packaging/build configuration; visionOS declarations belong in the Swift 5.9 manifest. +- Maintain Swift 5 SDK language mode. The package needs swift-tools-version 6.2 (Xcode 26+); prefer Swift Testing for new tests. Use `PostHogTests/TestUtils/MockPostHogServer.swift` for HTTP stubbing. +- Preserve deployment minima: iOS 15, tvOS 15, macOS 11, watchOS 10, visionOS 1. Keep platform guards and graceful degradation, especially for session replay. Availability annotations/checks do not require adding platform support or raising targets. Intentional support changes require checking `Package.swift` plus the Xcode project build configuration. - Public APIs must remain thread-safe and callable from any thread. Preserve offline operation and queue-based event batching; never assume connectivity. `PostHogSDK.shared` is the default singleton; independent instances use `PostHogSDK.with(_:)` with `PostHogConfig`. - Preserve replay masking/privacy behavior and privacy-safe error logging; do not expose sensitive user data. -- Prefer no new dependencies. libwebp is embedded; PHPLCrashReporter is vendored, prefixed PLCrashReporter for native crash reporting on iOS/macOS/tvOS, excluded on watchOS/visionOS. +- Prefer no new dependencies. libwebp is embedded; PHPLCrashReporter is vendored, prefixed PLCrashReporter for native crash reporting on iOS/macOS/tvOS, excluded on watchOS/visionOS. Both are behind default-on package traits (`SessionReplay`, which also gates the replay engine, and `CrashReporting`); gate code that uses them with `#if !SWIFT_PACKAGE || `. The survey engine and UI are behind the default-on `Surveys` trait the same way. Once released, never add a new default-on trait on its own: apps that list traits would silently lose the feature. Split an existing trait instead, keeping it as an umbrella that enables the new ones. ## Validation selector - Use `make` wrappers, not direct `swift`/`xcodebuild`. Iterate with focused tests; before submitting SDK changes, run contributor checks: `make lint`, `make test`, `make buildSdk`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a8d4b2319..00fbb2756a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ This repo requires all commits to be signed. To configure commit signing, see th Use the repository's `make` wrappers rather than invoking `swift` or `xcodebuild` directly; [Makefile](Makefile) owns the commands and options. -For initial setup, install Xcode and, if needed, use `make bootstrap` to install CocoaPods, xcpretty, SwiftLint, SwiftFormat, Periphery and [prek](https://github.com/j178/prek). Agents should report missing prerequisites rather than install tools without authorization. Tests import `Testing` and need a Swift 6+ toolchain with Swift Testing (Xcode 16+); use current stable Xcode as CI does. This is separate from the SDK's Swift 5 language compatibility. +For initial setup, install Xcode 26 or later and, if needed, use `make bootstrap` to install xcpretty, SwiftLint, SwiftFormat, Periphery and [prek](https://github.com/j178/prek). Agents should report missing prerequisites rather than install tools without authorization. The package uses swift-tools-version 6.2 and compiles in Swift 5 language mode; use current stable Xcode as CI does. Iterate with focused checks, then run the core CI-aligned checks before submitting **SDK changes**: @@ -41,11 +41,11 @@ Markdown-only changes need documentation/link checks and `git diff --check`, not | Replay presentation/privacy masking in an app host | `make testPresentationMasks` | | Masking golden-image verification | `make maskSnapshots` | | SDK across supported platforms, including Mac Catalyst | `make buildSdk` | +| Code gated by the `SessionReplay`, `CrashReporting` or `Surveys` package traits | `make buildSdkSpmNoTraits`, `make testNoTraits`, `make buildTestsSpmTraitsIOS` | | Example/platform integrations | `make buildExamplesPlatforms` | -| CocoaPods integration modes | `make buildExamplePodsStaticLib`, `make buildExamplePodsStaticFramework`, `make buildExamplePodsDynamicFramework` | | XCFramework integration | `make buildExampleXCFramework` | -Use relevant example/package checks for integration or packaging changes, and smoke test changed behavior in an example where applicable. `make buildExamples` runs all example builds; `make build` combines that with `make buildSdk`. These broad targets include CocoaPods installation and XCFramework generation, so they are not the default iteration loop. For package/support changes, also consult the package lint and Swift 5 compatibility lanes in [build CI](.github/workflows/build.yml) and [example CI](.github/workflows/build-examples.yml). +Use relevant example/package checks for integration or packaging changes, and smoke test changed behavior in an example where applicable. `make buildExamples` runs all example builds; `make build` combines that with `make buildSdk`. These broad targets include XCFramework generation, so they are not the default iteration loop. For package/support changes, also consult the public API and minimum-toolchain lanes in [build CI](.github/workflows/build.yml) and [example CI](.github/workflows/build-examples.yml). ### Simulator, UI and masking prerequisites @@ -77,6 +77,8 @@ This section is for external contributors. PostHog maintainers (members of the P When reviewing or fixing someone else's PR, don't ask for or open an issue. Note an external contributor's public API change when it has neither an agreed issue nor an API-defining published spec; the pre-implementation issue policy above is not a gate on that review work. -`make apiCheck` checks [api/posthog-ios.public-api.txt](api/posthog-ios.public-api.txt), as CI does. `make apiUpdate` regenerates it only after intentional public API changes; review the snapshot diff. Both commands build the SDK and extract its public symbol graph, requiring Xcode with the iOS SDK, xcpretty and Python 3. A diff in that file means your change touches public API. +`make apiCheck` checks [api/posthog-ios.public-api.txt](api/posthog-ios.public-api.txt) and [api/posthog-ios.public-api.no-traits.txt](api/posthog-ios.public-api.no-traits.txt), as CI does. `make apiUpdate` regenerates them only after intentional public API changes; review the snapshot diff. Both commands build the SDK and extract its public symbol graph, requiring Xcode with the iOS SDK, xcpretty and Python 3. A diff in either file means your change touches public API. + +Public API that only works with a package trait (`SessionReplay`, `CrashReporting` or `Surveys`) is gated with the same `#if !SWIFT_PACKAGE || ` condition as its implementation, so it is removed when the trait is off. The `no-traits` snapshot is the Swift Package Manager build with every trait off; a trait-specific symbol showing up there means its gate is missing. Above all, thank you for contributing! diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..b2cbf346e0 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,257 @@ +# Migrating from PostHog iOS 3.x to 4.0 + +This guide covers what you need to change when you upgrade an app from PostHog iOS 3.x to 4.0. + +1. [Before you upgrade](#before-you-upgrade): toolchain, deployment targets and installation. +2. [Changes the compiler won't flag](#changes-the-compiler-wont-flag): behavior that changes without a build error. Read this even if your app builds cleanly. +3. [Compile errors and how to fix them](#compile-errors-and-how-to-fix-them): removed and renamed APIs. +4. [Package traits](#package-traits): leaving out session replay, crash reporting or surveys. +5. [Upgrade checklist](#upgrade-checklist). + +For the full list of changes, see the [changelog](CHANGELOG.md). + +## Before you upgrade + +### Requirements + +| | 3.x | 4.0 | +| --- | --- | --- | +| Xcode | `swift-tools-version` 5.3 | 26 or later (`swift-tools-version` 6.2) | +| iOS and Mac Catalyst | 13.0 | 15.0 | +| macOS | 10.15 | 11.0 | +| tvOS | 13.0 | 15.0 | +| watchOS | 6.0 | 10.0 | +| visionOS | 1.0 | 1.0 | +| Installation | Swift Package Manager or CocoaPods | Swift Package Manager only | + +The SDK still compiles in Swift 5 language mode, so your app can use Swift 5 or Swift 6 mode. App Store Connect uploads already require Xcode 26; the toolchain floor mainly affects ad-hoc, enterprise, Mac and local builds still on Xcode 16 or older. + +### Move from CocoaPods to Swift Package Manager + +PostHog 4.0 is not published to CocoaPods, and the repository no longer has a `PostHog.podspec`, so `pod 'PostHog', :git => ...` can't install 4.x either. 3.x versions stay installable from CocoaPods, but [CocoaPods trunk becomes read-only on December 2, 2026](https://blog.cocoapods.org/CocoaPods-Specs-Repo/). + +1. Remove `pod 'PostHog'` from your `Podfile` and run `pod install`. +2. Add the package. In Xcode, choose **File > Add Package Dependencies…** and enter `https://github.com/PostHog/posthog-ios`. In a `Package.swift`: + + ```swift + dependencies: [ + .package(url: "https://github.com/PostHog/posthog-ios.git", from: "4.0.0") + ] + ``` + +3. Add the `PostHog` library to your app target. `import PostHog` doesn't change. +4. If a Run Script phase uploads dSYMs with `${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh`, change the path to `${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh`. The old path no longer exists once the pod is gone, so symbol upload fails and crash reports lose symbolication. + +### React Native, Flutter and Kotlin Multiplatform apps + +Don't add posthog-ios 4.0 to these apps yourself. They get the iOS SDK through their PostHog package (`@posthog/react-native-plugin`, `posthog_flutter`, `posthog-kmp`). Upgrade to the version of that package that moves to iOS SDK 4.0, and follow its migration notes. + +## Changes the compiler won't flag + +### Session replay always records screenshots + +In 3.x, session replay recorded wireframes unless you set `screenshotMode = true`. In 4.0 it always records screenshots of the screen, with sensitive content masked. Wireframe recording is gone. + +If your app didn't set `screenshotMode = true`: + +- Recordings now show real pixels for everything that isn't masked, including custom-drawn content such as `draw(_:)` views, maps, video and charts. +- `WKWebView` content is recorded in full when both `maskAllTextInputs` and `maskAllImages` are off. Wireframes only sent a placeholder. +- Each frame uses more bandwidth and CPU than a wireframe. + +If your app's window root is a SwiftUI view, replay recorded nothing in 3.x unless you set `screenshotMode = true`. With `config.sessionReplay = true`, these apps start recording on 4.0, which counts against your replay quota and may need a consent or privacy disclosure update. + +`$sdk_debug_replay_capture_mode` is now always `screenshot`, so update any filter on `wireframe`. + +Check what your screens show before you ship 4.0. Text inputs and images are masked by default (`maskAllTextInputs` and `maskAllImages` are `true`). To hide anything else: + +| To mask | SwiftUI | UIKit | +| --- | --- | --- | +| A view and everything inside it | `.postHogMask()` | add `ph-no-capture` to the view's `accessibilityIdentifier` | +| All text and text inputs | `sessionReplayConfig.maskAllTextInputs = true` | same | +| Every image | `sessionReplayConfig.maskAllImages = true` | same, except SF Symbols and images bundled with your app | +| System pickers (photos, contacts) | `sessionReplayConfig.maskAllSandboxedViews = true` | same | + +To mask an exempt image, tag its image view with `ph-no-capture`. `ph-no-capture` also works in `accessibilityLabel`. To keep pixels out of recordings entirely, turn replay off with `config.sessionReplay = false`. + +Secure-entry text fields now stay masked inside a `.postHogNoMask()` or `ph-no-mask` subtree. If you upgrade from a version before 3.93.1, masking also covers content a masked view draws outside its bounds (unclipped images, layer shadows, button titles) and image attachments in labels and text views when `maskAllImages` is on, with the same UIKit exceptions. + +### Replay screen names + +- The screen name shown for a recording comes from the screen that is visible. SwiftUI screens report their view name. +- A plain `UIViewController`'s `title` is no longer used as the replay screen name, so it reports no name. This includes storyboard scenes without a custom class. Subclasses still report their type name without `ViewController` (`CheckoutViewController` becomes `Checkout`). + +`$screen` events are named as in 3.x. + +### Feature flag payloads + +- A malformed, empty or whitespace-only payload now reads as `nil` from `getFeatureFlagResult(_:)` and `getAllFeatureFlags()`. In 3.x you got the raw string. The flag's value and enabled state don't change. +- Bootstrapped string payloads in `PostHogBootstrapConfig.featureFlagPayloads` stay strings. In 3.x, a bootstrapped `"123"` or `"true"` was decoded a second time and read back as a number or `Bool`. + +If your code relied on getting the raw string back, make the payload valid JSON in PostHog. + +If an app's last 3.x launch cached a bootstrapped string payload, got no `/flags` response afterwards and no longer passes bootstrap values, that payload can read as `nil` until the next `/flags` response. + +### Lifecycle events lose `version` and `build` + +`Application Installed`, `Application Updated` and `Application Opened` no longer send `version` and `build`. Every event already has `$app_version` and `$app_build`, so update insights, cohorts and actions that filter on `version` or `build`. `Application Updated` still sends `previous_version` and `previous_build`. If you used "`version` is set" on `Application Opened` to find cold launches, filter on `from_background = false` instead. Events already stored keep the old fields. + +### Apps updating straight from 2.x + +If an app updates from a 2.x SDK straight to 4.0, events still queued on disk by 2.x are deleted instead of sent. Distinct ID, anonymous ID, groups and cached feature flags still carry over. Updates from 3.x aren't affected. + +### Network reachability + +4.0 detects connectivity with `NWPathMonitor`: + +- A connection that needs user action, such as a VPN waiting for a password, counts as reachable. +- Queues pause while cellular data is turned off for your app. +- Events captured in the first 100 ms after `setup()` may leave out `$network_wifi` and `$network_cellular`. + +## Compile errors and how to fix them + +Removed and renamed APIs fail to build, usually with a "has no member" or "cannot find in scope" error that doesn't name the replacement, so look them up here. Objective-C names are listed where they differ. + +### Configuration + +| 3.x | 4.0 | +| --- | --- | +| `PostHogConfig(apiKey:)`, `PostHogConfig(apiKey:host:)`
ObjC: `apiKey:`, `apiKey:host:` | `PostHogConfig(projectToken:)`, `PostHogConfig(projectToken:host:)`
ObjC: `projectToken:`, `projectToken:host:` | +| `config.apiKey` | `config.projectToken` | +| `config.evaluationEnvironments` | `config.evaluationContexts` | +| `config.remoteConfig` | Delete the assignment. Remote config is always loaded. | +| `config.propertiesSanitizer`, `PostHogPropertiesSanitizer` | `config.setBeforeSend(_:)` (see below) | +| `config.dataMode = .cellular`
ObjC: `PostHogDataModeCellular` | `.any`
ObjC: `PostHogDataModeAny`. `PostHogDataMode(rawValue: 1)` now returns `nil`. | +| `config.storageManager`, `PostHogStorageManager` | `identify(_:)`, `getDistinctId()` and `reset()` on `PostHogSDK` | + +Replacing `propertiesSanitizer`: + +```swift +// 3.x +config.propertiesSanitizer = MySanitizer() + +// 4.0 +config.setBeforeSend { event in + event.properties = sanitize(event.properties) + return event // or nil to drop the event +} +``` + +In Objective-C, wrap each block in a `BoxedBeforeSendBlock`: + +```objc +[config setBeforeSend:@[[[BoxedBeforeSendBlock alloc] block:^PostHogEvent *(PostHogEvent *event) { + return event; +}]]]; +``` + +### Session replay + +| 3.x | 4.0 | +| --- | --- | +| `sessionReplayConfig.screenshotMode` | Delete the assignment. Replay always records masked screenshots: see [Session replay always records screenshots](#session-replay-always-records-screenshots). | +| `sessionReplayConfig.screenshotModeBackgroundCapture` | Delete the assignment. Screenshots are always captured on the main thread. | +| `sessionReplayConfig.maskPhotoLibraryImages` | Delete the assignment. It had no effect. | +| `sessionReplayConfig.debouncerDelay` | `sessionReplayConfig.throttleDelay` | +| `PostHogLogLevel`, `PostHogLogEntry`
ObjC: `PostHogLogLevelInfo`, `PostHogLogLevelWarn`, `PostHogLogLevelError` | `PostHogConsoleLogLevel`, `PostHogConsoleLogEntry`
ObjC: `PostHogConsoleLogLevelInfo`, `PostHogConsoleLogLevelWarn`, `PostHogConsoleLogLevelError` | + +### Feature flags + +| 3.x | 4.0 | +| --- | --- | +| `getFeatureFlagPayload(key)`
ObjC: `getFeatureFlagPayload:` | `getFeatureFlagResult(key, sendFeatureFlagEvent: false)?.payload`
ObjC: `[sdk getFeatureFlagResultWithKey:key sendFeatureFlagEvent:NO].payload` | +| `reloadFeatureFlags { ... }`
ObjC: `^{ ... }` | `reloadFeatureFlags { result in ... }` (or `{ _ in ... }`). `result.errorsLoading` is `true` when the reload failed (`result.variants` then holds the last known flags) or the SDK isn't set up. It's `false` when the project is over its flag quota.
ObjC: `^(PostHogFeatureFlagsLoaded *result) { ... }` | +| `PostHogSDK.didReceiveFeatureFlags` notification | `onFeatureFlags(_:)` (see below) | + +Replacing the `didReceiveFeatureFlags` notification: + +```swift +// 3.x +NotificationCenter.default.addObserver( + self, selector: #selector(flagsLoaded), + name: PostHogSDK.didReceiveFeatureFlags, object: nil +) + +// 4.0: runs on the main thread after each flag load +let subscription = PostHogSDK.shared.onFeatureFlags { loaded in + // loaded.flags, loaded.variants, loaded.errorsLoading +} +// later, to stop listening: +subscription.unsubscribe() +``` + +In Objective-C, call `[[PostHogSDK shared] onFeatureFlags:^(PostHogFeatureFlagsLoaded *loaded) { ... }]`. You can subscribe before `setup()`. + +### Callbacks are `@Sendable` + +Callbacks the SDK can call off the main thread are now `@Sendable`: `reloadFeatureFlags(_:)`, the blocks passed to `setBeforeSend(_:)` on `PostHogConfig` and `PostHogLogsConfig`, `getAnonymousId`, `pushIdentityProvider` and its `completion`, and `sessionReplayConfig.captureLogsConfig.logSanitizer`. + +In Swift 6 mode, a closure that reads or writes main-actor state no longer compiles (Swift 5 mode warns). For `reloadFeatureFlags(_:)` and `pushIdentityProvider`, hop to the main actor: + +```swift +PostHogSDK.shared.reloadFeatureFlags { result in + Task { @MainActor in + self.showNewFeature = !result.errorsLoading + } +} +``` + +The other callbacks must return right away, so don't read main-actor state in them. + +Arrays of plain closures still work with `setBeforeSend(_:)`. You don't need to retype them as `[BeforeSendBlock]`. + +### Internal and utility APIs + +These were public only for PostHog's own React Native and Flutter SDKs, or by accident: + +| 3.x | 4.0 | +| --- | --- | +| `PostHogSessionManager` | `getSessionId()`, `startSession()` and `endSession()` on `PostHogSDK` | +| `postHogSdkName`, `postHogVersion`, `config.snapshotEndpoint` | No longer public. A wrapper SDK that must read or set them can use `@_spi(PostHogInternal) import PostHog`, with no stability guarantees. | +| ObjC: `[PostHogSessionManager shared]`, `setSessionId:`, `config.snapshotEndpoint` | No longer exposed to Objective-C. `[[PostHogSDK shared] getSessionId]`, `startSession` and `endSession` replace the session calls. | +| `dateToMillis(_:)` | `Int64(date.timeIntervalSince1970 * 1000)` | +| `imageToBase64(_:_:)`, `toISO8601String(_:)`, `toISO8601Date(_:)`, `sanitizeDictionary(_:)`, `deleteSafely(_:)`, `postHogiOSSdkName`, `Gzip`, `GzipError`, `CompressionLevel`, `UIColor(hex:)`, `UIColor.hexDescription(_:)` | No longer public. Copy the code you need into your app. | +| `ReachabilityError`, `ReachabilityChangedNotification` | Removed. Use `NWPathMonitor` if your app needs reachability. | + +## Package traits + +4.0 splits three features into Swift package traits, all on by default: + +| Trait | What it includes | +| --- | --- | +| `SessionReplay` | Session replay on iOS, including the bundled libwebp | +| `CrashReporting` | Crash and uncaught-exception capture using the bundled PLCrashReporter, and out-of-memory capture | +| `Surveys` | The survey engine and UI on iOS | + +With the default traits nothing changes. To leave a feature out, list only the traits you want in your `Package.swift`: + +```swift +dependencies: [ + // Keep crash reporting, leave out session replay and surveys + .package(url: "https://github.com/PostHog/posthog-ios.git", from: "4.0.0", traits: ["CrashReporting"]) +] +``` + +Use `traits: []` to leave out all three. In an Xcode app project, traits for a package dependency can be chosen from Xcode 26.4. Check that the build actually leaves the feature out. + +When a trait is off, its public API is removed too, so code that uses it fails to compile instead of silently doing nothing: + +| Trait off | Removed | +| --- | --- | +| `SessionReplay` | `config.sessionReplay`, `config.sessionReplayConfig`, `PostHogSessionReplayConfig`, the console log types, `startSessionRecording()`, `stopSessionRecording()`, `isSessionReplayActive()`, `.postHogNoMask()` | +| `CrashReporting` | `errorTrackingConfig.autoCapture`. `captureException` and the rest of `errorTrackingConfig` still work. | +| `Surveys` | `config.surveys`, `config.surveysConfig`, `PostHogSurveysConfig`, `PostHogSurveysDelegate` and the survey display types | + +`.postHogMask()` stays available, because it also keeps tap autocapture out of masked areas. + +SwiftPM turns a trait on if any package in your dependency graph depends on posthog-ios with the default traits, so you can only leave a feature out if every dependency does too. + +## Upgrade checklist + +- [ ] Build with Xcode 26 or later, and raise your deployment targets to at least iOS 15, macOS 11, tvOS 15 and watchOS 10. +- [ ] Move from CocoaPods to Swift Package Manager, including any dSYM upload script path. +- [ ] If you use session replay, review recordings on a test build: custom-drawn content, maps, video and web views can now appear. Add masks where needed. +- [ ] If your app is SwiftUI-rooted and has replay on, expect recordings to start, and check quota and consent. +- [ ] Update insights, cohorts and actions that use `version` or `build` on lifecycle events, or `wireframe` in `$sdk_debug_replay_capture_mode`. +- [ ] Check code that reads feature flag payloads and could get `nil` for a malformed one. +- [ ] Fix the compile errors using the tables above, including `reloadFeatureFlags` closures. +- [ ] If you use package traits, list every trait you want to keep. diff --git a/Makefile b/Makefile index be2a2d5873..731b83f61b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: testSurveyUI build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility checkTestRegistration testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap releaseCocoaPods api apiCheck apiUpdate buildIOS +.PHONY: testSurveyUI build buildSdk buildExamples format swiftLint swiftFormat swiftLintCheck swiftFormatCheck installSwiftLint installSwiftFormat test testUploadSymbols recordEventShapeSnapshots testDowngradeCompatibility checkTestRegistration testOniOSSimulator testOnMacSimulator maskSnapshots recordMaskSnapshots checkMaskSnapshotRuntime lint bootstrap api apiCheck apiUpdate buildIOS buildSdkSpm buildSdkSpmNoTraits buildTestsSpmTraitsIOS testNoTraits build: buildSdk buildExamples @@ -7,22 +7,34 @@ buildIOS: buildSdk: set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=ios | xcpretty #ios - set -o pipefail && xcrun swift build --arch arm64 #macOS + $(MAKE) buildSdkSpm #macOS + $(MAKE) buildSdkSpmNoTraits #macOS + iOS, package traits off set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=macos | xcpretty #macOS set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination 'platform=macOS,variant=Mac Catalyst' | xcpretty #Mac Catalyst set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=tvos | xcpretty #tvOS set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=watchos | xcpretty #watchOS set -o pipefail && xcrun xcodebuild clean build -scheme PostHog -destination generic/platform=xros | xcpretty #visionOS +buildSdkSpm: + set -o pipefail && xcrun swift build --arch arm64 + +# Builds with every package trait off, so libwebp and PLCrashReporter are not compiled or linked. +# The iOS build covers the WebP gate, which macOS compiles out. +buildSdkSpmNoTraits: + set -o pipefail && xcrun swift build --target PostHog --arch arm64 --disable-default-traits #macOS + set -o pipefail && xcrun swift build --target PostHog --disable-default-traits --triple arm64-apple-ios15.0 --sdk "$$(xcrun --sdk iphoneos --show-sdk-path)" #iOS + +# Compiles the iOS test target with traits off and with each trait on its own, so trait-gated SDK code and +# the tests that use it stay in sync. `make test` runs on macOS, which compiles out the iOS-only code. +buildTestsSpmTraitsIOS: + set -o pipefail && xcrun swift build --build-tests --disable-default-traits --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits CrashReporting --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits SessionReplay --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + set -o pipefail && xcrun swift build --build-tests --traits Surveys --triple arm64-apple-ios15.0-simulator --sdk "$$(xcrun --sdk iphonesimulator --show-sdk-path)" -Xswiftc -DTESTING + buildExamples: \ buildExamplesPlatforms \ buildExampleXCFramework \ - buildExamplePods \ - -buildExamplePods: \ - buildExamplePodsStaticLib \ - buildExamplePodsStaticFramework \ - buildExamplePodsDynamicFramework \ buildExamplesPlatforms: set -o pipefail && xcrun xcodebuild clean build -scheme PostHogExample -destination generic/platform=ios | xcpretty #ios @@ -36,30 +48,6 @@ buildExamplesPlatforms: set -o pipefail && xcrun xcodebuild clean build -scheme PostHogExampleStoryboard -destination generic/platform=ios | xcpretty #Storyboard set -o pipefail && xcrun xcodebuild clean build -scheme PostHogExampleWithSPM -destination 'platform=macOS,variant=Mac Catalyst' | xcpretty #Mac Catalyst SPM -buildExamplePodsDynamicFramework: - cd PostHogExampleWithPods && \ - USE_FRAMEWORKS=dynamic pod install && cd .. && \ - set -o pipefail && xcrun xcodebuild clean build \ - -workspace PostHogExampleWithPods/PostHogExampleWithPods.xcworkspace \ - -scheme PostHogExampleWithPods \ - -destination generic/platform=ios | xcpretty - -buildExamplePodsStaticFramework: - cd PostHogExampleWithPods && \ - USE_FRAMEWORKS=static pod install && cd .. && \ - set -o pipefail && xcrun xcodebuild clean build \ - -workspace PostHogExampleWithPods/PostHogExampleWithPods.xcworkspace \ - -scheme PostHogExampleWithPods \ - -destination generic/platform=ios | xcpretty - -buildExamplePodsStaticLib: - cd PostHogExampleWithPods && \ - pod install && cd .. && \ - set -o pipefail && xcrun xcodebuild clean archive \ - -workspace PostHogExampleWithPods/PostHogExampleWithPods.xcworkspace \ - -scheme PostHogExampleWithPods \ - -destination generic/platform=ios | xcpretty - buildExampleXCFramework: ./PostHogExampleExternalSDK/build_xcframework.sh set -o pipefail && xcrun xcodebuild clean build \ @@ -122,9 +110,11 @@ testPresentationMasks: SWIFT_ACTIVE_COMPILATION_CONDITIONS='$$(inherited) TEST_PRESENTATION_MASKS' \ -only-testing:PostHogTests/PostHogMaskPresentationTest \ -only-testing:PostHogTests/PostHogMaskPresentationPrivacyTest \ + -only-testing:PostHogTests/PostHogReplayLayoutCaptureTest \ 2>&1 | tee presentation-masks.log | xcpretty @grep -q 'Suite "Replay masking behind a cover" passed' presentation-masks.log @grep -q 'Suite "Replay presentation privacy" passed' presentation-masks.log + @grep -q 'Suite "Replay layout-driven capture" passed' presentation-masks.log .PHONY: testCameraReplay @@ -202,6 +192,9 @@ testIOSResultParser: test: testUploadSymbols testIOSResultParser set -o pipefail && swift test --no-parallel -Xswiftc -DTESTING $(if $(filter),--filter $(filter)) +testNoTraits: + set -o pipefail && swift test --no-parallel --disable-default-traits -Xswiftc -DTESTING $(if $(filter),--filter $(filter)) + recordEventShapeSnapshots: UPDATE_EVENT_SHAPE_SNAPSHOTS=1 $(MAKE) test filter=PostHogEventSnapshotTests @@ -219,30 +212,22 @@ lint: swiftFormatCheck swiftLintCheck api: periphery scan +# Checks both public API snapshots: the default build, and the Swift Package Manager build with every +# package trait off (api/posthog-ios.public-api.no-traits.txt), where trait-gated public API is removed. apiCheck: scripts/check-public-api.sh + scripts/check-public-api.sh --no-traits apiUpdate: scripts/check-public-api.sh --update + scripts/check-public-api.sh --update --no-traits # requires gem and brew # xcpretty needs 'export LANG=en_US.UTF-8' bootstrap: - gem install cocoapods gem install xcpretty brew install swiftlint brew install swiftformat brew install peripheryapp/periphery/periphery brew install prek prek install - -# download SDKs and runtimes -# create Apple Vision Pro simulator if missing -# release pod -releaseCocoaPods: - set -o pipefail && xcrun xcodebuild -downloadAllPlatforms - @if ! xcrun simctl list devices | grep -q "Apple Vision Pro"; then \ - LATEST_RUNTIME=$$(xcrun simctl list runtimes | grep "com.apple.CoreSimulator.SimRuntime.xrOS" | sort -r | head -n 1 | awk '{print $$NF}') && \ - xcrun simctl create "Apple Vision Pro" "Apple Vision Pro" "$$LATEST_RUNTIME"; \ - fi - pod trunk push PostHog.podspec --allow-warnings \ No newline at end of file diff --git a/Package.swift b/Package.swift index 689e01e259..4d9ee752cd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,11 +1,10 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.2 import PackageDescription let package = Package( name: "PostHog", platforms: [ - // visionOS is supported via Package@swift-5.9.swift for Swift 5.9+ users - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), + .macOS(.v11), .iOS(.v15), .tvOS(.v15), .watchOS(.v10), .visionOS(.v1), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -14,6 +13,21 @@ let package = Package( targets: ["PostHog"] ), ], + traits: [ + .trait( + name: "SessionReplay", + description: "Session replay on iOS, including the vendored libwebp. When disabled, session replay is not available." + ), + .trait( + name: "CrashReporting", + description: "Captures crashes and uncaught exceptions using the vendored PLCrashReporter. When disabled, only manual captures are sent." + ), + .trait( + name: "Surveys", + description: "Shows mobile surveys on iOS. When disabled, surveys are not loaded or shown." + ), + .default(enabledTraits: ["SessionReplay", "CrashReporting", "Surveys"]), + ], dependencies: [ // Dependencies declare other packages that this package depends on. .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), @@ -25,8 +39,8 @@ let package = Package( name: "PostHog", dependencies: [ "PostHogObjCExceptionSupport", - "phlibwebp", - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), + .target(name: "phlibwebp", condition: .when(traits: ["SessionReplay"])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHog", exclude: [ @@ -96,12 +110,22 @@ let package = Package( "OHHTTPStubs", .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), // The crash-report processor tests import this directly to build a PHPLCrashReport. - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .tvOS])), + .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS], traits: ["CrashReporting"])), ], path: "PostHogTests", resources: [ .process("Resources"), ] ), - ] + // Compiles in Swift 6 language mode to check the public API the way a Swift 6 app sees it. + .testTarget( + name: "PostHogSwift6Tests", + dependencies: ["PostHog"], + path: "PostHogSwift6Tests", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ], + swiftLanguageModes: [.v5] ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift deleted file mode 100644 index ec757e3b4d..0000000000 --- a/Package@swift-5.9.swift +++ /dev/null @@ -1,106 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "PostHog", - platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .visionOS(.v1), - ], - products: [ - // Products define the executables and libraries a package produces, and make them visible to other packages. - .library( - name: "PostHog", - targets: ["PostHog"] - ), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/AliSoftware/OHHTTPStubs.git", from: "9.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages this package depends on. - .target( - name: "PostHog", - dependencies: [ - "PostHogObjCExceptionSupport", - "phlibwebp", - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS])), - ], - path: "PostHog", - exclude: [ - "ObjCExceptionSupport", - ], - resources: [ - .copy("Resources/PrivacyInfo.xcprivacy"), - ] - ), - .target( - name: "PostHogObjCExceptionSupport", - path: "PostHog/ObjCExceptionSupport", - publicHeadersPath: "." - ), - .target( - name: "PostHogTestsObjC", - path: "PostHogTestsObjC", - publicHeadersPath: "." - ), - .target( - name: "phlibwebp", - path: "vendor/libwebp", - publicHeadersPath: ".", - cSettings: [ - .define("PLCR_PRIVATE"), - .headerSearchPath("."), - ] - ), - .target( - name: "PHPLCrashReporter", - path: "vendor/PHPLCrashReporter", - exclude: [ - "Source/dwarf_opstream.hpp", - "Source/dwarf_stack.hpp", - "Source/PLCrashAsyncDwarfCFAState.hpp", - "Source/PLCrashAsyncDwarfCIE.hpp", - "Source/PLCrashAsyncDwarfEncoding.hpp", - "Source/PLCrashAsyncDwarfExpression.hpp", - "Source/PLCrashAsyncDwarfFDE.hpp", - "Source/PLCrashAsyncDwarfPrimitives.hpp", - "Source/PLCrashAsyncLinkedList.hpp", - "Source/PLCrashReport.proto", - "Dependencies/protobuf-c/generate-pb-c.sh", - "LICENSE", - ], - sources: [ - "Source", - ], - resources: [.process("Resources/PrivacyInfo.xcprivacy")], - publicHeadersPath: "include", - cSettings: [ - .define("PLCR_PRIVATE"), - .define("PLCF_RELEASE_BUILD"), - .define("PLCRASHREPORTER_PREFIX", to: "PH"), - .define("SWIFT_PACKAGE"), - .headerSearchPath("Source"), - ], - linkerSettings: [ - .linkedFramework("Foundation"), - ] - ), - .testTarget( - name: "PostHogTests", - dependencies: [ - "PostHog", - "PostHogTestsObjC", - "OHHTTPStubs", - .product(name: "OHHTTPStubsSwift", package: "OHHTTPStubs"), - // The crash-report processor tests import this directly to build a PHPLCrashReport. - .target(name: "PHPLCrashReporter", condition: .when(platforms: [.iOS, .macOS, .macCatalyst, .tvOS])), - ], - path: "PostHogTests", - resources: [ - .process("Resources"), - ] - ), - ] -) diff --git a/PostHog.podspec b/PostHog.podspec deleted file mode 100644 index a3b03fb1a8..0000000000 --- a/PostHog.podspec +++ /dev/null @@ -1,76 +0,0 @@ -Pod::Spec.new do |s| - s.name = "PostHog" - s.version = "3.93.1" - s.summary = "The hassle-free way to add posthog to your iOS app." - - s.description = <<-DESC - PostHog for iOS provides a single API that lets you - integrate with over 100s of tools. - DESC - - s.homepage = "http://posthog.com/" - s.license = { :type => 'MIT' } - s.author = { "PostHog" => "engineering@posthog.com" } - s.source = { :git => "https://github.com/PostHog/posthog-ios.git", :tag => s.version.to_s } - s.social_media_url = 'https://twitter.com/PostHog' - s.readme = "https://raw.githubusercontent.com/PostHog/posthog-ios/#{s.version.to_s}/README.md" - s.changelog = "https://raw.githubusercontent.com/PostHog/posthog-ios/#{s.version.to_s}/CHANGELOG.md" - - s.ios.deployment_target = '13.0' - s.tvos.deployment_target = '13.0' - s.osx.deployment_target = "10.15" - s.watchos.deployment_target = "6.0" - s.visionos.deployment_target = "1.0" - s.swift_versions = "5.3" - - s.frameworks = 'Foundation' - - # Vendored PLCrashReporter source (not available on watchOS/visionOS) - s.libraries = 'c++' - s.pod_target_xcconfig = { - 'BUILD_LIBRARY_FOR_DISTRIBUTION' => 'YES', - 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) PLCR_PRIVATE PLCF_RELEASE_BUILD PLCRASHREPORTER_PREFIX=PH SWIFT_PACKAGE', - # Static-library source pods cannot resolve the underlying Objective-C module - # while verifying the emitted Swift interface. Xcode 16.2 still schedules - # verification when the build setting is NO, so pass the compiler flag as well. - 'SWIFT_VERIFY_EMITTED_MODULE_INTERFACE' => 'NO', - 'OTHER_SWIFT_FLAGS' => '$(inherited) -no-verify-emitted-module-interface', - 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_TARGET_SRCROOT}/vendor/PHPLCrashReporter/Source"', - 'SWIFT_INCLUDE_PATHS' => '$(inherited) "${PODS_TARGET_SRCROOT}/PostHog/PrivateModules/phlibwebp" "${PODS_TARGET_SRCROOT}/PostHog/PrivateModules/PHPLCrashReporter" "${PODS_TARGET_SRCROOT}/PostHog/PrivateModules/PostHogObjCExceptionSupport"' - } - - s.source_files = [ - 'PostHog/**/*.{swift,h,hpp,m,mm,c,cpp}', - 'vendor/libwebp/**/*.{h,c}', - 'vendor/PHPLCrashReporter/Source/**/*.{h,hpp,m,mm,c,cpp,S}' - ] - - # Only PostHog's umbrella header should be public; vendored implementation headers stay private. - s.public_header_files = 'PostHog/PostHog.h' - s.private_header_files = [ - 'PostHog/ObjCExceptionSupport/PHObjCExceptionCatcher.h', - 'PostHog/ObjCExceptionSupport/PHURLSessionTaskSafeAccess.h', - 'vendor/libwebp/**/*.h', - 'vendor/PHPLCrashReporter/Source/**/*.{h,hpp}' - ] - - # Crash reporting is not supported on watchOS/visionOS - s.watchos.exclude_files = [ - 'vendor/PHPLCrashReporter/Source/**/*', - 'vendor/PHPLCrashReporter/Dependencies/**/*' - ] - s.visionos.exclude_files = [ - 'vendor/PHPLCrashReporter/Source/**/*', - 'vendor/PHPLCrashReporter/Dependencies/**/*' - ] - s.exclude_files = [ - 'vendor/PHPLCrashReporter/Source/PLCrashReport.proto' - ] - s.resource_bundles = { - 'PostHog' => 'PostHog/Resources/PrivacyInfo.xcprivacy', - 'PHPLCrashReporter' => 'vendor/PHPLCrashReporter/Resources/PrivacyInfo.xcprivacy' - } - - # Include the upload script and vendored notices in source distributions - s.preserve_paths = ['build-tools/upload-symbols.sh', 'vendor/PHPLCrashReporter/LICENSE', 'vendor/libwebp/COPYING', 'vendor/libwebp/PATENTS', 'PostHog/PrivateModules/**/*.modulemap'] -end diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 4e93b061ed..dd26ca9c62 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ BF5E00000000000000000002 /* PHObjCExceptionCatcher.h in Headers */ = {isa = PBXBuildFile; fileRef = BF5E00000000000000000006 /* PHObjCExceptionCatcher.h */; settings = {ATTRIBUTES = (Private, ); }; }; BF5E00000000000000000003 /* PHObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = BF5E00000000000000000007 /* PHObjCExceptionCatcher.m */; }; D0E500012FA0000100000002 /* PostHogEventSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */; }; + D0E500022FA0000100000002 /* PostHogEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E500022FA0000100000001 /* PostHogEventTest.swift */; }; D0E500012FA0000100000021 /* event-shapes-batch.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000011 /* event-shapes-batch.json */; }; D0E500012FA0000100000022 /* feature-flags-request.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000012 /* feature-flags-request.json */; }; D0E500012FA0000100000023 /* session-replay-request.json in Resources */ = {isa = PBXBuildFile; fileRef = D0E500012FA0000100000013 /* session-replay-request.json */; }; @@ -43,7 +44,6 @@ D3D3D3D3D3D3D3D3D3D30004 /* NetworkSampleTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3D3D3D3D3D3D3D3D3D30003 /* NetworkSampleTest.swift */; }; C0FFEE002F8000010000C006 /* PostHogMaskPresentationPrivacyTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE002F8000010000C005 /* PostHogMaskPresentationPrivacyTest.swift */; }; 0A0A0A0A0A0A0A0A0A0A0002 /* PostHogStackFrameTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0A0A0A0A0A0A0A0A0A0001 /* PostHogStackFrameTest.swift */; }; - E4E4E4E4E4E4E4E4E4E40002 /* RRStyleTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4E4E4E4E4E4E4E4E4E40001 /* RRStyleTest.swift */; }; F5F5F5F5F5F5F5F5F5F50002 /* PostHogBinaryImageInfoTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5F5F5F5F5F5F5F5F5F50001 /* PostHogBinaryImageInfoTest.swift */; }; B2B2B2B2B2B2B2B2B2B20002 /* PostHogReactNativeMaskingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2B2B2B2B2B2B2B2B20001 /* PostHogReactNativeMaskingTest.swift */; }; B2B2B2B2B2B2B2B2B2B20004 /* PostHogMaskFailClosedTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B2B2B2B2B2B2B2B2B20003 /* PostHogMaskFailClosedTest.swift */; }; @@ -101,8 +101,6 @@ 6A11C0DE2026092800000011 /* PostHogSurveySheetDetentsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */; }; 6A11C0DE2026092800000003 /* PostHogHingeStatusTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */; }; 690FF0BF2AEFA97F00A0B06B /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0BE2AEFA97F00A0B06B /* FileUtils.swift */; }; - 690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */; }; - 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */; }; 690FF0E12AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E02AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift */; }; 690FF0E32AEFD12900A0B06B /* PostHogConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */; }; 6A0C1F2E3B4D5A6B7C8D9E02 /* PostHogOptOutPersistenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */; }; @@ -128,8 +126,6 @@ 69278D412AE6BC7200BB541A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69278D402AE6BC7200BB541A /* Assets.xcassets */; }; 69278D442AE6BC7200BB541A /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 69278D422AE6BC7200BB541A /* LaunchScreen.storyboard */; }; 69278D472AE6BC7200BB541A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 69278D462AE6BC7200BB541A /* main.m */; }; - 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */; }; - 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */; }; 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6955CB722C517651008EFD8D /* CGSize+Util.swift */; }; 69779BEC2AE68E6900D7A48E /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69779BEB2AE68E6900D7A48E /* UIViewController.swift */; }; 6992AA832AFE51A000087600 /* PostHogExampleTvOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6992AA822AFE51A000087600 /* PostHogExampleTvOSApp.swift */; }; @@ -157,20 +153,16 @@ 69F23A782BB30991001194F6 /* NetworkSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A772BB30991001194F6 /* NetworkSample.swift */; }; 69F23A7A2BB309F3001194F6 /* MethodSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F23A792BB309F3001194F6 /* MethodSwizzler.swift */; }; 69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E72BAC675800F52C14 /* RRWireframe.swift */; }; - 69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517E92BAC684F00F52C14 /* RRStyle.swift */; }; - 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517F22BAC734300F52C14 /* UIColor+Util.swift */; }; 69F517FB2BAC768100F52C14 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FA2BAC768100F52C14 /* AppDelegate.swift */; }; 69F517FD2BAC768100F52C14 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FC2BAC768100F52C14 /* SceneDelegate.swift */; }; 69F517FF2BAC768100F52C14 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F517FE2BAC768100F52C14 /* ViewController.swift */; }; 69F518022BAC768100F52C14 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 69F518012BAC768100F52C14 /* Base */; }; 69F518042BAC768300F52C14 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69F518032BAC768300F52C14 /* Assets.xcassets */; }; 69F518072BAC768300F52C14 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 69F518062BAC768300F52C14 /* Base */; }; - 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518112BAC783300F52C14 /* CGColor+Util.swift */; }; 69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518132BAC7F4300F52C14 /* Date+Util.swift */; }; 69F518162BAC7F9200F52C14 /* UIView+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518152BAC7F9200F52C14 /* UIView+Util.swift */; }; 654EC5A16DFC454F974A30A0 /* PostHogMaskSettleAnalysis.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */; }; C0FFEE002F8000010000C002 /* PostHogPresentationCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */; }; - 69F518182BAC80A300F52C14 /* String+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518172BAC80A300F52C14 /* String+Util.swift */; }; 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */; }; 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */; }; 56061CE612D20303BC7F7E0E /* PushNotificationPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C3A4BC40DB1C70B84B25E /* PushNotificationPublisher.swift */; }; @@ -338,6 +330,7 @@ DA578E982D6768BC00B3A56C /* PostHogScreenViewIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E972D6768B400B3A56C /* PostHogScreenViewIntegration.swift */; }; DA578E9C2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */; }; C08700012E00000100000870 /* PostHogReplayCameraTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08700022E00000100000870 /* PostHogReplayCameraTest.swift */; }; + C0870A012E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */; }; C08700032E00000100000870 /* PostHogReplayScreenshotSizeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C08700042E00000100000870 /* PostHogReplayScreenshotSizeTest.swift */; }; C02440012E00000100000244 /* PostHogViewControllerTraversalTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */; }; DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */; }; @@ -467,8 +460,8 @@ DA8C9B982EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */; }; DA929C082D0B3A1D0018369C /* PostHog.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; }; DA929C092D0B3A1D0018369C /* PostHog.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3AC745B5296D6FE60025C109 /* PostHog.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - DA92C6412DCE09C700C02F3E /* PostHogLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */; }; - DA92C6492DCE09DE00C02F3E /* PostHogLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */; }; + DA92C6412DCE09C700C02F3E /* PostHogConsoleLogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */; }; + DA92C6492DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */; }; DA92C64B2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C64A2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift */; }; DA92C6632DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */; }; DA92C66B2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA92C66A2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift */; }; @@ -664,13 +657,6 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 690FF0332AE7C5BB00A0B06B /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 690FF02F2AE7C5BA00A0B06B /* PostHogExampleWithPods.xcodeproj */; - proxyType = 2; - remoteGlobalIDString = 690FF01E2AE7C5B900A0B06B; - remoteInfo = PostHogExampleWithPods; - }; 690FF0572AE7DB3700A0B06B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 690FF0532AE7DB3700A0B06B /* PostHogExampleWithSPM.xcodeproj */; @@ -871,6 +857,7 @@ BF5E00000000000000000006 /* PHObjCExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PHObjCExceptionCatcher.h; sourceTree = ""; }; BF5E00000000000000000007 /* PHObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PHObjCExceptionCatcher.m; sourceTree = ""; }; D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogEventSnapshotTests.swift; sourceTree = ""; }; + D0E500022FA0000100000001 /* PostHogEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogEventTest.swift; sourceTree = ""; }; D0E500012FA0000100000011 /* event-shapes-batch.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "event-shapes-batch.json"; sourceTree = ""; }; D0E500012FA0000100000012 /* feature-flags-request.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "feature-flags-request.json"; sourceTree = ""; }; D0E500012FA0000100000013 /* session-replay-request.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; path = "session-replay-request.json"; sourceTree = ""; }; @@ -894,7 +881,6 @@ D3D3D3D3D3D3D3D3D3D30003 /* NetworkSampleTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSampleTest.swift; sourceTree = ""; }; C0FFEE002F8000010000C005 /* PostHogMaskPresentationPrivacyTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskPresentationPrivacyTest.swift; sourceTree = ""; }; 0A0A0A0A0A0A0A0A0A0A0001 /* PostHogStackFrameTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogStackFrameTest.swift; sourceTree = ""; }; - E4E4E4E4E4E4E4E4E4E40001 /* RRStyleTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRStyleTest.swift; sourceTree = ""; }; F5F5F5F5F5F5F5F5F5F50001 /* PostHogBinaryImageInfoTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogBinaryImageInfoTest.swift; sourceTree = ""; }; B2B2B2B2B2B2B2B2B2B20001 /* PostHogReactNativeMaskingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReactNativeMaskingTest.swift; sourceTree = ""; }; B2B2B2B2B2B2B2B2B2B20003 /* PostHogMaskFailClosedTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogMaskFailClosedTest.swift; sourceTree = ""; }; @@ -961,7 +947,6 @@ 64F80D1792DC64921EF42266 /* PLCrashAsyncDwarfCFAState.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; name = PLCrashAsyncDwarfCFAState.cpp; path = vendor/PHPLCrashReporter/Source/PLCrashAsyncDwarfCFAState.cpp; sourceTree = ""; }; 68181997A5803BEDE63ABDEA /* PostHogLogRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostHogLogRecord.swift; sourceTree = ""; }; 690B2DF22C205B5600AE3B45 /* TimeBasedEpochGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeBasedEpochGenerator.swift; sourceTree = ""; }; - 690FF02F2AE7C5BA00A0B06B /* PostHogExampleWithPods.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWithPods.xcodeproj; path = PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj; sourceTree = ""; }; 690FF0532AE7DB3700A0B06B /* PostHogExampleWithSPM.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleWithSPM.xcodeproj; path = PostHogExampleWithSPM/PostHogExampleWithSPM.xcodeproj; sourceTree = ""; }; 690FF05E2AE7E2D400A0B06B /* Data+Gzip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Gzip.swift"; sourceTree = ""; }; 690FF0AE2AEB9C1400A0B06B /* DateUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateUtils.swift; sourceTree = ""; }; @@ -971,8 +956,6 @@ 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveySheetDetentsTest.swift; sourceTree = ""; }; 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogHingeStatusTest.swift; sourceTree = ""; }; 690FF0BE2AEFA97F00A0B06B /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = ""; }; - 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLegacyQueue.swift; sourceTree = ""; }; - 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLegacyQueueTest.swift; sourceTree = ""; }; 690FF0E02AEFC59100A0B06B /* PostHogFileBackedQueueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogFileBackedQueueTest.swift; sourceTree = ""; }; 690FF0E22AEFD12900A0B06B /* PostHogConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConfigTest.swift; sourceTree = ""; }; 6A0C1F2E3B4D5A6B7C8D9E01 /* PostHogOptOutPersistenceTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogOptOutPersistenceTest.swift; sourceTree = ""; }; @@ -1001,8 +984,6 @@ 69278D432AE6BC7200BB541A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 69278D452AE6BC7200BB541A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 69278D462AE6BC7200BB541A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogPropertiesSanitizer.swift; sourceTree = ""; }; - 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleSanitizer.swift; sourceTree = ""; }; 6955CB722C517651008EFD8D /* CGSize+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGSize+Util.swift"; sourceTree = ""; }; 6957A9B66813CC8C7E9B818C /* PLCrashFrameWalker.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = PLCrashFrameWalker.c; path = vendor/PHPLCrashReporter/Source/PLCrashFrameWalker.c; sourceTree = ""; }; 69779BEB2AE68E6900D7A48E /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; @@ -1036,8 +1017,6 @@ 69F23A772BB30991001194F6 /* NetworkSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSample.swift; sourceTree = ""; }; 69F23A792BB309F3001194F6 /* MethodSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MethodSwizzler.swift; sourceTree = ""; }; 69F517E72BAC675800F52C14 /* RRWireframe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRWireframe.swift; sourceTree = ""; }; - 69F517E92BAC684F00F52C14 /* RRStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RRStyle.swift; sourceTree = ""; }; - 69F517F22BAC734300F52C14 /* UIColor+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Util.swift"; sourceTree = ""; }; 69F517F82BAC768100F52C14 /* PostHogExampleStoryboard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PostHogExampleStoryboard.app; sourceTree = BUILT_PRODUCTS_DIR; }; 69F517FA2BAC768100F52C14 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 69F517FC2BAC768100F52C14 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -1046,12 +1025,10 @@ 69F518032BAC768300F52C14 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 69F518062BAC768300F52C14 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 69F518082BAC768300F52C14 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 69F518112BAC783300F52C14 /* CGColor+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGColor+Util.swift"; sourceTree = ""; }; 69F518132BAC7F4300F52C14 /* Date+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Util.swift"; sourceTree = ""; }; 69F518152BAC7F9200F52C14 /* UIView+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Util.swift"; sourceTree = ""; }; E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostHogMaskSettleAnalysis.swift"; sourceTree = ""; }; C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogPresentationCover.swift; sourceTree = ""; }; - 69F518172BAC80A300F52C14 /* String+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Util.swift"; sourceTree = ""; }; 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextInputTraits+Util.swift"; sourceTree = ""; }; 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSwizzler.swift; sourceTree = ""; }; 174C3A4BC40DB1C70B84B25E /* PushNotificationPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationPublisher.swift; sourceTree = ""; }; @@ -1198,6 +1175,7 @@ DA578E992D676AE700B3A56C /* ApplicationScreenViewPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationScreenViewPublisher.swift; sourceTree = ""; }; DA578E9B2D68578500B3A56C /* MockApplicationLifecyclePublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockApplicationLifecyclePublisher.swift; sourceTree = ""; }; C08700022E00000100000870 /* PostHogReplayCameraTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayCameraTest.swift; sourceTree = ""; }; + C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayLayoutCaptureTest.swift; sourceTree = ""; }; C08700042E00000100000870 /* PostHogReplayScreenshotSizeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogReplayScreenshotSizeTest.swift; sourceTree = ""; }; C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogViewControllerTraversalTest.swift; sourceTree = ""; }; DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogScreenViewIntegrationTest.swift; sourceTree = ""; }; @@ -1386,8 +1364,8 @@ DA879D792F9CC8AD0036A854 /* PostHogGraphicsImageRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogGraphicsImageRenderer.swift; sourceTree = ""; }; DA8C9B962EC633FA00C6EADB /* PostHogErrorTrackingConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogErrorTrackingConfig.swift; sourceTree = ""; }; DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = PostHogExampleAutocapture.xcodeproj; path = PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj; sourceTree = ""; }; - DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogEntry.swift; sourceTree = ""; }; - DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogLogLevel.swift; sourceTree = ""; }; + DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConsoleLogEntry.swift; sourceTree = ""; }; + DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogConsoleLogLevel.swift; sourceTree = ""; }; DA92C64A2DCE1A2600C02F3E /* PostHogSessionReplayConsoleLogConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayConsoleLogConfig.swift; sourceTree = ""; }; DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayConsoleLogsPlugin.swift; sourceTree = ""; }; DA92C66A2DD208F300C02F3E /* PostHogSessionReplayPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSessionReplayPlugin.swift; sourceTree = ""; }; @@ -1752,7 +1730,6 @@ DA8D37242CBEAC02005EBD27 /* PostHogExampleAutocapture.xcodeproj */, 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */, 690FF0532AE7DB3700A0B06B /* PostHogExampleWithSPM.xcodeproj */, - 690FF02F2AE7C5BA00A0B06B /* PostHogExampleWithPods.xcodeproj */, 3AAFB13129C0C699004F485B /* Package.swift */, 3AC745B7296D6FE60025C109 /* PostHog */, 3AC745C3296D6FE60025C109 /* PostHogTests */, @@ -1822,9 +1799,7 @@ 6926DA8D2ADD2876005760D2 /* PostHogContext.swift */, 6A11C0DE2026092800000002 /* PostHogHingeStatusObserver.swift */, 69779BEB2AE68E6900D7A48E /* UIViewController.swift */, - 690FF0C42AEFAE8200A0B06B /* PostHogLegacyQueue.swift */, 69F518372BB2BA0100F52C14 /* PostHogSwizzler.swift */, - 693E977A2C625208004B1030 /* PostHogPropertiesSanitizer.swift */, 69ED1A5B2C7F15F300FE7A91 /* PostHogSessionManager.swift */, B5EB989F28A201A9F44026EE /* PostHogCompression.swift */, 69ED1A9E2C8F451B00FE7A91 /* PostHogPersonProfiles.swift */, @@ -1862,6 +1837,7 @@ B74800000000000000000004 /* PostHogTests-Bridging-Header.h */, B74800000000000000000003 /* PostHogPushNotificationSwizzlingTest.swift */, D0E500012FA0000100000001 /* PostHogEventSnapshotTests.swift */, + D0E500022FA0000100000001 /* PostHogEventTest.swift */, DA27AEC82F56B3F3002A59CE /* PostHogSamplingTest.swift */, DA27AEF12F56B3F3002A59CE /* PostHogGeoIpTest.swift */, DB7100052F80000100000001 /* PostHogTracingHeadersTest.swift */, @@ -1876,7 +1852,6 @@ 0A0A0A0A0A0A0A0A0A0A0001 /* PostHogStackFrameTest.swift */, D3D3D3D3D3D3D3D3D3D30001 /* RRWireframeTest.swift */, D3D3D3D3D3D3D3D3D3D30003 /* NetworkSampleTest.swift */, - E4E4E4E4E4E4E4E4E4E40001 /* RRStyleTest.swift */, F5F5F5F5F5F5F5F5F5F50001 /* PostHogBinaryImageInfoTest.swift */, B2B2B2B2B2B2B2B2B2B20001 /* PostHogReactNativeMaskingTest.swift */, B2B2B2B2B2B2B2B2B2B20003 /* PostHogMaskFailClosedTest.swift */, @@ -1900,7 +1875,6 @@ 6A11C0DE2026092300000002 /* PostHogContextScreenSizeTest.swift */, 6A11C0DE2026092800000012 /* PostHogSurveySheetDetentsTest.swift */, 6A11C0DE2026092800000004 /* PostHogHingeStatusTest.swift */, - 690FF0DE2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift */, DA4FFB142DA93C78006BAEEA /* PostHogSessionReplayTest.swift */, DA4FFB182DA93C78006BAEEA /* PostHogReplayScreenshotDedupTest.swift */, EA5700022DA93C78006BAEEA /* PostHogReplayCaptureTouchesTest.swift */, @@ -1916,6 +1890,7 @@ DA6F24812D4A6CA100CA2777 /* PostHogApiTest.swift */, DA578E9D2D6858B200B3A56C /* PostHogScreenViewIntegrationTest.swift */, C08700022E00000100000870 /* PostHogReplayCameraTest.swift */, + C0870A022E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift */, C08700042E00000100000870 /* PostHogReplayScreenshotSizeTest.swift */, C02440022E00000100000244 /* PostHogViewControllerTraversalTest.swift */, DACF6D5C2CD2F5BC00F14133 /* PostHogAutocaptureIntegrationSpec.swift */, @@ -1932,7 +1907,6 @@ DA979D7A2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift */, 699C5FEE2C20242A007DB818 /* UUIDTest.swift */, DAF95F602D077C1C001E82BB /* PostHogWebPTest.swift */, - 693E977C2C6257F9004B1030 /* ExampleSanitizer.swift */, 69ED1AB52C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift */, DA52CAD22F2402E000305F3D /* PostHogMulticastCallbackTest.swift */, DA52CAEE2F24319800305F3D /* PostHogSessionReplayPluginTest.swift */, @@ -1956,14 +1930,6 @@ path = PostHogTests; sourceTree = ""; }; - 690FF0302AE7C5BA00A0B06B /* Products */ = { - isa = PBXGroup; - children = ( - 690FF0342AE7C5BB00A0B06B /* PostHogExampleWithPods.app */, - ); - name = Products; - sourceTree = ""; - }; 690FF0542AE7DB3700A0B06B /* Products */ = { isa = PBXGroup; children = ( @@ -2080,14 +2046,10 @@ 69EE82BB2BA9C53000EB9542 /* PostHogSessionReplayConfig.swift */, 69EE82CD2BAAC76000EB9542 /* ViewTreeSnapshotStatus.swift */, 69F517E72BAC675800F52C14 /* RRWireframe.swift */, - 69F517E92BAC684F00F52C14 /* RRStyle.swift */, - 69F517F22BAC734300F52C14 /* UIColor+Util.swift */, - 69F518112BAC783300F52C14 /* CGColor+Util.swift */, 69F518132BAC7F4300F52C14 /* Date+Util.swift */, 69F518152BAC7F9200F52C14 /* UIView+Util.swift */, E25D0AE93D314E7FBE8DA8C7 /* PostHogMaskSettleAnalysis.swift */, C0FFEE002F8000010000C001 /* PostHogPresentationCover.swift */, - 69F518172BAC80A300F52C14 /* String+Util.swift */, DA30AE682D3EFB4F00465A64 /* Optional+Util.swift */, 69F518192BAC81FC00F52C14 /* UITextInputTraits+Util.swift */, 69F23A772BB30991001194F6 /* NetworkSample.swift */, @@ -2483,8 +2445,8 @@ isa = PBXGroup; children = ( DA92C6602DCE304600C02F3E /* PostHogSessionReplayConsoleLogsPlugin.swift */, - DA92C6482DCE09DE00C02F3E /* PostHogLogLevel.swift */, - DA92C6402DCE09C700C02F3E /* PostHogLogEntry.swift */, + DA92C6482DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift */, + DA92C6402DCE09C700C02F3E /* PostHogConsoleLogEntry.swift */, DA74644E2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift */, ); path = "Console Logs"; @@ -3224,10 +3186,6 @@ ProductGroup = 690FF1742AF3CE8A00A0B06B /* Products */; ProjectRef = 690FF1732AF3CE8A00A0B06B /* PostHogExampleWatchOS.xcodeproj */; }, - { - ProductGroup = 690FF0302AE7C5BA00A0B06B /* Products */; - ProjectRef = 690FF02F2AE7C5BA00A0B06B /* PostHogExampleWithPods.xcodeproj */; - }, { ProductGroup = 690FF0542AE7DB3700A0B06B /* Products */; ProjectRef = 690FF0532AE7DB3700A0B06B /* PostHogExampleWithSPM.xcodeproj */; @@ -3249,13 +3207,6 @@ /* End PBXProject section */ /* Begin PBXReferenceProxy section */ - 690FF0342AE7C5BB00A0B06B /* PostHogExampleWithPods.app */ = { - isa = PBXReferenceProxy; - fileType = wrapper.application; - path = PostHogExampleWithPods.app; - remoteRef = 690FF0332AE7C5BB00A0B06B /* PBXContainerItemProxy */; - sourceTree = BUILT_PRODUCTS_DIR; - }; 690FF0582AE7DB3700A0B06B /* PostHogExampleWithSPM.app */ = { isa = PBXReferenceProxy; fileType = wrapper.application; @@ -3447,7 +3398,6 @@ 69261D1F2AD9681300232EC7 /* PostHogConsumerPayload.swift in Sources */, DAB565DF2D14C5660088F720 /* PostHogTagViewModifier.swift in Sources */, 6955CB732C517651008EFD8D /* CGSize+Util.swift in Sources */, - 69F517EA2BAC684F00F52C14 /* RRStyle.swift in Sources */, DA0CA6F12CFF6B6300AF9500 /* UIWindow+.swift in Sources */, DAB9E23C2DE8901D00E62DB9 /* PostHogSessionReplayNetworkPlugin.swift in Sources */, DB7100012F80000100000001 /* PostHogTracingHeadersIntegration.swift in Sources */, @@ -3500,7 +3450,6 @@ 69ED1A5C2C7F15F300FE7A91 /* PostHogSessionManager.swift in Sources */, 69F518142BAC7F4300F52C14 /* Date+Util.swift in Sources */, DA86D0E32E02D78C00CF3065 /* PostHogSurveysDefaultDelegate.swift in Sources */, - 69F518182BAC80A300F52C14 /* String+Util.swift in Sources */, DA9AE9602D8E9960002F1B44 /* EdgeBorder.swift in Sources */, DA9CE32B2D3108AD00DFE652 /* analysis_enc.c in Sources */, DA9CE32C2D3108AD00DFE652 /* lossless_enc_neon.c in Sources */, @@ -3533,7 +3482,7 @@ DA9CE3422D3108AD00DFE652 /* rescaler_sse2.c in Sources */, DA9CE3432D3108AD00DFE652 /* lossless_enc.c in Sources */, DA9CE3442D3108AD00DFE652 /* alpha_processing_sse2.c in Sources */, - DA92C6412DCE09C700C02F3E /* PostHogLogEntry.swift in Sources */, + DA92C6412DCE09C700C02F3E /* PostHogConsoleLogEntry.swift in Sources */, DA9CE3452D3108AD00DFE652 /* vp8l_enc.c in Sources */, DA9CE3462D3108AD00DFE652 /* dec_sse2.c in Sources */, DA9CE3472D3108AD00DFE652 /* sharpyuv.c in Sources */, @@ -3622,15 +3571,13 @@ DA9CE3812D3108AD00DFE652 /* sharpyuv_neon.c in Sources */, 69ED1A882C89B73100FE7A91 /* PostHogSwiftUIViewModifiers.swift in Sources */, 69F5181A2BAC81FC00F52C14 /* UITextInputTraits+Util.swift in Sources */, - DA92C6492DCE09DE00C02F3E /* PostHogLogLevel.swift in Sources */, + DA92C6492DCE09DE00C02F3E /* PostHogConsoleLogLevel.swift in Sources */, DAF95F512D072F04001E82BB /* UIImage+WebP.swift in Sources */, 69F517E82BAC675800F52C14 /* RRWireframe.swift in Sources */, - 693E977B2C625208004B1030 /* PostHogPropertiesSanitizer.swift in Sources */, DA74644F2DC8D58100C7D394 /* PostHogConsoleLogInterceptor.swift in Sources */, DAFEC4552E03F8EF0064535E /* PostHogNextSurveyQuestion.swift in Sources */, 3AE3FB3D29924E8200AFFC18 /* PostHogSDK.swift in Sources */, DA690C6A2DA54BCC0045FF4E /* PostHogSurveyAppearance.swift in Sources */, - 69F517F32BAC734300F52C14 /* UIColor+Util.swift in Sources */, 3AE3FB3F29924F4F00AFFC18 /* PostHogConfig.swift in Sources */, 69F518382BB2BA0100F52C14 /* PostHogSwizzler.swift in Sources */, 56061CE612D20303BC7F7E0E /* PushNotificationPublisher.swift in Sources */, @@ -3643,7 +3590,6 @@ DA3BB5052EDF15320097A97A /* PostHogStackFrame.swift in Sources */, DACC08BB2D6E3C6D00115E46 /* PostHogIntegration.swift in Sources */, DAD5DD0C2CB6DEF30087387B /* PostHogMaskViewModifier.swift in Sources */, - 690FF0C52AEFAE8200A0B06B /* PostHogLegacyQueue.swift in Sources */, 3AE3FB332991388500AFFC18 /* PostHogQueue.swift in Sources */, DA1044882D0B1D2D00C4ACF3 /* View+PostHogLabel.swift in Sources */, DAA1B2F12F8C100000112233 /* View+PostHogNoRageClick.swift in Sources */, @@ -3681,7 +3627,6 @@ 690B2DF32C205B5600AE3B45 /* TimeBasedEpochGenerator.swift in Sources */, 69261D1D2AD967CD00232EC7 /* PostHogFileBackedQueue.swift in Sources */, 3AE3FB432992985A00AFFC18 /* Reachability.swift in Sources */, - 69F518122BAC783300F52C14 /* CGColor+Util.swift in Sources */, D44A33B824C72C49F5A38E0E /* PLCrashAsync.c in Sources */, 990B703A6620B474798DD770 /* PLCrashAsyncCompactUnwindEncoding.c in Sources */, ABB09493E02B20855285B547 /* PLCrashAsyncMObject.c in Sources */, @@ -3771,6 +3716,7 @@ BF5E00000000000000000001 /* PHBeforeSendExceptionTestFixture.m in Sources */, B74800000000000000000002 /* PHNotificationDelegateTestFixture.m in Sources */, D0E500012FA0000100000002 /* PostHogEventSnapshotTests.swift in Sources */, + D0E500022FA0000100000002 /* PostHogEventTest.swift in Sources */, DA979D7B2CD370B700F56BAE /* PostHogAutocaptureEventTrackerSpec.swift in Sources */, DA0C944B2D54CDD000BFD9FB /* PostHogStorageMigrationTest.swift in Sources */, 690FF0F52AF0F06100A0B06B /* PostHogSDKTest.swift in Sources */, @@ -3798,6 +3744,7 @@ 69ED1AB62C90711D00FE7A91 /* PostHogSDKPersonProfilesTest.swift in Sources */, DA578E9E2D6858BA00B3A56C /* PostHogScreenViewIntegrationTest.swift in Sources */, C08700012E00000100000870 /* PostHogReplayCameraTest.swift in Sources */, + C0870A012E00000100000A01 /* PostHogReplayLayoutCaptureTest.swift in Sources */, C08700032E00000100000870 /* PostHogReplayScreenshotSizeTest.swift in Sources */, C02440012E00000100000244 /* PostHogViewControllerTraversalTest.swift in Sources */, 3A62646A29C9E385007E8C07 /* MockPostHogServer.swift in Sources */, @@ -3817,7 +3764,6 @@ AAC0FFEE2E20000100000002 /* PostHogMaskingCharacterizationTest.swift in Sources */, 3A62647129CAF67B007E8C07 /* PostHogStorageManagerTest.swift in Sources */, DA5E52EB2E0996C400159D20 /* PostHogSurveyEnumsTest.swift in Sources */, - 693E977D2C6257F9004B1030 /* ExampleSanitizer.swift in Sources */, 1F5558262DFC4802007643C0 /* PostHogStorageMergeTest.swift in Sources */, A1A1A1A1A1A1A1A1A1A10002 /* PostHogMaskScenarioTest.swift in Sources */, A1A1A1A1A1A1A1A1A1A1F002 /* PostHogMaskFlattenedContentTest.swift in Sources */, @@ -3826,7 +3772,6 @@ 0A0A0A0A0A0A0A0A0A0A0002 /* PostHogStackFrameTest.swift in Sources */, D3D3D3D3D3D3D3D3D3D30002 /* RRWireframeTest.swift in Sources */, D3D3D3D3D3D3D3D3D3D30004 /* NetworkSampleTest.swift in Sources */, - E4E4E4E4E4E4E4E4E4E40002 /* RRStyleTest.swift in Sources */, F5F5F5F5F5F5F5F5F5F50002 /* PostHogBinaryImageInfoTest.swift in Sources */, B2B2B2B2B2B2B2B2B2B20002 /* PostHogReactNativeMaskingTest.swift in Sources */, B2B2B2B2B2B2B2B2B2B20004 /* PostHogMaskFailClosedTest.swift in Sources */, @@ -3837,7 +3782,6 @@ DADE7DFE2F7EA90D00C4650C /* PostHogDeepLinkIntegrationTests.swift in Sources */, DA52CAEF2F24319800305F3D /* PostHogSessionReplayPluginTest.swift in Sources */, DA52CAEF2F2431980030AA01 /* PostHogConsoleLogInterceptorTest.swift in Sources */, - 690FF0DF2AEFBC5700A0B06B /* PostHogLegacyQueueTest.swift in Sources */, DA6F24822D4A6CA100CA2777 /* PostHogApiTest.swift in Sources */, AE3C6A5B6DAB01EF554A0270 /* PostHogPushNotificationTest.swift in Sources */, D36F92424A13D7CE61B6E348 /* PostHogSwizzlerTest.swift in Sources */, @@ -4041,7 +3985,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4056,7 +4000,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4091,7 +4035,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 4.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4107,7 +4051,7 @@ SWIFT_OBJC_BRIDGING_HEADER = "PostHogExample/PostHogExample-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4165,18 +4109,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -4227,18 +4171,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -4285,13 +4229,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4308,8 +4252,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4357,13 +4301,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4380,8 +4324,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4395,13 +4339,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4415,8 +4359,8 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Debug; @@ -4430,13 +4374,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4448,8 +4392,8 @@ SWIFT_OBJC_BRIDGING_HEADER = "PostHogTests/PostHogTests-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Release; @@ -4679,7 +4623,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4716,7 +4660,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -4788,18 +4732,18 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MACOSX_DEPLOYMENT_TARGET = 10.15; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Testing; }; @@ -4846,13 +4790,13 @@ ); INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; MODULEMAP_FILE = PostHog/PostHog.modulemap; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; @@ -4870,8 +4814,8 @@ SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -4887,13 +4831,13 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PNC2XCH2XP; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; OTHER_CFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogTests; @@ -4908,8 +4852,8 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; - WATCHOS_DEPLOYMENT_TARGET = 6.0; + TVOS_DEPLOYMENT_TARGET = 15.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -4942,7 +4886,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExample; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -4957,7 +4901,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; - TVOS_DEPLOYMENT_TARGET = 13.0; + TVOS_DEPLOYMENT_TARGET = 15.0; XROS_DEPLOYMENT_TARGET = 1.0; }; name = Testing; @@ -5084,7 +5028,7 @@ INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme b/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme deleted file mode 100644 index 4319ff1b52..0000000000 --- a/PostHog.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift b/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift index 6466f3ca01..2780e8cd39 100644 --- a/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift +++ b/PostHog/AppLifeCycle/ApplicationLifecyclePublisher.swift @@ -109,17 +109,10 @@ final class ApplicationLifecyclePublisher: AppLifecyclePublishing { 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) - } + NotificationCenter.default.addObserver(self, + selector: #selector(appDidBecomeActive), + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } diff --git a/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift b/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift index 61b5b12dd1..92c562b154 100644 --- a/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift +++ b/PostHog/AppLifeCycle/PostHogAppLifeCycleIntegration.swift @@ -153,12 +153,6 @@ final class PostHogAppLifeCycleIntegration: PostHogIntegration { } } - if let versionName { - props["version"] = versionName - } - if let versionCode { - props["build"] = parseBundleVersion(versionCode) - } // Toolchain only changes with a new build, so it rides on install/update instead of every event. props.merge(appBuildToolchainProperties(bundle.infoDictionary)) { current, _ in current } @@ -181,24 +175,8 @@ final class PostHogAppLifeCycleIntegration: PostHogIntegration { return } - var props: [String: Any] = [:] - props["from_background"] = !isFreshAppLaunch - - if isFreshAppLaunch { - let bundle = Bundle.main - - let versionName = appVersionString() - let versionCode = bundle.infoDictionary?["CFBundleVersion"] as? String - - if versionName != nil { - props["version"] = versionName - } - if let versionCode { - props["build"] = parseBundleVersion(versionCode) - } - - isFreshAppLaunch = false - } + let props: [String: Any] = ["from_background": !isFreshAppLaunch] + isFreshAppLaunch = false postHog.capture("Application Opened", properties: props) } diff --git a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift index 432c1479cd..2a45e55e2d 100644 --- a/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift +++ b/PostHog/Autocapture/PostHogAutocaptureEventTracker.swift @@ -315,7 +315,7 @@ extension UIControl { func event(for action: Selector, to target: Any?) -> UIControl.Event? { - var events: [UIControl.Event] = [ + let events: [UIControl.Event] = [ .valueChanged, .touchDown, .touchDownRepeat, @@ -331,12 +331,9 @@ .editingDidEnd, .editingDidEndOnExit, .primaryActionTriggered, + .menuActionTriggered, ] - 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 @@ -376,7 +373,7 @@ return EventType.kToggle } return EventType.kValueChange - } else if #available(iOS 14.0, tvOS 14.0, macCatalyst 14.0, *), self == .menuActionTriggered { + } else if self == .menuActionTriggered { return EventType.kMenuAction } @@ -475,9 +472,7 @@ } extension UIToolbar { - override var ph_autocaptureEvents: UIControl.Event { - if #available(iOS 14.0, *) { .menuActionTriggered } else { .primaryActionTriggered } - } + override var ph_autocaptureEvents: UIControl.Event { .menuActionTriggered } } extension UITextField { diff --git a/PostHog/Autocapture/SwiftUITapAutocapture.swift b/PostHog/Autocapture/SwiftUITapAutocapture.swift index f7e00a43f3..8de862ac88 100644 --- a/PostHog/Autocapture/SwiftUITapAutocapture.swift +++ b/PostHog/Autocapture/SwiftUITapAutocapture.swift @@ -262,16 +262,14 @@ } } } - #if compiler(>=6.0) - if #available(iOS 18.0, *), - let element = root.accessibilityHitTest(point, event: nil) as? NSObject, - element !== root - { - visit(element, depth: 0, path: "") - // Indexed traversal must still assign a path to an unlabeled hit-test leaf. - visited.removeAll(keepingCapacity: true) - } - #endif + if #available(iOS 18.0, *), + let element = root.accessibilityHitTest(point, event: nil) as? NSObject, + element !== root + { + visit(element, depth: 0, path: "") + // Indexed traversal must still assign a path to an unlabeled hit-test leaf. + visited.removeAll(keepingCapacity: true) + } visit(root, depth: 0, path: "") return AccessibilityTarget(identifier: best ?? fallback, excluded: excluded, traits: traits, developerIdentifier: best) } diff --git a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift index 929c6a74a6..110c26d3cd 100644 --- a/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift +++ b/PostHog/ErrorTracking/PostHogCrashReportProcessor.swift @@ -7,13 +7,8 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) - #if compiler(>=6.0) - internal import PHPLCrashReporter - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PHPLCrashReporter - #endif +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) + internal import PHPLCrashReporter enum PostHogCrashReportProcessor { /// Process a PHPLCrashReport and convert it to PostHog $exception event properties diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift index 6935b86027..0f8034cb5d 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingAutoCaptureIntegration.swift @@ -7,13 +7,8 @@ import Foundation -#if os(iOS) || os(macOS) || os(tvOS) - #if compiler(>=6.0) - internal import PHPLCrashReporter - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PHPLCrashReporter - #endif +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) + internal import PHPLCrashReporter class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { private static let integrationInstallState = PostHogIntegrationInstallState() @@ -302,12 +297,19 @@ import Foundation } #else - // watchOS/visionOS stub - crash reporting is not available on these platforms + // Stub for watchOS/visionOS, where crash reporting is not available, and for SPM builds + // with the `CrashReporting` trait disabled, where PHPLCrashReporter is not linked. class PostHogErrorTrackingAutoCaptureIntegration: PostHogIntegration { + static func clearInstalls() { /* no-op */ } + var requiresSwizzling: Bool { false } func install(_: PostHogSDK) -> PostHogIntegrationInstallResult { - .skipped(.notAvailableOnPlatform) + #if os(iOS) || os(macOS) || os(tvOS) + .skipped(.disabledByPackageTrait) + #else + .skipped(.notAvailableOnPlatform) + #endif } func uninstall(_: PostHogSDK) { /* no-op */ } diff --git a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift index a0c4d18476..3f89c07e54 100644 --- a/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift +++ b/PostHog/ErrorTracking/PostHogErrorTrackingConfig.swift @@ -14,31 +14,34 @@ import Foundation @objc public class PostHogErrorTrackingConfig: NSObject { // MARK: - Crash Reporting - /// Enable crash autocapture - /// - /// When enabled, the SDK will capture the following crash types: - /// - Mach exceptions (e.g., `EXC_BAD_ACCESS`, `EXC_CRASH`) - /// - POSIX signals (e.g., `SIGSEGV`, `SIGABRT`, `SIGBUS`) - /// - Uncaught `NSException`s - /// - /// Crashes are persisted to disk and sent as `$exception` events with level "fatal" **on the next app launch** - /// - /// - Note: Crash reporting is automatically disabled when a debugger is attached, - /// as the debugger intercepts signals before the crash handler can process them. - /// - /// Default: false - private var _autoCapture: Bool = false - - /// Whether crash autocapture is enabled. - /// - /// When enabled, fatal crashes are persisted and sent as `$exception` events on the next launch. - /// Default: `false`. - @available(watchOS, unavailable, message: "Crash autocapture is not available on watchOS") - @available(visionOS, unavailable, message: "Crash autocapture is not available on visionOS") - @objc public var autoCapture: Bool { - get { _autoCapture } - set { _autoCapture = newValue } - } + #if !SWIFT_PACKAGE || CrashReporting + /// Enable crash autocapture + /// + /// When enabled, the SDK will capture the following crash types: + /// - Mach exceptions (e.g., `EXC_BAD_ACCESS`, `EXC_CRASH`) + /// - POSIX signals (e.g., `SIGSEGV`, `SIGABRT`, `SIGBUS`) + /// - Uncaught `NSException`s + /// + /// Crashes are persisted to disk and sent as `$exception` events with level "fatal" **on the next app launch** + /// + /// - Note: Crash reporting is automatically disabled when a debugger is attached, + /// as the debugger intercepts signals before the crash handler can process them. + /// + /// Default: false + private var _autoCapture: Bool = false + + /// Whether crash autocapture is enabled. + /// + /// When enabled, fatal crashes are persisted and sent as `$exception` events on the next launch. + /// With Swift Package Manager, this is only available with the `CrashReporting` package trait, which is enabled by default. + /// Default: `false`. + @available(watchOS, unavailable, message: "Crash autocapture is not available on watchOS") + @available(visionOS, unavailable, message: "Crash autocapture is not available on visionOS") + @objc public var autoCapture: Bool { + get { _autoCapture } + set { _autoCapture = newValue } + } + #endif // MARK: - In-App Detection Configuration diff --git a/PostHog/Logs/PostHogLogsConfig.swift b/PostHog/Logs/PostHogLogsConfig.swift index 2a5d11c8b3..2115fea284 100644 --- a/PostHog/Logs/PostHogLogsConfig.swift +++ b/PostHog/Logs/PostHogLogsConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter record: The log record about to be queued. /// - Returns: The record to queue, or `nil` to drop it. -public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord? +public typealias PostHogBeforeSendLogBlock = @Sendable (PostHogLogRecord) -> PostHogLogRecord? /// Configuration for the PostHog logs subsystem. Mutate fields on `config.logs` /// before calling `PostHogSDK.setup(_:)`. @@ -99,13 +99,24 @@ public typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRec setBeforeSend(blocks) } + /// Replaces the log `beforeSend` chain with blocks from an array typed + /// `[(PostHogLogRecord) -> PostHogLogRecord?]`. + /// + /// The blocks can run on any thread, so they must be safe to call from any thread. + /// + /// - Parameter blocks: Ordered callbacks that can mutate or drop log records. + @_disfavoredOverload + public func setBeforeSend(_ blocks: [(PostHogLogRecord) -> PostHogLogRecord?]) { + setBeforeSend(BeforeSendChain.wrapping(blocks)) + } + /// Replaces the log `beforeSend` chain from Objective-C boxed callbacks. /// /// - Parameter blocks: Ordered Objective-C callback boxes. @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) { setBeforeSend(blocks.map { box in - { record in box.invokeSafely(with: record) } + { @Sendable record in box.invokeSafely(with: record) } }) } diff --git a/PostHog/Models/PostHogEvent.swift b/PostHog/Models/PostHogEvent.swift index 6418b3c60e..c200a8f587 100644 --- a/PostHog/Models/PostHogEvent.swift +++ b/PostHog/Models/PostHogEvent.swift @@ -29,20 +29,19 @@ import Foundation /// Unique event identifier generated by the SDK. @objc public private(set) var uuid: UUID // Only used for Replay. - // Publicly this remains named apiKey for BeforeSendBlock compatibility, - // but it carries the PostHog project token and is serialized as api_key on the wire. - var apiKey: String? + // Serialized as api_key on the wire. + var projectToken: String? /// Set by the SDK when this event took the throttled replay debug bundle, so it survives `beforeSend` renames or replacement. var carriesReplayDebugBundle = false - init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), apiKey: String? = nil) { + init(event: String, distinctId: String, properties: [String: Any]? = nil, timestamp: Date = Date(), uuid: UUID = UUID.v7(), projectToken: String? = nil) { self.event = event self.distinctId = distinctId self.properties = properties ?? [:] self.timestamp = timestamp self.uuid = uuid - self.apiKey = apiKey + self.projectToken = projectToken } // NOTE: Ideally we would use the NSCoding behaviour but it gets needlessly complex @@ -62,22 +61,15 @@ import Foundation 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 - } + let properties = (json["properties"] as? [String: Any]) ?? [:] guard let distinctId = (json["distinct_id"] as? String) ?? (properties["distinct_id"] as? String) else { return nil } - // `message_id` is deprecated and only accepted for backwards compatibility with legacy persisted events. - let uuid = ((json["uuid"] as? String) ?? (json["message_id"] as? String)) ?? UUID.v7String() + let uuid = (json["uuid"] as? String) ?? UUID.v7String() let uuidObj = UUID(uuidString: uuid) ?? UUID.v7() // Wire field name remains api_key, but it carries the PostHog project token. - let projectKey = json["api_key"] as? String + let projectToken = json["api_key"] as? String return PostHogEvent( event: event, @@ -85,7 +77,7 @@ import Foundation properties: properties, timestamp: timestampDate, uuid: uuidObj, - apiKey: projectKey + projectToken: projectToken ) } @@ -98,7 +90,7 @@ import Foundation "uuid": uuid.postHogUuidString, ] - if let projectToken = apiKey { + if let projectToken { // Wire field name remains api_key, but it carries the PostHog project token. json["api_key"] = projectToken } diff --git a/PostHog/Models/PostHogFeatureFlagsLoaded.swift b/PostHog/Models/PostHogFeatureFlagsLoaded.swift index 4ce21c8350..1782054cb0 100644 --- a/PostHog/Models/PostHogFeatureFlagsLoaded.swift +++ b/PostHog/Models/PostHogFeatureFlagsLoaded.swift @@ -22,7 +22,9 @@ import Foundation /// subscription.unsubscribe() /// ``` @objc(PostHogFeatureFlagsLoaded) -public final class PostHogFeatureFlagsLoaded: NSObject { +public final class PostHogFeatureFlagsLoaded: NSObject, @unchecked Sendable { + // @unchecked: every property is a `let`, and `variants` only holds Swift `Bool` and `String` values (see init). + /// The keys of the enabled feature flags. @objc public let flags: [String] @@ -38,11 +40,16 @@ public final class PostHogFeatureFlagsLoaded: NSObject { @objc public let errorsLoading: Bool init(featureFlags: [String: Any], errorsLoading: Bool) { - let enabled = featureFlags.filter { _, value in + // Store the bridged values, not the originals: a bootstrap `NSMutableString` would stay mutable. + var enabled: [String: Any] = [:] + for (key, value) in featureFlags { if let bool = value as? Bool { - return bool + if bool { + enabled[key] = bool + } + } else if let string = value as? String { + enabled[key] = string } - return value is String } flags = Array(enabled.keys) variants = enabled diff --git a/PostHog/Models/Surveys/PostHogSurvey+Display.swift b/PostHog/Models/Surveys/PostHogSurvey+Display.swift index cd4bcb7eed..dbe643abed 100644 --- a/PostHog/Models/Surveys/PostHogSurvey+Display.swift +++ b/PostHog/Models/Surveys/PostHogSurvey+Display.swift @@ -1,4 +1,4 @@ -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation extension PostHogSurvey { diff --git a/PostHog/PostHogApi.swift b/PostHog/PostHogApi.swift index d9cbf71d45..1f66010e3c 100644 --- a/PostHog/PostHogApi.swift +++ b/PostHog/PostHogApi.swift @@ -207,7 +207,7 @@ class PostHogApi { } for event in events { - event.apiKey = config.projectToken + event.projectToken = config.projectToken } let toSend = events.map { $0.toJSON() } diff --git a/PostHog/PostHogConfig.swift b/PostHog/PostHogConfig.swift index 543974feec..c9ebedc2e7 100644 --- a/PostHog/PostHogConfig.swift +++ b/PostHog/PostHogConfig.swift @@ -13,7 +13,7 @@ import Foundation /// /// - Parameter event: The event about to be queued. /// - Returns: The event to queue, or `nil` to drop it. -public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? +public typealias BeforeSendBlock = @Sendable (PostHogEvent) -> PostHogEvent? /// Runtime configuration for a `PostHogSDK` instance. /// @@ -39,12 +39,9 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Network connectivity mode required before queued data may be flushed. @frozen @objc(PostHogDataMode) public enum PostHogDataMode: Int { /// Flush only while the device is connected to Wi-Fi. - case wifi - /// Behaves the same as `.any`. Use `.any` instead. - @available(*, deprecated, message: "Behaves the same as .any. Use .any instead. This will be removed in the next major version.") - case cellular + case wifi = 0 /// Flush while any network connection is available. - case any + case any = 2 } /// PostHog ingestion host used for all SDK network requests. @@ -60,12 +57,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// This field was formerly named `apiKey`. @objc public let projectToken: String - /// Obsolete alias for `projectToken`. - @available(*, deprecated, message: "Use projectToken instead. This will be removed in the next major version.") - @objc public var apiKey: String { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - return projectToken - } /// Number of queued events that triggers an automatic flush. /// /// Lower values send data sooner but can increase battery and network usage. @@ -124,21 +115,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: `true`. @objc public var preloadFeatureFlags: Bool = true - /// Deprecated no-op for remote config loading. - /// - /// Remote config is now always loaded; setting this property has no effect. - /// - /// - Deprecated: Remote config is always loaded. This option will be removed in a future version. - @available(*, deprecated, message: "Remote config is now always loaded. This option is a no-op and will be removed in a future version.") - @objc public var remoteConfig: Bool { - get { true } - set { - if !newValue { - hedgeLog("remoteConfig is deprecated and is now always enabled. Setting it to false has no effect.") - } - } - } - /// Whether the SDK automatically captures application lifecycle events. /// /// When enabled, the SDK records events such as `Application Installed`, @@ -229,7 +205,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// here stalls the SDK's push retry/offline-resume flow (though not your app's main thread). /// /// Default: `nil` (requests carry no identity token). - @objc public var pushIdentityProvider: ((_ distinctId: String, _ appId: String, _ completion: @escaping (String?) -> Void) -> Void)? + @objc public var pushIdentityProvider: (@Sendable (_ distinctId: String, _ appId: String, _ completion: @escaping @Sendable (String?) -> Void) -> Void)? #if os(iOS) || targetEnvironment(macCatalyst) /// Enables UIKit element interaction autocapture on iOS and Mac Catalyst. @@ -306,11 +282,12 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Hook used to customize newly generated anonymous IDs. /// /// The SDK passes its generated UUID v7 and stores the UUID returned by this closure. - /// Existing stored anonymous IDs are not regenerated. + /// Existing stored anonymous IDs are not regenerated. The closure can be called on any thread, + /// so it must not read main-actor state. /// /// - Parameter uuid: The SDK-generated anonymous UUID. /// - Returns: The UUID to persist as the anonymous ID. - @objc public var getAnonymousId: ((UUID) -> UUID) = { uuid in uuid } + @objc public var getAnonymousId: (@Sendable (UUID) -> UUID) = { uuid in uuid } /// Pre-seeded identity and feature-flag state applied during setup, before any /// network request completes. @@ -347,18 +324,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Defaults to false. @objc public var reuseAnonymousId: Bool = false - private var _propertiesSanitizer: PostHogPropertiesSanitizer? - var legacyPropertiesSanitizer: PostHogPropertiesSanitizer? { - _propertiesSanitizer - } - - /// 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? { - get { _propertiesSanitizer } - set { _propertiesSanitizer = newValue } - } /// Determines the behavior for processing user profiles. @objc public var personProfiles: PostHogPersonProfiles = .identifiedOnly @@ -404,20 +369,6 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Default: nil (all flags are evaluated) @objc public var evaluationContexts: [String]? - /// Deprecated alias for `evaluationContexts`. - /// - /// - Deprecated: Use `evaluationContexts` instead. This property will be removed in a future version. - @available(*, deprecated, message: "Use evaluationContexts instead. This property will continue to work but will be removed in a future version.") - @objc public var evaluationEnvironments: [String]? { - get { evaluationContexts } - set { - if newValue != nil { - hedgeLog("evaluationEnvironments is deprecated. Use evaluationContexts instead.") - } - evaluationContexts = newValue - } - } - /// The identifier of the App Group that should be used to store shared analytics data. /// PostHog will try to get the physical location of the App Group’s shared container, otherwise fallback to the default location /// Default: nil @@ -427,7 +378,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// /// - Warning: This value is managed by the SDK from remote configuration and should not /// be changed by application code. - @objc public var snapshotEndpoint: String = "/s/" + @_spi(PostHogInternal) public var snapshotEndpoint: String = "/s/" /// Default PostHog ingestion host for US Cloud projects. /// @@ -448,15 +399,21 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// - iOS does not send `X-POSTHOG-WINDOW-ID` because mobile apps do not have a per-window/tab concept /// - Existing values for these headers will be overwritten @objc public var tracingHeaders: [String]? + #endif + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) /// Enable Recording of Session Replays for iOS /// + /// With Swift Package Manager, this is only available with the `SessionReplay` package trait, which is enabled by default. + /// /// Note: Ingestion controls (sampling, feature flags, and event triggers) are currently applied using AND logic. /// All configured conditions must be satisfied for recording to start. /// /// Default: false @objc public var sessionReplay: Bool = false /// Session Replay configuration + /// + /// With Swift Package Manager, this is only available with the `SessionReplay` package trait, which is enabled by default. @objc public let sessionReplayConfig: PostHogSessionReplayConfig = .init() #endif @@ -469,35 +426,37 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// Mutate fields on `config.logs` before calling `PostHogSDK.setup(_:)`. @objc public let logs: PostHogLogsConfig = .init() - /// 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) } - } + #if !SWIFT_PACKAGE || Surveys + /// Enable mobile surveys + /// + /// With Swift Package Manager, this is only available with the `Surveys` package trait, which is enabled by default. + /// + /// 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(watchOS, unavailable, message: "Surveys are only available on iOS") + @available(macOS, unavailable, message: "Surveys are only available on iOS") + @available(tvOS, unavailable, message: "Surveys are only available on iOS") + @available(visionOS, unavailable, message: "Surveys are only available on iOS") + @objc public var surveys: Bool { + get { _surveys } + set { _surveys = newValue } + } - /// Configuration for mobile survey presentation and localization. - /// - /// Mutate fields on `config.surveysConfig` or replace this object before calling setup. - /// Available on iOS 15 and later. - @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) } - } + /// Configuration for mobile survey presentation and localization. + /// + /// Mutate fields on `config.surveysConfig` or replace this object before calling setup. + /// With Swift Package Manager, this is only available with the `Surveys` package trait, which is enabled by default. + @available(watchOS, unavailable, message: "Surveys are only available on iOS") + @available(macOS, unavailable, message: "Surveys are only available on iOS") + @available(tvOS, unavailable, message: "Surveys are only available on iOS") + @available(visionOS, unavailable, message: "Surveys are only available on iOS") + @objc public var surveysConfig: PostHogSurveysConfig { + get { _surveysConfig } + set { _surveysConfig = newValue } + } + #endif /// Optional custom URLSessionConfiguration for network requests /// If not set, uses URLSessionConfiguration.default @@ -526,7 +485,7 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? /// /// - Warning: This is an SDK extension point used internally to share identity storage /// with SDK integrations and tests. Application code should not normally replace it. - public var storageManager: PostHogStorageManager? + @_spi(PostHogInternal) public var storageManager: PostHogStorageManager? private static func normalizeProjectToken(_ projectToken: String) -> String { projectToken.trimmingCharacters(in: .whitespacesAndNewlines) @@ -560,40 +519,11 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? self.host = URL(string: normalizedHost.isEmpty ? PostHogConfig.defaultHost : normalizedHost) ?? URL(string: PostHogConfig.defaultHost)! } - /// Creates a configuration using the deprecated `apiKey` name. - /// - /// - Parameter apiKey: Your PostHog project token. - /// - Deprecated: Use `init(projectToken:)` instead. - @available(*, deprecated, message: "Use init(projectToken:) instead. This will be removed in the next major version.") - @objc(apiKey:) - public convenience init( - apiKey: String - ) { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - self.init(projectToken: apiKey) - } - - /// Creates a configuration using the deprecated `apiKey` name and an explicit host. - /// - /// - Parameters: - /// - apiKey: Your PostHog project token. - /// - host: PostHog ingestion host. Empty or invalid values fall back to `defaultHost`. - /// - Deprecated: Use `init(projectToken:host:)` instead. - @available(*, deprecated, message: "Use init(projectToken:host:) instead. This will be removed in the next major version.") - @objc(apiKey:host:) - public convenience init( - apiKey: String, - host: String = defaultHost - ) { - hedgeLog("apiKey is deprecated and will be removed in the next major version. Use projectToken instead.") - self.init(projectToken: apiKey, host: host) - } - /// Returns an array of integrations to be installed based on current configuration func getIntegrations() -> [PostHogIntegration] { var integrations: [PostHogIntegration] = [] - #if os(iOS) || os(macOS) || os(tvOS) + #if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) if errorTrackingConfig.autoCapture { integrations.append(PostHogErrorTrackingAutoCaptureIntegration()) } @@ -609,15 +539,18 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? if tracingHeaders?.isEmpty == false { integrations.append(PostHogTracingHeadersIntegration()) } + #endif + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) if sessionReplay { integrations.append(PostHogReplayIntegration()) } + #endif + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) if _surveys { integrations.append(PostHogSurveyIntegration()) } - #endif #if os(iOS) || targetEnvironment(macCatalyst) @@ -631,17 +564,15 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? #endif #if os(iOS) || os(macOS) - if #available(iOS 14.0, macOS 11.0, *) { - // Token registration is iOS-only in v1 (the backend rejects `macos`); opened-capture - // works on both platforms. - #if os(iOS) - if capturePushNotificationSubscriptions { - integrations.append(PostHogPushNotificationSubscriptionIntegration()) - } - #endif - if installsPushNotificationOpenIntegration { - integrations.append(PostHogPushNotificationOpenIntegration()) + // Token registration is iOS-only in v1 (the backend rejects `macos`); opened-capture + // works on both platforms. + #if os(iOS) + if capturePushNotificationSubscriptions { + integrations.append(PostHogPushNotificationSubscriptionIntegration()) } + #endif + if installsPushNotificationOpenIntegration { + integrations.append(PostHogPushNotificationOpenIntegration()) } #endif @@ -657,23 +588,11 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? } #endif - 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 - } - } + #if !SWIFT_PACKAGE || Surveys + var _surveys: Bool = true // swiftlint:disable:this identifier_name - 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 - } - } + var _surveysConfig: PostHogSurveysConfig = .init() // swiftlint:disable:this identifier_name + #endif /// Hook that allows to sanitize the event /// The hook is called before the event is cached or sent over the wire @@ -696,13 +615,24 @@ public typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? setBeforeSend(blocks) } + /// Replaces the event `beforeSend` chain with blocks from an array typed + /// `[(PostHogEvent) -> PostHogEvent?]`. + /// + /// The blocks can run on any thread, so they must be safe to call from any thread. + /// + /// - Parameter blocks: Ordered callbacks that can mutate or drop events. + @_disfavoredOverload + public func setBeforeSend(_ blocks: [(PostHogEvent) -> PostHogEvent?]) { + setBeforeSend(BeforeSendChain.wrapping(blocks)) + } + /// Replaces the event `beforeSend` chain from Objective-C boxed callbacks. /// /// - Parameter blocks: Ordered Objective-C callback boxes. @available(swift, obsoleted: 1.0, message: "Use setBeforeSend(_ blocks: BeforeSendBlock...) instead") @objc public func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) { setBeforeSend(blocks.map { box in - { event in box.invokeSafely(with: event) } + { @Sendable event in box.invokeSafely(with: event) } }) } diff --git a/PostHog/PostHogContext.swift b/PostHog/PostHogContext.swift index d400b18cbf..e8ffef6582 100644 --- a/PostHog/PostHogContext.swift +++ b/PostHog/PostHogContext.swift @@ -138,22 +138,12 @@ class PostHogContext { } #else init() { - if #available(watchOS 7.0, *) { - registerNotifications() - } else { - onShouldUpdateScreenSize() - } + registerNotifications() } #endif deinit { - #if !os(watchOS) - unregisterNotifications() - #else - if #available(watchOS 7.0, *) { - unregisterNotifications() - } - #endif + unregisterNotifications() } private lazy var theSdkInfo: [String: Any] = { @@ -215,10 +205,8 @@ class PostHogContext { #if targetEnvironment(macCatalyst) sysctlName = "hw.model" #elseif os(iOS) || os(visionOS) - if #available(iOS 14.0, *) { - if ProcessInfo.processInfo.isiOSAppOnMac { - sysctlName = "hw.model" - } + if ProcessInfo.processInfo.isiOSAppOnMac { + sysctlName = "hw.model" } #endif @@ -307,9 +295,9 @@ class PostHogContext { properties["$timezone"] = TimeZone.current.identifier #if !os(watchOS) - if reachability != nil { - properties["$network_wifi"] = reachability?.connection == .wifi - properties["$network_cellular"] = reachability?.connection == .cellular + if let connection = reachability?.connection { + properties["$network_wifi"] = connection == .wifi + properties["$network_cellular"] = connection == .cellular } #endif @@ -348,12 +336,10 @@ class PostHogContext { 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) - } + NotificationCenter.default.addObserver(self, + selector: #selector(onShouldUpdateScreenSize), + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } @@ -379,11 +365,9 @@ class PostHogContext { name: NSApplication.didBecomeActiveNotification, object: nil) #elseif os(watchOS) - if #available(watchOS 7.0, *) { - NotificationCenter.default.removeObserver(self, - name: WKApplication.didBecomeActiveNotification, - object: nil) - } + NotificationCenter.default.removeObserver(self, + name: WKApplication.didBecomeActiveNotification, + object: nil) #endif } @@ -494,12 +478,7 @@ class PostHogContext { #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 isIOSAppOnMac: Bool = ProcessInfo.processInfo.isiOSAppOnMac static let isMacCatalystApp: Bool = { #if targetEnvironment(macCatalyst) diff --git a/PostHog/PostHogExtensions.swift b/PostHog/PostHogExtensions.swift index 933b155b8c..1c97db6a43 100644 --- a/PostHog/PostHogExtensions.swift +++ b/PostHog/PostHogExtensions.swift @@ -20,9 +20,4 @@ public extension PostHogSDK { /// /// The notification object is `nil`. @objc static let didStartNotification = Notification.Name("PostHogDidStart") // object: nil - - /// Posted after a feature flag reload finishes and cached flags are updated. - /// - /// The notification object is `nil`. - @objc static let didReceiveFeatureFlags = Notification.Name("PostHogDidReceiveFeatureFlags") // object: nil } diff --git a/PostHog/PostHogFileBackedQueue.swift b/PostHog/PostHogFileBackedQueue.swift index 5ea44b3afc..9206ca0a00 100644 --- a/PostHog/PostHogFileBackedQueue.swift +++ b/PostHog/PostHogFileBackedQueue.swift @@ -43,7 +43,7 @@ class PostHogFileBackedQueue { migrateOldQueueFolder(queue: queue, oldQueueFolder: oldQueue) } else { // old plist file - migrateOldQueue(queue: queue, oldQueue: oldQueue) + deleteSafely(oldQueue) } } } diff --git a/PostHog/PostHogIntegration.swift b/PostHog/PostHogIntegration.swift index 82902b502e..1f23bfa770 100644 --- a/PostHog/PostHogIntegration.swift +++ b/PostHog/PostHogIntegration.swift @@ -12,6 +12,7 @@ enum PostHogIntegrationInstallSkipReason: String, CustomStringConvertible { case alreadyInstalled = "Already installed to another PostHogSDK instance" case disabledByRemoteConfig = "Disabled in remote config" case notAvailableOnPlatform = "Not available on this platform" + case disabledByPackageTrait = "Disabled by a package trait" } enum PostHogIntegrationInstallResult: Equatable { diff --git a/PostHog/PostHogLegacyQueue.swift b/PostHog/PostHogLegacyQueue.swift deleted file mode 100644 index 8b7285259c..0000000000 --- a/PostHog/PostHogLegacyQueue.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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) - guard let array = try JSONSerialization.jsonObject(with: data) as? [Any] else { - hedgeLog("Failed to migrate queue: invalid data format") - 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)") - } -} diff --git a/PostHog/PostHogPropertiesSanitizer.swift b/PostHog/PostHogPropertiesSanitizer.swift deleted file mode 100644 index 9882f32087..0000000000 --- a/PostHog/PostHogPropertiesSanitizer.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 - /// - /// - Note: `inout` cannot be used in Swift protocols, so clone the properties before mutating them. - /// - /// ```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] -} diff --git a/PostHog/PostHogQueue.swift b/PostHog/PostHogQueue.swift index 27cb490c84..51e5149d9a 100644 --- a/PostHog/PostHogQueue.swift +++ b/PostHog/PostHogQueue.swift @@ -241,15 +241,7 @@ class PostHogQueue { _ = updatePauseState(for: reachability) } - do { - try reachability?.startNotifier() - } catch { - // Without a running notifier no callback can ever clear a - // reachability pause, so it must not outlive the failure. - // URLSession errors and the retry backoff cover connectivity. - hedgeLog("Error: Unable to monitor network reachability: \(error)") - stateLock.withLock { paused = false } - } + reachability?.startNotifier() #endif } diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index 0281379fc6..47e7f15216 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -156,7 +156,7 @@ class PostHogRemoteConfig { preloadErrorTrackingConfig() preloadPushConfig() - // Remote config is always loaded (config.remoteConfig is now a no-op) + // Remote config is always loaded preloadRemoteConfig() } @@ -224,7 +224,7 @@ class PostHogRemoteConfig { func reloadRemoteConfig( callback: (([String: Any]?) -> Void)? = nil ) { - // Remote config is always loaded (config.remoteConfig is now a no-op) + // Remote config is always loaded // Note: this guard has the same withLock closure-return bug as loadFeatureFlags // had, but for remote config duplicate concurrent requests are harmless (no // identity-sensitive params). Fixing it properly requires a pending callback @@ -280,7 +280,7 @@ class PostHogRemoteConfig { } func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)? = nil + callback: ((FeatureFlagsLoadResult) -> Void)? = nil ) { reloadFeatureFlags(callback: callback, surveyCompletion: nil) } @@ -290,7 +290,7 @@ class PostHogRemoteConfig { } private func reloadFeatureFlags( - callback: (([String: Any]?) -> Void)?, + callback: ((FeatureFlagsLoadResult) -> Void)?, surveyCompletion: (([String: Any]?) -> Void)? ) { guard canReloadFlagsForTesting else { @@ -300,7 +300,7 @@ class PostHogRemoteConfig { guard let storageManager = config.storageManager else { hedgeLog("No PostHogStorageManager found in config, skipping loading feature flags") - callback?(nil) + callback?(failedLoadResult()) surveyCompletion?(nil) return } @@ -422,7 +422,7 @@ class PostHogRemoteConfig { anonymousId: String?, deviceId: String? = nil, groups: [String: String], - callback: (([String: Any]?) -> Void)? = nil, + callback: ((FeatureFlagsLoadResult) -> Void)? = nil, surveyCompletion: (([String: Any]?) -> Void)? = nil, coalesceWithCurrentRequest: Bool = false ) { @@ -492,15 +492,16 @@ class PostHogRemoteConfig { self.processErrorTrackingConfig(nil) self.notifyFeatureFlagsAndRelease(cachedFeatureFlags) - callback?(cachedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: cachedFeatureFlags, lastKnownFeatureFlags: cachedFeatureFlags)) return } // Safely handle optional data guard var data = data else { hedgeLog("Error: Flags response data is nil") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -512,8 +513,9 @@ class PostHogRemoteConfig { let featureFlagPayloads = data["featureFlagPayloads"] as? [String: Any] else { hedgeLog("Error: Flags response missing correct featureFlags format") + let result = self.failedLoadResult() self.notifyFeatureFlagsAndRelease(nil) - callback?(nil) + callback?(result) return } @@ -576,7 +578,7 @@ class PostHogRemoteConfig { } self.notifyFeatureFlagsAndRelease(loadedFeatureFlags) - callback?(loadedFeatureFlags) + callback?(FeatureFlagsLoadResult(featureFlags: loadedFeatureFlags, lastKnownFeatureFlags: loadedFeatureFlags ?? [:])) } } } @@ -790,10 +792,15 @@ class PostHogRemoteConfig { self.latestFeatureFlagsLoadFailed = featureFlags == nil } self.onFeatureFlagsLoaded.invoke(featureFlags) - NotificationCenter.default.post(name: PostHogSDK.didReceiveFeatureFlags, object: nil) } } + /// Snapshots the cached flags for a failed load. Taken before listeners and callbacks run. + private func failedLoadResult() -> FeatureFlagsLoadResult { + let cached = featureFlagsLock.withLock { getCachedFeatureFlags() ?? [:] } + return FeatureFlagsLoadResult(featureFlags: nil, lastKnownFeatureFlags: cached) + } + private func notifyFeatureFlagsAndRelease(_ featureFlags: [String: Any]?) { let (pending, surveyWaiters): (PendingFeatureFlagsRequest?, [([String: Any]?) -> Void]) = loadingFeatureFlagsLock.withLock { self.loadingFeatureFlags = false @@ -812,7 +819,7 @@ class PostHogRemoteConfig { anonymousId: pending.anonymousId, deviceId: pending.deviceId, groups: pending.groups, - callback: { flags in pending.callbacks.forEach { $0(flags) } } + callback: { result in pending.callbacks.forEach { $0(result) } } ) } else { for waiter in surveyWaiters { @@ -878,7 +885,12 @@ class PostHogRemoteConfig { featureFlagsLock.withLock { guard !bootstrappedFlags.isEmpty else { return false } setCachedFeatureFlags(bootstrappedFlags) - setCachedFeatureFlagPayload(bootstrappedPayloads) + // Bootstrap payloads are already decoded; serialize them like /flags payloads so reads + // decode every cached payload exactly once (a bootstrapped "123" stays a String). + setCachedFeatureFlagPayload(bootstrappedPayloads.compactMapValues { payload in + (try? JSONSerialization.data(withJSONObject: payload, options: .fragmentsAllowed)) + .flatMap { String(data: $0, encoding: .utf8) } + }) return true } } @@ -1055,22 +1067,19 @@ class PostHogRemoteConfig { flags = getCachedFeatureFlagPayload() } - let value = flags?[key] - - guard let stringValue = value as? String else { - return value - } + return Self.decodePayload(flags?[key], key: key) + } + /// Decodes a cached payload, which is stored as serialized JSON (mimicking posthog-js's `JSON.parse`). + /// Malformed, empty, or whitespace-only input returns `nil`, never the raw string. + private static func decodePayload(_ value: Any?, key: String) -> Any? { + guard let serialized = 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) + return try JSONSerialization.jsonObject(with: Data(serialized.utf8), options: .fragmentsAllowed) } catch { - hedgeLog("Error parsing the object \(String(describing: value)): \(error)") + hedgeLog("Ignoring malformed payload for feature flag \(key): \(error)") + return nil } - - // fallback to original value if not possible to serialize - return value } func getFeatureFlagResult(_ key: String) -> PostHogFeatureFlagResult? { @@ -1104,17 +1113,7 @@ class PostHogRemoteConfig { } private func makeFeatureFlagResult(key: String, flagValue: Any?, payloadValue: Any?) -> PostHogFeatureFlagResult { - let payload: Any? - if let stringValue = payloadValue as? String { - do { - payload = try JSONSerialization.jsonObject(with: stringValue.data(using: .utf8)!, options: .fragmentsAllowed) - } catch { - hedgeLog("Error parsing the object \(String(describing: payloadValue)): \(error)") - payload = payloadValue - } - } else { - payload = payloadValue - } + let payload = Self.decodePayload(payloadValue, key: key) let isEnabled: Bool let variant: String? @@ -1324,7 +1323,16 @@ private struct PendingFeatureFlagsRequest { let anonymousId: String? let deviceId: String? let groups: [String: String] - let callbacks: [([String: Any]?) -> Void] + let callbacks: [(FeatureFlagsLoadResult) -> Void] +} + +/// The outcome of one feature flags load, captured once when it completes so every coalesced +/// callback sees the same values even if the cache changes while they run (for example a `reset()`). +struct FeatureFlagsLoadResult { + /// The loaded flags, or `nil` if the load failed. + let featureFlags: [String: Any]? + /// The loaded flags, or the cached flags when the load failed. + let lastKnownFeatureFlags: [String: Any] } #if TESTING diff --git a/PostHog/PostHogSDK.swift b/PostHog/PostHogSDK.swift index d8bbbcb1b8..de967d0a76 100644 --- a/PostHog/PostHogSDK.swift +++ b/PostHog/PostHogSDK.swift @@ -121,7 +121,7 @@ let maxRetryDelay = 30.0 /// `nil` before `setup(_:)` is called. @objc public private(set) var logger: PostHogLogger? - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) private weak var _replayIntegration: PostHogReplayIntegration? /// Same shape as `queue`/`replayQueue` above: written under `setupLock` by install/uninstall, /// read from any caller thread by `buildProperties`. @@ -130,26 +130,21 @@ let maxRetryDelay = 30.0 set { setupLock.withLock { _replayIntegration = newValue } } } - private weak var surveysIntegration: PostHogSurveyIntegration? /// Whether `optIn()` starts session replay: whether the app wants it, from `config.sessionReplay` /// and the app's last `startSessionRecording()`/`stopSessionRecording()` while opted in, so consent /// doesn't override a stop. Internal stops don't change it. Guarded by `setupLock`. private var startSessionRecordingOnOptIn = true #endif - // nonisolated(unsafe) is introduced in Swift 5.10 - #if swift(>=5.10) - /// Shared singleton SDK instance used by most applications. - /// - /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. - @objc public nonisolated(unsafe) static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) - #else - /// Shared singleton SDK instance used by most applications. - /// - /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. - @objc public static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) + private weak var surveysIntegration: PostHogSurveyIntegration? #endif + /// Shared singleton SDK instance used by most applications. + /// + /// Call `setup(_:)` once with a `PostHogConfig` before using capture APIs. + @objc public nonisolated(unsafe) static let shared: PostHogSDK = .init(PostHogConfig(projectToken: "")) + deinit { #if !os(watchOS) self.reachability?.stopNotifier() @@ -185,7 +180,7 @@ let maxRetryDelay = 30.0 } if config.projectToken.isEmpty { - hedgeLog("PostHog SDK will be disabled because projectToken or apiKey is empty.") + hedgeLog("PostHog SDK will be disabled because projectToken is empty.") return } @@ -214,11 +209,7 @@ let maxRetryDelay = 30.0 }) #if !os(watchOS) - do { - reachability = try Reachability() - } catch { - // ignored - } + reachability = Reachability() context = PostHogContext(reachability) #else context = PostHogContext() @@ -329,7 +320,7 @@ let maxRetryDelay = 30.0 // this only reconciles an existing local identity. reconcileBootstrapIdentityIfNeeded() - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) // Launched opted out: optIn() starts replay only when the config does startSessionRecordingOnOptIn = config.sessionReplay #endif @@ -348,10 +339,8 @@ let maxRetryDelay = 30.0 #if os(iOS) || os(macOS) // Releases a prewarm this setup turns out not to want — including while opted out, // where the integrations above were never installed and so could never release it. - if #available(iOS 14.0, macOS 11.0, *) { - if !config.installsPushNotificationOpenIntegration { - DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() - } + if !config.installsPushNotificationOpenIntegration { + DI.main.pushNotificationPublisher.discardPrewarmedNotificationResponseCapture() } #endif @@ -563,7 +552,6 @@ let maxRetryDelay = 30.0 /// Only the first URL context is captured. /// /// - Parameter openURLContexts: The set of URL contexts from the scene delegate. - @available(iOS 13.0, tvOS 13.0, *) @objc public func captureDeepLink(openURLContexts: Set) { if let context = openURLContexts.first { captureDeepLink(url: context.url, referrer: context.options.sourceApplication) @@ -765,7 +753,7 @@ let maxRetryDelay = 30.0 } // only Session replay requires $window_id, so we set as the same as $session_id. // the backend might fallback to $session_id if $window_id is not present next. - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) if !appendSharedProps, isSessionReplayActive() { props["$window_id"] = sessionId } @@ -809,10 +797,10 @@ let maxRetryDelay = 30.0 // SDK-computed debug keys overwrite a same-named registered super property (js: `extend` // after super properties), so a stale `register()` can't shadow the live status. - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) var replayDebugProperties = replayIntegration?.debugProperties() ?? [ "$recording_status": "disabled", - "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode(config: config), + "$sdk_debug_replay_capture_mode": PostHogReplayIntegration.captureMode, ] #else var replayDebugProperties: [String: Any] = ["$recording_status": "disabled"] @@ -1257,10 +1245,7 @@ let maxRetryDelay = 30.0 return true } guard let reachability else { return true } - if case .unavailable = reachability.connection { - return false - } - return true + return reachability.connection != .unavailable #else return true #endif @@ -1642,9 +1627,9 @@ let maxRetryDelay = 30.0 } // Filtering after the full build stays robust as new context properties are added later: - // anything not explicitly allowlisted is stripped. beforeSend hooks and the legacy - // propertiesSanitizer run later (in buildEvent) and may re-add keys — an accepted - // escape hatch, codified in the minimal-event contract. + // anything not explicitly allowlisted is stripped. beforeSend hooks run later (in + // buildEvent) and may re-add keys — an accepted escape hatch, codified in the + // minimal-event contract. if let propertyAllowlist { finalProperties = finalProperties.filter { propertyAllowlist.contains($0.key) || $0.key == Self.replayDebugClaimMarkerKey @@ -1838,13 +1823,6 @@ let maxRetryDelay = 30.0 queueEvent(event, queue: queue) } - private func sanitizeProperties(_ properties: [String: Any]) -> [String: Any] { - if let sanitizer = config.legacyPropertiesSanitizer { - return sanitizer.sanitize(properties) - } - return properties - } - /// Assigns an additional distinct ID to the current user. /// /// Use alias when a user should be connected to another identifier that was previously used @@ -1957,12 +1935,11 @@ let maxRetryDelay = 30.0 func buildEvent(event eventName: String, distinctId: String, properties: [String: Any], timestamp: Date = Date()) -> PostHogEvent? { var properties = properties let carriesReplayDebugBundle = properties.removeValue(forKey: Self.replayDebugClaimMarkerKey) != nil - let sanitizedProperties = sanitizeProperties(properties) let event = PostHogEvent( event: eventName, distinctId: distinctId, - properties: sanitizedProperties, + properties: properties, timestamp: timestamp ) @@ -2147,7 +2124,7 @@ let maxRetryDelay = 30.0 /// /// ```swift /// PostHogSDK.shared.setPersonPropertiesForFlags(["plan": "premium"], reloadFeatureFlags: false) - /// PostHogSDK.shared.reloadFeatureFlags { + /// PostHogSDK.shared.reloadFeatureFlags { _ in /// let flagValue = PostHogSDK.shared.isFeatureEnabled("new_feature") /// } /// ``` @@ -2156,9 +2133,10 @@ let maxRetryDelay = 30.0 /// leaves your app in control of when flags load. /// /// - Note: `reset()` clears person properties set here, so they must be set again afterwards. - /// - Note: `reloadFeatureFlags(_:)` reports that the reload finished, not that it succeeded. If the - /// request fails, or the project is over its feature flag quota, the handler still runs and the - /// flags you read are the previously cached ones. + /// - Note: The `reloadFeatureFlags(_:)` handler runs whether or not the reload succeeded. If the + /// request fails, `errorsLoading` is `true` and the flags you read are the previously cached ones. + /// If the project is over its feature flag quota, `errorsLoading` is `false` but the flags are + /// still the previously cached ones. /// /// - Parameters: /// - properties: Dictionary of person properties to include in flag evaluation @@ -2352,29 +2330,43 @@ let maxRetryDelay = 30.0 /// Reloads feature flags for the current user and group context. @objc public func reloadFeatureFlags() { - reloadFeatureFlags { + reloadFeatureFlags { _ in // No use case } } - /// Reloads feature flags and invokes a callback when finished. + /// Reloads feature flags and invokes a callback with the result when finished. + /// + /// ```swift + /// PostHogSDK.shared.reloadFeatureFlags { result in + /// if result.errorsLoading { + /// // The reload failed. result.variants holds the last known flags. + /// } + /// } + /// ``` /// - /// - Parameter callback: Invoked when the reload finishes, or immediately if the reload - /// is skipped (SDK disabled/opted-out, or no remote config available). + /// - Parameter callback: Invoked when the reload finishes, possibly on a background thread. + /// If the request fails, ``PostHogFeatureFlagsLoaded/errorsLoading`` is `true` and the flags are + /// the last known ones. If the reload is skipped because the SDK isn't set up, it's invoked + /// right away with no flags and `errorsLoading` set to `true`. To update UI or other main-actor + /// state, hop to the main actor, for example with `Task { @MainActor in ... }`. @objc(reloadFeatureFlagsWithCallback:) - public func reloadFeatureFlags(_ callback: @escaping () -> Void) { + public func reloadFeatureFlags(_ callback: @escaping @Sendable (PostHogFeatureFlagsLoaded) -> Void) { if !isEnabled() { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } guard let remoteConfig else { - callback() + callback(PostHogFeatureFlagsLoaded(featureFlags: [:], errorsLoading: true)) return } - remoteConfig.reloadFeatureFlags { _ in - callback() + remoteConfig.reloadFeatureFlags { result in + callback(PostHogFeatureFlagsLoaded( + featureFlags: result.lastKnownFeatureFlags, + errorsLoading: result.featureFlags == nil + )) } } @@ -2612,19 +2604,6 @@ let maxRetryDelay = 30.0 return remoteConfig?.getAllFeatureFlagResults() } - /// Returns the payload for a feature flag. - /// - /// - Parameter key: The feature flag key. - /// - Returns: The flag payload, or `nil` if the flag or payload is unavailable. - /// - Warning: This method does not send the `$feature_flag_called` event. - /// Use `getFeatureFlagResult(_:)` instead for proper analytics tracking. - @available(*, deprecated, message: "Use getFeatureFlagResult(_:) instead which properly tracks feature flag usage") - @objc public func getFeatureFlagPayload(_ key: String) -> Any? { - // Don't send event to maintain backwards compatibility - let result = getFeatureFlagResult(key, sendEvent: false) - return result?.payload - } - private func flagValuesEqual(_ lhs: Any?, _ rhs: Any?) -> Bool { switch (lhs, rhs) { case (nil, nil): @@ -2796,14 +2775,14 @@ let maxRetryDelay = 30.0 } } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) // Replay restarts in a new session (a no-op under React Native, which owns the session id) if startSessionRecordingOnOptIn { sessionManager.startSession() } #endif installIntegrations() - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) if !startSessionRecordingOnOptIn { // install() starts recording; undo it if the app had stopped replay before opting out replayIntegration?.stop() @@ -2833,7 +2812,7 @@ let maxRetryDelay = 30.0 // Gate on the same conditions that install the subscription integration: auto-capture and // swizzling. Without swizzling the integration is skipped, so refetching would fire the host's // APNs lifecycle with no observer to forward the token. - if #available(iOS 14.0, *), config.capturePushNotificationSubscriptions, config.enableSwizzling { + if config.capturePushNotificationSubscriptions, config.enableSwizzling { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh() } #endif @@ -2911,7 +2890,7 @@ let maxRetryDelay = 30.0 replayQueue = nil logsQueue = nil pushSubscriptionHandler = nil - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) startSessionRecordingOnOptIn = true #endif replayDebugPropertiesLock.withLock { @@ -2950,7 +2929,7 @@ let maxRetryDelay = 30.0 lastScreenLock.withLock { _lastScreenName = nil } } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) /** Starts session recording. @@ -3114,7 +3093,7 @@ let maxRetryDelay = 30.0 return postHog } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) /// Returns whether session replay is currently recording. /// /// - Returns: `true` only when replay is active, a session ID exists, and remote config allows recording. @@ -3380,12 +3359,14 @@ let maxRetryDelay = 30.0 installed.append(integration) - #if os(iOS) - // TODO: Decouple these two integrations from PostHogSDK intance + // TODO: Decouple these two integrations from PostHogSDK intance + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) if let replayIntegration = integration as? PostHogReplayIntegration { self.replayIntegration = replayIntegration } + #endif + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) if let surveysIntegration = integration as? PostHogSurveyIntegration { self.surveysIntegration = surveysIntegration } @@ -3397,7 +3378,7 @@ let maxRetryDelay = 30.0 installedIntegrations = installed } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) private func installReplayIntegration() { guard replayIntegration == nil else { return } @@ -3452,8 +3433,10 @@ let maxRetryDelay = 30.0 } installedIntegrations = [] - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) replayIntegration = nil + #endif + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) surveysIntegration = nil #endif } @@ -3597,7 +3580,6 @@ let maxRetryDelay = 30.0 /// (`close()`), or at `setup()` when the config disables push-open capture or the app is /// opted out. If `setup()` is never called they stay for the process lifetime. The per-class /// delegate wrapper, as elsewhere in this SDK, stays for the process lifetime regardless. - @available(iOS 14.0, macOS 11.0, *) @objc public static func prewarmPushNotificationOpenCapture() { DI.main.pushNotificationPublisher.prewarmNotificationResponseCapture() } @@ -3626,7 +3608,6 @@ let maxRetryDelay = 30.0 /// `notification.request.identifier` differ are two taps, not one reported twice. /// /// - Parameter response: The `UNNotificationResponse` received from the system. - @available(iOS 14.0, macOS 11.0, *) @objc public func capturePushNotificationOpened(response: UNNotificationResponse) { let content = response.notification.request.content // Free-text content is captured only for PostHog-attributed pushes: forwarding the @@ -3827,7 +3808,7 @@ let maxRetryDelay = 30.0 } #endif - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) func getReplayIntegration() -> PostHogReplayIntegration? { getIntegration() } @@ -3840,7 +3821,6 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) || os(macOS) - @available(iOS 14.0, macOS 11.0, *) func getPushNotificationIntegration() -> PostHogPushNotificationOpenIntegration? { getIntegration() } @@ -3848,7 +3828,6 @@ let maxRetryDelay = 30.0 #endif #if os(iOS) - @available(iOS 14.0, *) func getPushNotificationSubscriptionIntegration() -> PostHogPushNotificationSubscriptionIntegration? { getIntegration() } diff --git a/PostHog/PostHogSessionManager.swift b/PostHog/PostHogSessionManager.swift index d239902f61..7220dd2245 100644 --- a/PostHog/PostHogSessionManager.swift +++ b/PostHog/PostHogSessionManager.swift @@ -9,10 +9,10 @@ import Foundation /// Manages the active PostHog session ID and session rotation state. /// -/// - Warning: This class is public for backwards compatibility, but is intended for -/// SDK-internal use only. Application code should use `PostHogSDK.getSessionId()`, +/// - Warning: For PostHog's wrapper SDKs only, which import it with +/// `@_spi(PostHogInternal)`. Application code should use `PostHogSDK.getSessionId()`, /// `startSession()`, and `endSession()` instead of interacting with this manager directly. -@objc public class PostHogSessionManager: NSObject { +@_spi(PostHogInternal) @objc public class PostHogSessionManager: NSObject { enum SessionIDChangeReason: String { case sessionIdEmpty = "Session id was empty" case sessionStart = "Session started" @@ -24,8 +24,7 @@ import Foundation } /// Session manager used by `PostHogSDK.shared`. - @available(*, deprecated, message: "PostHogSessionManager becomes SDK-internal in PostHog 4.0. Use PostHogSDK.shared.getSessionId(), startSession() and endSession() instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - @objc public static var shared: PostHogSessionManager { + public static var shared: PostHogSessionManager { PostHogSDK.shared.sessionManager } @@ -75,8 +74,7 @@ import Foundation /// Use with care: changing the session ID affects analytics session attribution and session replay. /// /// - Parameter sessionId: Session ID to use for subsequent events. - @available(*, deprecated, message: "PostHogSessionManager becomes SDK-internal in PostHog 4.0. Use PostHogSDK.shared.getSessionId(), startSession() and endSession() instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - @objc public func setSessionId(_ sessionId: String) { + public func setSessionId(_ sessionId: String) { setSessionIdInternal(sessionId, at: now(), reason: .customSessionId) } diff --git a/PostHog/PostHogStorageManager.swift b/PostHog/PostHogStorageManager.swift index a18393a6bf..25571844e4 100644 --- a/PostHog/PostHogStorageManager.swift +++ b/PostHog/PostHogStorageManager.swift @@ -9,10 +9,10 @@ import Foundation /// Manages persisted identity metadata for a PostHog SDK instance. /// -/// - Warning: This class is public for backwards compatibility, but is intended for -/// SDK-internal use only. Application code should use `PostHogSDK` identity APIs +/// - Warning: For PostHog's wrapper SDKs only, which import it with +/// `@_spi(PostHogInternal)`. Application code should use `PostHogSDK` identity APIs /// instead of interacting with storage directly. -public class PostHogStorageManager { +@_spi(PostHogInternal) public class PostHogStorageManager { private let storage: PostHogStorage! private let anonLock = NSLock() diff --git a/PostHog/PostHogVersion.swift b/PostHog/PostHogVersion.swift index 2592557672..e57db6caec 100644 --- a/PostHog/PostHogVersion.swift +++ b/PostHog/PostHogVersion.swift @@ -7,16 +7,16 @@ 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 +// if you change this, check if the script scripts/bump-version.sh still works /// Current SDK version string. /// /// - Warning: This is intended for SDK internals and integrations. Application code should not mutate it. -public var postHogVersion = "3.93.1" +@_spi(PostHogInternal) public var postHogVersion = "3.93.1" /// Default SDK name reported by the native iOS SDK. -public let postHogiOSSdkName = "posthog-ios" +let postHogiOSSdkName = "posthog-ios" /// SDK name included in captured event context. /// /// - Warning: This is intended for SDK internals and wrapper SDKs. Application code should not mutate it. -public var postHogSdkName = postHogiOSSdkName +@_spi(PostHogInternal) public var postHogSdkName = postHogiOSSdkName diff --git a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift index 855d938727..c079e942aa 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationOpenIntegration.swift @@ -7,7 +7,6 @@ /// /// Swizzle installation and teardown are driven entirely by the publisher (via the subscriber-count /// callback), so this integration only owns the subscription token. - @available(iOS 14.0, macOS 11.0, *) final class PostHogPushNotificationOpenIntegration: PostHogIntegration { var requiresSwizzling: Bool { true } @@ -99,7 +98,6 @@ } #if TESTING - @available(iOS 14.0, macOS 11.0, *) extension PostHogPushNotificationOpenIntegration { static func clearInstalls() { integrationInstallState.clear() diff --git a/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift b/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift index ed70212c87..f1904c3576 100644 --- a/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift +++ b/PostHog/PushNotifications/PostHogPushNotificationSubscriptionIntegration.swift @@ -10,7 +10,6 @@ /// /// Swizzle installation and teardown are driven entirely by the publisher (via the subscriber-count /// callback), so this integration only owns the subscription token. - @available(iOS 14.0, *) final class PostHogPushNotificationSubscriptionIntegration: PostHogIntegration { var requiresSwizzling: Bool { true } @@ -65,7 +64,6 @@ } #if TESTING - @available(iOS 14.0, *) extension PostHogPushNotificationSubscriptionIntegration { static func clearInstalls() { integrationInstallState.clear() diff --git a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift index 198c96bb51..e0bb2748b4 100644 --- a/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift +++ b/PostHog/PushNotifications/PostHogPushSubscriptionHandler.swift @@ -697,7 +697,8 @@ final class PostHogPushSubscriptionHandler { return completion(cached) } - var completed = false + // Guarded by stateLock. + nonisolated(unsafe) var completed = false // A provider that never calls its completion would hold isSending for the whole process and // wedge every later send. Bound the wait: if the mint doesn't land in time, fall back to a // token-less send. A late real completion doesn't deliver (via `completed`) but still diff --git a/PostHog/Replay/CGColor+Util.swift b/PostHog/Replay/CGColor+Util.swift deleted file mode 100644 index 9fee8b564b..0000000000 --- a/PostHog/Replay/CGColor+Util.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// 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 diff --git a/PostHog/Replay/Date+Util.swift b/PostHog/Replay/Date+Util.swift index 842425d4c0..1492da5273 100644 --- a/PostHog/Replay/Date+Util.swift +++ b/PostHog/Replay/Date+Util.swift @@ -17,7 +17,6 @@ extension Date { /// /// - Parameter date: Date to convert. /// - Returns: Milliseconds since 1970-01-01 00:00:00 UTC. -@available(*, deprecated, message: "dateToMillis(_:) becomes SDK-internal in PostHog 4.0. Use Int64(date.timeIntervalSince1970 * 1000) instead. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") -public func dateToMillis(_ date: Date) -> Int64 { +@_spi(PostHogInternal) public func dateToMillis(_ date: Date) -> Int64 { date.toMillis() } diff --git a/PostHog/Replay/NetworkSample.swift b/PostHog/Replay/NetworkSample.swift index e06b3b6655..f182467e6e 100644 --- a/PostHog/Replay/NetworkSample.swift +++ b/PostHog/Replay/NetworkSample.swift @@ -5,7 +5,7 @@ // Created by Manoel Aranda Neto on 26.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation struct NetworkSample { diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift similarity index 79% rename from PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift rename to PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift index a0b0662ec1..14be75e31d 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogLogEntry.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogEntry.swift @@ -1,11 +1,11 @@ // -// PostHogLogEntry.swift +// PostHogConsoleLogEntry.swift // PostHog // // Created by Ioannis Josephides on 09/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /** @@ -14,11 +14,11 @@ 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 { + @objc public class PostHogConsoleLogEntry: 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 + @objc public let level: PostHogConsoleLogLevel /// The actual content of the log message. /// This is the processed and sanitized log message @@ -28,7 +28,7 @@ /// - Parameters: /// - level: The severity level of the log entry /// - message: The processed log message content - @objc public init(level: PostHogLogLevel, message: String) { + @objc public init(level: PostHogConsoleLogLevel, message: String) { self.level = level self.message = message super.init() diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift index f08f01f56c..383041f104 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogInterceptor.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 05/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation final class PostHogConsoleLogInterceptor { @@ -14,7 +14,7 @@ struct ConsoleOutput { let timestamp: Date let text: String - let level: PostHogLogLevel + let level: PostHogConsoleLogLevel } private enum Stream { @@ -161,7 +161,7 @@ } /// Determines if the log message should be captured, based on config - private func shouldCaptureLog(entry: PostHogLogEntry, config: PostHogConfig) -> Bool { + private func shouldCaptureLog(entry: PostHogConsoleLogEntry, config: PostHogConfig) -> Bool { entry.level.rawValue >= config.sessionReplayConfig.captureLogsConfig.minLogLevel.rawValue } diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift similarity index 79% rename from PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift rename to PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift index ff3f23d70d..b4428eda68 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogLogLevel.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogConsoleLogLevel.swift @@ -1,17 +1,17 @@ // -// PostHogLogLevel.swift +// PostHogConsoleLogLevel.swift // PostHog // // Created by Ioannis Josephides on 09/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) 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 { + @objc public enum PostHogConsoleLogLevel: Int { /// Informational messages, debugging output, and general logs case info /// Warning messages indicating potential issues or deprecation notices diff --git a/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift b/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift index 04858437a4..b89ab4f640 100644 --- a/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift +++ b/PostHog/Replay/Plugins/Console Logs/PostHogSessionReplayConsoleLogsPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 09/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation final class PostHogSessionReplayConsoleLogsPlugin: PostHogSessionReplayPlugin { @@ -64,7 +64,7 @@ return } - // `PostHogLogLevel`` needs to be an Int enum for objc interop + // `PostHogConsoleLogLevel`` 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" diff --git a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift index 38ed755635..3934c29f90 100644 --- a/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift +++ b/PostHog/Replay/Plugins/Network/PostHogSessionReplayNetworkPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 28/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Session replay plugin that captures network requests using URLSession swizzling. diff --git a/PostHog/Replay/Plugins/Network/URLSessionExtension.swift b/PostHog/Replay/Plugins/Network/URLSessionExtension.swift index 40016a4d40..e7e0f0691c 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionExtension.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionExtension.swift @@ -116,7 +116,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.data(for:delegate:)`. - @available(iOS 15.0, *) func postHogData( for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, @@ -134,7 +133,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.data(from:delegate:)`. - @available(iOS 15.0, *) func postHogData( from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, @@ -153,7 +151,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.upload(for:fromFile:delegate:)`. - @available(iOS 15.0, *) func postHogUpload( for request: URLRequest, fromFile fileURL: URL, @@ -173,7 +170,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The data and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.upload(for:from:delegate:)`. - @available(iOS 15.0, *) func postHogUpload( for request: URLRequest, from bodyData: Data, @@ -192,7 +188,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(for:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, @@ -210,7 +205,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(from:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, @@ -228,7 +222,6 @@ /// Pass an initialized instance; `nil` performs the request without capturing replay telemetry. /// - Returns: The downloaded file URL and URL response returned by `URLSession`. /// - Throws: Any error thrown by `URLSession.download(resumeFrom:delegate:)`. - @available(iOS 15.0, *) func postHogDownload( resumeFrom resumeData: Data, delegate: (any URLSessionTaskDelegate)? = nil, @@ -248,51 +241,53 @@ end: UInt64? = nil, postHog: PostHogSDK? ) { - let instance = postHog ?? PostHogSDK.shared + #if !SWIFT_PACKAGE || SessionReplay + 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() + // 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] = [] + 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()] + 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 - } + // 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 - } + 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 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) + 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 - ) - } + instance.capture( + "$snapshot", + properties: [ + "$snapshot_source": "mobile", + "$snapshot_data": snapshotsData, + "$session_id": sessionId, + ], + timestamp: timestamp + ) + } + #endif } } #endif diff --git a/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift b/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift index ab63ab1a0b..c17bafed29 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionInterceptor.swift @@ -4,7 +4,7 @@ * Copyright 2019-Present Datadog, Inc. */ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation diff --git a/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift b/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift index ca446b8cab..8c68b4d488 100644 --- a/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift +++ b/PostHog/Replay/Plugins/Network/URLSessionSwizzler.swift @@ -9,12 +9,7 @@ #if os(iOS) import Foundation - #if compiler(>=6.0) - internal import PostHogObjCExceptionSupport - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import PostHogObjCExceptionSupport - #endif + internal import PostHogObjCExceptionSupport final class URLSessionInstrumentation { typealias RequestModifier = (URLRequest) -> URLRequest @@ -168,7 +163,7 @@ private let downloadTaskWithURLAndCompletion: DownloadTaskWithURLAndCompletion private let downloadTaskWithRequest: DownloadTaskWithRequest private let downloadTaskWithURL: DownloadTaskWithURL - private let taskResume: TaskResume? + private let taskResume: TaskResume private var hasSwizzled = false @@ -224,13 +219,7 @@ ) downloadTaskWithURL = try DownloadTaskWithURL.build(modifyRequest: modifyRequest) - // Async/await URLSession convenience APIs are iOS 15+, so only install the - // task-level resume fallback on those OS versions. - if #available(iOS 15, *) { - taskResume = try TaskResume.build(modifyRequest: modifyRequest) - } else { - taskResume = nil - } + taskResume = try TaskResume.build(modifyRequest: modifyRequest) } func swizzle() { @@ -249,7 +238,7 @@ downloadTaskWithURLAndCompletion.swizzle() downloadTaskWithRequest.swizzle() downloadTaskWithURL.swizzle() - taskResume?.swizzle() + taskResume.swizzle() hasSwizzled = true } @@ -274,7 +263,7 @@ downloadTaskWithURLAndCompletion.unswizzle() downloadTaskWithRequest.unswizzle() downloadTaskWithURL.unswizzle() - taskResume?.unswizzle() + taskResume.unswizzle() hasSwizzled = false } diff --git a/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift b/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift index 2350d513a9..f5ac2578ad 100644 --- a/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift +++ b/PostHog/Replay/Plugins/PostHogSessionReplayPlugin.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 12/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Session replay plugins are used to capture specific types of meta data during a session, diff --git a/PostHog/Replay/PostHogMaskSettleAnalysis.swift b/PostHog/Replay/PostHogMaskSettleAnalysis.swift index 0c63155345..fd804f9734 100644 --- a/PostHog/Replay/PostHogMaskSettleAnalysis.swift +++ b/PostHog/Replay/PostHogMaskSettleAnalysis.swift @@ -111,37 +111,6 @@ return oldRects } - /// Per-owner union of two samples taken either side of a render: covers wherever the - /// content sat while the render ran. An owner present in only one sample was still on - /// screen for part of that window, so its rect is emitted as collected rather than - /// discarding the frame — a scroll that recycles cells changes the owner set constantly, - /// and dropping every such frame freezes the recording across the whole interaction. - /// Unlike `pairedOldRects` the result is a mask list, not positionally tied to `after`. - /// nil only for a repeated owner within one sample, where the pairing is genuinely - /// ambiguous and there is no safe rect to emit. - static func sweptRects(before: [MaskedRegion]?, after: [MaskedRegion]?) -> [CGRect]? { - guard let before, let after, let beforeByOwner = rectsByOwner(before) else { - return nil - } - - var rects: [CGRect] = [] - rects.reserveCapacity(max(before.count, after.count)) - var seenAfterOwners: Set = [] - seenAfterOwners.reserveCapacity(after.count) - for region in after { - guard seenAfterOwners.insert(region.owner).inserted else { - return nil - } - // No counterpart means the owner appeared mid-render; cover where it landed. - rects.append(beforeByOwner[region.owner].map { $0.union(region.rect) } ?? region.rect) - } - // Owners that went away mid-render were still displayed for part of it. - for region in before where !seenAfterOwners.contains(region.owner) { - rects.append(region.rect) - } - return rects - } - /// Banded by measured drift: /// - still (≤ tolerance): rects as collected. /// - drift (≤ budget): each mask inflated to the swept region (both sampled positions diff --git a/PostHog/Replay/PostHogPresentationCover.swift b/PostHog/Replay/PostHogPresentationCover.swift index 9f94f4226f..03e9b566cf 100644 --- a/PostHog/Replay/PostHogPresentationCover.swift +++ b/PostHog/Replay/PostHogPresentationCover.swift @@ -3,7 +3,7 @@ // PostHog // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import UIKit diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift index 2d7b1d1b6b..bd64c929c4 100644 --- a/PostHog/Replay/PostHogReplayIntegration.swift +++ b/PostHog/Replay/PostHogReplayIntegration.swift @@ -6,10 +6,9 @@ // // Created by Manoel Aranda Neto on 19.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation import PhotosUI - import SwiftUI import UIKit import WebKit @@ -765,25 +764,6 @@ } } - private func generateSnapshot(_ window: UIWindow, _ screenName: String? = nil, postHog: PostHogSDK, timestampDate: Date) { - guard - let wireframe = autoreleasepool(invoking: { - toWireframe(window) - }) - else { - return - } - - captureSnapshot( - wireframe, - window: window, - windowSize: window.bounds.size, - screenName: screenName, - postHog: postHog, - timestampDate: timestampDate - ) - } - func captureSnapshot( _ wireframe: RRWireframe, window: UIWindow, @@ -880,24 +860,6 @@ return imageHash == lastImageHash } - 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() @@ -944,12 +906,13 @@ // Checked first so an explicit unmask wins over the sensitive-type early-returns // below, matching the modifier's precedence. if view.isNoMask() { + // Secure-entry fields stay masked even under an explicit unmask. + findSecureTextEntries(view, window, &maskableWidgets) return } if let textView = view as? UITextView { // TextEditor, SwiftUI.TextEditorTextView, SwiftUI.UIKitTextView - // textStorage rather than attributedText, which copies the whole text - if isTextViewSensitive(textView) || hasSensitiveImageAttachment(textView.textStorage) { + if isTextViewSensitive(textView) { maskableWidgets.append(.init(view, in: window)) // an unclipped subview can draw outside the text view's mask findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture()) @@ -1015,7 +978,7 @@ } if let label = view as? UILabel { // Text, this code might never be reachable in SwiftUI, see swiftUIImageTypes instead - if isLabelSensitive(label) || hasSensitiveImageAttachment(label.attributedText) { + if isLabelSensitive(label) { maskableWidgets.append(.init(view, in: window)) // an unclipped subview can draw outside the label's mask findMaskableWidgets(inSubviewsOf: view, window, &maskableWidgets, maskChildren || view.isNoCapture()) @@ -1130,6 +1093,17 @@ } } + private func findSecureTextEntries(_ view: UIView, _ window: UIWindow, _ maskableWidgets: inout [MaskedRegion]) { + if (view as? UITextInputTraits)?.isSecureTextEntry == true { + maskableWidgets.append(.init(view, in: window)) + return + } + + for child in view.subviews where child.isVisibleForMasking() { + findSecureTextEntries(child, window, &maskableWidgets) + } + } + /// Recursively iterate through layer hierarchy to find maskable layers (iOS 26+) /// /// On iOS 26, SwiftUI primitives (Text, Image, Button) are rendered as CALayer sublayers @@ -1200,9 +1174,6 @@ /// All regions to redact in `window`: heuristic widgets from the hierarchy walk plus the /// live regions of `postHogMask()` reporters. Returns nil when a reporter hasn't laid out /// yet — the caller must skip the frame rather than capture it under-masked. - /// Pre-existing limitation with `screenshotModeBackgroundCapture` (off by default): pixels - /// render after this collection, so any rect source can go stale for content committed in - /// between. private func collectMaskedRegions(in window: UIWindow) -> [MaskedRegion]? { guard !window.hasCameraForReplay() else { return nil @@ -1253,8 +1224,8 @@ } // The settled path renders here so the pixels come from the same main-thread tick that - // measured the mask rects — any later and the presentation tree has moved on. Callers - // that render themselves (background capture, the bridge's first frame) pass false. + // measured the mask rects — any later and the presentation tree has moved on. The + // bridge's first frame renders itself and passes false. let image = renderImage ? window.toImage(preferFidelityRenderer: preferFidelityRenderer) : nil return ScreenshotCapture(wireframe: wireframe, windowSize: window.bounds.size, timestampDate: Date(), image: image) @@ -1381,7 +1352,7 @@ } private func isLabelSensitive(_ view: UILabel) -> Bool { - isTextInputSensitive(view) && hasText(view.text) + (isTextInputSensitive(view) && hasText(view.text)) || hasSensitiveImageAttachment(view.attributedText) } private func isButtonSensitive(_ view: UIButton) -> Bool { @@ -1389,22 +1360,40 @@ } private func isTextViewSensitive(_ view: UITextView) -> Bool { - (isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text) + // textStorage rather than attributedText, which copies the whole text + ((isTextInputSensitive(view) || view.isSensitiveText()) && hasText(view.text)) + || hasSensitiveImageAttachment(view.textStorage) } private func isSwitchSensitive(_ view: UISwitch) -> Bool { - var containsText = true - if #available(iOS 14.0, *) { - containsText = hasText(view.title) - } - - return isTextInputSensitive(view) && containsText + isTextInputSensitive(view) && hasText(view.title) } private func isTextFieldSensitive(_ view: UITextField) -> Bool { (isTextInputSensitive(view) || view.isSensitiveText()) && (hasText(view.text) || hasText(view.placeholder)) } + /// Image attachments in text are drawn by the label or text view itself, with no image view to + /// mask, so masking images masks text that holds one. Like image views, symbols and + /// asset-catalog images stay visible; an attachment whose image can't be read is masked. + private func hasSensitiveImageAttachment(_ text: NSAttributedString?) -> Bool { + guard config?.sessionReplayConfig.maskAllImages == true, let text, text.length > 0 else { + return false + } + var found = false + text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, _, stop in + guard let attachment = value as? NSTextAttachment else { + return + } + if let image = attachment.image, image.isSymbolImage || isAssetsImage(image) { + return + } + found = true + stop.pointee = true + } + return found + } + private func isSwiftUILayerSafe(_ layer: CALayer) -> Bool { swiftUISafeLayerTypes.contains(where: { layer.isKind(of: $0) }) } @@ -1444,143 +1433,6 @@ && sources.contains { !$0.isSymbolImage && !isAssetsImage($0) } } - 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 - } - /// Captures the current native window for the native-screen bridge. /// [episodeFirstFrame] renders with `afterScreenUpdates` so a /// freshly-presented screen isn't captured black, and re-arms the @@ -1622,49 +1474,6 @@ ) } - /// The render sits between two mask samples instead of one: measure geometry, render - /// off-main, measure again, mask the per-owner union — provably covering wherever the - /// content sat while the render ran, so no threshold is needed. Always uses - /// `drawHierarchy`, never the presentation-tree renderer: that reads `layer.presentation()`, - /// which is main-only, and this path's other reads already sit inside `main.sync`. - @discardableResult - private func performBracketedBackgroundCapture(window: UIWindow, screenName: String?, postHog: PostHogSDK) -> Bool { - defer { finishScreenshotRender() } - - guard let before = DispatchQueue.main.sync(execute: { self.collectMaskedRegions(in: window) }) else { - return false - } - // Off-main on purpose, and the reason this mode exists: drawHierarchy on main was too - // slow to keep up. UIKit documents it as main-thread-only, so it stays experimental - // behind `screenshotModeBackgroundCapture` — the bracketing above is what keeps masks - // aligned with pixels despite the render happening on this thread. - let image = window.toImage(preferFidelityRenderer: true) - let capture = DispatchQueue.main.sync { () -> ScreenshotCapture? in - let after = self.collectMaskedRegions(in: window) - guard let rects = Self.sweptRects(before: before, after: after) else { - hedgeLog("[Session Replay] Skipping snapshot: mask samples could not be paired") - return nil - } - // No renderer preference: `renderImage: false` means this call never renders — the - // image was already taken off-main above. - return self.collectScreenshotMetadata(window, overrideMaskRects: rects, renderImage: false) - } - - guard let capture, let image, postHog.isSessionReplayActive() else { - return false - } - - return renderAndEnqueueScreenshot( - capture.wireframe, - window: window, - windowSize: capture.windowSize, - screenName: screenName, - postHog: postHog, - timestampDate: capture.timestampDate, - image: image - ) - } - /// Settle-then-shoot: after one display-pipeline depth, unchanged mask geometry proves the /// displayed frame identical to the current tree, so full-fidelity drawHierarchy is safe /// (blur/video/Metal intact); drift within budget keeps drawHierarchy with masks swept to @@ -1704,36 +1513,17 @@ 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) - } - } - - if postHog.config.sessionReplayConfig.screenshotMode { - guard tryStartScreenshotRender() else { - return - } - - if postHog.config.sessionReplayConfig.screenshotModeBackgroundCapture { - PostHogReplayIntegration.dispatchQueue.async { [weak self] in - self?.performBracketedBackgroundCapture(window: window, screenName: screenName, postHog: postHog) - } - } else { - scheduleSettledCapture(window: window, screenName: screenName, postHog: postHog) - } + guard tryStartScreenshotRender() else { return } - // Wireframe mode always stays on main thread - generateSnapshot(window, screenName, postHog: postHog, timestampDate: Date()) + // The replay meta `href`: the visible screen, with SwiftUI hosting generics unwrapped. + // Type names only: a controller's title is display text and isn't masked. + let screenName = UIViewController.ph_topViewController(base: window.rootViewController) + .flatMap(UIViewController.getViewControllerTypeName) + .flatMap { PostHogScreenNameSanitizer.sanitize(rawScreenName: $0) } + + scheduleSettledCapture(window: window, screenName: screenName, postHog: postHog) } private func handleEventCaptured(event: String) { @@ -1808,29 +1598,6 @@ } } - private extension PostHogReplayIntegration { - /// Image attachments in text are drawn by the label or text view itself, with no image view to - /// mask, so masking images masks text that holds one. Like image views, symbols and - /// asset-catalog images stay visible; an attachment whose image can't be read is masked. - func hasSensitiveImageAttachment(_ text: NSAttributedString?) -> Bool { - guard config?.sessionReplayConfig.maskAllImages == true, let text, text.length > 0 else { - return false - } - var found = false - text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, _, stop in - guard let attachment = value as? NSTextAttachment else { - return - } - if let image = attachment.image, image.isSymbolImage || isAssetsImage(image) { - return - } - found = true - stop.pointee = true - } - return found - } - } - // MARK: - Plugins extension PostHogReplayIntegration { @@ -1890,12 +1657,9 @@ // MARK: - Debug properties extension PostHogReplayIntegration { - // ponytail: host-name heuristic; replace with an explicit host mode if a third hybrid host appears /// `$sdk_debug_replay_capture_mode`, shared by `debugProperties()` and the no-integration /// fallback in `PostHogSDK.buildProperties`, so both stay in sync. - static func captureMode(config: PostHogConfig?) -> String { - (config?.sessionReplayConfig.screenshotMode == true || postHogSdkName == "posthog-flutter") ? "screenshot" : "wireframe" - } + static let captureMode = "screenshot" /// Shared by `$sdk_debug_replay_linked_flag_trigger_status` and `$sdk_debug_replay_event_trigger_status`. static func triggerStatus(isConfigured: Bool, isActivated: Bool) -> String { @@ -1950,7 +1714,7 @@ // Config-derived and freshly-computed keys stay present regardless of `enabled` — they // never go stale, unlike a cached hold reason would. - props["$sdk_debug_replay_capture_mode"] = Self.captureMode(config: config) + props["$sdk_debug_replay_capture_mode"] = Self.captureMode // The unsent snapshot count: the held buffer while buffering, else the persisted queue. props["$sdk_debug_replay_internal_buffer_length"] = (buffering ? replayQueue?.bufferDepth : replayQueue?.depth) ?? 0 @@ -2046,8 +1810,10 @@ // pixels, and the cover hides those. var maskChildren = false for ancestor in ancestors.reversed() { - // `ph-no-mask` drops every heuristic mask below it, the cover included. + // `ph-no-mask` drops every heuristic mask below it, the cover included, + // except for secure-entry fields. if ancestor.isNoMask() { + findSecureTextEntries(cover, window, &maskableWidgets) return } guard ancestor.isNoCapture() || maskChildren else { @@ -2096,10 +1862,6 @@ } } - private protocol AnyObjectUIHostingViewController: AnyObject {} - - extension UIHostingController: AnyObjectUIHostingViewController {} - #if TESTING extension PostHogReplayIntegration { static func clearInstalls() { @@ -2108,6 +1870,11 @@ } #endif +#elseif os(iOS) + // SPM builds with the `SessionReplay` trait disabled don't compile the replay engine, libwebp or + // the replay API. This type only namespaces the mask-region helpers in PostHogMaskSettleAnalysis.swift, + // which `postHogMask()` still uses to keep autocapture out of masked regions. + enum PostHogReplayIntegration {} #endif // swiftlint:enable cyclomatic_complexity file_length diff --git a/PostHog/Replay/PostHogSessionReplayConfig.swift b/PostHog/Replay/PostHogSessionReplayConfig.swift index 0444d3177a..c29165e3de 100644 --- a/PostHog/Replay/PostHogSessionReplayConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConfig.swift @@ -4,7 +4,7 @@ // // Created by Manoel Aranda Neto on 19.03.24. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Configuration for iOS session replay capture. @@ -24,15 +24,6 @@ /// Default: true @objc public var maskAllSandboxedViews: Bool = true - /// Deprecated placeholder for photo-library image masking. - /// - /// This property has no effect. To manually mask user photos, see the privacy controls - /// documentation: https://posthog.com/docs/session-replay/privacy?tab=iOS - /// - /// Default: `false`. - @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 recording touch coordinates in session replay. Screenshot capture is unaffected. /// Set before SDK setup. Runtime changes are not supported. /// Default: true @@ -41,33 +32,17 @@ /// Enable capturing network telemetry /// Default: true /// - /// Note: When enabled, can be disabled remotely via project settings (requires `PostHogConfig.remoteConfig` to be enabled) + /// Note: When enabled, can be disabled remotely via project settings @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 - - /// Schedule screenshot image capture on a background queue when `screenshotMode` is enabled. - /// - /// Warning: Enabling this option will trigger Main Thread Checker warnings and may - /// briefly freeze the app the first time a screenshot is captured. Consider disabling - /// Main Thread Checker in your scheme's run diagnostics when enabling this. - /// - /// Experimental. - /// Default: false - @objc public var screenshotModeBackgroundCapture: Bool = false - - /// Resolution of `screenshotMode` screenshots, as a multiplier of the screen's native resolution. + /// Resolution of screenshots, as a multiplier of the screen's native resolution. /// /// - `nil`: one pixel per point on every device, so a 402×874 pt screen uploads 402×874 pixels. /// - A value: that fraction of native resolution. On a 3x device, `1.0` uploads 1206×2622 pixels /// (native) and `0.5` uploads 603×1311 (1.5 pixels per point). /// /// Values are clamped to 0.1...1.0, and NaN or infinite values reset it to `nil`. The replay's viewport in - /// points is unchanged. Does not affect wireframe mode or compression quality. Exposed as `NSNumber` so + /// points is unchanged. Does not affect compression quality. Exposed as `NSNumber` so /// Objective-C can leave it unset. /// /// Experimental. @@ -83,22 +58,10 @@ screenshotScale.map { nativeScale * CGFloat($0.doubleValue) } ?? 1 } - /// 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 + /// This is used for capturing the 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. @@ -114,7 +77,7 @@ /// /// Defaults to `false` /// - /// Note: When enabled, can be disabled remotely via project settings (requires `PostHogConfig.remoteConfig` to be enabled) + /// Note: When enabled, can be disabled remotely via project settings @objc public var captureLogs: Bool = false /// Further configuration for capturing console output @@ -141,19 +104,21 @@ } } - /// Returns an array of plugin types based on current configuration - func getPluginTypes() -> [PostHogSessionReplayPlugin.Type] { - var types: [PostHogSessionReplayPlugin.Type] = [] + #if !SWIFT_PACKAGE || SessionReplay + /// Returns an array of plugin types based on current configuration + func getPluginTypes() -> [PostHogSessionReplayPlugin.Type] { + var types: [PostHogSessionReplayPlugin.Type] = [] - if captureLogs { - types.append(PostHogSessionReplayConsoleLogsPlugin.self) - } + if captureLogs { + types.append(PostHogSessionReplayConsoleLogsPlugin.self) + } - if captureNetworkTelemetry { - types.append(PostHogSessionReplayNetworkPlugin.self) - } + if captureNetworkTelemetry { + types.append(PostHogSessionReplayNetworkPlugin.self) + } - return types - } + return types + } + #endif } #endif diff --git a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift index 1dda69e788..4e5b7ac6c7 100644 --- a/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift +++ b/PostHog/Replay/PostHogSessionReplayConsoleLogConfig.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 09/05/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation /// Configuration for console log capture inside session replay recordings. @@ -23,9 +23,11 @@ /// - Detect log level (best effort) /// - Process OSLog messages to remove metadata /// + /// The block runs on a background thread, so it must not read main-actor state. + /// /// - Parameter output: The raw console output to process. - /// - Returns: A `PostHogLogEntry` to capture, or `nil` to skip the log output. - @objc public var logSanitizer: ((String) -> PostHogLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer + /// - Returns: A `PostHogConsoleLogEntry` to capture, or `nil` to skip the log output. + @objc public var logSanitizer: (@Sendable (String) -> PostHogConsoleLogEntry?) = PostHogSessionReplayConsoleLogConfig.defaultLogSanitizer /// The minimum log level to capture in session replay. /// Only log messages with this level or higher will be captured. @@ -35,13 +37,13 @@ /// - `.info` messages will be skipped /// /// Defaults to `.error` to minimize noise in session replays. - @objc public var minLogLevel: PostHogLogLevel = .error + @objc public var minLogLevel: PostHogConsoleLogLevel = .error /// Default implementation for processing console output. - static func defaultLogSanitizer(_ message: String) -> PostHogLogEntry? { + @Sendable static func defaultLogSanitizer(_ message: String) -> PostHogConsoleLogEntry? { let message = String(message) // Determine console log level - let level: PostHogLogLevel = { + let level: PostHogConsoleLogLevel = { if message.range(of: logMessageWarningPattern, options: .regularExpression) != nil { return .warn } if message.range(of: logMessageErrorPattern, options: .regularExpression) != nil { return .error } return .info @@ -55,7 +57,7 @@ return message }() : message - return PostHogLogEntry(level: level, message: sanitizedMessage) + return PostHogConsoleLogEntry(level: level, message: sanitizedMessage) } /// Default regular expression pattern used to identify error-level log messages. diff --git a/PostHog/Replay/RRStyle.swift b/PostHog/Replay/RRStyle.swift deleted file mode 100644 index 0cfaaf3ef4..0000000000 --- a/PostHog/Replay/RRStyle.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// 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] = [:] - - dict["color"] = color - dict["backgroundColor"] = backgroundColor - dict["backgroundImage"] = backgroundImage - dict["borderWidth"] = borderWidth - dict["borderRadius"] = borderRadius - dict["borderColor"] = borderColor - dict["fontSize"] = fontSize - dict["fontFamily"] = fontFamily - dict["horizontalAlign"] = horizontalAlign - dict["verticalAlign"] = verticalAlign - dict["paddingTop"] = paddingTop - dict["paddingBottom"] = paddingBottom - dict["paddingLeft"] = paddingLeft - dict["paddingRight"] = paddingRight - dict["bar"] = bar - - return dict - } -} diff --git a/PostHog/Replay/RRWireframe.swift b/PostHog/Replay/RRWireframe.swift index ae6be4f815..b73fd417b9 100644 --- a/PostHog/Replay/RRWireframe.swift +++ b/PostHog/Replay/RRWireframe.swift @@ -16,12 +16,7 @@ class RRWireframe { 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 + var type: String? // screenshot #if os(iOS) var image: UIImage? var maskableWidgets: [CGRect]? @@ -32,13 +27,6 @@ class RRWireframe { private(set) var maskRenderFailed = false #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 hasMaskableWidgets() -> Bool { @@ -103,22 +91,8 @@ class RRWireframe { "height": height, ] - if let childWireframes = childWireframes { - dict["childWireframes"] = childWireframes.map { $0.toDict() } - } - dict["type"] = type - dict["inputType"] = inputType - - dict["text"] = text - - dict["label"] = label - - if let value = value { - dict["value"] = value - } - #if os(iOS) if let image = image { if hasMaskableWidgets() { @@ -142,18 +116,6 @@ class RRWireframe { dict["base64"] = base64 - if let style = style { - dict["style"] = style.toDict() - } - - dict["disabled"] = disabled - - dict["checked"] = checked - - dict["options"] = options - - dict["max"] = max - return dict } } diff --git a/PostHog/Replay/String+Util.swift b/PostHog/Replay/String+Util.swift deleted file mode 100644 index ea8d1fe6fd..0000000000 --- a/PostHog/Replay/String+Util.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// String+Util.swift -// PostHog -// -// Created by Manoel Aranda Neto on 21.03.24. -// - -import Foundation - -extension String { - func mask() -> String { - String(repeating: "*", count: count) - } -} diff --git a/PostHog/Replay/UIColor+Util.swift b/PostHog/Replay/UIColor+Util.swift deleted file mode 100644 index b3d04639b7..0000000000 --- a/PostHog/Replay/UIColor+Util.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// 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 diff --git a/PostHog/Replay/UIImage+Util.swift b/PostHog/Replay/UIImage+Util.swift index 1d43290061..5de42f401a 100644 --- a/PostHog/Replay/UIImage+Util.swift +++ b/PostHog/Replay/UIImage+Util.swift @@ -17,7 +17,11 @@ } private func toWebPBase64(_ compressionQuality: CGFloat) -> String? { - toImageBase64(mimeType: "webp", data: webpData(compressionQuality: compressionQuality)) + #if !SWIFT_PACKAGE || SessionReplay + toImageBase64(mimeType: "webp", data: webpData(compressionQuality: compressionQuality)) + #else + nil + #endif } private func toJpegBase64(_ compressionQuality: CGFloat) -> String? { @@ -37,8 +41,7 @@ /// - image: Image to encode. /// - compressionQuality: Compression quality from `0.0` to `1.0`. Defaults to `0.3`. /// - Returns: A `data:image/...;base64` string, or `nil` if encoding fails. - @available(*, deprecated, message: "imageToBase64(_:_:) becomes SDK-internal in PostHog 4.0. From 4.0, wrapper SDKs must import PostHog with @_spi(PostHogInternal) to use it.") - public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? { + @_spi(PostHogInternal) public func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? { image.toBase64(compressionQuality) } #endif diff --git a/PostHog/Replay/UIView+Util.swift b/PostHog/Replay/UIView+Util.swift index 37e538de5f..7864ec0466 100644 --- a/PostHog/Replay/UIView+Util.swift +++ b/PostHog/Replay/UIView+Util.swift @@ -111,7 +111,7 @@ } func toImage(afterScreenUpdates: Bool = false, preferFidelityRenderer: Bool = true) -> UIImage? { - // Background capture also enters here; controller traversal belongs on main. + // Replay callers render on main; the sync is defensive, since controller traversal belongs on main. let hasCamera = { (self as? UIWindow ?? self.window)?.hasCameraForReplay() ?? false } @@ -160,8 +160,7 @@ /// Mask geometry only. During a CA animation the model layer parks at the destination /// while the presentation layer holds the in-flight position the screenshot renders, so - /// mask rects have to come from the presentation tree. Wireframe geometry is a separate - /// consumer with no pixels to agree with and stays on `toAbsoluteRect`. + /// mask rects have to come from the presentation tree. /// `outsets` grows the rect past the bounds, for content drawn outside them. func toPresentationRect(_ window: UIWindow?, outsets: UIEdgeInsets = .zero) -> CGRect { guard layer.presentation() != nil else { diff --git a/PostHog/Surveys/BottomSection.swift b/PostHog/Surveys/BottomSection.swift index c12f647db0..e617caab19 100644 --- a/PostHog/Surveys/BottomSection.swift +++ b/PostHog/Surveys/BottomSection.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 18/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct BottomSection: View { let label: String let action: () -> Void diff --git a/PostHog/Surveys/ConfirmationMessage.swift b/PostHog/Surveys/ConfirmationMessage.swift index 283ac05cda..a7e1ffb3c7 100644 --- a/PostHog/Surveys/ConfirmationMessage.swift +++ b/PostHog/Surveys/ConfirmationMessage.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct ConfirmationMessage: View { @Environment(\.surveyAppearance) private var appearance @@ -35,7 +34,6 @@ } } - @available(iOS 15.0, *) #Preview { ConfirmationMessage {} } diff --git a/PostHog/Surveys/IntroMessage.swift b/PostHog/Surveys/IntroMessage.swift index a74da86d99..370f83b48d 100644 --- a/PostHog/Surveys/IntroMessage.swift +++ b/PostHog/Surveys/IntroMessage.swift @@ -5,12 +5,11 @@ // Created by PostHog Code on 2026-08-06. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI /// Optional intro screen shown before the first question — the leading mirror of the /// trailing `ConfirmationMessage`. Advancing records no response and sends no survey event. - @available(iOS 15.0, *) struct IntroMessage: View { @Environment(\.surveyAppearance) private var appearance @@ -39,7 +38,6 @@ } } - @available(iOS 15.0, *) #Preview { IntroMessage {} } diff --git a/PostHog/Surveys/Models/PostHogDisplaySurvey.swift b/PostHog/Surveys/Models/PostHogDisplaySurvey.swift index 444f049d50..216f1c6b85 100644 --- a/PostHog/Surveys/Models/PostHogDisplaySurvey.swift +++ b/PostHog/Surveys/Models/PostHogDisplaySurvey.swift @@ -7,55 +7,57 @@ 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] - /// The question to show when restoring an unfinished survey. Zero for a new survey. - public let initialQuestionIndex: Int - /// 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? +#if !SWIFT_PACKAGE || Surveys + /// 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] + /// The question to show when restoring an unfinished survey. Zero for a new survey. + public let initialQuestionIndex: Int + /// 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?, - initialQuestionIndex: Int = 0 - ) { - self.id = id - self.name = name - self.questions = questions - self.appearance = appearance - self.startDate = startDate - self.endDate = endDate - self.initialQuestionIndex = initialQuestionIndex - super.init() + init( + id: String, + name: String, + questions: [PostHogDisplaySurveyQuestion], + appearance: PostHogDisplaySurveyAppearance?, + startDate: Date?, + endDate: Date?, + initialQuestionIndex: Int = 0 + ) { + self.id = id + self.name = name + self.questions = questions + self.appearance = appearance + self.startDate = startDate + self.endDate = endDate + self.initialQuestionIndex = initialQuestionIndex + 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 -} + /// 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 -} + /// 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 + } +#endif diff --git a/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift b/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift index 935f8e4a04..78d1d9fa6d 100644 --- a/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift +++ b/PostHog/Surveys/Models/PostHogDisplaySurveyAppearance.swift @@ -7,122 +7,124 @@ 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? +#if !SWIFT_PACKAGE || Surveys + /// 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? + // 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 primary text color as web color (for questions, close button, etc.) - public let textColor: String? - /// Optional color for description text as web color - public let descriptionTextColor: String? + // Text colors + /// Optional primary text color as web color (for questions, close button, etc.) + public let textColor: String? + /// 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? + // 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 background color for input fields as web color - public let inputBackground: String? - /// Optional text color for input fields as web color - public let inputTextColor: String? - /// Optional placeholder text for input fields - public let placeholder: String? + // Input + /// Optional background color for input fields as web color + public let inputBackground: String? + /// Optional text color for input fields as web color + public let inputTextColor: String? + /// Optional placeholder text for input fields + public let placeholder: String? - // Display - /// Optional delay in seconds before showing the survey - public let surveyPopupDelaySeconds: TimeInterval? + // Display + /// Optional delay in seconds before showing the survey + public let surveyPopupDelaySeconds: TimeInterval? - // 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? + // 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? - // Intro screen - /// Whether to show an intro screen before the first question - public let displayIntroScreen: Bool - /// Optional header text for the intro screen - public let introScreenHeader: String? - /// Optional description text for the intro screen - public let introScreenDescription: String? - /// Optional content type for the intro screen description - public let introScreenDescriptionContentType: PostHogDisplaySurveyTextContentType? - /// Optional text for the button that advances past the intro screen - public let introScreenButtonText: String? + // Intro screen + /// Whether to show an intro screen before the first question + public let displayIntroScreen: Bool + /// Optional header text for the intro screen + public let introScreenHeader: String? + /// Optional description text for the intro screen + public let introScreenDescription: String? + /// Optional content type for the intro screen description + public let introScreenDescriptionContentType: PostHogDisplaySurveyTextContentType? + /// Optional text for the button that advances past the intro screen + public let introScreenButtonText: String? - init( - fontFamily: String?, - backgroundColor: String?, - borderColor: String?, - submitButtonColor: String?, - submitButtonText: String?, - submitButtonTextColor: String?, - textColor: String?, - descriptionTextColor: String?, - ratingButtonColor: String?, - ratingButtonActiveColor: String?, - inputBackground: String?, - inputTextColor: String?, - placeholder: String?, - surveyPopupDelaySeconds: TimeInterval?, - displayThankYouMessage: Bool, - thankYouMessageHeader: String?, - thankYouMessageDescription: String?, - thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType?, - thankYouMessageCloseButtonText: String?, - displayIntroScreen: Bool = false, - introScreenHeader: String? = nil, - introScreenDescription: String? = nil, - introScreenDescriptionContentType: PostHogDisplaySurveyTextContentType? = nil, - introScreenButtonText: String? = nil - ) { - self.fontFamily = fontFamily - self.backgroundColor = backgroundColor - self.borderColor = borderColor - self.submitButtonColor = submitButtonColor - self.submitButtonText = submitButtonText - self.submitButtonTextColor = submitButtonTextColor - self.textColor = textColor - self.descriptionTextColor = descriptionTextColor - self.ratingButtonColor = ratingButtonColor - self.ratingButtonActiveColor = ratingButtonActiveColor - self.inputBackground = inputBackground - self.inputTextColor = inputTextColor - self.placeholder = placeholder - self.surveyPopupDelaySeconds = surveyPopupDelaySeconds - self.displayThankYouMessage = displayThankYouMessage - self.thankYouMessageHeader = thankYouMessageHeader - self.thankYouMessageDescription = thankYouMessageDescription - self.thankYouMessageDescriptionContentType = thankYouMessageDescriptionContentType - self.thankYouMessageCloseButtonText = thankYouMessageCloseButtonText - self.displayIntroScreen = displayIntroScreen - self.introScreenHeader = introScreenHeader - self.introScreenDescription = introScreenDescription - self.introScreenDescriptionContentType = introScreenDescriptionContentType - self.introScreenButtonText = introScreenButtonText - super.init() + init( + fontFamily: String?, + backgroundColor: String?, + borderColor: String?, + submitButtonColor: String?, + submitButtonText: String?, + submitButtonTextColor: String?, + textColor: String?, + descriptionTextColor: String?, + ratingButtonColor: String?, + ratingButtonActiveColor: String?, + inputBackground: String?, + inputTextColor: String?, + placeholder: String?, + surveyPopupDelaySeconds: TimeInterval?, + displayThankYouMessage: Bool, + thankYouMessageHeader: String?, + thankYouMessageDescription: String?, + thankYouMessageDescriptionContentType: PostHogDisplaySurveyTextContentType?, + thankYouMessageCloseButtonText: String?, + displayIntroScreen: Bool = false, + introScreenHeader: String? = nil, + introScreenDescription: String? = nil, + introScreenDescriptionContentType: PostHogDisplaySurveyTextContentType? = nil, + introScreenButtonText: String? = nil + ) { + self.fontFamily = fontFamily + self.backgroundColor = backgroundColor + self.borderColor = borderColor + self.submitButtonColor = submitButtonColor + self.submitButtonText = submitButtonText + self.submitButtonTextColor = submitButtonTextColor + self.textColor = textColor + self.descriptionTextColor = descriptionTextColor + self.ratingButtonColor = ratingButtonColor + self.ratingButtonActiveColor = ratingButtonActiveColor + self.inputBackground = inputBackground + self.inputTextColor = inputTextColor + self.placeholder = placeholder + self.surveyPopupDelaySeconds = surveyPopupDelaySeconds + self.displayThankYouMessage = displayThankYouMessage + self.thankYouMessageHeader = thankYouMessageHeader + self.thankYouMessageDescription = thankYouMessageDescription + self.thankYouMessageDescriptionContentType = thankYouMessageDescriptionContentType + self.thankYouMessageCloseButtonText = thankYouMessageCloseButtonText + self.displayIntroScreen = displayIntroScreen + self.introScreenHeader = introScreenHeader + self.introScreenDescription = introScreenDescription + self.introScreenDescriptionContentType = introScreenDescriptionContentType + self.introScreenButtonText = introScreenButtonText + super.init() + } } -} +#endif diff --git a/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift b/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift index 1e2fd49fba..c5a866199d 100644 --- a/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift +++ b/PostHog/Surveys/Models/PostHogDisplaySurveyQuestion.swift @@ -7,154 +7,156 @@ 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? +#if !SWIFT_PACKAGE || Surveys + /// 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() + 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 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? + /// 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 - ) + 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 - /// Whether selecting a rating submits the answer without a submit button - public let skipSubmitButton: Bool + /// 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 + /// Whether selecting a rating submits the answer without a submit button + public let skipSubmitButton: Bool - init( - id: String, - question: String, - questionDescription: String?, - questionDescriptionContentType: PostHogDisplaySurveyTextContentType?, - isOptional: Bool, - buttonText: String?, - ratingType: PostHogDisplaySurveyRatingType, - scaleLowerBound: Int, - scaleUpperBound: Int, - lowerBoundLabel: String, - upperBoundLabel: String, - skipSubmitButton: Bool = false - ) { - self.ratingType = ratingType - self.scaleLowerBound = scaleLowerBound - self.scaleUpperBound = scaleUpperBound - self.lowerBoundLabel = lowerBoundLabel - self.upperBoundLabel = upperBoundLabel - self.skipSubmitButton = skipSubmitButton - super.init( - id: id, - question: question, - questionDescription: questionDescription, - questionDescriptionContentType: questionDescriptionContentType, - isOptional: isOptional, - buttonText: buttonText - ) + init( + id: String, + question: String, + questionDescription: String?, + questionDescriptionContentType: PostHogDisplaySurveyTextContentType?, + isOptional: Bool, + buttonText: String?, + ratingType: PostHogDisplaySurveyRatingType, + scaleLowerBound: Int, + scaleUpperBound: Int, + lowerBoundLabel: String, + upperBoundLabel: String, + skipSubmitButton: Bool = false + ) { + self.ratingType = ratingType + self.scaleLowerBound = scaleLowerBound + self.scaleUpperBound = scaleUpperBound + self.lowerBoundLabel = lowerBoundLabel + self.upperBoundLabel = upperBoundLabel + self.skipSubmitButton = skipSubmitButton + 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 - /// Whether selection should submit automatically for single-choice questions without an open choice - public let skipSubmitButton: Bool + /// 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 + /// Whether selection should submit automatically for single-choice questions without an open choice + public let skipSubmitButton: Bool - var shouldAutoSubmit: Bool { skipSubmitButton && !isMultipleChoice && !hasOpenChoice } + var shouldAutoSubmit: Bool { skipSubmitButton && !isMultipleChoice && !hasOpenChoice } - init( - id: String, - question: String, - questionDescription: String?, - questionDescriptionContentType: PostHogDisplaySurveyTextContentType?, - isOptional: Bool, - buttonText: String?, - choices: [String], - hasOpenChoice: Bool, - shuffleOptions: Bool, - isMultipleChoice: Bool, - skipSubmitButton: Bool = false - ) { - self.choices = choices - self.hasOpenChoice = hasOpenChoice - self.shuffleOptions = shuffleOptions - self.isMultipleChoice = isMultipleChoice - self.skipSubmitButton = skipSubmitButton - super.init( - id: id, - question: question, - questionDescription: questionDescription, - questionDescriptionContentType: questionDescriptionContentType, - isOptional: isOptional, - buttonText: buttonText - ) + init( + id: String, + question: String, + questionDescription: String?, + questionDescriptionContentType: PostHogDisplaySurveyTextContentType?, + isOptional: Bool, + buttonText: String?, + choices: [String], + hasOpenChoice: Bool, + shuffleOptions: Bool, + isMultipleChoice: Bool, + skipSubmitButton: Bool = false + ) { + self.choices = choices + self.hasOpenChoice = hasOpenChoice + self.shuffleOptions = shuffleOptions + self.isMultipleChoice = isMultipleChoice + self.skipSubmitButton = skipSubmitButton + super.init( + id: id, + question: question, + questionDescription: questionDescription, + questionDescriptionContentType: questionDescriptionContentType, + isOptional: isOptional, + buttonText: buttonText + ) + } } -} +#endif diff --git a/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift b/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift index 4e09350a66..bb5d2839a7 100644 --- a/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift +++ b/PostHog/Surveys/Models/PostHogNextSurveyQuestion.swift @@ -7,17 +7,19 @@ 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 +#if !SWIFT_PACKAGE || Surveys + /// 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() + init(questionIndex: Int, isSurveyCompleted: Bool) { + self.questionIndex = questionIndex + self.isSurveyCompleted = isSurveyCompleted + super.init() + } } -} +#endif diff --git a/PostHog/Surveys/Models/PostHogSurveyResponse.swift b/PostHog/Surveys/Models/PostHogSurveyResponse.swift index f39b79ac18..392d9c2129 100644 --- a/PostHog/Surveys/Models/PostHogSurveyResponse.swift +++ b/PostHog/Surveys/Models/PostHogSurveyResponse.swift @@ -7,117 +7,119 @@ import Foundation -/// A model representing a user's response to a survey question -@objc @objcMembers -public class PostHogSurveyResponse: NSObject { - /// The response kind: link, rating, open-ended, single-choice, 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]? +#if !SWIFT_PACKAGE || Surveys + /// A model representing a user's response to a survey question + @objc @objcMembers + public class PostHogSurveyResponse: NSObject { + /// The response kind: link, rating, open-ended, single-choice, 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 - } + 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. - /// - Returns: A response with type `.link` and `linkClicked` populated. - public static func link(_ clicked: Bool) -> PostHogSurveyResponse { - PostHogSurveyResponse( - type: .link, - linkClicked: clicked, - ratingValue: nil, - textValue: nil, - multipleChoiceValues: nil - ) - } + /// Creates a response for a link question. + /// + /// - Parameter clicked: Whether the link was clicked. + /// - Returns: A response with type `.link` and `linkClicked` populated. + 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. - /// - Returns: A response with type `.rating` and `ratingValue` populated. - public static func rating(_ rating: Int?) -> PostHogSurveyResponse { - PostHogSurveyResponse( - type: .rating, - linkClicked: nil, - ratingValue: rating, - textValue: nil, - multipleChoiceValues: nil - ) - } + /// Creates a response for a rating question. + /// + /// - Parameter rating: The selected rating value. + /// - Returns: A response with type `.rating` and `ratingValue` populated. + 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. - /// - Returns: A response with type `.openEnded` and `textValue` populated. - public static func openEnded(_ openEnded: String?) -> PostHogSurveyResponse { - PostHogSurveyResponse( - type: .openEnded, - linkClicked: nil, - ratingValue: nil, - textValue: openEnded, - multipleChoiceValues: nil - ) - } + /// Creates a response for an open-ended question. + /// + /// - Parameter openEnded: The text response. + /// - Returns: A response with type `.openEnded` and `textValue` populated. + 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. - /// - Returns: A response with type `.singleChoice` and `selectedOptions` containing the option when non-nil. - 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 single-choice question. + /// + /// - Parameter singleChoice: The selected option. + /// - Returns: A response with type `.singleChoice` and `selectedOptions` containing the option when non-nil. + 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. - /// - Returns: A response with type `.multipleChoice` and `selectedOptions` populated. - public static func multipleChoice(_ multipleChoice: [String]?) -> PostHogSurveyResponse { - PostHogSurveyResponse( - type: .multipleChoice, - linkClicked: nil, - ratingValue: nil, - textValue: nil, - multipleChoiceValues: multipleChoice - ) + /// Creates a response for a multiple-choice question. + /// + /// - Parameter multipleChoice: The selected options. + /// - Returns: A response with type `.multipleChoice` and `selectedOptions` populated. + public static func multipleChoice(_ multipleChoice: [String]?) -> PostHogSurveyResponse { + PostHogSurveyResponse( + type: .multipleChoice, + linkClicked: nil, + ratingValue: nil, + textValue: nil, + multipleChoiceValues: multipleChoice + ) + } } -} -/// Type of response submitted for a survey question. -@objc public enum PostHogSurveyResponseType: Int { - /// Response for a link question. - case link - /// Response for a rating question. - case rating - /// Response for an open-ended text question. - case openEnded - /// Response for a single-choice question. - case singleChoice - /// Response for a multiple-choice question. - case multipleChoice -} + /// Type of response submitted for a survey question. + @objc public enum PostHogSurveyResponseType: Int { + /// Response for a link question. + case link + /// Response for a rating question. + case rating + /// Response for an open-ended text question. + case openEnded + /// Response for a single-choice question. + case singleChoice + /// Response for a multiple-choice question. + case multipleChoice + } +#endif diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index e7bdb272e2..984f44b23a 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 20/02/2025. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation #if os(iOS) @@ -119,9 +119,7 @@ clearActiveSurvey() unsubscribeFromRemoteConfigUpdates() #if os(iOS) - if #available(iOS 15.0, *) { - config?.surveysConfig.surveysDelegate.cleanupSurveys() - } + config?.surveysConfig.surveysDelegate.cleanupSurveys() #endif } @@ -241,7 +239,9 @@ ) { getCachedOrReload( getCached: remoteConfig.getFeatureFlags, - reload: { remoteConfig.reloadFeatureFlags(callback: $0) }, + reload: { callback in + remoteConfig.reloadFeatureFlags(callback: callback.map { callback in { callback($0.featureFlags) } }) + }, forceReload: forceReload, callback: callback ) @@ -291,11 +291,6 @@ /// Shows next survey in queue. No-op if a survey is already being shown func showNextSurvey() { #if os(iOS) - guard #available(iOS 15.0, *) else { - hedgeLog("[Surveys] Surveys can be rendered only on iOS 15+") - return - } - guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.showNextSurvey() } return @@ -340,8 +335,7 @@ private func refreshActiveSurveyTranslations() { // `updateSurvey` is optional; without it, skip so the tracked language never advances past // what's actually on screen. - guard #available(iOS 15.0, *), - let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey + guard let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey else { return } // Enqueue the update inside `activeSurveyLock` so main-queue order matches commit order: @@ -551,8 +545,7 @@ /// but before the survey was on screen — a window where `updateSurvey` is dropped and later /// refreshes no-op. Pushes one update to catch up. private func reconcileRenderedTranslationOnShow(activeSurvey: PostHogSurvey, attemptId: UUID?) { - guard #available(iOS 15.0, *), - let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey + guard let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey else { return } activeSurveyLock.withLock { diff --git a/PostHog/Surveys/PostHogSurveysConfig.swift b/PostHog/Surveys/PostHogSurveysConfig.swift index 08a18350c3..60e41b3e13 100644 --- a/PostHog/Surveys/PostHogSurveysConfig.swift +++ b/PostHog/Surveys/PostHogSurveysConfig.swift @@ -7,104 +7,107 @@ import Foundation -/// Configuration for mobile survey rendering. -/// -/// Mutate fields on `PostHogConfig.surveysConfig` before SDK setup to customize survey UI, -/// lifecycle handling, or localization. -@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. +#if !SWIFT_PACKAGE || Surveys + /// Configuration for mobile survey rendering. /// - /// Defaults to `PostHogSurveysDefaultDelegate` which provides a standard survey UI. - public var surveysDelegate: PostHogSurveysDelegate = PostHogSurveysDefaultDelegate() + /// Mutate fields on `PostHogConfig.surveysConfig` before SDK setup to customize survey UI, + /// lifecycle handling, or localization. + /// With Swift Package Manager, this is only available with the `Surveys` package trait, which is enabled by default. + @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() - /// Optional explicit override for the language used when rendering surveys. - /// - /// When set, surveys with matching entries in `translations` will be rendered in - /// this language regardless of the device locale or any `language` person property. - /// - /// Format: a language tag such as `"fr"`, `"pt-BR"`, `"zh-CN"`. Matching is - /// case-insensitive and falls back to the base language (e.g. `"pt"` if `"pt-BR"` - /// is requested but only `"pt"` is provided). - /// - /// Blank or `nil` values are treated as unset. - /// - /// Default: `nil`. - @objc public var overrideDisplayLanguage: String? -} + /// Optional explicit override for the language used when rendering surveys. + /// + /// When set, surveys with matching entries in `translations` will be rendered in + /// this language regardless of the device locale or any `language` person property. + /// + /// Format: a language tag such as `"fr"`, `"pt-BR"`, `"zh-CN"`. Matching is + /// case-insensitive and falls back to the base language (e.g. `"pt"` if `"pt-BR"` + /// is requested but only `"pt"` is provided). + /// + /// Blank or `nil` values are treated as unset. + /// + /// Default: `nil`. + @objc public var overrideDisplayLanguage: String? + } -/// 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 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 survey state, or `nil` if the attempt is no longer valid. The built-in renderer closes an invalidated survey. -public typealias OnPostHogSurveyResponse = (_ survey: PostHogDisplaySurvey, _ index: Int, _ response: PostHogSurveyResponse) -> PostHogNextSurveyQuestion? + /// 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 survey state, or `nil` if the attempt is no longer valid. The built-in renderer closes an invalidated survey. + 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 + /// To be called when a survey is dismissed + /// - Parameter survey: The survey that was closed + public typealias OnPostHogSurveyClosed = (_ survey: PostHogDisplaySurvey) -> Void -/// Delegate used by the SDK to present surveys and receive survey lifecycle callbacks. -@objc public protocol PostHogSurveysDelegate { - /// Whether this delegate can resume unfinished surveys. Defaults to `false` when omitted. - /// - /// Return `true` only if the renderer starts at `survey.initialQuestionIndex`. - /// The SDK restores the saved answers and submission ID for that attempt. - /// Delegates that omit this property or return `false` start a fresh attempt at question 0. - /// - /// For example, a renderer that supports the restored position can opt in with: - /// ```swift - /// var supportsSurveyResume: Bool { true } - /// // In renderSurvey, initialize the renderer at survey.initialQuestionIndex. - /// ``` - @objc optional var supportsSurveyResume: Bool { get } + /// Delegate used by the SDK to present surveys and receive survey lifecycle callbacks. + @objc public protocol PostHogSurveysDelegate { + /// Whether this delegate can resume unfinished surveys. Defaults to `false` when omitted. + /// + /// Return `true` only if the renderer starts at `survey.initialQuestionIndex`. + /// The SDK restores the saved answers and submission ID for that attempt. + /// Delegates that omit this property or return `false` start a fresh attempt at question 0. + /// + /// For example, a renderer that supports the restored position can opt in with: + /// ```swift + /// var supportsSurveyResume: Bool { true } + /// // In renderSurvey, initialize the renderer at survey.initialQuestionIndex. + /// ``` + @objc optional var supportsSurveyResume: Bool { get } - /// Called when an activated PostHog survey needs to be rendered on the app's UI - /// Delegates that opt in through `supportsSurveyResume` must start at - /// `survey.initialQuestionIndex` to resume an unfinished survey. - /// - /// - Parameters: - /// - survey: The survey to be displayed to the user - /// - onSurveyShown: To be called when the survey is successfully displayed to the user. - /// - onSurveyResponse: Call when the user submits a response to a question; use the returned state to advance or complete the survey. - /// - 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 an activated PostHog survey needs to be rendered on the app's UI + /// Delegates that opt in through `supportsSurveyResume` must start at + /// `survey.initialQuestionIndex` to resume an unfinished survey. + /// + /// - Parameters: + /// - survey: The survey to be displayed to the user + /// - onSurveyShown: To be called when the survey is successfully displayed to the user. + /// - onSurveyResponse: Call when the user submits a response to a question; use the returned state to advance or complete the survey. + /// - 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 the content of the currently displayed survey changes without restarting it, - /// for example when the user's language is updated and a new translation becomes available. - /// - /// Implementations should update the on-screen survey in place — preserving the current - /// question, progress, and any in-progress answers — rather than presenting it again. - /// Optional: delegates that don't support live updates can omit it and the survey simply - /// keeps the language it was first rendered with. - /// - /// Always delivered on the main thread. May arrive *before* the survey is visibly shown — - /// e.g. while a display delay (`surveyPopupDelaySeconds`) is still pending — so a delegate - /// that defers presentation must also apply the update to its pending survey, otherwise the - /// update is dropped and the survey shows the language it was first resolved with. - /// - /// - Parameter survey: The survey with refreshed (e.g. re-translated) content. Its `id` - /// matches the survey passed to `renderSurvey`. - @objc optional func updateSurvey(_ survey: PostHogDisplaySurvey) + /// Called when the content of the currently displayed survey changes without restarting it, + /// for example when the user's language is updated and a new translation becomes available. + /// + /// Implementations should update the on-screen survey in place — preserving the current + /// question, progress, and any in-progress answers — rather than presenting it again. + /// Optional: delegates that don't support live updates can omit it and the survey simply + /// keeps the language it was first rendered with. + /// + /// Always delivered on the main thread. May arrive *before* the survey is visibly shown — + /// e.g. while a display delay (`surveyPopupDelaySeconds`) is still pending — so a delegate + /// that defers presentation must also apply the update to its pending survey, otherwise the + /// update is dropped and the survey shows the language it was first resolved with. + /// + /// - Parameter survey: The survey with refreshed (e.g. re-translated) content. Its `id` + /// matches the survey passed to `renderSurvey`. + @objc optional func updateSurvey(_ survey: PostHogDisplaySurvey) - /// 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() -} + /// 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() + } -struct SurveyCallbacks { - let shown: OnPostHogSurveyShown - let response: OnPostHogSurveyResponse - let closed: OnPostHogSurveyClosed -} + struct SurveyCallbacks { + let shown: OnPostHogSurveyShown + let response: OnPostHogSurveyResponse + let closed: OnPostHogSurveyClosed + } +#endif diff --git a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift index e11f7821dc..5d4d91b11c 100644 --- a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift +++ b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift @@ -5,148 +5,145 @@ // Created by Ioannis Josephides on 18/06/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import UIKit #else import Foundation #endif -final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { - var supportsSurveyResume: Bool { true } - - #if os(iOS) - private var surveysWindow: UIWindow? - private var displayController: SurveyDisplayController? - private var pendingDisplayWorkItem: DispatchWorkItem? - private var pendingSurvey: PostHogDisplaySurvey? - private var pendingSurveyClosedHandler: OnPostHogSurveyClosed? - #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 displayController == nil { - // setup window for first-time display - setupWindow() - } - - guard let displayController else { - // If we cannot render the survey UI, treat this as a pre-render dismiss so integration state is cleared. - onSurveyClosed(survey) - return - } - - // Setup handlers - displayController.onSurveyShown = onSurveyShown - displayController.onSurveyResponse = onSurveyResponse - displayController.onSurveyClosed = onSurveyClosed +#if !SWIFT_PACKAGE || Surveys + final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { + var supportsSurveyResume: Bool { true } - scheduleSurveyDisplay(survey, displayController: displayController, onSurveyClosed: onSurveyClosed) + #if os(iOS) + private var surveysWindow: UIWindow? + private var displayController: SurveyDisplayController? + private var pendingDisplayWorkItem: DispatchWorkItem? + private var pendingSurvey: PostHogDisplaySurvey? + private var pendingSurveyClosedHandler: OnPostHogSurveyClosed? #endif - } - func updateSurvey(_ survey: PostHogDisplaySurvey) { - #if os(iOS) - guard #available(iOS 15.0, *) else { return } + func renderSurvey( + _ survey: PostHogDisplaySurvey, + onSurveyShown: @escaping OnPostHogSurveyShown, + onSurveyResponse: @escaping OnPostHogSurveyResponse, + onSurveyClosed: @escaping OnPostHogSurveyClosed + ) { + #if os(iOS) + if displayController == nil { + // setup window for first-time display + setupWindow() + } - // If the survey is still waiting out its display delay, refresh the queued copy so - // it gets shown with the latest content. - if pendingSurvey?.id == survey.id { - pendingSurvey = survey - } + guard let displayController else { + // If we cannot render the survey UI, treat this as a pre-render dismiss so integration state is cleared. + onSurveyClosed(survey) + return + } - displayController?.updateSurvey(survey) - #endif - } + // Setup handlers + displayController.onSurveyShown = onSurveyShown + displayController.onSurveyResponse = onSurveyResponse + displayController.onSurveyClosed = onSurveyClosed - func cleanupSurveys() { - #if os(iOS) - dismissPendingSurveyIfNeeded() - displayController?.dismissSurvey() // dismiss any active surveys - surveysWindow?.rootViewController?.dismiss(animated: true) { - self.surveysWindow?.isHidden = true - self.surveysWindow = nil - self.displayController = nil - } - #endif - } + scheduleSurveyDisplay(survey, displayController: displayController, onSurveyClosed: onSurveyClosed) + #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 - } + func updateSurvey(_ survey: PostHogDisplaySurvey) { + #if os(iOS) + // If the survey is still waiting out its display delay, refresh the queued copy so + // it gets shown with the latest content. + if pendingSurvey?.id == survey.id { + pendingSurvey = survey + } + + displayController?.updateSurvey(survey) + #endif } - private func scheduleSurveyDisplay( - _ survey: PostHogDisplaySurvey, - displayController: SurveyDisplayController, - onSurveyClosed: @escaping OnPostHogSurveyClosed - ) { - dismissPendingSurveyIfNeeded() + func cleanupSurveys() { + #if os(iOS) + dismissPendingSurveyIfNeeded() + displayController?.dismissSurvey() // dismiss any active surveys + surveysWindow?.rootViewController?.dismiss(animated: true) { + self.surveysWindow?.isHidden = true + self.surveysWindow = nil + self.displayController = nil + } + #endif + } - let delay = max(survey.appearance?.surveyPopupDelaySeconds ?? 0, 0) - guard delay > 0 else { - // show the survey directly - displayController.showSurvey(survey) - return + #if os(iOS) + 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 + } } - hedgeLog("[Surveys] Scheduling survey \(survey.id) display in \(delay) seconds") + private func scheduleSurveyDisplay( + _ survey: PostHogDisplaySurvey, + displayController: SurveyDisplayController, + onSurveyClosed: @escaping OnPostHogSurveyClosed + ) { + dismissPendingSurveyIfNeeded() + + let delay = max(survey.appearance?.surveyPopupDelaySeconds ?? 0, 0) + guard delay > 0 else { + // show the survey directly + displayController.showSurvey(survey) + return + } - // schedule a survey display for now + delay - pendingSurvey = survey - pendingSurveyClosedHandler = onSurveyClosed + hedgeLog("[Surveys] Scheduling survey \(survey.id) display in \(delay) seconds") - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - // Read the latest pending copy: `updateSurvey` may have replaced it with a - // re-translated version (same id) while the delay was counting down. Showing - // the captured `survey` here would drop that update. - guard let pending = self.pendingSurvey, pending.id == survey.id else { - // current survey? - return + // schedule a survey display for now + delay + pendingSurvey = survey + pendingSurveyClosedHandler = onSurveyClosed + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + // Read the latest pending copy: `updateSurvey` may have replaced it with a + // re-translated version (same id) while the delay was counting down. Showing + // the captured `survey` here would drop that update. + guard let pending = self.pendingSurvey, pending.id == survey.id else { + // current survey? + return + } + + self.pendingDisplayWorkItem = nil + self.pendingSurvey = nil + self.pendingSurveyClosedHandler = nil + displayController.showSurvey(pending) } - self.pendingDisplayWorkItem = nil - self.pendingSurvey = nil - self.pendingSurveyClosedHandler = nil - displayController.showSurvey(pending) + pendingDisplayWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } - pendingDisplayWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) - } - - private func dismissPendingSurveyIfNeeded() { - guard let pendingSurvey else { return } + private func dismissPendingSurveyIfNeeded() { + guard let pendingSurvey else { return } - pendingDisplayWorkItem?.cancel() - pendingDisplayWorkItem = nil - self.pendingSurvey = nil + pendingDisplayWorkItem?.cancel() + pendingDisplayWorkItem = nil + self.pendingSurvey = nil - let pendingClosedHandler = pendingSurveyClosedHandler - pendingSurveyClosedHandler = nil - pendingClosedHandler?(pendingSurvey) - } - #endif -} + let pendingClosedHandler = pendingSurveyClosedHandler + pendingSurveyClosedHandler = nil + pendingClosedHandler?(pendingSurvey) + } + #endif + } +#endif -#if os(iOS) && TESTING +#if os(iOS) && TESTING && (!SWIFT_PACKAGE || Surveys) extension PostHogSurveysDefaultDelegate { /// Injects a display controller so tests can drive rendering without a `UIWindowScene` func setDisplayControllerForTesting(_ controller: SurveyDisplayController) { diff --git a/PostHog/Surveys/QuestionHeader.swift b/PostHog/Surveys/QuestionHeader.swift index 403b8c5938..b621492a2f 100644 --- a/PostHog/Surveys/QuestionHeader.swift +++ b/PostHog/Surveys/QuestionHeader.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct QuestionHeader: View { @Environment(\.surveyAppearance) private var appearance @@ -38,7 +37,6 @@ } } - @available(iOS 15.0, *) #Preview { QuestionHeader( question: "What can we do to improve our product?", diff --git a/PostHog/Surveys/QuestionTypes.swift b/PostHog/Surveys/QuestionTypes.swift index c8f1d7f361..bd26a9d9a1 100644 --- a/PostHog/Surveys/QuestionTypes.swift +++ b/PostHog/Surveys/QuestionTypes.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 13/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct OpenTextQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -81,7 +80,6 @@ } } - @available(iOS 15.0, *) struct LinkQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -113,7 +111,6 @@ } } - @available(iOS 15.0, *) struct RatingQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -173,7 +170,6 @@ } } - @available(iOS 15.0, *) struct SingleChoiceQuestionView: View { @Environment(\.surveyAppearance) private var appearance @@ -240,7 +236,6 @@ } } - @available(iOS 15.0, *) struct MultipleChoiceQuestionView: View { @Environment(\.surveyAppearance) private var appearance diff --git a/PostHog/Surveys/SurveyDisplayController.swift b/PostHog/Surveys/SurveyDisplayController.swift index a56e7d85f4..07e30329a6 100644 --- a/PostHog/Surveys/SurveyDisplayController.swift +++ b/PostHog/Surveys/SurveyDisplayController.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 07/03/2025. // -#if os(iOS) || Testing +#if (os(iOS) || Testing) && (!SWIFT_PACKAGE || Surveys) import SwiftUI final class SurveyDisplayController: ObservableObject { diff --git a/PostHog/Surveys/SurveyProgressStore.swift b/PostHog/Surveys/SurveyProgressStore.swift index f4c967b626..f5b3e1b680 100644 --- a/PostHog/Surveys/SurveyProgressStore.swift +++ b/PostHog/Surveys/SurveyProgressStore.swift @@ -1,6 +1,6 @@ import Foundation -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) struct SurveyProgress: Codable { var version = 2 let resetEpoch: String diff --git a/PostHog/Surveys/SurveySheet.swift b/PostHog/Surveys/SurveySheet.swift index 80a4f81193..5118515863 100644 --- a/PostHog/Surveys/SurveySheet.swift +++ b/PostHog/Surveys/SurveySheet.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 12/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15, *) struct SurveySheet: View { // Observed so the sheet re-renders when the displayed survey is updated in place // (e.g. re-translated after a language change), not just when questions advance. @@ -90,7 +89,6 @@ } } - @available(iOS 15, *) private struct SurveyDismissButton: View { @Environment(\.surveyAppearance) private var appearance let action: () -> Void @@ -106,7 +104,6 @@ } extension View { - @available(iOS 15, *) func surveyBottomSheet(height: CGFloat) -> some View { modifier( SurveyBottomSheetWithWithDetents(height: height) @@ -114,7 +111,6 @@ } } - @available(iOS 15.0, *) private struct SurveyBottomSheetWithWithDetents: ViewModifier { @Environment(\.surveyAppearance) private var appearance @@ -195,13 +191,11 @@ 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 } @@ -209,7 +203,6 @@ } extension SwiftUISurveyAppearance { - @available(iOS 15.0, *) static func getAppearanceWithDefaults(_ appearance: PostHogDisplaySurveyAppearance? = nil) -> SwiftUISurveyAppearance { SwiftUISurveyAppearance( fontFamily: Font.customFont(family: appearance?.fontFamily ?? "") ?? Font.body, @@ -237,13 +230,11 @@ ) } - @available(iOS 15.0, *) private static func colorFrom(css hex: String?, defaultColor: UIColor) -> Color { guard let hex = hex, !hex.isEmpty else { return Color(uiColor: defaultColor) } return Color(uiColor: UIColor(hex: hex)) } - @available(iOS 15.0, *) private static func colorFrom(css hex: String?) -> Color? { guard let hex = hex, !hex.isEmpty else { return nil } return Color(uiColor: UIColor(hex: hex)) @@ -253,7 +244,6 @@ /// - Use user-provided inputBackground if set /// - Otherwise use #f8f8f8 if survey background is white (for slight contrast) /// - Otherwise default to white - @available(iOS 15.0, *) var effectiveInputBackground: Color { if let userInputBg = inputBackground { return userInputBg @@ -267,7 +257,6 @@ /// Computed input text color matching JS SDK behavior: /// - Use user-provided inputTextColor if set /// - Otherwise auto-contrast from effectiveInputBackground - @available(iOS 15.0, *) var effectiveInputTextColor: Color { inputTextColor ?? effectiveInputBackground.getContrastingTextColor() } diff --git a/PostHog/Surveys/SurveysRootView.swift b/PostHog/Surveys/SurveysRootView.swift index ede54adbfa..96662d1959 100644 --- a/PostHog/Surveys/SurveysRootView.swift +++ b/PostHog/Surveys/SurveysRootView.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 07/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveysRootView: View { @EnvironmentObject private var displayManager: SurveyDisplayController diff --git a/PostHog/Surveys/SurveysWindow.swift b/PostHog/Surveys/SurveysWindow.swift index e810ce238c..245451cc8a 100644 --- a/PostHog/Surveys/SurveysWindow.swift +++ b/PostHog/Surveys/SurveysWindow.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 06/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI import UIKit - @available(iOS 15.0, *) final class SurveysWindow: PassthroughWindow { init(controller: SurveyDisplayController, scene: UIWindowScene) { super.init(windowScene: scene) diff --git a/PostHog/Surveys/Utils/EdgeBorder.swift b/PostHog/Surveys/Utils/EdgeBorder.swift index d03196c923..be0c11e636 100644 --- a/PostHog/Surveys/Utils/EdgeBorder.swift +++ b/PostHog/Surveys/Utils/EdgeBorder.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 22/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct EdgeBorder: Shape { diff --git a/PostHog/Surveys/Utils/EmojiRating.swift b/PostHog/Surveys/Utils/EmojiRating.swift index 913dd25a8b..b87235890b 100644 --- a/PostHog/Surveys/Utils/EmojiRating.swift +++ b/PostHog/Surveys/Utils/EmojiRating.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct EmojiRating: View { @Environment(\.surveyAppearance) private var appearance @Binding var selectedValue: Int? diff --git a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift index 4d9b1c7dc5..1c63f43b4a 100644 --- a/PostHog/Surveys/Utils/MultipleChoiceOptions.swift +++ b/PostHog/Surveys/Utils/MultipleChoiceOptions.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct MultipleChoiceOptions: View { @Environment(\.surveyAppearance) private var appearance @@ -131,7 +130,6 @@ } } - @available(iOS 15.0, *) private struct SurveyOptionStyle: ViewModifier { let isChecked: Bool let textColor: Color diff --git a/PostHog/Surveys/Utils/NumberRating.swift b/PostHog/Surveys/Utils/NumberRating.swift index eeb7a9287d..2bafac3284 100644 --- a/PostHog/Surveys/Utils/NumberRating.swift +++ b/PostHog/Surveys/Utils/NumberRating.swift @@ -5,10 +5,9 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct NumberRating: View { @Environment(\.surveyAppearance) private var appearance diff --git a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift index 9b432a95fa..990b95b277 100644 --- a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift +++ b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift @@ -9,7 +9,7 @@ // the integration stays within the file-length limit. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation diff --git a/PostHog/Surveys/Utils/Resources.swift b/PostHog/Surveys/Utils/Resources.swift index b8b0f2930e..c8a66d15af 100644 --- a/PostHog/Surveys/Utils/Resources.swift +++ b/PostHog/Surveys/Utils/Resources.swift @@ -8,7 +8,7 @@ // see: https://github.com/bring-shrubbery/SVG-to-SwiftUI // swiftlint:disable line_length -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct VeryDissatisfiedEmoji: Shape { diff --git a/PostHog/Surveys/Utils/SegmentedControl.swift b/PostHog/Surveys/Utils/SegmentedControl.swift index 2e152aaca5..31f6c09170 100644 --- a/PostHog/Surveys/Utils/SegmentedControl.swift +++ b/PostHog/Surveys/Utils/SegmentedControl.swift @@ -5,7 +5,7 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI struct SegmentedControl: View { diff --git a/PostHog/Surveys/Utils/Survey+Util.swift b/PostHog/Surveys/Utils/Survey+Util.swift index ec440b982c..c25a66f6da 100644 --- a/PostHog/Surveys/Utils/Survey+Util.swift +++ b/PostHog/Surveys/Utils/Survey+Util.swift @@ -24,13 +24,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI import UIKit // swiftlint:disable identifier_name /// Convenience helpers for creating and describing survey colors. - public extension UIColor { + extension UIColor { /** Creates an immutable UIColor instance specified by a hex string, CSS color name, or nil. @@ -71,7 +71,6 @@ - Returns: A new `String` with the color's hexadecimal value. */ - @available(*, deprecated, message: "UIColor.hexDescription(_:) is removed from the public API in PostHog 4.0.") func hexDescription(_ includeAlpha: Bool = false) -> String { guard cgColor.numberOfComponents == 4 else { return "Color not RGB." @@ -291,7 +290,6 @@ } extension Color { - @available(iOS 15.0, *) func getContrastingTextColor() -> Color { var r, g, b, a: CGFloat (r, g, b, a) = (0, 0, 0, 0) diff --git a/PostHog/Surveys/Utils/SurveyButton.swift b/PostHog/Surveys/Utils/SurveyButton.swift index 7ac992f485..6bf1c45886 100644 --- a/PostHog/Surveys/Utils/SurveyButton.swift +++ b/PostHog/Surveys/Utils/SurveyButton.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 11/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveyButtonStyle: ButtonStyle { @Environment(\.surveyAppearance) private var appearance @Environment(\.isEnabled) private var isEnabled diff --git a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift index 22f757d7e0..9736a33904 100644 --- a/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift +++ b/PostHog/Surveys/Utils/SurveyPresentationDetentsRepresentable.swift @@ -5,11 +5,10 @@ // Created by Ioannis Josephides on 22/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import SwiftUI - @available(iOS 15.0, *) struct SurveyPresentationDetentsRepresentable: UIViewControllerRepresentable { enum Detent: Hashable, Identifiable, Comparable { case medium diff --git a/PostHog/Surveys/Utils/SurveyTranslationResolver.swift b/PostHog/Surveys/Utils/SurveyTranslationResolver.swift index 106c5afd2c..6cc23c9041 100644 --- a/PostHog/Surveys/Utils/SurveyTranslationResolver.swift +++ b/PostHog/Surveys/Utils/SurveyTranslationResolver.swift @@ -5,7 +5,7 @@ // Created by PostHog Code on 2026-05-13. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation diff --git a/PostHog/Surveys/Utils/SwiftUI+Util.swift b/PostHog/Surveys/Utils/SwiftUI+Util.swift index 36f4689e97..8d0b8a7f73 100644 --- a/PostHog/Surveys/Utils/SwiftUI+Util.swift +++ b/PostHog/Surveys/Utils/SwiftUI+Util.swift @@ -5,12 +5,11 @@ // Created by Ioannis Josephides on 10/03/2025. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) 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 @@ -24,7 +23,6 @@ } /// Reads current view's safe area insets - @available(iOS 14.0, *) func readSafeAreaInsets( onSafeAreaInsets: @escaping (EdgeInsets) -> Void ) -> some View { @@ -36,7 +34,6 @@ } } - @available(iOS 14.0, *) private struct ReadFrameModifier: ViewModifier { /// Helper for notifying parents for child view frame changes struct FramePreferenceKey: PreferenceKey { @@ -64,7 +61,6 @@ } } - @available(iOS 14.0, *) private struct ReadSafeAreaInsetsModifier: ViewModifier { /// Helper for notifying parents for child view's safe area insets struct SafeAreaInsetsPreferenceKey: PreferenceKey { diff --git a/PostHog/SwiftUI/PostHogDeepLinkListener.swift b/PostHog/SwiftUI/PostHogDeepLinkListener.swift index 6744129772..126f80ab9a 100644 --- a/PostHog/SwiftUI/PostHogDeepLinkListener.swift +++ b/PostHog/SwiftUI/PostHogDeepLinkListener.swift @@ -15,17 +15,13 @@ func body(content: Content) -> some View { #if os(iOS) || os(tvOS) || os(macOS) - if #available(iOS 14.0, tvOS 14.0, macOS 11.0, *) { - content - .onOpenURL { url in - posthog?.captureDeepLink(url: url) - } - .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in - posthog?.captureDeepLink(userActivity: activity) - } - } else { - content - } + content + .onOpenURL { url in + posthog?.captureDeepLink(url: url) + } + .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in + posthog?.captureDeepLink(userActivity: activity) + } #else content #endif diff --git a/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift b/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift index 2a1fa3f516..5954ff8aa7 100644 --- a/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift +++ b/PostHog/SwiftUI/PostHogNoMaskViewModifier.swift @@ -9,53 +9,55 @@ import SwiftUI - /// SwiftUI session replay unmasking helpers. - public extension View { - /** - Marks a SwiftUI View to be excluded from masking in PostHog session replay recordings. - - Note: On iOS 26+ (Xcode 26 SwiftUI rendering engine), SwiftUI views may no longer map - reliably to a backing `UIView`, so this modifier may behave inconsistently. A future SDK - update will try to address this limitation, but for now, we recommend using this modifier - with caution. - - 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. - - Note: for `.drawingGroup()` or `Canvas` content, apply this modifier to the group itself. - - 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( - onChange: { owner, views, layers in - views.forEach { $0.setPostHogNoMask(true, owner: owner) } - layers.forEach { $0.setPostHogNoMask(true, owner: owner) } - }, - onRemove: { owner, views, layers in - views.forEach { $0.setPostHogNoMask(false, owner: owner) } - layers.forEach { $0.setPostHogNoMask(false, owner: owner) } - } + #if !SWIFT_PACKAGE || SessionReplay + /// SwiftUI session replay unmasking helpers. + public extension View { + /** + Marks a SwiftUI View to be excluded from masking in PostHog session replay recordings. + + Note: On iOS 26+ (Xcode 26 SwiftUI rendering engine), SwiftUI views may no longer map + reliably to a backing `UIView`, so this modifier may behave inconsistently. A future SDK + update will try to address this limitation, but for now, we recommend using this modifier + with caution. + + 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. + + Note: for `.drawingGroup()` or `Canvas` content, apply this modifier to the group itself. + + 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( + onChange: { owner, views, layers in + views.forEach { $0.setPostHogNoMask(true, owner: owner) } + layers.forEach { $0.setPostHogNoMask(true, owner: owner) } + }, + onRemove: { owner, views, layers in + views.forEach { $0.setPostHogNoMask(false, owner: owner) } + layers.forEach { $0.setPostHogNoMask(false, owner: owner) } + } + ) ) - ) + } } - } + #endif // Ref-counted ownership (see PostHogFlagOwners): overlapping no-mask regions // must not clear each other on teardown. diff --git a/PostHog/UIViewController.swift b/PostHog/UIViewController.swift index 9265bd2e0c..6450575379 100644 --- a/PostHog/UIViewController.swift +++ b/PostHog/UIViewController.swift @@ -90,7 +90,7 @@ } private static func secondaryViewController(of split: UISplitViewController) -> UIViewController? { - if #available(iOS 14.0, tvOS 14.0, *), split.style != .unspecified { + if split.style != .unspecified { let secondary = split.viewController(for: .secondary) // Column-style splits wrap a plain column controller in a navigation // controller that holds the pushed screens. `navigationController` can also @@ -124,15 +124,19 @@ } static func getViewControllerName(_ viewController: UIViewController) -> String? { - var title: String? = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "") + // Plain storyboard controllers have no meaningful class name, so fall back to the title. + getViewControllerTypeName(viewController) ?? viewController.title + } - // Plain storyboard controllers have no meaningful class name (stripping - // "ViewController" from "UIViewController" would otherwise leave "UI"). - if title?.isEmpty == true || viewController.classForCoder == UIViewController.self { - title = viewController.title ?? nil + /// The controller's class name without "ViewController", or nil for a plain `UIViewController`. + /// Never the display title, which can contain user data. + static func getViewControllerTypeName(_ viewController: UIViewController) -> String? { + // Stripping "ViewController" from "UIViewController" would otherwise leave "UI". + if viewController.classForCoder == UIViewController.self { + return nil } - - return title + let name = String(describing: viewController.classForCoder).replacingOccurrences(of: "ViewController", with: "") + return name.isEmpty ? nil : name } } #endif diff --git a/PostHog/Utils/BeforeSendChain.swift b/PostHog/Utils/BeforeSendChain.swift index 446ad4cc2e..6a97e338e1 100644 --- a/PostHog/Utils/BeforeSendChain.swift +++ b/PostHog/Utils/BeforeSendChain.swift @@ -5,11 +5,13 @@ import Foundation -/// Composable `(T) -> T?` pipeline shared by the events and logs configs. +/// Composable `@Sendable (T) -> T?` pipeline shared by the events and logs configs. /// Blocks run in registration order; the first `nil` drops the value. /// Reference type so concurrent `set` / `run` see a coherent block snapshot. final class BeforeSendChain { - typealias Block = (T) -> T? + // @Sendable to match the public block types: converting their arrays to a + // non-Sendable element type fails at runtime (`_arrayForceCast`). + typealias Block = @Sendable (T) -> T? private let lock = NSLock() private var block: Block = { $0 } @@ -27,4 +29,22 @@ final class BeforeSendChain { let snapshot = lock.withLock { self.block } return snapshot(value) } + + /// Wraps each non-Sendable block in a box instead of force-casting the array, + /// which traps at runtime in Swift 5 mode. + static func wrapping(_ blocks: [(T) -> T?]) -> [Block] { + blocks.map { block in + let box = UncheckedSendableBlock(block) + return { value in box.block(value) } + } + } +} + +/// The app owns the thread safety of what its callback captures. +private final class UncheckedSendableBlock: @unchecked Sendable { + let block: (T) -> T? + + init(_ block: @escaping (T) -> T?) { + self.block = block + } } diff --git a/PostHog/Utils/BoxedBeforeSend.swift b/PostHog/Utils/BoxedBeforeSend.swift index 15e36f39c4..f3c29e862e 100644 --- a/PostHog/Utils/BoxedBeforeSend.swift +++ b/PostHog/Utils/BoxedBeforeSend.swift @@ -4,11 +4,7 @@ // import Foundation -#if compiler(>=6.0) - internal import PostHogObjCExceptionSupport -#else - @_implementationOnly import PostHogObjCExceptionSupport -#endif +internal import PostHogObjCExceptionSupport /// ObjC wrappers for the Swift function-typed `beforeSend` chains: Swift /// function types aren't `@objc`-bridgeable, and `@objc` classes can't be @@ -17,7 +13,7 @@ import Foundation /// ObjC wrapper for the events `beforeSend` block. Use with /// `PostHogConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendBlock: NSObject { +@objc public final class BoxedBeforeSendBlock: NSObject, Sendable { /// Wrapped event callback. @objc public let block: BeforeSendBlock @@ -39,7 +35,7 @@ import Foundation /// ObjC wrapper for the logs `beforeSend` block. Use with /// `PostHogLogsConfig.setBeforeSend(_:)`. -@objc public final class BoxedBeforeSendLogBlock: NSObject { +@objc public final class BoxedBeforeSendLogBlock: NSObject, Sendable { /// Wrapped log callback. @objc public let block: PostHogBeforeSendLogBlock diff --git a/PostHog/Utils/Data+Gzip.swift b/PostHog/Utils/Data+Gzip.swift index 74fb0d36f5..c81fc9918c 100644 --- a/PostHog/Utils/Data+Gzip.swift +++ b/PostHog/Utils/Data+Gzip.swift @@ -38,47 +38,47 @@ import Foundation #endif /// Namespace for gzip constants used by the SDK's compression helpers. -public enum Gzip { +enum Gzip { /// Maximum value for windowBits (`MAX_WBITS`). - public static let maxWindowBits = MAX_WBITS + static let maxWindowBits = MAX_WBITS } /// Compression level whose raw value is based on zlib constants. -public struct CompressionLevel: RawRepresentable, Sendable { +struct CompressionLevel: RawRepresentable, Sendable { /// Compression level in the range of `0` (no compression) to `9` (maximum compression). - public let rawValue: Int32 + let rawValue: Int32 /// zlib `Z_NO_COMPRESSION` level. - public static let noCompression = Self(Z_NO_COMPRESSION) + static let noCompression = Self(Z_NO_COMPRESSION) /// zlib `Z_BEST_SPEED` level. - public static let bestSpeed = Self(Z_BEST_SPEED) + static let bestSpeed = Self(Z_BEST_SPEED) /// zlib `Z_BEST_COMPRESSION` level. - public static let bestCompression = Self(Z_BEST_COMPRESSION) + static let bestCompression = Self(Z_BEST_COMPRESSION) /// zlib `Z_DEFAULT_COMPRESSION` level. - public static let defaultCompression = Self(Z_DEFAULT_COMPRESSION) + static let defaultCompression = Self(Z_DEFAULT_COMPRESSION) /// Creates a compression level from a zlib raw value. /// /// - Parameter rawValue: zlib compression level constant. - public init(rawValue: Int32) { + init(rawValue: Int32) { self.rawValue = rawValue } /// Creates a compression level from a zlib raw value. /// /// - Parameter rawValue: zlib compression level constant. - public init(_ rawValue: Int32) { + init(_ rawValue: Int32) { self.rawValue = rawValue } } /// Errors on gzipping/gunzipping based on the zlib error codes. -public struct GzipError: Swift.Error, Sendable { +struct GzipError: Swift.Error, Sendable { // cf. http://www.zlib.net/manual.html /// High-level zlib error category. - public enum Kind: Equatable, Sendable { + enum Kind: Equatable, Sendable { /// The stream structure was inconsistent. /// /// - Note: Underlying zlib error: `Z_STREAM_ERROR` (-2). @@ -112,10 +112,10 @@ public struct GzipError: Swift.Error, Sendable { } /// Error kind. - public let kind: Kind + let kind: Kind /// Returned message by zlib. - public let message: String + let message: String init(code: Int32, msg: UnsafePointer?) { message = msg.flatMap(String.init(validatingUTF8:)) ?? "Unknown gzip error" @@ -123,7 +123,7 @@ public struct GzipError: Swift.Error, Sendable { } /// Human-readable zlib error message. - public var localizedDescription: String { + var localizedDescription: String { message } } diff --git a/PostHog/Utils/DateUtils.swift b/PostHog/Utils/DateUtils.swift index c59b558f13..818996a2d6 100644 --- a/PostHog/Utils/DateUtils.swift +++ b/PostHog/Utils/DateUtils.swift @@ -36,7 +36,7 @@ let apiDateFormatter = PostHogAPIDateFormatter() /// /// - Parameter date: Date to format. /// - Returns: An ISO-8601-like timestamp string with milliseconds. -public func toISO8601String(_ date: Date) -> String { +func toISO8601String(_ date: Date) -> String { apiDateFormatter.string(from: date) } @@ -44,7 +44,7 @@ public func toISO8601String(_ date: Date) -> String { /// /// - Parameter date: Timestamp string with optional milliseconds. /// - Returns: A parsed `Date`, or `nil` when the string is invalid. -public func toISO8601Date(_ date: String) -> Date? { +func toISO8601Date(_ date: String) -> Date? { apiDateFormatter.date(from: date) } diff --git a/PostHog/Utils/DictUtils.swift b/PostHog/Utils/DictUtils.swift index 61ab6834fc..66a8936320 100644 --- a/PostHog/Utils/DictUtils.swift +++ b/PostHog/Utils/DictUtils.swift @@ -45,7 +45,7 @@ func fromJSONData(_ data: Data, options: JSONSerialization.ReadingOptions = []) /// /// - Parameter dict: Dictionary to sanitize. /// - Returns: A sanitized dictionary, or `nil` when the input is `nil` or empty. -public func sanitizeDictionary(_ dict: [String: Any]?) -> [String: Any]? { +func sanitizeDictionary(_ dict: [String: Any]?) -> [String: Any]? { if dict == nil || dict!.isEmpty { return nil } diff --git a/PostHog/Utils/FileUtils.swift b/PostHog/Utils/FileUtils.swift index dcc7bb1f51..abbe3a67be 100644 --- a/PostHog/Utils/FileUtils.swift +++ b/PostHog/Utils/FileUtils.swift @@ -10,7 +10,7 @@ import Foundation /// Deletes a file if it exists, logging and ignoring any removal error. /// /// - Parameter file: File URL to delete. -public func deleteSafely(_ file: URL) { +func deleteSafely(_ file: URL) { if FileManager.default.fileExists(atPath: file.path) { do { try FileManager.default.removeItem(at: file) diff --git a/PostHog/Utils/Reachability.swift b/PostHog/Utils/Reachability.swift index 19d66b9c51..bd9daefeeb 100644 --- a/PostHog/Utils/Reachability.swift +++ b/PostHog/Utils/Reachability.swift @@ -1,93 +1,17 @@ -/* - Copyright (c) 2014, Ashley Mills - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - POSSIBILITY OF SUCH DAMAGE. - */ +// +// Reachability.swift +// PostHog +// import Foundation #if !os(watchOS) - import SystemConfiguration - - /// Errors thrown while creating or configuring reachability monitoring. - public enum ReachabilityError: Error { - /// Failed to create reachability with a socket address. - /// - /// - Parameters: - /// - sockaddr: Socket address used to create the reachability reference. - /// - errorCode: SystemConfiguration `SCError()` code. - case failedToCreateWithAddress(sockaddr, Int32) - /// Failed to create reachability with a hostname. - /// - /// - Parameters: - /// - hostname: Hostname used to create the reachability reference. - /// - errorCode: SystemConfiguration `SCError()` code. - case failedToCreateWithHostname(String, Int32) - /// Failed to install the reachability callback. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToSetCallback(Int32) - /// Failed to set the reachability dispatch queue. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToSetDispatchQueue(Int32) - /// Failed to read current reachability flags. - /// - /// - Parameter errorCode: SystemConfiguration `SCError()` code. - case unableToGetFlags(Int32) - } - - /// Legacy reachability notification name. - @available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") - public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") - - extension Notification.Name { - static let reachabilityChanged = Notification.Name("reachabilityChanged") - } + import Network - class Reachability { - @available(*, unavailable, renamed: "Connection") - enum NetworkStatus: CustomStringConvertible { - case notReachable, reachableViaWiFi, reachableViaWWAN - var description: String { - switch self { - case .reachableViaWWAN: return "Cellular" - case .reachableViaWiFi: return "WiFi" - case .notReachable: return "No Connection" - } - } - } - - enum Connection: CustomStringConvertible { + /// Tracks network connectivity with `NWPathMonitor`. + final class Reachability { + enum Connection { case unavailable, wifi, cellular - var description: String { - switch self { - case .cellular: return "Cellular" - case .wifi: return "WiFi" - case .unavailable: return "No Connection" - } - } } /// Multicast hooks: every subscriber gets called on every transition. @@ -96,307 +20,101 @@ import Foundation let onReachable = PostHogMulticastCallback() let onUnreachable = PostHogMulticastCallback() - /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) - var allowsCellularConnection: Bool - - // The notification center on which "reachability changed" events are being posted - var notificationCenter: NotificationCenter = .default - - @available(*, unavailable, renamed: "connection") - var currentReachabilityStatus: Connection { - connection - } - - var connection: Connection { - if flags == nil { - try? setReachabilityFlags() - } - - switch flags?.connection { - case .unavailable?, nil: return .unavailable - case .cellular?: return allowsCellularConnection ? .cellular : .unavailable - case .wifi?: return .wifi - } - } - - private(set) var notifierRunning = false - private let reachabilityRef: SCNetworkReachability - private let reachabilitySerialQueue: DispatchQueue + private let monitor = NWPathMonitor() + private let monitorQueue = DispatchQueue(label: "com.posthog.Reachability") private let notificationQueue: DispatchQueue? - private(set) var flags: SCNetworkReachabilityFlags? { - didSet { - guard flags != oldValue else { return } - notifyReachabilityChanged() - } - } - - required init(reachabilityRef: SCNetworkReachability, - queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) - { - allowsCellularConnection = true - self.reachabilityRef = reachabilityRef - reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue) + private let firstPath = DispatchGroup() + private let firstPathDeadline = DispatchTime.now() + .milliseconds(100) + private let lock = NSLock() + private var latestConnection: Connection? + private var notifierRunning = false + + /// - Parameter monitorsPaths: `false` skips `NWPathMonitor` so tests can drive `update(_:)`. + init(notificationQueue: DispatchQueue? = .main, monitorsPaths: Bool = true) { self.notificationQueue = notificationQueue - } - - convenience init(hostname: String, - queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) throws - { - guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { - throw ReachabilityError.failedToCreateWithHostname(hostname, SCError()) + firstPath.enter() + guard monitorsPaths else { return } + monitor.pathUpdateHandler = { [weak self] path in + self?.update(Self.connection(for: path)) } - self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) - } - - convenience init(queueQoS: DispatchQoS = .default, - targetQueue: DispatchQueue? = nil, - notificationQueue: DispatchQueue? = .main) throws - { - var zeroAddress = sockaddr() - zeroAddress.sa_len = UInt8(MemoryLayout.size) - zeroAddress.sa_family = sa_family_t(AF_INET) - - guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { - throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError()) - } - - self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + monitor.start(queue: monitorQueue) } deinit { - stopNotifier() + monitor.cancel() } - } - extension Reachability { - // MARK: - *** Notifier methods *** - - func startNotifier() throws { - guard !notifierRunning else { return } - - let callback: SCNetworkReachabilityCallBack = { _, flags, info in - guard let info = info else { return } - - // `weakifiedReachability` is guaranteed to exist by virtue of our - // retain/release callbacks which we provided to the `SCNetworkReachabilityContext`. - let weakifiedReachability = Unmanaged.fromOpaque(info).takeUnretainedValue() - - // The weak `reachability` _may_ no longer exist if the `Reachability` - // object has since been deallocated but a callback was already in flight. - weakifiedReachability.reachability?.flags = flags + /// The current connection, or `nil` if the monitor hasn't reported a path yet. + /// + /// `NWPathMonitor` delivers its first path asynchronously right after it starts, + /// so reads wait for it, up to 100 ms after init in total. Events captured during + /// SDK setup would otherwise miss their network properties. + var connection: Connection? { + if let connection = lock.withLock({ latestConnection }) { + return connection } + _ = firstPath.wait(timeout: firstPathDeadline) + return lock.withLock { latestConnection } + } - let weakifiedReachability = ReachabilityWeakifier(reachability: self) - let opaqueWeakifiedReachability = Unmanaged.passUnretained(weakifiedReachability).toOpaque() - - var context = SCNetworkReachabilityContext( - version: 0, - info: UnsafeMutableRawPointer(opaqueWeakifiedReachability), - retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - _ = unmanagedWeakifiedReachability.retain() - return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque()) - }, - release: { (info: UnsafeRawPointer) in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - unmanagedWeakifiedReachability.release() - }, - copyDescription: { (info: UnsafeRawPointer) -> Unmanaged in - let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) - let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue() - let description = weakifiedReachability.reachability?.description ?? "nil" - return Unmanaged.passRetained(description as CFString) - } - ) - - if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { - stopNotifier() - throw ReachabilityError.unableToSetCallback(SCError()) + /// Starts calling `onReachable` / `onUnreachable` on connectivity changes. + /// + /// Like the old `SCNetworkReachability` initial check, the first start also reports + /// the current connection, so subscribers flush anything queued while the app was closed. + func startNotifier() { + let (started, hasPath) = lock.withLock { () -> (Bool, Bool) in + defer { notifierRunning = true } + return (!notifierRunning, latestConnection != nil) } - - if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { - stopNotifier() - throw ReachabilityError.unableToSetDispatchQueue(SCError()) + // If no path has arrived yet, `update(_:)` reports the first one. + if started, hasPath { + notify() } - - // Perform an initial check - try setReachabilityFlags() - - notifierRunning = true } func stopNotifier() { - defer { notifierRunning = false } - - SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) + lock.withLock { notifierRunning = false } } - var description: String { - flags?.description ?? "unavailable flags" + /// Wi-Fi means "reachable and not cellular", so wired connections count as Wi-Fi. + /// `.requiresConnection` (e.g. VPN on demand) counts as reachable, since sending a + /// request brings the connection up. + private static func connection(for path: NWPath) -> Connection { + guard path.status != .unsatisfied else { return .unavailable } + return path.usesInterfaceType(.cellular) ? .cellular : .wifi } - } - private extension Reachability { - func setReachabilityFlags() throws { - try reachabilitySerialQueue.sync { [unowned self] in - var flags = SCNetworkReachabilityFlags() - if !SCNetworkReachabilityGetFlags(reachabilityRef, &flags) { - stopNotifier() - throw ReachabilityError.unableToGetFlags(SCError()) - } - - self.flags = flags + func update(_ connection: Connection) { + // Notify on every path update, like the old `SCNetworkReachability` callback did on + // every flags change. Paths also update for changes that keep the same connection + // (e.g. DNS), which only costs a no-op flush. + let (isFirstPath, shouldNotify) = lock.withLock { () -> (Bool, Bool) in + let isFirstPath = latestConnection == nil + latestConnection = connection + return (isFirstPath, notifierRunning) + } + if isFirstPath { + firstPath.leave() + } + if shouldNotify { + notify() } } - func notifyReachabilityChanged() { + /// Reads the connection at delivery, not when queued, so a notification queued + /// before a newer path can't deliver a stale state after it. + private func notify() { let notify = { [weak self] in - guard let self = self else { return } - if self.connection != .unavailable { + guard let self else { return } + if self.lock.withLock({ self.latestConnection }) != .unavailable { self.onReachable.invoke(self) } else { self.onUnreachable.invoke(self) } - self.notificationCenter.post(name: .reachabilityChanged, object: self) } - // notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`) + // notify on the configured `notificationQueue`, or the monitor's queue notificationQueue?.async(execute: notify) ?? notify() } } - - extension SCNetworkReachabilityFlags { - typealias Connection = Reachability.Connection - - var connection: Connection { - guard isReachableFlagSet else { return .unavailable } - - // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - #if targetEnvironment(simulator) - return .wifi - #else - var connection = Connection.unavailable - - if !isConnectionRequiredFlagSet { - connection = .wifi - } - - if isConnectionOnTrafficOrDemandFlagSet { - if !isInterventionRequiredFlagSet { - connection = .wifi - } - } - - if isOnWWANFlagSet { - connection = .cellular - } - - return connection - #endif - } - - var isOnWWANFlagSet: Bool { - #if os(iOS) || os(visionOS) - return contains(.isWWAN) - #else - return false - #endif - } - - var isReachableFlagSet: Bool { - contains(.reachable) - } - - var isConnectionRequiredFlagSet: Bool { - contains(.connectionRequired) - } - - var isInterventionRequiredFlagSet: Bool { - contains(.interventionRequired) - } - - var isConnectionOnTrafficFlagSet: Bool { - contains(.connectionOnTraffic) - } - - var isConnectionOnDemandFlagSet: Bool { - contains(.connectionOnDemand) - } - - var isConnectionOnTrafficOrDemandFlagSet: Bool { - !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty - } - - var isTransientConnectionFlagSet: Bool { - contains(.transientConnection) - } - - var isLocalAddressFlagSet: Bool { - contains(.isLocalAddress) - } - - var isDirectFlagSet: Bool { - contains(.isDirect) - } - - var description: String { - let W = isOnWWANFlagSet ? "W" : "-" - let R = isReachableFlagSet ? "R" : "-" - let c = isConnectionRequiredFlagSet ? "c" : "-" - let t = isTransientConnectionFlagSet ? "t" : "-" - let i = isInterventionRequiredFlagSet ? "i" : "-" - let C = isConnectionOnTrafficFlagSet ? "C" : "-" - let D = isConnectionOnDemandFlagSet ? "D" : "-" - let l = isLocalAddressFlagSet ? "l" : "-" - let d = isDirectFlagSet ? "d" : "-" - - return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" - } - } - - /** - `ReachabilityWeakifier` weakly wraps the `Reachability` class - in order to break retain cycles when interacting with CoreFoundation. - - CoreFoundation callbacks expect a pair of retain/release whenever an - opaque `info` parameter is provided. These callbacks exist to guard - against memory management race conditions when invoking the callbacks. - - #### Race Condition - - If we passed `SCNetworkReachabilitySetCallback` a direct reference to our - `Reachability` class without also providing corresponding retain/release - callbacks, then a race condition can lead to crashes when: - - `Reachability` is deallocated on thread X - - A `SCNetworkReachability` callback(s) is already in flight on thread Y - - #### Retain Cycle - - If we pass `Reachability` to CoreFoundtion while also providing retain/ - release callbacks, we would create a retain cycle once CoreFoundation - retains our `Reachability` class. This fixes the crashes and his how - CoreFoundation expects the API to be used, but doesn't play nicely with - Swift/ARC. This cycle would only be broken after manually calling - `stopNotifier()` — `deinit` would never be called. - - #### ReachabilityWeakifier - - By providing both retain/release callbacks and wrapping `Reachability` in - a weak wrapper, we: - - interact correctly with CoreFoundation, thereby avoiding a crash. - See "Memory Management Programming Guide for Core Foundation". - - don't alter the public API of `Reachability.swift` in any way - - still allow for automatic stopping of the notifier on `deinit`. - */ - private class ReachabilityWeakifier { - weak var reachability: Reachability? - init(reachability: Reachability) { - self.reachability = reachability - } - } #endif diff --git a/PostHog/Utils/UIApplication+.swift b/PostHog/Utils/UIApplication+.swift index afe9b2bc00..a6df53a17e 100644 --- a/PostHog/Utils/UIApplication+.swift +++ b/PostHog/Utils/UIApplication+.swift @@ -19,15 +19,8 @@ for scene in windowScenes { // attempt to retrieve directly from UIWindowScene - if #available(iOS 15.0, tvOS 15.0, *) { - if let keyWindow = scene.keyWindow { - return keyWindow - } - } else { - // check scene.windows.isKeyWindow - for window in scene.windows where window.isKeyWindow { - return window - } + if let keyWindow = scene.keyWindow { + return keyWindow } // check scene.delegate.window property diff --git a/PostHog/Utils/UIImage+WebP.swift b/PostHog/Utils/UIImage+WebP.swift index b0c3669320..3fa2ceeea2 100644 --- a/PostHog/Utils/UIImage+WebP.swift +++ b/PostHog/Utils/UIImage+WebP.swift @@ -6,16 +6,11 @@ // // Adapted from: https://github.com/SDWebImage/SDWebImageWebPCoder/blob/master/SDWebImageWebPCoder/Classes/SDImageWebPCoder.m -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Accelerate import CoreGraphics import Foundation - #if compiler(>=6.0) - internal import phlibwebp - #else - // swiftlint:disable:next duplicate_imports - @_implementationOnly import phlibwebp - #endif + internal import phlibwebp import UIKit extension UIImage { diff --git a/PostHogExample/AppDelegate.swift b/PostHogExample/AppDelegate.swift index 5fb36adbb4..90b055d9f8 100644 --- a/PostHogExample/AppDelegate.swift +++ b/PostHogExample/AppDelegate.swift @@ -31,13 +31,10 @@ class AppDelegate: NSObject, UIApplicationDelegate { #endif #if os(iOS) - if #available(iOS 15.0, *) { - config.surveys = true - // Uncomment to force-render any matching survey in a specific language regardless of device locale. - // config.surveysConfig.overrideDisplayLanguage = "fr" - } + config.surveys = true + // Uncomment to force-render any matching survey in a specific language regardless of device locale. + // config.surveysConfig.overrideDisplayLanguage = "fr" config.sessionReplay = false - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = true config.sessionReplayConfig.captureLogs = true @@ -46,7 +43,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { // Skip some logs guard !log.contains("[SKIP]") else { return nil } // all logs are lowercased and info level - return PostHogLogEntry(level: .info, message: log.lowercased()) + return PostHogConsoleLogEntry(level: .info, message: log.lowercased()) } #endif @@ -57,19 +54,16 @@ class AppDelegate: NSObject, UIApplicationDelegate { // PostHogSDK.shared.identify("Manoel") - let defaultCenter = NotificationCenter.default - #if os(iOS) || os(tvOS) || os(visionOS) - defaultCenter.addObserver(self, - selector: #selector(receiveFeatureFlags), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil) + PostHogSDK.shared.onFeatureFlags { [weak self] _ in + self?.receiveFeatureFlags() + } #endif return true } - @objc func receiveFeatureFlags() { + func receiveFeatureFlags() { print("user receiveFeatureFlags callback") #if DEBUG let testFlag = PostHogSDK.shared.getFeatureFlag("bool-value") diff --git a/PostHogExample/ContentView.swift b/PostHogExample/ContentView.swift index 9229609c3f..16df487da7 100644 --- a/PostHogExample/ContentView.swift +++ b/PostHogExample/ContentView.swift @@ -36,17 +36,26 @@ class SignInViewModel: NSObject, ObservableObject, ASWebAuthenticationPresentati } } +@MainActor class FeatureFlagsModel: ObservableObject { @Published var boolValue: Bool? @Published var stringValue: String? @Published var payloadValue: [String: String]? @Published var isReloading: Bool = false + private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? init() { - NotificationCenter.default.addObserver(self, selector: #selector(reloaded), name: PostHogSDK.didReceiveFeatureFlags, object: nil) + // onFeatureFlags calls back on the main thread. + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] _ in + MainActor.assumeIsolated { self?.reloaded() } + } + } + + deinit { + featureFlagsSubscription?.unsubscribe() } - @objc func reloaded() { + func reloaded() { boolValue = PostHogSDK.shared.isFeatureEnabled("4535-funnel-bar-viz") stringValue = PostHogSDK.shared.getFeatureFlag("multivariant") as? String payloadValue = PostHogSDK.shared.getFeatureFlagResult("multivariant")?.payload as? [String: String] @@ -55,8 +64,11 @@ class FeatureFlagsModel: ObservableObject { func reload() { isReloading = true - PostHogSDK.shared.reloadFeatureFlags { - self.isReloading = false + PostHogSDK.shared.reloadFeatureFlags { _ in + // The callback can run on a background thread; @Published changes belong on main. + Task { @MainActor in + self.isReloading = false + } } } } diff --git a/PostHogExample/SessionRecordingView.swift b/PostHogExample/SessionRecordingView.swift index ba7e5b8dff..fdfb641d75 100644 --- a/PostHogExample/SessionRecordingView.swift +++ b/PostHogExample/SessionRecordingView.swift @@ -52,8 +52,9 @@ struct SessionRecordingView: View { Section("Event Trigger") { Button { PostHogSDK.shared.stopSessionRecording() + PostHogSDK.shared.endSession() + PostHogSDK.shared.startSession() PostHogSDK.shared.startSessionRecording() - PostHogSessionManager.shared.setSessionId(UUID().uuidString) refreshStatus() } label: { Text("Restart & Rotate Session Id") diff --git a/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj b/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj index eade8a3a76..fd9de73bc1 100644 --- a/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj +++ b/PostHogExampleAutocapture/PostHogExampleAutocapture.xcodeproj/project.pbxproj @@ -929,7 +929,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_VERSION = ""; @@ -978,7 +978,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; SDKROOT = iphoneos; SWIFT_VERSION = ""; TARGETED_DEVICE_FAMILY = "1,2"; @@ -995,7 +995,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/PostHogExampleAutocapture/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; MARKETING_VERSION = 17; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleAutocapture; @@ -1018,7 +1018,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/PostHogExampleAutocapture/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; MARKETING_VERSION = 17; PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleAutocapture; diff --git a/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift b/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift index 32e237279e..c83c6b968c 100755 --- a/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift +++ b/PostHogExampleAutocapture/PostHogExampleAutocapture/AppDelegate.swift @@ -22,7 +22,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.sessionReplay = true config.captureScreenViews = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllTextInputs = false config.sessionReplayConfig.maskAllImages = false diff --git a/PostHogExampleStoryboard/AppDelegate.swift b/PostHogExampleStoryboard/AppDelegate.swift index b75a0189b1..52f663bc86 100644 --- a/PostHogExampleStoryboard/AppDelegate.swift +++ b/PostHogExampleStoryboard/AppDelegate.swift @@ -24,7 +24,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { config.sendFeatureFlagEvent = false config.sessionReplay = true config.sessionReplayConfig.maskAllTextInputs = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.maskAllImages = true config.sessionReplayConfig.captureNetworkTelemetry = true diff --git a/PostHogExampleWithPods/Podfile b/PostHogExampleWithPods/Podfile deleted file mode 100644 index d080eb36f2..0000000000 --- a/PostHogExampleWithPods/Podfile +++ /dev/null @@ -1,13 +0,0 @@ -platform :ios, '16.0' - -linkage = ENV['USE_FRAMEWORKS'] -if linkage != nil - Pod::UI.puts "Configuring Pod as #{linkage}ally linked Frameworks".green - use_frameworks! :linkage => linkage.to_sym -else - Pod::UI.puts "Configuring Pod as static library".green -end - -target 'PostHogExampleWithPods' do - pod 'PostHog', :git => '../' -end diff --git a/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/project.pbxproj b/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/project.pbxproj deleted file mode 100644 index 4991a997dd..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/project.pbxproj +++ /dev/null @@ -1,427 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - 690FF0222AE7C5B900A0B06B /* PostHogExampleWithPodsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0212AE7C5B900A0B06B /* PostHogExampleWithPodsApp.swift */; }; - 690FF0242AE7C5B900A0B06B /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0232AE7C5B900A0B06B /* ContentView.swift */; }; - 690FF0262AE7C5BA00A0B06B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 690FF0252AE7C5BA00A0B06B /* Assets.xcassets */; }; - 690FF0292AE7C5BA00A0B06B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 690FF0282AE7C5BA00A0B06B /* Preview Assets.xcassets */; }; - 690FF0362AE7C61300A0B06B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 690FF0352AE7C61300A0B06B /* AppDelegate.swift */; }; - E7087D98D8DE0D2DD84278DD /* Pods_PostHogExampleWithPods.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8266354C5603FEBCC7C01E4B /* Pods_PostHogExampleWithPods.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 4C2D1D1AA0327AA47D050EED /* Pods-PostHogExampleWithPods.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PostHogExampleWithPods.release.xcconfig"; path = "Target Support Files/Pods-PostHogExampleWithPods/Pods-PostHogExampleWithPods.release.xcconfig"; sourceTree = ""; }; - 690FF01E2AE7C5B900A0B06B /* PostHogExampleWithPods.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PostHogExampleWithPods.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 690FF0212AE7C5B900A0B06B /* PostHogExampleWithPodsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogExampleWithPodsApp.swift; sourceTree = ""; }; - 690FF0232AE7C5B900A0B06B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 690FF0252AE7C5BA00A0B06B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 690FF0282AE7C5BA00A0B06B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 690FF0352AE7C61300A0B06B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 8266354C5603FEBCC7C01E4B /* Pods_PostHogExampleWithPods.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PostHogExampleWithPods.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 9E7CD1F6231834F08BF61E6C /* Pods-PostHogExampleWithPods.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-PostHogExampleWithPods.debug.xcconfig"; path = "Target Support Files/Pods-PostHogExampleWithPods/Pods-PostHogExampleWithPods.debug.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 690FF01B2AE7C5B900A0B06B /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E7087D98D8DE0D2DD84278DD /* Pods_PostHogExampleWithPods.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 40EAAE95CA89DD225C1A62CA /* Pods */ = { - isa = PBXGroup; - children = ( - 9E7CD1F6231834F08BF61E6C /* Pods-PostHogExampleWithPods.debug.xcconfig */, - 4C2D1D1AA0327AA47D050EED /* Pods-PostHogExampleWithPods.release.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - 690FF0152AE7C5B900A0B06B = { - isa = PBXGroup; - children = ( - 690FF0202AE7C5B900A0B06B /* PostHogExampleWithPods */, - 690FF01F2AE7C5B900A0B06B /* Products */, - 40EAAE95CA89DD225C1A62CA /* Pods */, - 9A20B85D4D10AA6F7B0C13FB /* Frameworks */, - ); - sourceTree = ""; - }; - 690FF01F2AE7C5B900A0B06B /* Products */ = { - isa = PBXGroup; - children = ( - 690FF01E2AE7C5B900A0B06B /* PostHogExampleWithPods.app */, - ); - name = Products; - sourceTree = ""; - }; - 690FF0202AE7C5B900A0B06B /* PostHogExampleWithPods */ = { - isa = PBXGroup; - children = ( - 690FF0352AE7C61300A0B06B /* AppDelegate.swift */, - 690FF0212AE7C5B900A0B06B /* PostHogExampleWithPodsApp.swift */, - 690FF0232AE7C5B900A0B06B /* ContentView.swift */, - 690FF0252AE7C5BA00A0B06B /* Assets.xcassets */, - 690FF0272AE7C5BA00A0B06B /* Preview Content */, - ); - path = PostHogExampleWithPods; - sourceTree = ""; - }; - 690FF0272AE7C5BA00A0B06B /* Preview Content */ = { - isa = PBXGroup; - children = ( - 690FF0282AE7C5BA00A0B06B /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; - 9A20B85D4D10AA6F7B0C13FB /* Frameworks */ = { - isa = PBXGroup; - children = ( - 8266354C5603FEBCC7C01E4B /* Pods_PostHogExampleWithPods.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 690FF01D2AE7C5B900A0B06B /* PostHogExampleWithPods */ = { - isa = PBXNativeTarget; - buildConfigurationList = 690FF02C2AE7C5BA00A0B06B /* Build configuration list for PBXNativeTarget "PostHogExampleWithPods" */; - buildPhases = ( - ACA09137F648E598E95E500F /* [CP] Check Pods Manifest.lock */, - 690FF01A2AE7C5B900A0B06B /* Sources */, - 690FF01B2AE7C5B900A0B06B /* Frameworks */, - 690FF01C2AE7C5B900A0B06B /* Resources */, - 6E4C2BEF8CB8F259A129729E /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = PostHogExampleWithPods; - productName = PostHogExampleWithPods; - productReference = 690FF01E2AE7C5B900A0B06B /* PostHogExampleWithPods.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 690FF0162AE7C5B900A0B06B /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; - TargetAttributes = { - 690FF01D2AE7C5B900A0B06B = { - CreatedOnToolsVersion = 15.0.1; - }; - }; - }; - buildConfigurationList = 690FF0192AE7C5B900A0B06B /* Build configuration list for PBXProject "PostHogExampleWithPods" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 690FF0152AE7C5B900A0B06B; - productRefGroup = 690FF01F2AE7C5B900A0B06B /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 690FF01D2AE7C5B900A0B06B /* PostHogExampleWithPods */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 690FF01C2AE7C5B900A0B06B /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 690FF0292AE7C5BA00A0B06B /* Preview Assets.xcassets in Resources */, - 690FF0262AE7C5BA00A0B06B /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 6E4C2BEF8CB8F259A129729E /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-PostHogExampleWithPods/Pods-PostHogExampleWithPods-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-PostHogExampleWithPods/Pods-PostHogExampleWithPods-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-PostHogExampleWithPods/Pods-PostHogExampleWithPods-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - ACA09137F648E598E95E500F /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-PostHogExampleWithPods-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 690FF01A2AE7C5B900A0B06B /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 690FF0362AE7C61300A0B06B /* AppDelegate.swift in Sources */, - 690FF0242AE7C5B900A0B06B /* ContentView.swift in Sources */, - 690FF0222AE7C5B900A0B06B /* PostHogExampleWithPodsApp.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 690FF02A2AE7C5BA00A0B06B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 690FF02B2AE7C5BA00A0B06B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 690FF02D2AE7C5BA00A0B06B /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9E7CD1F6231834F08BF61E6C /* Pods-PostHogExampleWithPods.debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = ""; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"PostHogExampleWithPods/Preview Content\""; - DEVELOPMENT_TEAM = PNC2XCH2XP; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleWithPods; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 690FF02E2AE7C5BA00A0B06B /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 4C2D1D1AA0327AA47D050EED /* Pods-PostHogExampleWithPods.release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_IDENTITY = ""; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"PostHogExampleWithPods/Preview Content\""; - DEVELOPMENT_TEAM = PNC2XCH2XP; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.posthog.PostHogExampleWithPods; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 690FF0192AE7C5B900A0B06B /* Build configuration list for PBXProject "PostHogExampleWithPods" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 690FF02A2AE7C5BA00A0B06B /* Debug */, - 690FF02B2AE7C5BA00A0B06B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 690FF02C2AE7C5BA00A0B06B /* Build configuration list for PBXNativeTarget "PostHogExampleWithPods" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 690FF02D2AE7C5BA00A0B06B /* Debug */, - 690FF02E2AE7C5BA00A0B06B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 690FF0162AE7C5B900A0B06B /* Project object */; -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme b/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme deleted file mode 100644 index 411e04f114..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods.xcodeproj/xcshareddata/xcschemes/PostHogExampleWithPods.xcscheme +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift b/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift deleted file mode 100644 index 13bb02d4b4..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/AppDelegate.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AppDelegate.swift -// PostHogExampleWithPods -// -// Created by Manoel Aranda Neto on 24.10.23. -// - -import Foundation -import PostHog -import UIKit - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - let defaultCenter = NotificationCenter.default - - defaultCenter.addObserver(self, - selector: #selector(receiveFeatureFlags), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil) - - let config = PostHogConfig( - projectToken: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D" - ) - - PostHogSDK.shared.setup(config) - PostHogSDK.shared.debug() - PostHogSDK.shared.capture("Event from CocoaPods example!") - - return true - } - - @objc func receiveFeatureFlags() { - print("receiveFeatureFlags") - } -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AccentColor.colorset/Contents.json b/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897008..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AppIcon.appiconset/Contents.json b/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3ee1..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/Contents.json b/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/ContentView.swift b/PostHogExampleWithPods/PostHogExampleWithPods/ContentView.swift deleted file mode 100644 index 3ae9c1062a..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// PostHogExampleWithPods -// -// Created by Manoel Aranda Neto on 24.10.23. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/PostHogExampleWithPodsApp.swift b/PostHogExampleWithPods/PostHogExampleWithPods/PostHogExampleWithPodsApp.swift deleted file mode 100644 index 45d3027305..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/PostHogExampleWithPodsApp.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// PostHogExampleWithPodsApp.swift -// PostHogExampleWithPods -// -// Created by Manoel Aranda Neto on 24.10.23. -// - -import SwiftUI - -@main -struct PostHogExampleWithPodsApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/PostHogExampleWithPods/PostHogExampleWithPods/Preview Content/Preview Assets.xcassets/Contents.json b/PostHogExampleWithPods/PostHogExampleWithPods/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c00596a7..0000000000 --- a/PostHogExampleWithPods/PostHogExampleWithPods/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/PostHogExampleWithPods/README.md b/PostHogExampleWithPods/README.md deleted file mode 100644 index af34b60b46..0000000000 --- a/PostHogExampleWithPods/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Example with CocoaPods - -This is an example of how to use the PostHog iOS SDK with CocoaPods. - -## Installation - -1. Install CocoaPods if you haven't already: - - - -```bash -sudo gem install cocoapods -``` - -## Add a Podfile to the project - - - -```text -platform :ios, '16.0' - -use_frameworks! - -target '$your_project' do - pod 'PostHog', '~> 3.0' -end -``` - -Replace `$your_project` to your project name. - -## Run `pod install` - -```bash -pod install -``` - -## Open the project - -Always open the `*.xcworkspace` file, not the `*.xcodeproj` file. diff --git a/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift b/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift index 6c1ecdba2a..d79a255591 100644 --- a/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift +++ b/PostHogExampleWithSPM/PostHogExampleWithSPM/AppDelegate.swift @@ -10,12 +10,9 @@ import UIKit class AppDelegate: NSObject, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { - let defaultCenter = NotificationCenter.default - - defaultCenter.addObserver(self, - selector: #selector(receiveFeatureFlags), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil) + PostHogSDK.shared.onFeatureFlags { [weak self] _ in + self?.receiveFeatureFlags() + } let config = PostHogConfig( projectToken: "phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D" @@ -28,7 +25,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { return true } - @objc func receiveFeatureFlags() { + func receiveFeatureFlags() { print("receiveFeatureFlags") } } diff --git a/PostHogObjCExample/AppDelegate.m b/PostHogObjCExample/AppDelegate.m index 0f680a993f..3df93236df 100644 --- a/PostHogObjCExample/AppDelegate.m +++ b/PostHogObjCExample/AppDelegate.m @@ -30,10 +30,10 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( PostHogConfig *config = [[PostHogConfig alloc] projectToken:@"_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI"]; config.preloadFeatureFlags = YES; config.sessionReplayConfig.captureLogs = YES; - config.sessionReplayConfig.captureLogsConfig.minLogLevel = PostHogLogLevelWarn; - config.sessionReplayConfig.captureLogsConfig.logSanitizer = ^PostHogLogEntry * _Nullable(NSString * _Nonnull text) { + config.sessionReplayConfig.captureLogsConfig.minLogLevel = PostHogConsoleLogLevelWarn; + config.sessionReplayConfig.captureLogsConfig.logSanitizer = ^PostHogConsoleLogEntry * _Nullable(NSString * _Nonnull text) { // Simple implementation: just return the text as-is with info level - return [[PostHogLogEntry alloc] initWithLevel:PostHogLogLevelInfo message:text]; + return [[PostHogConsoleLogEntry alloc] initWithLevel:PostHogConsoleLogLevelInfo message:text]; }; @@ -94,7 +94,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( NSLog(@"getFeatureFlagResult payload: %@", [[PostHogSDK shared] getFeatureFlagResult:@"myFlag"].payload); [[PostHogSDK shared] reloadFeatureFlags]; - [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(){ + [[PostHogSDK shared] reloadFeatureFlagsWithCallback:^(PostHogFeatureFlagsLoaded *result){ NSLog(@"called"); }]; diff --git a/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift new file mode 100644 index 0000000000..40aa2549f5 --- /dev/null +++ b/PostHogSwift6Tests/PostHogCallbackIsolationTests.swift @@ -0,0 +1,108 @@ +// +// PostHogCallbackIsolationTests.swift +// PostHogSwift6Tests +// + +import Foundation +import PostHog +import Testing + +/// Calls the public callback APIs from main-actor code, as a Swift 6 app would. +/// +/// In Swift 6 mode, a closure written in main-actor code and passed as a non-`Sendable` +/// parameter is main-actor isolated, and the runtime traps if the SDK calls it off the main thread. +#if os(macOS) + // `.timeLimit` needs iOS 16, above the package's iOS 15 minimum. + @MainActor + @Suite(.timeLimit(.minutes(1))) +#else + @MainActor +#endif +struct PostHogCallbackIsolationTests { + @MainActor final class AppState { + var reloadResult: PostHogFeatureFlagsLoaded? + var loaded: PostHogFeatureFlagsLoaded? + var deliveredOnMain = false + } + + /// Nothing listens on this port, so requests fail fast without leaving the machine. + private func makeSDK(bootstrapFlags: [String: Any]? = nil) -> PostHogSDK { + let config = PostHogConfig(projectToken: "phc_swift6_\(UUID().uuidString)", host: "http://127.0.0.1:9") + config.captureApplicationLifecycleEvents = false + config.preloadFeatureFlags = false + if let bootstrapFlags { + let bootstrap = PostHogBootstrapConfig() + bootstrap.featureFlags = bootstrapFlags + config.bootstrap = bootstrap + } + return PostHogSDK.with(config) + } + + @Test("reloadFeatureFlags callback written in main-actor code runs without an isolation trap") + func reloadCallbackFromMainActorCode() async { + let sdk = makeSDK() + defer { sdk.close() } + let state = AppState() + + // The SDK invokes this from its network completion, off the main thread. + await withCheckedContinuation { continuation in + sdk.reloadFeatureFlags { result in + // Sending the result into the hop requires it to be Sendable. + Task { @MainActor in + state.reloadResult = result + continuation.resume() + } + } + } + + #expect(state.reloadResult != nil) + } + + @Test("onFeatureFlags callback written in main-actor code runs on main with a Sendable payload") + func onFeatureFlagsDeliversOnMainActor() async { + let sdk = makeSDK(bootstrapFlags: ["swift6-flag": true]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + // Touches main-actor state directly, with no hop. + guard state.loaded == nil else { return } + state.loaded = loaded + state.deliveredOnMain = Thread.isMainThread + continuation.resume() + } + } + subscription?.unsubscribe() + + #expect(state.deliveredOnMain) + // Sending the payload to another task requires it to be Sendable. + let loaded = state.loaded + let flags = await Task.detached { loaded?.flags ?? [] }.value + #expect(flags == ["swift6-flag"]) + } + + @Test("onFeatureFlags payload keeps its variant values when a mutable bootstrap value changes") + func onFeatureFlagsPayloadIsImmutable() async { + let source = NSMutableString(string: "original") + let sdk = makeSDK(bootstrapFlags: ["swift6-variant": source]) + defer { sdk.close() } + let state = AppState() + + var subscription: PostHogFeatureFlagsSubscription? + await withCheckedContinuation { continuation in + subscription = sdk.onFeatureFlags { loaded in + guard state.loaded == nil else { return } + state.loaded = loaded + continuation.resume() + } + } + subscription?.unsubscribe() + + source.setString("changed") + let loaded = state.loaded + let variant = await Task.detached { loaded?.variants["swift6-variant"] as? String }.value + #expect(variant == "original") + } +} diff --git a/PostHogTests/ExampleSanitizer.swift b/PostHogTests/ExampleSanitizer.swift deleted file mode 100644 index 92714ebefc..0000000000 --- a/PostHogTests/ExampleSanitizer.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// ExampleSanitizer.swift -// PostHogTests -// -// Created by Manoel Aranda Neto on 06.08.24. -// - -import Foundation -import PostHog - -class ExampleSanitizer: PostHogPropertiesSanitizer { - 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 - } -} diff --git a/PostHogTests/NetworkSampleTest.swift b/PostHogTests/NetworkSampleTest.swift index 51902b1653..a48dae05fd 100644 --- a/PostHogTests/NetworkSampleTest.swift +++ b/PostHogTests/NetworkSampleTest.swift @@ -3,7 +3,7 @@ // PostHogTests // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift b/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift index 11b468bbe2..93a3cf6b0c 100644 --- a/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift +++ b/PostHogTests/PostHogAppLifeCycleIntegrationTest.swift @@ -208,7 +208,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(events.first?.properties["$app_build_xcode"] as? String == Bundle.main.infoDictionary?["DTXcode"] as? String) #expect(UserDefaults.standard.string(forKey: "PHGVersionKey") != nil) @@ -243,7 +244,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(events.first?.properties["previous_build"] != nil) #expect(events.first?.properties["$app_build_xcode"] as? String == Bundle.main.infoDictionary?["DTXcode"] as? String) @@ -279,7 +281,8 @@ final class PostHogAppLifeCycleIntegrationTest { print("running tests") #expect(events.first?.properties["$app_version"] != nil) #expect(events.first?.properties["$app_build"] != nil) - #expect(events.first?.properties["build"] != nil) + #expect(events.first?.properties["version"] == nil) + #expect(events.first?.properties["build"] == nil) #expect(UserDefaults.standard.string(forKey: "PHGVersionKey") != nil) #expect(UserDefaults.standard.string(forKey: "PHGBuildKeyV2") != nil) @@ -351,8 +354,9 @@ final class PostHogAppLifeCycleIntegrationTest { #expect(events[1].properties["from_background"] as? Bool == false) #if targetEnvironment(simulator) - #expect(events[1].properties["version"] != nil) - #expect(events[1].properties["build"] != nil) + #expect(events[1].properties["version"] == nil) + #expect(events[1].properties["build"] == nil) + #expect(events[1].properties["$app_version"] != nil) #expect(events[1].properties["$app_build"] != nil) #endif @@ -430,9 +434,10 @@ final class PostHogAppLifeCycleIntegrationTest { #expect(events[3].properties["from_background"] as? Bool == true) #if targetEnvironment(simulator) - #expect(events[1].properties["version"] != nil) - #expect(events[1].properties["build"] != nil) - #expect(events[1].properties["$app_build"] != nil) + #expect(events[3].properties["version"] == nil) + #expect(events[3].properties["build"] == nil) + #expect(events[3].properties["$app_version"] != nil) + #expect(events[3].properties["$app_build"] != nil) #endif sut.close() diff --git a/PostHogTests/PostHogConfigTest.swift b/PostHogTests/PostHogConfigTest.swift index d856efd046..5c7400c788 100644 --- a/PostHogTests/PostHogConfigTest.swift +++ b/PostHogTests/PostHogConfigTest.swift @@ -41,37 +41,6 @@ struct PostHogConfigTest { let config = PostHogConfig(projectToken: testProjectToken) #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - } - - @Test("deprecated init(apiKey:) maps to project token") - func deprecatedInitApiKeyMapsToProjectToken() { - let config = PostHogConfig(apiKey: testProjectToken) - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == URL(string: PostHogConfig.defaultHost)) - } - - @Test("deprecated init(apiKey:host:) maps to project token and host") - func deprecatedInitApiKeyHostMapsToProjectTokenAndHost() throws { - let config = PostHogConfig(apiKey: testProjectToken, host: "localhost:9000") - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == (try #require(URL(string: "localhost:9000")))) - } - - @Test("deprecated init(apiKey:host:) trims whitespace-sensitive values") - func deprecatedInitApiKeyHostTrimsWhitespace() { - let config = PostHogConfig( - apiKey: " \n\(testProjectToken)\t ", - host: " \nhttps://eu.i.posthog.com/\t " - ) - - #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) - #expect(config.host == URL(string: "https://eu.i.posthog.com/")) } @Test("trims whitespace-sensitive config values") @@ -82,7 +51,6 @@ struct PostHogConfigTest { ) #expect(config.projectToken == testProjectToken) - #expect(config.apiKey == testProjectToken) #expect(config.host == URL(string: "https://eu.i.posthog.com/")) } @@ -100,6 +68,27 @@ struct PostHogConfigTest { #expect(config.host == (try #require(URL(string: "localhost:9000")))) } + @Test("setBeforeSend accepts an array of non-Sendable closures") + func setBeforeSendAcceptsNonSendableClosureArray() throws { + let config = PostHogConfig(projectToken: testProjectToken) + // An app-side array typed with the plain function type, as written before 4.0. + let blocks: [(PostHogEvent) -> PostHogEvent?] = [ + { event in + event.event += "-first" + return event + }, + { event in + event.event += "-second" + return event + }, + ] + + config.setBeforeSend(blocks) + + let event = try #require(config.runBeforeSend(PostHogEvent(event: "original", distinctId: "id"))) + #expect(event.event == "original-first-second") + } + #if os(iOS) @Suite("when initialized with default values for captureElementInteractions") struct DefaultCaptureElementInteractions { diff --git a/PostHogTests/PostHogConsoleLogInterceptorTest.swift b/PostHogTests/PostHogConsoleLogInterceptorTest.swift index d6cb046c7d..d20579ef0d 100644 --- a/PostHogTests/PostHogConsoleLogInterceptorTest.swift +++ b/PostHogTests/PostHogConsoleLogInterceptorTest.swift @@ -5,7 +5,7 @@ // Regression coverage for https://github.com/PostHog/posthog-ios/issues/780 // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing @@ -13,7 +13,7 @@ @Suite("Console Log Interceptor Tests", .serialized) class PostHogConsoleLogInterceptorTest { private func getConfig() -> PostHogConfig { - PostHogConfig(apiKey: "test-api-key") + PostHogConfig(projectToken: "test-api-key") } /// A broken pipe on a descriptor the SDK owns has to surface as `EPIPE`, not as a diff --git a/PostHogTests/PostHogContextTest.swift b/PostHogTests/PostHogContextTest.swift index 38ba5abfb9..fd9cd53eee 100644 --- a/PostHogTests/PostHogContextTest.swift +++ b/PostHogTests/PostHogContextTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("PostHogContext", .serialized, .resetsGlobalState) @@ -18,13 +18,7 @@ struct PostHogContextTest { private func getSut() -> PostHogContext { #if !os(watchOS) - var reachability: Reachability? - do { - reachability = try Reachability() - } catch { - // ignored - } - return PostHogContext(reachability) + return PostHogContext(Reachability(notificationQueue: nil, monitorsPaths: false)) #else return PostHogContext() #endif @@ -62,10 +56,36 @@ struct PostHogContextTest { #expect(context["$locale"] as? String != nil) #expect(context["$timezone"] as? String != nil) - #expect(context["$network_wifi"] as? Bool != nil) - #expect(context["$network_cellular"] as? Bool != nil) } + #if !os(watchOS) + @Test("omits network properties until a path arrives") + func omitsNetworkPropertiesWhileConnectionUnknown() { + let sut = PostHogContext(Reachability(notificationQueue: nil, monitorsPaths: false)) + + let context = sut.dynamicContext() + + #expect(context["$network_wifi"] == nil) + #expect(context["$network_cellular"] == nil) + } + + @Test("reports network properties from the current connection") + func reportsNetworkPropertiesFromConnection() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sut = PostHogContext(reachability) + + reachability.update(.wifi) + var context = sut.dynamicContext() + #expect(context["$network_wifi"] as? Bool == true) + #expect(context["$network_cellular"] as? Bool == false) + + reachability.update(.cellular) + context = sut.dynamicContext() + #expect(context["$network_wifi"] as? Bool == false) + #expect(context["$network_cellular"] as? Bool == true) + } + #endif + @Test("returns sdk info") func returnsSdkInfo() { let sut = getSut() diff --git a/PostHogTests/PostHogCrashReportProcessorTest.swift b/PostHogTests/PostHogCrashReportProcessorTest.swift index e4cc0d09b7..ca892a00d6 100644 --- a/PostHogTests/PostHogCrashReportProcessorTest.swift +++ b/PostHogTests/PostHogCrashReportProcessorTest.swift @@ -9,7 +9,7 @@ import Foundation @testable import PostHog import Testing -#if os(iOS) || os(macOS) || os(tvOS) +#if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) import PHPLCrashReporter /// A live report snapshots every thread while the process keeps running, so a thread starting or diff --git a/PostHogTests/PostHogDeviceBucketingTests.swift b/PostHogTests/PostHogDeviceBucketingTests.swift index 4ec8648353..65aadbff56 100644 --- a/PostHogTests/PostHogDeviceBucketingTests.swift +++ b/PostHogTests/PostHogDeviceBucketingTests.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -28,7 +28,6 @@ class PostHogDeviceBucketingTests { config.maxBatchSize = flushAt config.disableFlushOnBackgroundForTesting = true config.disableQueueTimerForTesting = true - config.remoteConfig = false config.preloadFeatureFlags = false let sut = PostHogSDK.with(config) cleanupJobs.append { @@ -103,7 +102,7 @@ class PostHogDeviceBucketingTests { let deviceId = sut.getDeviceId() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -121,7 +120,7 @@ class PostHogDeviceBucketingTests { sut.identify("user-123") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogEventSnapshotTests.swift b/PostHogTests/PostHogEventSnapshotTests.swift index b9d55b5ee9..b59d7069a7 100644 --- a/PostHogTests/PostHogEventSnapshotTests.swift +++ b/PostHogTests/PostHogEventSnapshotTests.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -118,7 +118,7 @@ final class PostHogEventSnapshotTests { ) let loaded = AsyncLatch() - sut.reloadFeatureFlags { loaded.signal() } + sut.reloadFeatureFlags { _ in loaded.signal() } await loaded.wait() let request = try #require(server.flagsRequests.first) @@ -273,8 +273,7 @@ final class PostHogEventSnapshotTests { // destination (xcodebuild), absent under SwiftPM's macOS command-line test bundle. // Validate then omit so the golden doesn't depend on which host compiled the test. if let captureMode = properties["$sdk_debug_replay_capture_mode"] { - let mode = try #require(captureMode as? String) - #expect(mode == "wireframe" || mode == "screenshot") + #expect(captureMode as? String == "screenshot") properties.removeValue(forKey: "$sdk_debug_replay_capture_mode") } if let appBuild = properties["$app_build"] { diff --git a/PostHogTests/PostHogEventTest.swift b/PostHogTests/PostHogEventTest.swift new file mode 100644 index 0000000000..18ef37a43b --- /dev/null +++ b/PostHogTests/PostHogEventTest.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import PostHog +import Testing + +@Suite("PostHogEvent JSON decoding") +struct PostHogEventTest { + @Test("ignores the 2.x top-level $set and message_id") + func ignoresV2Fields() throws { + let messageId = "5CE069F8-E967-4B47-9D89-207EF7519453" + let json: [String: Any] = [ + "event": "test", + "distinct_id": "user", + "properties": ["key": "value"], + "$set": ["plan": "pro"], + "message_id": messageId, + ] + + let event = try #require(PostHogEvent.fromJSON(json)) + + #expect(event.properties["$set"] == nil) + #expect(event.properties["key"] as? String == "value") + #expect(event.uuid.uuidString != messageId) + } + + @Test("reads uuid") + func readsUuid() throws { + let uuid = "019A0000-0000-7000-8000-000000000001" + let json: [String: Any] = [ + "event": "test", + "distinct_id": "user", + "uuid": uuid, + ] + + let event = try #require(PostHogEvent.fromJSON(json)) + + #expect(event.uuid.uuidString == uuid) + } +} diff --git a/PostHogTests/PostHogFeatureFlagsTest.swift b/PostHogTests/PostHogFeatureFlagsTest.swift index 89954bd5f8..2139544b7f 100644 --- a/PostHogTests/PostHogFeatureFlagsTest.swift +++ b/PostHogTests/PostHogFeatureFlagsTest.swift @@ -145,6 +145,38 @@ enum PostHogFeatureFlagsTest { #expect(sut.getFeatureFlagPayload("payload-json") as? [String: String] == ["foo": "bar"]) } + + private func sutWithCachedPayloads(_ payloads: [String: Any]) -> PostHogRemoteConfig { + let storage = PostHogStorage(config) + storage.setDictionary(forKey: .enabledFeatureFlags, contents: ["checkout": "blue", "beta-ui": true]) + storage.setDictionary(forKey: .enabledFeatureFlagPayloads, contents: payloads) + return getSut(storage: storage) + } + + @Test("malformed payload is nil, keeping the flag and sibling payloads", arguments: ["{broken", "", " "]) + func malformedPayloadIsNil(input: String) throws { + let sut = sutWithCachedPayloads(["checkout": input, "beta-ui": "{\"color\":\"green\"}"]) + + #expect(sut.getFeatureFlagPayload("checkout") == nil) + let result = try #require(sut.getFeatureFlagResult("checkout")) + #expect(result.enabled && result.variant == "blue") + #expect(result.payload == nil) + + let all = Dictionary(uniqueKeysWithValues: (sut.getAllFeatureFlagResults() ?? []).map { ($0.key, $0) }) + #expect(all["checkout"]?.variant == "blue") + #expect(all["checkout"]?.payload == nil) + #expect(all["beta-ui"]?.payload as? [String: String] == ["color": "green"]) + } + + @Test("valid JSON payloads keep their decoded value") + func validPayloadsKeepValue() { + let sut = sutWithCachedPayloads(["string": "\"hello\"", "empty": "\"\"", "false": "false", "null": "null"]) + + #expect(sut.getFeatureFlagPayload("string") as? String == "hello") + #expect(sut.getFeatureFlagPayload("empty") as? String == "") + #expect(sut.getFeatureFlagPayload("false") as? Bool == false) + #expect(sut.getFeatureFlagPayload("null") is NSNull) + } } @Suite("Test feature flags loading") @@ -309,7 +341,7 @@ enum PostHogFeatureFlagsTest { // Verify they can be retrieved by testing the internal state // Since getPersonPropertiesForFlags is private, we'll test via flag loading await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -348,7 +380,7 @@ enum PostHogFeatureFlagsTest { sut.setPersonPropertiesForFlags(["property2": "value2", "shared": "updated"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -386,7 +418,7 @@ enum PostHogFeatureFlagsTest { sut.resetPersonPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -423,7 +455,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: properties) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -464,7 +496,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("team", properties: ["role": "engineering"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -502,7 +534,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags("organization") await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -540,7 +572,7 @@ enum PostHogFeatureFlagsTest { sut.resetGroupPropertiesForFlags() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -569,7 +601,7 @@ enum PostHogFeatureFlagsTest { sut.setGroupPropertiesForFlags("organization", properties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -614,7 +646,7 @@ enum PostHogFeatureFlagsTest { sut.capture("test_event", properties: ["event_prop": "value"], userProperties: ["user_plan": "premium"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -651,7 +683,7 @@ enum PostHogFeatureFlagsTest { sut.group(type: "organization", key: "org123", groupProperties: ["org_plan": "enterprise"]) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -685,7 +717,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -703,7 +735,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -721,7 +753,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -739,7 +771,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -754,7 +786,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -777,7 +809,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -804,7 +836,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -831,7 +863,7 @@ enum PostHogFeatureFlagsTest { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -865,7 +897,7 @@ enum PostHogFeatureFlagsTest { func returnsAllFlagsIncludingDisabled() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let all = sut.getAllFeatureFlags() @@ -894,7 +926,7 @@ enum PostHogFeatureFlagsTest { func decodesPayloads() async { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let byKey = Dictionary(uniqueKeysWithValues: (sut.getAllFeatureFlags() ?? []).map { ($0.key, $0) }) @@ -911,7 +943,7 @@ enum PostHogFeatureFlagsTest { func matchesSingleKeyResult() async throws { let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } let flags = try #require(sut.getAllFeatureFlags()) @@ -932,7 +964,7 @@ enum PostHogFeatureFlagsTest { config.flushAt = 1 let sut = track(PostHogSDK.with(config)) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } server.reset(batchCount: 1) @@ -1033,7 +1065,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1073,7 +1105,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1104,7 +1136,7 @@ enum PostHogFeatureFlagsTest { // Load feature flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1134,7 +1166,7 @@ enum PostHogFeatureFlagsTest { // Initially no evaluation contexts await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1153,7 +1185,7 @@ enum PostHogFeatureFlagsTest { // Reload flags await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1174,55 +1206,6 @@ enum PostHogFeatureFlagsTest { #expect(evaluationContexts.contains("staging"), "Expected 'staging' in evaluation contexts") #expect(evaluationContexts.contains("mobile"), "Expected 'mobile' in evaluation contexts") } - - @Test("Deprecated evaluationEnvironments property still works") - func deprecatedEvaluationEnvironmentsStillWorks() async { - // Use the deprecated property - config.evaluationEnvironments = ["production", "api"] - let sut = track(PostHogSDK.with(config)) - - // Verify the deprecated property maps to evaluationContexts - #expect(config.evaluationContexts?.count == 2, "Expected evaluationContexts to be set via deprecated property") - #expect(config.evaluationContexts?.contains("production") == true, "Expected 'production' in evaluationContexts") - #expect(config.evaluationContexts?.contains("api") == true, "Expected 'api' in evaluationContexts") - - // Verify reading from deprecated property returns same values - #expect(config.evaluationEnvironments?.count == 2, "Expected evaluationEnvironments getter to return same values") - #expect(config.evaluationEnvironments?.contains("production") == true, "Expected 'production' from evaluationEnvironments getter") - #expect(config.evaluationEnvironments?.contains("api") == true, "Expected 'api' from evaluationEnvironments getter") - - // Enable person processing - sut.identify("test_user") - - // Load feature flags - await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { - continuation.resume() - } - } - - // Verify the request included evaluation contexts (set via deprecated property) - #expect(server.flagsRequests.count > 0, "Expected at least one flags request to be made") - - guard let lastRequest = server.flagsRequests.last else { - #expect(Bool(false), "No flags request found") - return - } - - guard let requestBody = server.parseRequest(lastRequest, gzip: false) else { - #expect(Bool(false), "Failed to parse request body") - return - } - - guard let evaluationContexts = requestBody["evaluation_contexts"] as? [String] else { - #expect(Bool(false), "Evaluation contexts not found in request body: \(requestBody)") - return - } - - #expect(evaluationContexts.count == 2, "Expected 2 evaluation contexts") - #expect(evaluationContexts.contains("production"), "Expected 'production' in evaluation contexts") - #expect(evaluationContexts.contains("api"), "Expected 'api' in evaluation contexts") - } } @Suite("Test Bootstrap Feature Flags") @@ -1285,6 +1268,27 @@ enum PostHogFeatureFlagsTest { #expect(sut.getFeatureFlagPayload("beta-ui") as? [String: String] == ["color": "blue"]) } + @Test("Bootstrapped string payloads are not JSON-decoded again", arguments: ["hello", "123", "true"]) + func bootstrappedStringPayloadNotDecodedAgain(payload: String) { + let config = bootstrapConfig(featureFlags: ["beta-ui": "variant-a"], featureFlagPayloads: ["beta-ui": payload]) + let sut = getSut(storage: freshStorage(config), config: config) + + #expect(sut.getFeatureFlagResult("beta-ui")?.payload as? String == payload) + } + + @Test("Bootstrapped string payloads survive a restart without bootstrap", arguments: ["hello", "123", "true"]) + func bootstrappedStringPayloadSurvivesRestart(payload: String) { + let config = bootstrapConfig(featureFlags: ["beta-ui": "variant-a"], featureFlagPayloads: ["beta-ui": payload]) + let storage = freshStorage(config) + _ = getSut(storage: storage, config: config) + + // Next launch: same storage, no bootstrap, and no /flags response yet. + let restartConfig = bootstrapConfig(featureFlags: [:]) + let sut = getSut(storage: PostHogStorage(restartConfig), config: restartConfig) + + #expect(sut.getFeatureFlagResult("beta-ui")?.payload as? String == payload) + } + @Test("Loaded flags override bootstrapped values") func loadedFlagsOverrideBootstrap() async { let config = bootstrapConfig(featureFlags: ["bool-value": "bootstrapped-variant"]) @@ -1451,7 +1455,7 @@ enum PostHogFeatureFlagsTest { // Receive a /flags response first await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1474,7 +1478,7 @@ enum PostHogFeatureFlagsTest { } } - #if SWIFT_PACKAGE + #if SWIFT_PACKAGE && os(macOS) @Suite("Test concurrent flag reload coalescing", .timeLimit(.minutes(1))) #else @Suite("Test concurrent flag reload coalescing") @@ -1523,7 +1527,7 @@ enum PostHogFeatureFlagsTest { let flags = await withCheckedContinuation { (continuation: CheckedContinuation<[String: Any]?, Never>) in sut.loadFeatureFlags(distinctId: "distinctId", anonymousId: nil, groups: [:], callback: { flags in - continuation.resume(returning: flags) + continuation.resume(returning: flags.featureFlags) }) // displaces the reload above out of the pending slot @@ -1559,7 +1563,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"]) sut.identify("test_user") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -1574,7 +1578,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1587,7 +1591,7 @@ enum PostHogFeatureFlagsTest { await withCheckedContinuation { continuation in sut.reloadFeatureFlags() - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) } @@ -1604,7 +1608,7 @@ enum PostHogFeatureFlagsTest { stubFlagsRequiringOverride(delay: 0) await withCheckedContinuation { continuation in sut.setPersonPropertiesForFlags(["app_version_semver": "3.09.0"], reloadFeatureFlags: false) - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true) @@ -1612,7 +1616,7 @@ enum PostHogFeatureFlagsTest { HTTPStubsResponse(jsonObject: ["error": "nope"], statusCode: 400, headers: nil) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } #expect(sut.getFeatureFlag("override-flag") as? Bool == true, "a failed reload must not clear cached flags") @@ -1637,7 +1641,7 @@ enum PostHogFeatureFlagsTest { /// Waits for the main-queue delivery that follows a completed reload. private func reload(_ sut: PostHogSDK) async { await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { continuation.resume() } + sut.reloadFeatureFlags { _ in continuation.resume() } } await MainActor.run {} } @@ -1701,6 +1705,70 @@ enum PostHogFeatureFlagsTest { #expect(received.last?.variants["string-value"] as? String == "test") } + @Test("reloadFeatureFlags passes the loaded flags to its callback") + func reloadCallbackResult() async { + let sut = track(PostHogSDK.with(config)) + + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == false) + #expect(result.variants["string-value"] as? String == "test") + } + + @Test("reloadFeatureFlags reports errorsLoading with the last known flags when the request fails") + func reloadCallbackErrorsLoading() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + let result = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: $0) } + } + + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + } + + @Test("coalesced reload callbacks keep the failed attempt's last known flags if one calls reset()") + func reloadCallbackCoalescedFailureReset() async { + let sut = track(PostHogSDK.with(config)) + await reload(sut) + + // Hold the first failed response so the next two reloads coalesce into one pending request + let firstResponseGate = DispatchSemaphore(value: 0) + let lock = NSLock() + var failedRequests = 0 + server.flagsResponseHandler = { _ in + let count = lock.withLock { + failedRequests += 1 + return failedRequests + } + if count == 1 { + _ = firstResponseGate.wait(timeout: .now() + 5) + } + return HTTPStubsResponse(jsonObject: [], statusCode: 500, headers: nil) + } + sut.reloadFeatureFlags { _ in } + var requestsBeforeReset = 0 + sut.reloadFeatureFlags { _ in + requestsBeforeReset = lock.withLock { failedRequests } + sut.reset() + } + let (result, flagAfterReset) = await withCheckedContinuation { continuation in + sut.reloadFeatureFlags { continuation.resume(returning: ($0, sut.getFeatureFlag("string-value"))) } + firstResponseGate.signal() + } + + #expect(requestsBeforeReset == 2) + #expect(result.errorsLoading == true) + #expect(result.variants["string-value"] as? String == "test") + #expect(flagAfterReset == nil) + } + @Test("a listener registered while a delivery is queued on main ends on the newest values") func lateListenerAfterQueuedDelivery() async { let sut = track(PostHogSDK.with(config)) @@ -1720,7 +1788,7 @@ enum PostHogFeatureFlagsTest { var received: [PostHogFeatureFlagsLoaded] = [] await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.onFeatureFlags { received.append($0) } mainBlocked.signal() continuation.resume() @@ -1802,7 +1870,7 @@ enum PostHogFeatureFlagsTest { let mainBlocked = DispatchSemaphore(value: 0) DispatchQueue.main.async { _ = mainBlocked.wait(timeout: .now() + 5) } await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in sut.remoteConfig?.canReloadFlagsForTesting = false sut.reset() sut.onFeatureFlags { received.append($0) } diff --git a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift index 8ad9e6726b..a6b2629576 100644 --- a/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift +++ b/PostHogTests/PostHogFileBackedQueueAlignmentTest.swift @@ -31,6 +31,22 @@ struct PostHogFileBackedQueueAlignmentTest { data.map { String(data: $0, encoding: .utf8)! } } + @Test("deletes a v2 queue file without importing its events") + func deletesLegacyQueueFile() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-align-\(UUID().uuidString)") + let oldQueue = FileManager.default.temporaryDirectory + .appendingPathComponent("ph-queue-plist-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + try Data(#"[{"event":"v2 event","distinct_id":"user","timestamp":"2023-10-25T14:14:04.407Z"}]"#.utf8) + .write(to: oldQueue) + + let queue = PostHogFileBackedQueue(queue: dir, oldQueues: [oldQueue]) + + #expect(!FileManager.default.fileExists(atPath: oldQueue.path)) + #expect(queue.depth == 0) + } + @Test("a failed write to a full queue preserves existing entries", .enabled(if: geteuid() != 0)) func failedWritePreservesFullQueue() throws { let (queue, dir) = makeQueue() diff --git a/PostHogTests/PostHogGeoIpTest.swift b/PostHogTests/PostHogGeoIpTest.swift index 8291066c35..bcbaa1a35c 100644 --- a/PostHogTests/PostHogGeoIpTest.swift +++ b/PostHogTests/PostHogGeoIpTest.swift @@ -69,7 +69,7 @@ class PostHogGeoIpTests { let sut = getSut() await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } @@ -87,7 +87,7 @@ class PostHogGeoIpTests { let sut = getSut(disableGeoIp: true) await withCheckedContinuation { continuation in - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in continuation.resume() } } diff --git a/PostHogTests/PostHogIdentityTests.swift b/PostHogTests/PostHogIdentityTests.swift index a8d8ff1a67..cfb4697d97 100644 --- a/PostHogTests/PostHogIdentityTests.swift +++ b/PostHogTests/PostHogIdentityTests.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Identity tests", .serialized) diff --git a/PostHogTests/PostHogIntegrationInstallationTest.swift b/PostHogTests/PostHogIntegrationInstallationTest.swift index 3bfb3def9a..0fd0942323 100644 --- a/PostHogTests/PostHogIntegrationInstallationTest.swift +++ b/PostHogTests/PostHogIntegrationInstallationTest.swift @@ -16,21 +16,17 @@ class PostHogIntegrationInstallationTest { init() { server = MockPostHogServer() server.start() - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) PostHogReplayIntegration.clearInstalls() #endif #if os(iOS) || targetEnvironment(macCatalyst) PostHogAutocaptureIntegration.clearInstalls() #endif #if os(iOS) || os(macOS) - if #available(iOS 14.0, macOS 11.0, *) { - PostHogPushNotificationOpenIntegration.clearInstalls() - } + PostHogPushNotificationOpenIntegration.clearInstalls() #endif #if os(iOS) - if #available(iOS 14.0, *) { - PostHogPushNotificationSubscriptionIntegration.clearInstalls() - } + PostHogPushNotificationSubscriptionIntegration.clearInstalls() #endif PostHogAppLifeCycleIntegration.clearInstalls() PostHogScreenViewIntegration.clearInstalls() @@ -63,7 +59,7 @@ class PostHogIntegrationInstallationTest { config.disableReachabilityForTesting = true config.enableSwizzling = enableSwizzling - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) config.sessionReplay = sessionReplay #endif @@ -78,7 +74,9 @@ class PostHogIntegrationInstallationTest { #endif config.captureScreenViews = captureScreenViews - config.errorTrackingConfig.autoCapture = errorTrackingAutoCapture + #if !SWIFT_PACKAGE || CrashReporting + config.errorTrackingConfig.autoCapture = errorTrackingAutoCapture + #endif let storage = PostHogStorage(config) storage.reset() @@ -86,7 +84,7 @@ class PostHogIntegrationInstallationTest { return PostHogSDK.with(config) } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) @Test("replay integration installed only once, on first instance") func replayIntegrationInstalledOnce() { let first = getSut(projectToken: "test_project_token", sessionReplay: true) @@ -190,7 +188,7 @@ class PostHogIntegrationInstallationTest { // MARK: - Error tracking integration - #if os(iOS) || os(macOS) || os(tvOS) + #if (os(iOS) || os(macOS) || os(tvOS)) && (!SWIFT_PACKAGE || CrashReporting) @Test("error tracking integration installed on first launch before remote config arrives") func errorTrackingInstalledBeforeRemoteConfig() { // disableRemoteConfig=true means hasFetchedRemoteConfig stays false. @@ -404,7 +402,6 @@ class PostHogIntegrationInstallationTest { #if os(iOS) || os(macOS) @Test("push notification opened integration installed only once, on first instance") func pushNotificationOpenedIntegrationInstalledOnce() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let first = getSut(projectToken: "test_project_token", capturePushNotificationOpened: true) let second = getSut(projectToken: "test_project_token", capturePushNotificationOpened: true) @@ -417,7 +414,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification opened integration not installed when the flag is disabled") func pushNotificationOpenedIntegrationNotInstalledWhenDisabled() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let sut = getSut(projectToken: "test_project_token", capturePushNotificationOpened: false) #expect(sut.getPushNotificationIntegration() == nil) @@ -427,7 +423,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification opened integration skipped when swizzling is disabled") func pushNotificationOpenedIntegrationSkippedWithoutSwizzling() async { - guard #available(iOS 14.0, macOS 11.0, *) else { return } let sut = getSut(projectToken: "test_project_token", enableSwizzling: false, capturePushNotificationOpened: true) #expect(sut.getPushNotificationIntegration() == nil) @@ -440,7 +435,6 @@ class PostHogIntegrationInstallationTest { #if os(iOS) @Test("push notification subscription integration installed only once, on first instance") func pushNotificationSubscriptionIntegrationInstalledOnce() async { - guard #available(iOS 14.0, *) else { return } let first = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: true) let second = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: true) @@ -453,7 +447,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification subscription integration not installed when the flag is disabled") func pushNotificationSubscriptionIntegrationNotInstalledWhenDisabled() async { - guard #available(iOS 14.0, *) else { return } let sut = getSut(projectToken: "test_project_token", capturePushNotificationSubscriptions: false) #expect(sut.getPushNotificationSubscriptionIntegration() == nil) @@ -463,7 +456,6 @@ class PostHogIntegrationInstallationTest { @Test("push notification subscription integration skipped when swizzling is disabled") func pushNotificationSubscriptionIntegrationSkippedWithoutSwizzling() async { - guard #available(iOS 14.0, *) else { return } let sut = getSut(projectToken: "test_project_token", enableSwizzling: false, capturePushNotificationSubscriptions: true) #expect(sut.getPushNotificationSubscriptionIntegration() == nil) diff --git a/PostHogTests/PostHogLegacyQueueTest.swift b/PostHogTests/PostHogLegacyQueueTest.swift deleted file mode 100644 index 7cb0870cfd..0000000000 --- a/PostHogTests/PostHogLegacyQueueTest.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// PostHogLegacyQueueTest.swift -// PostHogTests -// -// Created by Manoel Aranda Neto on 30.10.23. -// - -import Foundation -@testable import PostHog -import Testing - -@Suite("PostHog legacy queue migration", .serialized, .resetsGlobalState) -struct PostHogLegacyQueueTest { - init() { - deleteSafely(applicationSupportDirectoryURL()) - } - - @Test("migrate old queue to new queue") - func migrateOldQueueToNewQueue() throws { - let baseUrl = applicationSupportDirectoryURL() - try FileManager.default.createDirectory(atPath: baseUrl.path, withIntermediateDirectories: true) - - let newURL = baseUrl.appendingPathComponent("queue") - try FileManager.default.createDirectory(atPath: newURL.path, withIntermediateDirectories: true) - - let oldURL = baseUrl.appendingPathComponent("oldQueue") - - let eventsArray = - """ - [ - { - "properties": { - "$network_cellular": false, - "$groups": { - "some-group": "id:4" - }, - "$app_build": 1, - "$os_name": "iOS", - "$feature/multivariant": "payload", - "$screen_width": 852, - "$app_version": "1.0", - "$device_type": "Mobile", - "$active_feature_flags__0": "multivariant", - "$feature/4535-funnel-bar-viz": true, - "$network_wifi": true, - "$timezone": "Europe/Vienna", - "$device_id": "48DA3429-495A-4903-B347-F096FC31C3AB", - "$active_feature_flags": [ - "$feature/multivariant", - "$feature/testJson", - "$feature/4535-funnel-bar-viz", - "$feature/disabledFlag" - ], - "$active_feature_flags__3": "disabledFlag", - "$device_name": "iPhone", - "$app_name": "", - "$app_namespace": "com.posthog.CocoapodsExample", - "$locale": "en-US", - "$feature/disabledFlag": true, - "$active_feature_flags__1": "testJson", - "$screen_height": 393, - "$device_model": "arm64", - "$device_manufacturer": "Apple", - "$feature/testJson": "theInteger", - "$lib_version": "2.1.0", - "$os_version": "17.0.1", - "$lib": "posthog-ios", - "$active_feature_flags__2": "4535-funnel-bar-viz" - }, - "timestamp": "2023-10-25T14:14:04.407Z", - "message_id": "5CE069F8-E967-4B47-9D89-207EF7519453", - "event": "Cocoapods Example Button", - "distinct_id": "Prateek" - } - ] - """ - - let eventsData = try #require(eventsArray.data(using: .utf8)) - try eventsData.write(to: oldURL) - - #expect(FileManager.default.fileExists(atPath: oldURL.path)) - - migrateOldQueue(queue: newURL, oldQueue: oldURL) - - #expect(!FileManager.default.fileExists(atPath: oldURL.path)) - - let items = try FileManager.default.contentsOfDirectory(atPath: newURL.path) - - let firstItem = try #require(items.first) - let eventURL = newURL.appendingPathComponent(firstItem) - #expect(FileManager.default.fileExists(atPath: eventURL.path)) - - let eventData = try Data(contentsOf: eventURL) - let eventObject = try #require(try JSONSerialization.jsonObject(with: eventData, options: .allowFragments) as? [String: Any]) - - #expect(eventObject["distinct_id"] as? String == "Prateek") - #expect(eventObject["event"] as? String == "Cocoapods Example Button") - #expect(eventObject["message_id"] as? String == "5CE069F8-E967-4B47-9D89-207EF7519453") - #expect(eventObject["timestamp"] as? String == "2023-10-25T14:14:04.407Z") - #expect(eventObject["properties"] as? [String: Any] != nil) - - deleteSafely(oldURL) - deleteSafely(newURL) - } - - @Test("ignore and delete corrupted file") - func ignoreAndDeleteCorruptedFile() throws { - let baseUrl = applicationSupportDirectoryURL() - try FileManager.default.createDirectory(atPath: baseUrl.path, withIntermediateDirectories: true) - - let newURL = baseUrl.appendingPathComponent("queue") - try FileManager.default.createDirectory(atPath: newURL.path, withIntermediateDirectories: true) - - let oldURL = baseUrl.appendingPathComponent("oldQueue") - - let eventsArray = - """ - [ - i am broken - ] - """ - - let eventsData = try #require(eventsArray.data(using: .utf8)) - try eventsData.write(to: oldURL) - - #expect(FileManager.default.fileExists(atPath: oldURL.path)) - - migrateOldQueue(queue: newURL, oldQueue: oldURL) - - #expect(!FileManager.default.fileExists(atPath: oldURL.path)) - - let items = try FileManager.default.contentsOfDirectory(atPath: newURL.path) - #expect(items.isEmpty) - - deleteSafely(oldURL) - deleteSafely(newURL) - } -} diff --git a/PostHogTests/PostHogLogsQueueTest.swift b/PostHogTests/PostHogLogsQueueTest.swift index 7d2a533256..6a3022c007 100644 --- a/PostHogTests/PostHogLogsQueueTest.swift +++ b/PostHogTests/PostHogLogsQueueTest.swift @@ -6,7 +6,7 @@ import Foundation import OHHTTPStubs import OHHTTPStubsSwift -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -591,6 +591,27 @@ final class PostHogLogsQueueTests { #expect(record.body == "redacted") } + @Test("setBeforeSend accepts an array of non-Sendable closures") + func beforeSendAcceptsNonSendableClosureArray() throws { + let config = PostHogConfig(projectToken: "test") + // An app-side array typed with the plain function type, as written before 4.0. + let blocks: [(PostHogLogRecord) -> PostHogLogRecord?] = [ + { record in + record.body += "-first" + return record + }, + { record in + record.body += "-second" + return record + }, + ] + + config.logs.setBeforeSend(blocks) + + let processed = try #require(config.logs.runBeforeSend(makeRecord(body: "original"))) + #expect(processed.body == "original-first-second") + } + // MARK: - Concurrency @Test("concurrent add() from many threads — all records accounted for, no crash") @@ -701,12 +722,10 @@ final class PostHogLogsQueueTests { @Test("flush is suppressed while reachability reports unreachable, resumes on reconnect") func reachabilityPauseAndResume() async throws { - // `notificationQueue: nil` makes reachability notify synchronously instead of dispatching to - // main. Otherwise the initial check inside `startNotifier()` queues an async `onReachable` on - // main that lands *after* our manual `onUnreachable` below, wiping the paused flag and flaking - // the test. With nil, that initial notification fires during `start()`, before we stop the - // notifier — so only the manual `invoke(...)` calls drive state from here on. - let reachability = try Reachability(notificationQueue: nil) + // No live `NWPathMonitor`: only the `update(_:)` calls below change connectivity, and + // `notificationQueue: nil` delivers each one synchronously. + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + reachability.update(.wifi) let (queue, _) = makeQueue( reachability: reachability, disableReachabilityForTesting: false @@ -715,11 +734,8 @@ final class PostHogLogsQueueTests { queue.stop() } - // Stop the live notifier so no further system callbacks can race the manual events below. - reachability.stopNotifier() - - // Simulate network going down. Subsequent flushes are paused. - reachability.onUnreachable.invoke(reachability) + // Network goes down. Subsequent flushes are paused. + reachability.update(.unavailable) queue.add(makeRecord(body: "while-offline")) for _ in 0 ..< 3 { @@ -730,9 +746,9 @@ final class PostHogLogsQueueTests { #expect(queue.depth == 1) #expect(queue.currentRetryCountForTesting == 0) - // Simulate WiFi back. The reachable callback unpauses and proactively + // WiFi is back. The reachable callback unpauses and proactively // triggers a flush. - reachability.onReachable.invoke(reachability) + reachability.update(.wifi) waitForLogsRequests(count: 1) #expect(server.logsRequests.count == 1) await waitUntil { queue.depth == 0 } diff --git a/PostHogTests/PostHogMaskFailClosedTest.swift b/PostHogTests/PostHogMaskFailClosedTest.swift index 54a916c4c3..fb31be1a05 100644 --- a/PostHogTests/PostHogMaskFailClosedTest.swift +++ b/PostHogTests/PostHogMaskFailClosedTest.swift @@ -7,7 +7,7 @@ // while it fades out. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import CoreGraphics import Foundation @testable import PostHog @@ -313,6 +313,19 @@ #expect(rects.contains(secret.frame)) } + @Test("a full-window no-capture leaf is masked itself") + func fullWindowNoCaptureLeaf() throws { + let sut = makeSut() + defer { teardown(sut) } + + let secret = CustomDrawnSecretView(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) + secret.accessibilityIdentifier = "ph-no-capture" + let window = makeWindow(containing: secret) + try #require(secret.toAbsoluteRect(window).equalTo(window.frame)) + + #expect(sut.integration.collectMaskableRects(in: window) == [secret.frame]) + } + @Test("inherited masking respects explicit no-mask and stays within the marked subtree") func inheritedMaskRespectsScopeAndNoMask() { let sut = makeSut() diff --git a/PostHogTests/PostHogMaskFlattenedContentTest.swift b/PostHogTests/PostHogMaskFlattenedContentTest.swift index d5339c0aea..68bc5851c3 100644 --- a/PostHogTests/PostHogMaskFlattenedContentTest.swift +++ b/PostHogTests/PostHogMaskFlattenedContentTest.swift @@ -7,7 +7,7 @@ // per-Text masking heuristics can't see the text inside. These tests host real SwiftUI and // check the collected mask rects geometrically. -#if os(iOS) && canImport(SwiftUI) +#if os(iOS) && canImport(SwiftUI) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import SwiftUI @@ -34,7 +34,6 @@ config.captureScreenViews = false config.sessionReplayConfig.maskAllTextInputs = maskAllTextInputs config.sessionReplayConfig.maskAllImages = maskAllImages - config.sessionReplayConfig.screenshotMode = true sdk = PostHogSDK.with(config) _ = integration.install(sdk) integration.stop() diff --git a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift index ef8f4fe562..c70bc322e7 100644 --- a/PostHogTests/PostHogMaskPresentationPrivacyTest.swift +++ b/PostHogTests/PostHogMaskPresentationPrivacyTest.swift @@ -68,7 +68,6 @@ config.captureScreenViews = false config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = true - config.sessionReplayConfig.screenshotMode = true sdk = PostHogSDK.with(config) _ = integration.install(sdk) integration.stop() @@ -161,7 +160,6 @@ } @Test("medium sheet keeps visible presenter PII and sheet PII masked") - @available(iOS 15.0, *) func mediumSheet() async throws { let base = Secrets() let h = try Harness(root: base.controller) @@ -230,7 +228,6 @@ func updateUIView(_: UIView, context _: Context) {} } - @available(iOS 14.0, *) private struct SwiftUIScreen: View { @ObservedObject var model: SwiftUIModel var body: some View { @@ -250,7 +247,6 @@ } } - @available(iOS 14.0, *) @Test("SwiftUI fullScreenCover masks its explicit text, automatic Text, TextField and Image") func swiftUISensitiveCover() async throws { let model = SwiftUIModel() @@ -595,6 +591,24 @@ #expect(try h.rects().isEmpty, "Fully faded banner must not veto the opaque cover") } + @Test("secure fields in a cover stay masked under a no-mask ancestor") + func secureFieldInCoverUnderNoMaskAncestor() async throws { + let base = UIViewController() + let h = try Harness(root: base) + defer { h.close() } + h.window.accessibilityIdentifier = "ph-no-mask" + let cover = Secrets() + cover.field.isSecureTextEntry = true + try await present(cover.controller, over: base, in: h) + let selected = try #require(PostHogPresentationCover.frontmostFullWindowCover(in: h.window)) + try #require(selected !== h.window) + try #require(cover.field.isDescendant(of: selected)) + + try h.expectMasked(cover.field, context: "secure field under no-mask window") + let labelRect = cover.label.toPresentationRect(h.window) + #expect(try h.rects().allSatisfy { !$0.intersects(labelRect) }, "No-mask still unmasks ordinary text") + } + @Test("rotated cover bounding box is not proof the presenter is hidden") func rotatedCover() async throws { let base = Secrets(background: .yellow) diff --git a/PostHogTests/PostHogMaskPresentationTest.swift b/PostHogTests/PostHogMaskPresentationTest.swift index aecbcb7386..0849a24169 100644 --- a/PostHogTests/PostHogMaskPresentationTest.swift +++ b/PostHogTests/PostHogMaskPresentationTest.swift @@ -25,7 +25,6 @@ @Published var isPresented = false } - @available(iOS 14.0, *) private struct MaskedScreen: View { @ObservedObject var model: CoverModel @@ -85,7 +84,6 @@ // MARK: - Tests - @available(iOS 14.0, *) @Test("a SwiftUI fullScreenCover drops the masks of the screen it covers") func fullScreenCoverDropsMasksBehindIt() async { let model = CoverModel() diff --git a/PostHogTests/PostHogMaskScenarioTest.swift b/PostHogTests/PostHogMaskScenarioTest.swift index aa45519052..cc5fa23b8f 100644 --- a/PostHogTests/PostHogMaskScenarioTest.swift +++ b/PostHogTests/PostHogMaskScenarioTest.swift @@ -10,7 +10,7 @@ // layer-scan logic is covered separately in PostHogMaskingCharacterizationTest; // rendered-pixel coverage lives in PostHogMaskSnapshotTest. -#if os(iOS) && canImport(SwiftUI) +#if os(iOS) && canImport(SwiftUI) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import SwiftUI @@ -497,116 +497,5 @@ #expect(inflated[0].minY <= 95) #expect(inflated[0].maxY >= 125.31) } - - @Test("swept union covers both sample positions, in after order") - func sweptRectsCoversBothSamplesInAfterOrder() throws { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - // b moves; a is stationary. after is in swapped order vs before, so - // ordering must be recovered from `owner`, not index. - let after = [ - region(b, CGRect(x: 0, y: 130, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept.count == 2) - - // swept[0] pairs with after[0] (b): union of y=100..120 and y=130..150. - #expect(swept[0] == CGRect(x: 0, y: 100, width: 100, height: 50)) - - // swept[1] pairs with after[1] (a): stationary, so it collapses to a's rect. - #expect(swept[1] == CGRect(x: 0, y: 0, width: 100, height: 20)) - } - - @Test("nil before makes sweptRects nil") - func sweptRectsNilBefore() { - let a = NSObject() - let after = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - #expect(PostHogReplayIntegration.sweptRects(before: nil, after: after) == nil) - } - - @Test("nil after makes sweptRects nil") - func sweptRectsNilAfter() { - let a = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: nil) == nil) - } - - @Test("an owner that appears mid-render is covered, not dropped") - func sweptRectsCountMismatch() throws { - let a = NSObject() - let b = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - // b appeared mid-render, so it was displayed for part of the window and is covered - // rather than costing the whole frame. - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [ - CGRect(x: 0, y: 0, width: 100, height: 20), - CGRect(x: 0, y: 100, width: 100, height: 20), - ]) - } - - @Test("duplicate owner in before makes sweptRects nil") - func sweptRectsDuplicateOwnerInBefore() { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: after) == nil) - } - - @Test("duplicate owner in after makes sweptRects nil") - func sweptRectsDuplicateOwnerInAfter() { - let a = NSObject() - let b = NSObject() - let before = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(b, CGRect(x: 0, y: 100, width: 100, height: 20)), - ] - let after = [ - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - region(a, CGRect(x: 0, y: 0, width: 100, height: 20)), - ] - #expect(PostHogReplayIntegration.sweptRects(before: before, after: after) == nil) - } - - @Test("a fully recycled owner set covers both samples, not nil") - func sweptRectsDisjointOwnerSet() throws { - let a = NSObject() - let b = NSObject() - let before = [region(a, CGRect(x: 0, y: 0, width: 100, height: 20))] - let after = [region(b, CGRect(x: 0, y: 200, width: 100, height: 20))] - // Neither owner can be paired, but both were on screen during the render window: - // `after` entries come first, then the ones that went away. - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [ - CGRect(x: 0, y: 200, width: 100, height: 20), - CGRect(x: 0, y: 0, width: 100, height: 20), - ]) - } - - @Test("a stationary owner's swept rect equals its rect unchanged") - func sweptRectsStationaryOwnerUnchanged() throws { - let a = NSObject() - let rect = CGRect(x: 10, y: 20, width: 100, height: 20) - let before = [region(a, rect)] - let after = [region(a, rect)] - let swept = try #require(PostHogReplayIntegration.sweptRects(before: before, after: after)) - #expect(swept == [rect]) - } } #endif diff --git a/PostHogTests/PostHogMaskSnapshotTest.swift b/PostHogTests/PostHogMaskSnapshotTest.swift index 34f967d06d..fd2dea71ae 100644 --- a/PostHogTests/PostHogMaskSnapshotTest.swift +++ b/PostHogTests/PostHogMaskSnapshotTest.swift @@ -489,7 +489,7 @@ private let integration = PostHogReplayIntegration() init() { - let config = PostHogConfig(apiKey: "phc_maskSnapshotTest") + let config = PostHogConfig(projectToken: "phc_maskSnapshotTest") config.disableReachabilityForTesting = true sdk = PostHogSDK.with(config) _ = integration.install(sdk) diff --git a/PostHogTests/PostHogMaskingCharacterizationTest.swift b/PostHogTests/PostHogMaskingCharacterizationTest.swift index a7f8511cd0..906fdb85fd 100644 --- a/PostHogTests/PostHogMaskingCharacterizationTest.swift +++ b/PostHogTests/PostHogMaskingCharacterizationTest.swift @@ -5,7 +5,7 @@ // Created by Jiri Urbasek on 16/07/2026. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import SwiftUI diff --git a/PostHogTests/PostHogMemoryExceptionReporterTest.swift b/PostHogTests/PostHogMemoryExceptionReporterTest.swift index b723b9c281..140291186e 100644 --- a/PostHogTests/PostHogMemoryExceptionReporterTest.swift +++ b/PostHogTests/PostHogMemoryExceptionReporterTest.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing #if os(iOS) && !targetEnvironment(macCatalyst) && compiler(>=6.4) diff --git a/PostHogTests/PostHogOptOutPersistenceTest.swift b/PostHogTests/PostHogOptOutPersistenceTest.swift index cf35ff3711..619f338254 100644 --- a/PostHogTests/PostHogOptOutPersistenceTest.swift +++ b/PostHogTests/PostHogOptOutPersistenceTest.swift @@ -19,8 +19,10 @@ final class PostHogOptOutPersistenceTest { config.disableFlushOnBackgroundForTesting = true config.captureApplicationLifecycleEvents = false config.captureScreenViews = false - config.errorTrackingConfig.autoCapture = false - #if os(iOS) + #if !SWIFT_PACKAGE || CrashReporting + config.errorTrackingConfig.autoCapture = false + #endif + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) config.sessionReplay = false #endif config.optOut = optOut diff --git a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift index 8dce0ec542..0e02409faa 100644 --- a/PostHogTests/PostHogPushNotificationSwizzlingTest.swift +++ b/PostHogTests/PostHogPushNotificationSwizzlingTest.swift @@ -249,7 +249,6 @@ } @Test("the public prewarm API reaches the publisher") - @available(iOS 14.0, macOS 11.0, *) func publicPrewarmApiReachesPublisher() { #expect(publisher.prewarmCount == 0) PostHogSDK.prewarmPushNotificationOpenCapture() @@ -257,7 +256,6 @@ } /// Mirrors `PostHogIntegrationInstallationTest.getSut`, trimmed to what the discard gate reads. - @available(iOS 14.0, macOS 11.0, *) private func makeSut( optOut: Bool = false, capturePushNotificationOpened: Bool = true, @@ -269,8 +267,10 @@ config.disableReachabilityForTesting = true config.captureApplicationLifecycleEvents = false config.captureScreenViews = false - config.errorTrackingConfig.autoCapture = false - #if os(iOS) + #if !SWIFT_PACKAGE || CrashReporting + config.errorTrackingConfig.autoCapture = false + #endif + #if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) config.sessionReplay = false #endif config.optOut = optOut @@ -286,7 +286,6 @@ } @Test("setup() releases a prewarm when push-open capture is disabled") - @available(iOS 14.0, macOS 11.0, *) func setupDiscardsPrewarmWhenCaptureDisabled() { let sut = makeSut(capturePushNotificationOpened: false) defer { sut.close() } @@ -297,7 +296,6 @@ /// `setup()` skips `installIntegrations()` entirely while opted out, so the discard cannot /// live there without leaving an opted-out app swizzled for the process lifetime. @Test("setup() releases a prewarm while opted out, even with push-open capture enabled") - @available(iOS 14.0, macOS 11.0, *) func setupDiscardsPrewarmWhileOptedOut() { let sut = makeSut(optOut: true, capturePushNotificationOpened: true) defer { sut.close() } @@ -306,7 +304,6 @@ } @Test("setup() with push-open capture enabled does not discard the prewarm") - @available(iOS 14.0, macOS 11.0, *) func setupKeepsPrewarmWhenCaptureEnabled() { publisher.prewarmNotificationResponseCapture() diff --git a/PostHogTests/PostHogPushNotificationTest.swift b/PostHogTests/PostHogPushNotificationTest.swift index 8add01fe3b..403e715f0f 100644 --- a/PostHogTests/PostHogPushNotificationTest.swift +++ b/PostHogTests/PostHogPushNotificationTest.swift @@ -15,13 +15,9 @@ let projectToken = "push_\(UUID().uuidString)" init() { - if #available(iOS 14.0, macOS 11.0, *) { - PostHogPushNotificationOpenIntegration.clearInstalls() - } + PostHogPushNotificationOpenIntegration.clearInstalls() #if os(iOS) - if #available(iOS 14.0, *) { - PostHogPushNotificationSubscriptionIntegration.clearInstalls() - } + PostHogPushNotificationSubscriptionIntegration.clearInstalls() #endif PostHogAppLifeCycleIntegration.clearInstalls() PostHogScreenViewIntegration.clearInstalls() @@ -197,8 +193,6 @@ @Test("getIntegrations includes the opened integration only when its flag is enabled") func getIntegrationsGatesOpenedIntegration() { - guard #available(iOS 14.0, macOS 11.0, *) else { return } - let enabled = PostHogConfig(projectToken: projectToken) enabled.capturePushNotificationOpened = true #expect(enabled.getIntegrations().contains { $0 is PostHogPushNotificationOpenIntegration }) @@ -211,8 +205,6 @@ #if os(iOS) @Test("getIntegrations includes the subscription integration only when its flag is enabled (iOS)") func getIntegrationsGatesSubscriptionIntegration() { - guard #available(iOS 14.0, *) else { return } - let enabled = PostHogConfig(projectToken: projectToken) enabled.capturePushNotificationSubscriptions = true #expect(enabled.getIntegrations().contains { $0 is PostHogPushNotificationSubscriptionIntegration }) @@ -1769,9 +1761,7 @@ let sut = getSDK(enableSwizzling: false, capturePushNotificationOpened: true) defer { sut.close() } - if #available(iOS 14.0, macOS 11.0, *) { - #expect(sut.getPushNotificationIntegration() == nil) - } + #expect(sut.getPushNotificationIntegration() == nil) sut.capturePushNotificationOpened( title: "Hello", @@ -2074,53 +2064,47 @@ #if os(iOS) @Test("opting back in re-requests the push token so push re-arms without an app restart") func optInReRequestsPushToken() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - - let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: true) - defer { sut.close() } - - #expect(sut.isOptOut()) - sut.optIn() - #expect(refetchCount == 1, "opt-in should re-request the APNs token, not just restore consent") - } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + + let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: true) + defer { sut.close() } + + #expect(sut.isOptOut()) + sut.optIn() + #expect(refetchCount == 1, "opt-in should re-request the APNs token, not just restore consent") } @Test("opt-in does not re-request the token when auto-capture is disabled") func optInSkipsReRequestWhenAutoCaptureDisabled() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: false) - defer { sut.close() } + let sut = getSDK(optOut: true, capturePushNotificationSubscriptions: false) + defer { sut.close() } - sut.optIn() - #expect(refetchCount == 0, "manual push mode leaves the token lifecycle to the host") - } + sut.optIn() + #expect(refetchCount == 0, "manual push mode leaves the token lifecycle to the host") } @Test("opt-in does not re-request the token when swizzling is disabled") func optInSkipsReRequestWhenSwizzlingDisabled() { - if #available(iOS 14.0, *) { - let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh - defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } - var refetchCount = 0 - PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } - - // Auto-capture on, but swizzling off: the subscription integration is not installed, - // so refetching would fire the host's APNs lifecycle with no observer to catch it. - let sut = getSDK(optOut: true, enableSwizzling: false, capturePushNotificationSubscriptions: true) - defer { sut.close() } - - sut.optIn() - #expect(refetchCount == 0, "no observer is installed without swizzling, so opt-in must not refetch") - } + let original = PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh + defer { PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = original } + var refetchCount = 0 + PostHogPushNotificationSubscriptionIntegration.requestTokenRefresh = { refetchCount += 1 } + + // Auto-capture on, but swizzling off: the subscription integration is not installed, + // so refetching would fire the host's APNs lifecycle with no observer to catch it. + let sut = getSDK(optOut: true, enableSwizzling: false, capturePushNotificationSubscriptions: true) + defer { sut.close() } + + sut.optIn() + #expect(refetchCount == 0, "no observer is installed without swizzling, so opt-in must not refetch") } #endif @@ -2295,7 +2279,6 @@ #expect(try #require(server.parseRequest(del))["distinct_id"] as? String == "user-1") } - @available(iOS 14.0, macOS 11.0, *) @Test("posthog-ios#746: opt-out during an in-flight unregister must still send the DELETE") func optOutDuringUnregisterStrandsDelete() async throws { let parked = ParkedMint() diff --git a/PostHogTests/PostHogReachabilityTest.swift b/PostHogTests/PostHogReachabilityTest.swift index 12db040a49..d5d03aba09 100644 --- a/PostHogTests/PostHogReachabilityTest.swift +++ b/PostHogTests/PostHogReachabilityTest.swift @@ -11,8 +11,8 @@ import Testing @Suite("Reachability multicast") final class PostHogReachabilityTests { @Test("multiple subscribers all fire on every transition") - func multicastNoStomp() throws { - let reachability = try Reachability() + func multicastNoStomp() { + let reachability = Reachability() var subscriberAReachable = 0 var subscriberBReachable = 0 var subscriberAUnreachable = 0 @@ -44,8 +44,8 @@ import Testing } @Test("releasing a reachable subscription token unregisters that subscriber") - func tokenDeallocUnsubscribesOnReachable() throws { - let reachability = try Reachability() + func tokenDeallocUnsubscribesOnReachable() { + let reachability = Reachability() var calls = 0 do { @@ -62,8 +62,8 @@ import Testing } @Test("releasing an unreachable subscription token unregisters that subscriber") - func tokenDeallocUnsubscribesOnUnreachable() throws { - let reachability = try Reachability() + func tokenDeallocUnsubscribesOnUnreachable() { + let reachability = Reachability() var calls = 0 do { @@ -77,4 +77,85 @@ import Testing #expect(calls == 1) } } + + @Suite("Reachability notifier") + final class PostHogReachabilityNotifierTests { + @Test("the first start reports the current connection once") + func firstStartReportsCurrentConnection() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + var reachable = 0 + let token = reachability.onReachable.subscribe { _ in reachable += 1 } + defer { _ = token } + + reachability.update(.wifi) + #expect(reachable == 0) + + reachability.startNotifier() + #expect(reachable == 1) + + reachability.startNotifier() + #expect(reachable == 1) + } + + @Test("paths after start are reported, paths after stop are not") + func reportsPathsWhileRunning() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + var reachable = 0 + var unreachable = 0 + let tokens = [ + reachability.onReachable.subscribe { _ in reachable += 1 }, + reachability.onUnreachable.subscribe { _ in unreachable += 1 }, + ] + defer { _ = tokens } + + // No path yet, so starting reports nothing. + reachability.startNotifier() + #expect(reachable == 0) + + reachability.update(.cellular) + reachability.update(.unavailable) + #expect(reachable == 1) + #expect(unreachable == 1) + + reachability.stopNotifier() + reachability.update(.wifi) + #expect(reachable == 1) + #expect(reachability.connection == .wifi) + } + + @Test("queued notifications report the connection at delivery, not a stale one") + func notificationsReportConnectionAtDelivery() { + let notificationQueue = DispatchQueue(label: "test.reachability.notifications") + let reachability = Reachability(notificationQueue: notificationQueue, monitorsPaths: false) + var reachable = 0 + var unreachable = 0 + let tokens = [ + reachability.onReachable.subscribe { _ in reachable += 1 }, + reachability.onUnreachable.subscribe { _ in unreachable += 1 }, + ] + defer { _ = tokens } + + // Offline at launch, then the network comes back before the start notification runs. + reachability.update(.unavailable) + notificationQueue.suspend() + reachability.startNotifier() + reachability.update(.wifi) + notificationQueue.resume() + notificationQueue.sync {} + + // A stale `onUnreachable` would pause the queues while online. + #expect(unreachable == 0) + #expect(reachable == 2) + } + + @Test("without a path, connection is nil and only the first reads wait") + func connectionWithoutPath() { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + #expect(reachability.connection == nil) + + let start = Date() + #expect(reachability.connection == nil) + #expect(Date().timeIntervalSince(start) < 0.05) + } + } #endif diff --git a/PostHogTests/PostHogReactNativeMaskingTest.swift b/PostHogTests/PostHogReactNativeMaskingTest.swift index e1b95083f5..f1ff92753c 100644 --- a/PostHogTests/PostHogReactNativeMaskingTest.swift +++ b/PostHogTests/PostHogReactNativeMaskingTest.swift @@ -9,7 +9,7 @@ // classes do in a React Native host app. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing @@ -144,6 +144,25 @@ #expect(sut.integration.collectMaskableRects(in: window) == []) } + @Test("A secure UITextField nested under a ph-no-mask ancestor stays masked") + func noMaskAncestorKeepsSecureFieldMasked() { + let sut = makeSut(maskText: false, maskImages: false) + defer { teardown(sut) } + + let container = UIView(frame: CGRect(x: 0, y: 80, width: 320, height: 200)) + container.accessibilityIdentifier = "ph-no-mask" + let wrapper = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 200)) + let secureField = UITextField(frame: CGRect(x: 12, y: 12, width: 160, height: 40)) + secureField.isSecureTextEntry = true + secureField.text = "hunter2" + wrapper.addSubview(secureField) + wrapper.addSubview(UITextField(frame: CGRect(x: 12, y: 64, width: 160, height: 40))) + container.addSubview(wrapper) + let window = makeWindow(containing: container) + + #expect(sut.integration.collectMaskableRects(in: window) == [CGRect(x: 12, y: 92, width: 160, height: 40)]) + } + @Test("A ph-no-mask accessibilityLabel token unmasks Fabric paragraph view") func noMaskLabelTokenUnmasksParagraph() { let sut = makeSut(maskText: true, maskImages: false) diff --git a/PostHogTests/PostHogRemoteConfigTest.swift b/PostHogTests/PostHogRemoteConfigTest.swift index d8e6237617..a6defd2acb 100644 --- a/PostHogTests/PostHogRemoteConfigTest.swift +++ b/PostHogTests/PostHogRemoteConfigTest.swift @@ -5,7 +5,7 @@ // Created by Yiannis Josephides on 20/01/2025. // -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -430,11 +430,11 @@ enum PostHogRemoteConfigTest { let bothResults = AsyncLatch(count: 2) sut.loadFeatureFlags(distinctId: "user1", anonymousId: nil, groups: [:]) { flags in - firstResult = flags + firstResult = flags.featureFlags bothResults.signal() } sut.loadFeatureFlags(distinctId: "user2", anonymousId: nil, groups: [:]) { flags in - secondResult = flags + secondResult = flags.featureFlags bothResults.signal() } @@ -456,7 +456,7 @@ enum PostHogRemoteConfigTest { await withCheckedContinuation { continuation in sut.loadFeatureFlags(distinctId: "single_user", anonymousId: nil, groups: [:]) { flags in - result = flags + result = flags.featureFlags continuation.resume() } } diff --git a/PostHogTests/PostHogReplayBatchBoundaryTest.swift b/PostHogTests/PostHogReplayBatchBoundaryTest.swift index 5f633c2523..ccd859e98e 100644 --- a/PostHogTests/PostHogReplayBatchBoundaryTest.swift +++ b/PostHogTests/PostHogReplayBatchBoundaryTest.swift @@ -151,29 +151,71 @@ struct PostHogReplayBatchBoundaryTest { @Test("Reachability pauses continuation between groups") func pauseBetweenGroups() async throws { - let reachability = try Reachability(notificationQueue: nil) + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + reachability.update(.wifi) let sender = Sender() let queue = queue(config(), sender, reachability: reachability) queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) - reachability.stopNotifier() defer { queue.clear() queue.stop() } queue.add(event("first")) queue.add(event("later", session: "b")) try await flush(queue, sender, count: 1) - reachability.onUnreachable.invoke(reachability) + reachability.update(.unavailable) sender.complete(0, status: 200) try await Task.sleep(nanoseconds: 50_000_000) #expect(sender.batches.count == 1) #expect(queue.depth == 1) - reachability.onReachable.invoke(reachability) + reachability.update(.wifi) await waitUntil { sender.batches.count == 2 } try #require(sender.batches.count == 2) sender.complete(1, status: 200) #expect(queue.depth == 0) } + @Test("Wi-Fi data mode waits for a Wi-Fi path and stays paused on cellular") + func wifiModeWaitsForWifi() async throws { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sender = Sender() + let config = config() + config.dataMode = .wifi + let queue = queue(config, sender, reachability: reachability) + queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) + defer { queue.clear() + queue.stop() + } + queue.add(event("first")) + + // No path yet. + queue.flush() + try await Task.sleep(nanoseconds: 50_000_000) + #expect(sender.batches.isEmpty) + + reachability.update(.cellular) + queue.flush() + try await Task.sleep(nanoseconds: 50_000_000) + #expect(sender.batches.isEmpty) + + // The reachable callback unpauses and flushes. + reachability.update(.wifi) + await waitUntil { sender.batches.count == 1 } + #expect(sender.batches.count == 1) + } + + @Test("Any data mode sends before the first path arrives") + func anyModeSendsWithoutPath() async throws { + let reachability = Reachability(notificationQueue: nil, monitorsPaths: false) + let sender = Sender() + let queue = queue(config(), sender, reachability: reachability) + queue.start(disableReachabilityForTesting: false, disableQueueTimerForTesting: true) + defer { queue.clear() + queue.stop() + } + queue.add(event("first")) + try await flush(queue, sender, count: 1) + } + @Test("Terminal responses continue to the next group", arguments: [400, 413]) func terminalContinues(status: Int) async throws { let sender = Sender() diff --git a/PostHogTests/PostHogReplayCameraTest.swift b/PostHogTests/PostHogReplayCameraTest.swift index fdc3313467..527418113d 100644 --- a/PostHogTests/PostHogReplayCameraTest.swift +++ b/PostHogTests/PostHogReplayCameraTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) @testable import PostHog import Testing import UIKit @@ -68,7 +68,6 @@ func bridgeRecoversAfterCamera() throws { let config = PostHogConfig(projectToken: UUID().uuidString) config.sessionReplay = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.captureNetworkTelemetry = false config.disableReachabilityForTesting = true config.disableQueueTimerForTesting = true @@ -118,7 +117,7 @@ } #endif -#if os(iOS) && TEST_CAMERA_REPLAY +#if os(iOS) && TEST_CAMERA_REPLAY && (!SWIFT_PACKAGE || SessionReplay) @Suite("System camera replay regression", .serialized) @MainActor struct PostHogSystemCameraReplayTest { diff --git a/PostHogTests/PostHogReplayCaptureTouchesTest.swift b/PostHogTests/PostHogReplayCaptureTouchesTest.swift index ef673d6047..6139dde819 100644 --- a/PostHogTests/PostHogReplayCaptureTouchesTest.swift +++ b/PostHogTests/PostHogReplayCaptureTouchesTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing @@ -62,7 +62,6 @@ private func makeSut(captureTouches: Bool? = nil) throws -> (PostHogSDK, PostHogReplayIntegration, Snapshots) { let config = PostHogConfig(projectToken: UUID().uuidString) config.sessionReplay = true - config.sessionReplayConfig.screenshotMode = true config.sessionReplayConfig.captureNetworkTelemetry = false if let captureTouches { config.sessionReplayConfig.captureTouches = captureTouches diff --git a/PostHogTests/PostHogReplayLayoutCaptureTest.swift b/PostHogTests/PostHogReplayLayoutCaptureTest.swift new file mode 100644 index 0000000000..8c1a5b010d --- /dev/null +++ b/PostHogTests/PostHogReplayLayoutCaptureTest.swift @@ -0,0 +1,120 @@ +#if os(iOS) && TEST_PRESENTATION_MASKS + import Foundation + @testable import PostHog + import SwiftUI + import Testing + import UIKit + + // Needs the app host: snapshot() reads the key window of a foreground-active scene. + @Suite("Replay layout-driven capture", .serialized) + @MainActor + struct PostHogReplayLayoutCaptureTest { + enum Root: String, CaseIterable, CustomTestStringConvertible { + case uiKit, plainTitled, swiftUI + + var testDescription: String { rawValue } + + var expectedHref: String? { + switch self { + case .uiKit: "Checkout" + // The title is display text, so it never becomes the href. + case .plainTitled: nil + case .swiftUI: "Text" + } + } + + @MainActor func makeController() -> UIViewController { + switch self { + case .uiKit, .plainTitled: + let controller = self == .uiKit ? CheckoutViewController() : UIViewController() + controller.title = "Invoice for Jane Doe" + let label = UILabel(frame: CGRect(x: 20, y: 100, width: 200, height: 40)) + label.text = "secret" + controller.view.addSubview(label) + return controller + case .swiftUI: + return UIHostingController(rootView: Text("secret")) + } + } + } + + private final class CheckoutViewController: UIViewController {} + + private final class Snapshots { + private let lock = NSLock() + private var values: [[String: Any]] = [] + + func record(_ event: PostHogEvent) { + guard event.event == "$snapshot", + let data = event.properties["$snapshot_data"] as? [[String: Any]] + else { return } + lock.withLock { values.append(contentsOf: data) } + } + + private func data(ofType type: Int) -> [[String: Any]] { + lock.withLock { + values.filter { $0["type"] as? Int == type }.compactMap { $0["data"] as? [String: Any] } + } + } + + var metas: [[String: Any]] { data(ofType: 4) } + var wireframes: [[String: Any]] { data(ofType: 2).flatMap { $0["wireframes"] as? [[String: Any]] ?? [] } } + } + + private func drainReplayQueue() async { + await withCheckedContinuation { continuation in + PostHogReplayIntegration.dispatchQueue.async { continuation.resume() } + } + } + + @Test("Default config captures UIKit and SwiftUI screens as screenshots", arguments: Root.allCases) + func defaultConfigCapturesScreenshots(_ root: Root) async throws { + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + let window = UIWindow(windowScene: scene) + window.frame = scene.coordinateSpace.bounds + window.rootViewController = root.makeController() + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + try #require(UIApplication.getCurrentWindow() === window) + + let config = PostHogConfig(projectToken: UUID().uuidString) + config.sessionReplay = true + config.sessionReplayConfig.captureNetworkTelemetry = false + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.disableRemoteConfigForTesting = true + config.preloadFeatureFlags = false + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + let snapshots = Snapshots() + config.setBeforeSend { event in + snapshots.record(event) + return nil + } + PostHogStorage(config).setDictionary(forKey: .remoteConfig, contents: ["sessionRecording": ["endpoint": "/s/"]]) + PostHogReplayIntegration.clearInstalls() + let sut = PostHogSDK.with(config) + defer { sut.close() } + #expect(sut.isSessionReplayActive()) + + // Each frame is encoded on the serial replay queue, which is slow on the simulator. + // Nudge sparingly so frames don't pile up, then let the queue drain before closing. + for _ in 0 ..< 40 where snapshots.wireframes.isEmpty { + window.setNeedsLayout() + window.layoutIfNeeded() + try await Task.sleep(nanoseconds: 500_000_000) + } + await drainReplayQueue() + + let wireframe = try #require(snapshots.wireframes.first, "No snapshot was captured") + #expect(wireframe["type"] as? String == "screenshot") + #expect(!(wireframe["base64"] as? String ?? "").isEmpty) + let meta = try #require(snapshots.metas.first) + #expect(meta["href"] as? String == root.expectedHref) + } + } +#endif diff --git a/PostHogTests/PostHogReplayScreenshotDedupTest.swift b/PostHogTests/PostHogReplayScreenshotDedupTest.swift index d021502013..8f71358560 100644 --- a/PostHogTests/PostHogReplayScreenshotDedupTest.swift +++ b/PostHogTests/PostHogReplayScreenshotDedupTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing @@ -29,7 +29,7 @@ imageHash: 42, lastImageHash: 42, hasPendingSnapshotData: true, expectedSkip: false ), DedupCase( - name: "never skips when there is no image hash (wireframe mode)", + name: "never skips when there is no image hash", imageHash: nil, lastImageHash: 42, hasPendingSnapshotData: false, expectedSkip: false ), DedupCase( diff --git a/PostHogTests/PostHogSDKTest.swift b/PostHogTests/PostHogSDKTest.swift index f6e5525425..74b5868534 100644 --- a/PostHogTests/PostHogSDKTest.swift +++ b/PostHogTests/PostHogSDKTest.swift @@ -61,7 +61,6 @@ private final class SDKTestFixture { captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil, personProfiles: PostHogPersonProfiles = .identifiedOnly, setDefaultPersonProperties: Bool = true, beforeSend: [BeforeSendBlock]? = nil) -> PostHogSDK @@ -75,7 +74,6 @@ private final class SDKTestFixture { config.disableFlushOnBackgroundForTesting = true config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents config.optOut = optOut - config.propertiesSanitizer = propertiesSanitizer config.personProfiles = personProfiles config.setDefaultPersonProperties = setDefaultPersonProperties @@ -142,15 +140,13 @@ final class PostHogSDKTests { sendFeatureFlagEvent: Bool = false, captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, - optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil) -> PostHogSDK + optOut: Bool = false) -> PostHogSDK { fixture.getSut(preloadFeatureFlags: preloadFeatureFlags, sendFeatureFlagEvent: sendFeatureFlagEvent, captureApplicationLifecycleEvents: captureApplicationLifecycleEvents, flushAt: flushAt, - optOut: optOut, - propertiesSanitizer: propertiesSanitizer) + optOut: optOut) } private func bootstrapReconcileConfig(existing: (anon: String, distinct: String?, identified: Bool)) -> PostHogConfig { @@ -183,18 +179,6 @@ final class PostHogSDKTests { #expect(sut.getSessionId() == nil) } - @Test("no-ops setup when legacy api key is empty after trimming") - func noOpsSetupWhenLegacyApiKeyIsEmpty() { - let config = PostHogConfig(apiKey: " \n\t ", host: "http://localhost:9001") - - let sut = PostHogSDK.with(config) - - #expect(sut.config.projectToken.isEmpty) - #expect(sut.storage == nil) - #expect(sut.getDistinctId().isEmpty) - #expect(sut.getSessionId() == nil) - } - @Test("merges an anonymous local user into an identified bootstrap") func mergesAnonymousUserIntoIdentifiedBootstrap() { let config = bootstrapReconcileConfig(existing: (anon: "anon-abc", distinct: nil, identified: false)) @@ -471,38 +455,40 @@ final class PostHogSDKTests { } #if os(iOS) - @Test("captures $recording_status on every event and the full replay debug bundle on the first eligible SDK event only") - func capturesRecordingStatusAndReplayDebugBundle() throws { - server.reset(batchCount: 1) - let sut = getSut(flushAt: 3) - - sut.capture("test event") - sut.screen("theScreen") - sut.capture("$exception", properties: ["foo": "bar"]) - - let events = getBatchedEvents(server) - try #require(events.count == 3) - - #expect(events[0].properties["$recording_status"] as? String == "disabled") - #expect(events[0].properties["$sdk_debug_session_start"] == nil) - #expect(events[0].properties["$sdk_debug_replay_capture_mode"] == nil) - - #expect(events[1].properties["$recording_status"] as? String == "disabled") - #expect(events[1].properties["$sdk_debug_replay_capture_mode"] as? String == "wireframe") - #expect(events[1].properties["$sdk_debug_session_start"] != nil) - - // Inside the 30s window opened by $screen. - #expect(events[2].properties["$recording_status"] as? String == "disabled") - #expect(events[2].properties["$sdk_debug_session_start"] == nil) - #expect(events[2].properties["$sdk_debug_replay_capture_mode"] == nil) + #if !SWIFT_PACKAGE || SessionReplay + @Test("captures $recording_status on every event and the full replay debug bundle on the first eligible SDK event only") + func capturesRecordingStatusAndReplayDebugBundle() throws { + server.reset(batchCount: 1) + let sut = getSut(flushAt: 3) + + sut.capture("test event") + sut.screen("theScreen") + sut.capture("$exception", properties: ["foo": "bar"]) + + let events = getBatchedEvents(server) + try #require(events.count == 3) + + #expect(events[0].properties["$recording_status"] as? String == "disabled") + #expect(events[0].properties["$sdk_debug_session_start"] == nil) + #expect(events[0].properties["$sdk_debug_replay_capture_mode"] == nil) + + #expect(events[1].properties["$recording_status"] as? String == "disabled") + #expect(events[1].properties["$sdk_debug_replay_capture_mode"] as? String == "screenshot") + #expect(events[1].properties["$sdk_debug_session_start"] != nil) + + // Inside the 30s window opened by $screen. + #expect(events[2].properties["$recording_status"] as? String == "disabled") + #expect(events[2].properties["$sdk_debug_session_start"] == nil) + #expect(events[2].properties["$sdk_debug_replay_capture_mode"] == nil) + + for event in events { + #expect(event.properties["$sdk_debug_pending_queue_size"] != nil) + } - for event in events { - #expect(event.properties["$sdk_debug_pending_queue_size"] != nil) + sut.reset() + sut.close() } - - sut.reset() - sut.close() - } + #endif @Test("excludes $recording_status and $sdk_debug_* properties from $snapshot events") func excludesRecordingStatusAndDebugPropertiesFromSnapshotEvents() throws { @@ -539,23 +525,6 @@ final class PostHogSDKTests { sut.close() } - @Test("reports screenshot capture mode for the flutter host") - func reportsScreenshotCaptureModeForFlutterHost() { - server.reset(batchCount: 1) - let original = postHogSdkName - postHogSdkName = "posthog-flutter" - defer { postHogSdkName = original } - - let sut = getSut() - sut.screen("theScreen") - - let events = getBatchedEvents(server) - #expect(events.first?.properties["$sdk_debug_replay_capture_mode"] as? String == "screenshot") - - sut.reset() - sut.close() - } - @Test("SDK-computed debug keys win over a same-named registered super property") func sdkComputedDebugKeysWinOverRegisteredSuperProperty() throws { server.reset(batchCount: 1) @@ -600,12 +569,11 @@ final class PostHogSDKTests { let sut = getSut() sut.close() - var called = false - sut.reloadFeatureFlags { - called = true - } + var result: PostHogFeatureFlagsLoaded? + sut.reloadFeatureFlags { result = $0 } - #expect(called) + #expect(result?.errorsLoading == true) + #expect(result?.flags.isEmpty == true) } @Test("captures a screen event") @@ -760,7 +728,7 @@ final class PostHogSDKTests { let group = DispatchGroup() group.enter() - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in group.leave() } @@ -1515,24 +1483,6 @@ final class PostHogSDKTests { #expect(FileManager.default.fileExists(atPath: appFolder.path) == true) } - @Test("client sanitize properties") - func clientSanitizeProperties() throws { - let sanitizer = ExampleSanitizer() - let sut = getSut(propertiesSanitizer: sanitizer) - - let props: [String: Any] = ["empty": ""] - - sut.capture("event", properties: props) - - let events = getBatchedEvents(server) - - let event = try #require(events.first) - #expect(event.properties["empty"] as? String == nil) - - sut.reset() - sut.close() - } - @Test("reset reloads flags as anon user") func resetReloadsFlagsAsAnonUser() { let sut = getSut() @@ -1635,7 +1585,7 @@ final class PostHogSDKTests { _ = sut.getFeatureFlag("some_key") let reloaded = XCTestExpectation(description: "second flag lookup completed") - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in _ = sut.getFeatureFlag("some_key") reloaded.fulfill() } @@ -1661,7 +1611,7 @@ final class PostHogSDKTests { // Change the mock server to return a different value for the same key server.disabledFlag = true - sut.reloadFeatureFlags { + sut.reloadFeatureFlags { _ in // Second call gets a true value _ = sut.getFeatureFlag("disabled-flag") sut.capture("force_batch_flush") diff --git a/PostHogTests/PostHogSamplingTest.swift b/PostHogTests/PostHogSamplingTest.swift index bc571aae0e..6d64cf8441 100644 --- a/PostHogTests/PostHogSamplingTest.swift +++ b/PostHogTests/PostHogSamplingTest.swift @@ -1,5 +1,5 @@ import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Sampling Tests") @@ -100,7 +100,7 @@ class PostHogSamplingTests { } } -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) // MARK: - parseSampleRate Tests @@ -225,7 +225,6 @@ class PostHogSamplingTests { @Test("parses sample rate from remote config response") func parsesSampleRateFromRemoteConfig() async { let config = PostHogConfig(projectToken: self.config.projectToken, host: "http://localhost:9001") - config.remoteConfig = true config.preloadFeatureFlags = false config.storageManager = PostHogStorageManager(config) @@ -248,7 +247,6 @@ class PostHogSamplingTests { @Test("remote config without sample rate leaves it nil") func remoteConfigWithoutSampleRate() async { let config = PostHogConfig(projectToken: self.config.projectToken, host: "http://localhost:9001") - config.remoteConfig = true config.preloadFeatureFlags = false config.storageManager = PostHogStorageManager(config) diff --git a/PostHogTests/PostHogSessionManagerTest.swift b/PostHogTests/PostHogSessionManagerTest.swift index 4043183f35..c8f0c5fd53 100644 --- a/PostHogTests/PostHogSessionManagerTest.swift +++ b/PostHogTests/PostHogSessionManagerTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite(.serialized, .resetsGlobalState) @@ -198,7 +198,6 @@ enum PostHogSessionManagerTest { captureApplicationLifecycleEvents: Bool = false, flushAt: Int = 1, optOut: Bool = false, - propertiesSanitizer: PostHogPropertiesSanitizer? = nil, personProfiles: PostHogPersonProfiles = .identifiedOnly ) -> PostHogSDK { let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9001") @@ -211,7 +210,6 @@ enum PostHogSessionManagerTest { config.captureApplicationLifecycleEvents = captureApplicationLifecycleEvents config.captureScreenViews = false config.optOut = optOut - config.propertiesSanitizer = propertiesSanitizer config.personProfiles = personProfiles config.maxBatchSize = max(flushAt, config.maxBatchSize) server.batchProjectToken = config.projectToken diff --git a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift index cbe49504a6..a11917f5cd 100644 --- a/PostHogTests/PostHogSessionReplayEventTriggersTest.swift +++ b/PostHogTests/PostHogSessionReplayEventTriggersTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Event Triggers", .serialized) diff --git a/PostHogTests/PostHogSessionReplayManualModeTest.swift b/PostHogTests/PostHogSessionReplayManualModeTest.swift index ebebf35c89..117f71c4c4 100644 --- a/PostHogTests/PostHogSessionReplayManualModeTest.swift +++ b/PostHogTests/PostHogSessionReplayManualModeTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog import Testing @Suite("Session Replay Manual Mode", .serialized) diff --git a/PostHogTests/PostHogSessionReplayPluginTest.swift b/PostHogTests/PostHogSessionReplayPluginTest.swift index d877a244b6..148c3f0497 100644 --- a/PostHogTests/PostHogSessionReplayPluginTest.swift +++ b/PostHogTests/PostHogSessionReplayPluginTest.swift @@ -1,6 +1,6 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation - @testable import PostHog + @_spi(PostHogInternal) @testable import PostHog #if !SWIFT_PACKAGE import CwlCatchException #endif diff --git a/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift b/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift index 3eeff1b057..e5890e0165 100644 --- a/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift +++ b/PostHogTests/PostHogSessionReplayRemoteConfigBufferTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @_spi(PostHogInternal) @testable import PostHog import Testing diff --git a/PostHogTests/PostHogSessionReplayTest.swift b/PostHogTests/PostHogSessionReplayTest.swift index fdf263d123..747a723e9d 100644 --- a/PostHogTests/PostHogSessionReplayTest.swift +++ b/PostHogTests/PostHogSessionReplayTest.swift @@ -1,4 +1,4 @@ -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/PostHogStorageManagerTest.swift b/PostHogTests/PostHogStorageManagerTest.swift index 9eab4a41a8..cb120c433b 100644 --- a/PostHogTests/PostHogStorageManagerTest.swift +++ b/PostHogTests/PostHogStorageManagerTest.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing @Suite("PostHog storage manager", .serialized, .resetsGlobalState) diff --git a/PostHogTests/PostHogStorageMigrationTest.swift b/PostHogTests/PostHogStorageMigrationTest.swift index a270a8c1d3..901038d4c7 100644 --- a/PostHogTests/PostHogStorageMigrationTest.swift +++ b/PostHogTests/PostHogStorageMigrationTest.swift @@ -256,7 +256,7 @@ class PostHogStorageMigrationTest { ], timestamp: Date(timeIntervalSince1970: 1707221234000), uuid: UUID(), - apiKey: "test_project_token" + projectToken: "test_project_token" ), PostHogEvent( event: "$snapshot", @@ -285,7 +285,7 @@ class PostHogStorageMigrationTest { ], timestamp: Date(timeIntervalSince1970: 1707221235), uuid: UUID(), - apiKey: "test_project_token" + projectToken: "test_project_token" ), ] diff --git a/PostHogTests/PostHogStorageTest.swift b/PostHogTests/PostHogStorageTest.swift index 9bbed3cb3b..80cf1e943a 100644 --- a/PostHogTests/PostHogStorageTest.swift +++ b/PostHogTests/PostHogStorageTest.swift @@ -124,6 +124,28 @@ class PostHogStorageTest { sut.reset() } + @Test("deletes a v2 queue file left in the legacy base folder without importing it") + func deletesLegacyV2QueueFileFromBaseFolder() throws { + let fileManager = FileManager.default + let baseUrl = applicationSupportDirectoryURL() + try fileManager.createDirectory(at: baseUrl, withIntermediateDirectories: true) + let legacyPlist = baseUrl.appendingPathComponent(PostHogStorage.StorageKey.oldQueuePlist.rawValue) + try Data(#"[{"event":"v2 event","distinct_id":"user","timestamp":"2023-10-25T14:14:04.407Z"}]"#.utf8) + .write(to: legacyPlist) + + let storage = PostHogStorage(PostHogConfig(projectToken: "v2_queue_\(UUID().uuidString)")) + defer { try? fileManager.removeItem(at: storage.appFolderUrl) } + // Same keys as the `/batch` endpoint. + let queue = PostHogFileBackedQueue( + queue: storage.url(forKey: .queue), + oldQueues: [.oldQueueFolder, .oldQueuePlist].map { storage.url(forKey: $0) } + ) + + #expect(!fileManager.fileExists(atPath: legacyPlist.path)) + #expect(!fileManager.fileExists(atPath: storage.url(forKey: .oldQueuePlist).path)) + #expect(queue.depth == 0) + } + @Test("writes to disk in a project token folder under application support directory") func writesToDiskInAProjectTokenFolderUnderApplicationSupportDirectory() { let config = PostHogConfig(projectToken: "test_project_token") diff --git a/PostHogTests/PostHogSurveyEventsTest.swift b/PostHogTests/PostHogSurveyEventsTest.swift index 5a01cc21ff..012d4fe127 100644 --- a/PostHogTests/PostHogSurveyEventsTest.swift +++ b/PostHogTests/PostHogSurveyEventsTest.swift @@ -5,962 +5,880 @@ // Created by Ioannis Josephides on 03/10/2025. // -import Foundation -@testable import PostHog -import Testing - -@Suite("Test Survey Events", .serialized) -class PostHogSurveyEventsTest { - let server: MockPostHogServer - - init() { - server = MockPostHogServer() - server.start() - } - - deinit { - server.stop() - } - - var defaultQuestions: [PostHogSurveyQuestion] = [ - .open(PostHogOpenSurveyQuestion( - id: "qID1", - question: "What do you think about our product?", - description: "Please share your thoughts", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 0, - branching: nil, - translations: nil - )), - .singleChoice(PostHogMultipleSurveyQuestion( - id: "qID2", - question: "How likely are you to recommend us?", - description: "Please select one option", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 1, - branching: nil, - translations: nil, - choices: ["Very likely", "Somewhat likely", "Not likely"], - hasOpenChoice: false, - shuffleOptions: false - )), - .rating(PostHogRatingSurveyQuestion( - id: "qID3", - question: "Rate your experience", - description: "1 = Poor, 5 = Excellent", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 2, - branching: nil, - translations: nil, - display: .number, - scale: .fivePoint, - lowerBoundLabel: "Poor", - upperBoundLabel: "Excellent" - )), - ] - - func getTestSurvey( - id: String = "test-survey-id", - name: String = "Test Survey", - questions: [PostHogSurveyQuestion], - currentIteration: Int? = nil, - currentIterationStartDate: Date? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: id, - name: name, - type: .popover, - questions: questions, - featureFlagKeys: nil, - linkedFlagKey: nil, - targetingFlagKey: nil, - internalTargetingFlagKey: nil, - conditions: nil, - appearance: nil, - currentIteration: currentIteration, - currentIterationStartDate: currentIterationStartDate, - startDate: Date(), - endDate: nil, - schedule: nil, - translations: nil - ) - } - - func getSut(resetStorage: Bool = true, installSurveys: Bool = true) -> PostHogSDK { - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = installSurveys - config.disableRemoteConfigForTesting = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.disableFlushOnBackgroundForTesting = true - config.captureApplicationLifecycleEvents = false - config.captureScreenViews = false - - if resetStorage { PostHogStorage(config).reset() } - return PostHogSDK.with(config) - } - - func getSurveyIntegration(_ postHog: PostHogSDK) throws -> PostHogSurveyIntegration { - PostHogSurveyIntegration.clearInstalls() - let integration = PostHogSurveyIntegration() - let installResult = integration.install(postHog) - try #require(installResult == .installed) - // These tests drive callbacks directly; remote refreshes must not replace their fixture surveys. - integration.stop() - return integration - } +#if !SWIFT_PACKAGE || Surveys + import Foundation + @_spi(PostHogInternal) @testable import PostHog + import Testing + + @Suite("Test Survey Events", .serialized) + class PostHogSurveyEventsTest { + let server: MockPostHogServer + + init() { + server = MockPostHogServer() + server.start() + } - func surveyJSON(questions: [[String: Any]]) throws -> PostHogSurvey { - let json: [String: Any] = [ - "id": "branch-survey", - "name": "Branch survey", - "type": "popover", - "questions": questions, - ] - let data = try JSONSerialization.data(withJSONObject: json) - return try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - } + deinit { + server.stop() + } - func partialResponseSurvey(enabled: Bool?, branching: [String: Any]? = nil, properties: [String: Any] = [:]) throws -> PostHogSurvey { - var first: [String: Any] = ["id": "first", "type": "open", "question": "First?", "optional": true] - first["branching"] = branching - var json: [String: Any] = [ - "id": "partial-survey", "name": "Partial survey", "type": "popover", - "questions": [first, ["id": "second", "type": "open", "question": "Second?"]], + var defaultQuestions: [PostHogSurveyQuestion] = [ + .open(PostHogOpenSurveyQuestion( + id: "qID1", + question: "What do you think about our product?", + description: "Please share your thoughts", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil + )), + .singleChoice(PostHogMultipleSurveyQuestion( + id: "qID2", + question: "How likely are you to recommend us?", + description: "Please select one option", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 1, + branching: nil, + translations: nil, + choices: ["Very likely", "Somewhat likely", "Not likely"], + hasOpenChoice: false, + shuffleOptions: false + )), + .rating(PostHogRatingSurveyQuestion( + id: "qID3", + question: "Rate your experience", + description: "1 = Poor, 5 = Excellent", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 2, + branching: nil, + translations: nil, + display: .number, + scale: .fivePoint, + lowerBoundLabel: "Poor", + upperBoundLabel: "Excellent" + )), ] - json["enable_partial_responses"] = enabled - json.merge(properties) { _, new in new } - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode(PostHogSurvey.self, from: JSONSerialization.data(withJSONObject: json)) - } - @Test("surveys with a question type the SDK can't render are not shown") - func unsupportedQuestionTypeIsNotRendered() throws { - let integration = PostHogSurveyIntegration() - let first: [String: Any] = ["id": "first", "type": "open", "question": "First?"] - let second: [String: Any] = ["id": "second", "type": "open", "question": "Second?"] - let future: [String: Any] = ["id": "future", "type": "future_question_type", "question": "Future?"] - - #expect(try integration.canRenderSurvey(survey: surveyJSON(questions: [first, second]))) - // Rendering only the known questions would shift `second` to index 1 while branching and - // responses still treat index 1 as `future`, so the survey could never complete. - #expect(try !integration.canRenderSurvey(survey: surveyJSON(questions: [first, future, second]))) - } - - @Test("resumed completion and dismissal preserve seen history", arguments: [false, true]) - func resumedSurveyPreservesSeenHistory(closeWithoutCompleting: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func getTestSurvey( + id: String = "test-survey-id", + name: String = "Test Survey", + questions: [PostHogSurveyQuestion], + currentIteration: Int? = nil, + currentIterationStartDate: Date? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: id, + name: name, + type: .popover, + questions: questions, + featureFlagKeys: nil, + linkedFlagKey: nil, + targetingFlagKey: nil, + internalTargetingFlagKey: nil, + conditions: nil, + appearance: nil, + currentIteration: currentIteration, + currentIterationStartDate: currentIterationStartDate, + startDate: Date(), + endDate: nil, + schedule: nil, + translations: nil + ) } - let storage = try #require(postHog.storage) - storage.setDictionary(forKey: .surveySeen, contents: ["seenSurvey_previous": true]) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let first = try getSurveyIntegration(postHog) - first.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - first.getActiveMatchingSurveys { matching = $0 } - try #require(matching.count == 1) - first.setShownSurvey(survey) - _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) - try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) - try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - first.uninstall(postHog) - let resumed = try getSurveyIntegration(postHog) - resumed.setSurveys([survey]) - resumed.getActiveMatchingSurveys { matching = $0 } - try #require(matching.count == 1) - resumed.setShownSurvey(survey) - try #require(resumed.testActiveQuestionIndex == 1) - if !closeWithoutCompleting { - _ = resumed.getNextQuestion(index: 1, response: .openEnded("Final")) + + func getSut(resetStorage: Bool = true, installSurveys: Bool = true) -> PostHogSDK { + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = installSurveys + config.disableRemoteConfigForTesting = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.captureScreenViews = false + + if resetStorage { PostHogStorage(config).reset() } + return PostHogSDK.with(config) } - resumed.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - resumed.uninstall(postHog) - } - @Test("a survey shown before a restart starts the wait period without marking it seen") - func shownSurveyStartsWaitPeriod() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func getSurveyIntegration(_ postHog: PostHogSDK) throws -> PostHogSurveyIntegration { + PostHogSurveyIntegration.clearInstalls() + let integration = PostHogSurveyIntegration() + let installResult = integration.install(postHog) + try #require(installResult == .installed) + // These tests drive callbacks directly; remote refreshes must not replace their fixture surveys. + integration.stop() + return integration } - let storage = try #require(postHog.storage) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: false, properties: ["start_date": 0]) - let first = try getSurveyIntegration(postHog) - first.setShownSurvey(survey) - first.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] == nil) - first.uninstall(postHog) - let waiting = try partialResponseSurvey(enabled: false, properties: [ - "id": "waiting-survey", "start_date": 0, "conditions": ["seenSurveyWaitPeriodInDays": 7], - ]) - let relaunched = try getSurveyIntegration(postHog) - relaunched.setSurveys([waiting]) - var matching: [PostHogSurvey] = [] - relaunched.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - relaunched.uninstall(postHog) - } - @Test("reset invalidates an attempt before its first answer", arguments: [false, true]) - func resetBeforeFirstAnswer(keepAnonymousId: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + func surveyJSON(questions: [[String: Any]]) throws -> PostHogSurvey { + let json: [String: Any] = [ + "id": "branch-survey", + "name": "Branch survey", + "type": "popover", + "questions": questions, + ] + let data = try JSONSerialization.data(withJSONObject: json) + return try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) } - postHog.config.reuseAnonymousId = keepAnonymousId - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil + + func partialResponseSurvey(enabled: Bool?, branching: [String: Any]? = nil, properties: [String: Any] = [:]) throws -> PostHogSurvey { + var first: [String: Any] = ["id": "first", "type": "open", "question": "First?", "optional": true] + first["branching"] = branching + var json: [String: Any] = [ + "id": "partial-survey", "name": "Partial survey", "type": "popover", + "questions": [first, ["id": "second", "type": "open", "question": "Second?"]], + ] + json["enable_partial_responses"] = enabled + json.merge(properties) { _, new in new } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(PostHogSurvey.self, from: JSONSerialization.data(withJSONObject: json)) } - integration.setShownSurvey(survey) - postHog.reset() - #expect(integration.getNextQuestion(index: 0, response: .openEnded("Stale")) == nil) - #expect(events.isEmpty) - #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) - integration.uninstall(postHog) - } - @Test("reset in a dismissal hook preserves the new identity's attempt") - func resetDuringDismissalPreservesNewAttempt() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() + @Test("surveys with a question type the SDK can't render are not shown") + func unsupportedQuestionTypeIsNotRendered() throws { + let integration = PostHogSurveyIntegration() + let first: [String: Any] = ["id": "first", "type": "open", "question": "First?"] + let second: [String: Any] = ["id": "second", "type": "open", "question": "Second?"] + let future: [String: Any] = ["id": "future", "type": "future_question_type", "question": "Future?"] + + #expect(try integration.canRenderSurvey(survey: surveyJSON(questions: [first, second]))) + // Rendering only the known questions would shift `second` to index 1 while branching and + // responses still treat index 1 as `future`, so the survey could never complete. + #expect(try !integration.canRenderSurvey(survey: surveyJSON(questions: [first, future, second]))) } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var switchedIdentity = false - postHog.config.setBeforeSend { event in - if event.event == "survey dismissed", !switchedIdentity { - switchedIdentity = true - DispatchQueue.global().sync { postHog.reset() } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + + @Test("resumed completion and dismissal preserve seen history", arguments: [false, true]) + func resumedSurveyPreservesSeenHistory(closeWithoutCompleting: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - return nil + let storage = try #require(postHog.storage) + storage.setDictionary(forKey: .surveySeen, contents: ["seenSurvey_previous": true]) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let first = try getSurveyIntegration(postHog) + first.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + first.getActiveMatchingSurveys { matching = $0 } + try #require(matching.count == 1) + first.setShownSurvey(survey) + _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) + try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) + try #require(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + first.uninstall(postHog) + let resumed = try getSurveyIntegration(postHog) + resumed.setSurveys([survey]) + resumed.getActiveMatchingSurveys { matching = $0 } + try #require(matching.count == 1) + resumed.setShownSurvey(survey) + try #require(resumed.testActiveQuestionIndex == 1) + if !closeWithoutCompleting { + _ = resumed.getNextQuestion(index: 1, response: .openEnded("Final")) + } + resumed.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_previous"] as? Bool == true) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) + resumed.uninstall(postHog) } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - let oldSubmission = integration.testActiveSubmissionId - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - try #require(switchedIdentity) - let storage = try #require(postHog.storage) - let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) - #expect(progress.submissionId != oldSubmission) - #expect(progress.responses["$survey_response_first"]?.text == "New identity") - #expect(integration.testActiveSubmissionId == progress.submissionId) - #expect(integration.testActiveQuestionIndex == 1) - integration.uninstall(postHog) - } - @Test("callbacks from an old render cannot mutate a new attempt", arguments: [false, true]) - func staleRenderCallbacks(reset: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil + @Test("a survey shown before a restart starts the wait period without marking it seen") + func shownSurveyStartsWaitPeriod() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: false, properties: ["start_date": 0]) + let first = try getSurveyIntegration(postHog) + first.setShownSurvey(survey) + first.testHandleSurveyShown(survey: survey.toDisplaySurvey()) + #expect(storage.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] == nil) + first.uninstall(postHog) + let waiting = try partialResponseSurvey(enabled: false, properties: [ + "id": "waiting-survey", "start_date": 0, "conditions": ["seenSurveyWaitPeriodInDays": 7], + ]) + let relaunched = try getSurveyIntegration(postHog) + relaunched.setSurveys([waiting]) + var matching: [PostHogSurvey] = [] + relaunched.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) + relaunched.uninstall(postHog) } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("First render")) - let oldCallbacks = integration.testSurveyCallbacks() - if reset { postHog.reset() } - integration.setShownSurvey(survey) - if reset { _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) } - let submissionId = integration.testActiveSubmissionId - let eventCount = events.count - let display = survey.toDisplaySurvey() - oldCallbacks.shown(display) - #expect(oldCallbacks.response(display, 1, .openEnded("Stale response")) == nil) - oldCallbacks.closed(display) - #expect(events.count == eventCount) - #expect(integration.testActiveSubmissionId == submissionId) - let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) - #expect(progress.submissionId == submissionId) - #expect(progress.responses["$survey_response_first"]?.text == (reset ? "New identity" : "First render")) - integration.uninstall(postHog) - } - @Test("SDK reset waits for a survey storage transaction") - func resetSerializesSurveyState() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - postHog.identify("identity-before-reset") - let oldDistinctId = postHog.getDistinctId() - let oldAnonymousId = postHog.getAnonymousId() - let resetStarted = DispatchSemaphore(value: 0) - let resetFinished = DispatchSemaphore(value: 0) - let oldGeneration = storage.withSurveyState { generation in - DispatchQueue.global().async { - resetStarted.signal() - postHog.reset() - resetFinished.signal() + @Test("reset invalidates an attempt before its first answer", arguments: [false, true]) + func resetBeforeFirstAnswer(keepAnonymousId: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - #expect(resetStarted.wait(timeout: .now() + 5) == .success) - // Reset must not delete identities or progress halfway through a read-modify-write. - #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) - #expect(postHog.getDistinctId() == oldDistinctId) - #expect(storage.getString(forKey: .distinctId) == oldDistinctId) - #expect(postHog.getAnonymousId() == oldAnonymousId) - #expect(storage.getString(forKey: .anonymousId) == oldAnonymousId) - integration.updateSurveyCache([survey], events: [:]) - return generation + postHog.config.reuseAnonymousId = keepAnonymousId + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + postHog.reset() + #expect(integration.getNextQuestion(index: 0, response: .openEnded("Stale")) == nil) + #expect(events.isEmpty) + #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) + integration.uninstall(postHog) } - try #require(resetFinished.wait(timeout: .now() + 5) == .success) - #expect(storage.withSurveyState { $0 } != oldGeneration) - #expect(postHog.getDistinctId() != oldDistinctId) - #expect(postHog.getAnonymousId() != oldAnonymousId) - #expect(storage.getString(forKey: .distinctId) == nil) - #expect(postHog.getDistinctId() == postHog.getAnonymousId()) - #expect(storage.getString(forKey: .anonymousId) == postHog.getAnonymousId()) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) - integration.updateSurveyCache([survey], events: [:]) - #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "New identity") - integration.uninstall(postHog) - } - @Test("reset during a progress read cannot restore or remove another identity's state", arguments: ["load", "save", "remove", "reconcile"], [false, true]) - func resetDuringProgressRead(operation: String, startNewAttempt: Bool) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let store = SurveyProgressStore(storage: storage) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - let oldProgress = try #require(store.load(survey)) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - postHog.reset() - if startNewAttempt { - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + @Test("reset in a dismissal hook preserves the new identity's attempt") + func resetDuringDismissalPreservesNewAttempt() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() } - } - switch operation { - case "load": #expect(store.load(survey) == nil) - case "save": store.save(oldProgress, for: survey) - case "remove": store.remove(survey) - default: integration.updateSurveyCache([survey], events: [:]) - } - #expect(storage.testOnSurveyProgressRead == nil) - let progress = store.load(survey) - if startNewAttempt { - let progress = try #require(progress) - #expect(progress.submissionId != oldProgress.submissionId) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var switchedIdentity = false + postHog.config.setBeforeSend { event in + if event.event == "survey dismissed", !switchedIdentity { + switchedIdentity = true + DispatchQueue.global().sync { postHog.reset() } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + } + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + let oldSubmission = integration.testActiveSubmissionId + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + try #require(switchedIdentity) + let storage = try #require(postHog.storage) + let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) + #expect(progress.submissionId != oldSubmission) #expect(progress.responses["$survey_response_first"]?.text == "New identity") - } else { - #expect(progress == nil) + #expect(integration.testActiveSubmissionId == progress.submissionId) + #expect(integration.testActiveQuestionIndex == 1) + integration.uninstall(postHog) } - integration.uninstall(postHog) - } - @Test("reset during response validation invalidates the pending answer") - func resetDuringResponseValidation() throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil - } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - postHog.reset() + @Test("callbacks from an old render cannot mutate a new attempt", arguments: [false, true]) + func staleRenderCallbacks(reset: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("First render")) + let oldCallbacks = integration.testSurveyCallbacks() + if reset { postHog.reset() } + integration.setShownSurvey(survey) + if reset { _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) } + let submissionId = integration.testActiveSubmissionId + let eventCount = events.count + let display = survey.toDisplaySurvey() + oldCallbacks.shown(display) + #expect(oldCallbacks.response(display, 1, .openEnded("Stale response")) == nil) + oldCallbacks.closed(display) + #expect(events.count == eventCount) + #expect(integration.testActiveSubmissionId == submissionId) + let progress = try #require(SurveyProgressStore(storage: storage).load(survey)) + #expect(progress.submissionId == submissionId) + #expect(progress.responses["$survey_response_first"]?.text == (reset ? "New identity" : "First render")) + integration.uninstall(postHog) } - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) - #expect(events.count == 1) - #expect(integration.testActiveSubmissionId == nil) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - integration.uninstall(postHog) - } - @Test("dismissal and reset remove saved progress", arguments: [false, true]) - func clearSavedProgress(reset: Bool) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - #expect(store.load(survey)?.questionIndex == 1) - if reset { - postHog.reset() - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) - } else { - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + @Test("SDK reset waits for a survey storage transaction") + func resetSerializesSurveyState() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + postHog.identify("identity-before-reset") + let oldDistinctId = postHog.getDistinctId() + let oldAnonymousId = postHog.getAnonymousId() + let resetStarted = DispatchSemaphore(value: 0) + let resetFinished = DispatchSemaphore(value: 0) + let oldGeneration = storage.withSurveyState { generation in + DispatchQueue.global().async { + resetStarted.signal() + postHog.reset() + resetFinished.signal() + } + #expect(resetStarted.wait(timeout: .now() + 5) == .success) + // Reset must not delete identities or progress halfway through a read-modify-write. + #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) + #expect(postHog.getDistinctId() == oldDistinctId) + #expect(storage.getString(forKey: .distinctId) == oldDistinctId) + #expect(postHog.getAnonymousId() == oldAnonymousId) + #expect(storage.getString(forKey: .anonymousId) == oldAnonymousId) + integration.updateSurveyCache([survey], events: [:]) + return generation + } + try #require(resetFinished.wait(timeout: .now() + 5) == .success) + #expect(storage.withSurveyState { $0 } != oldGeneration) + #expect(postHog.getDistinctId() != oldDistinctId) + #expect(postHog.getAnonymousId() != oldAnonymousId) + #expect(storage.getString(forKey: .distinctId) == nil) + #expect(postHog.getDistinctId() == postHog.getAnonymousId()) + #expect(storage.getString(forKey: .anonymousId) == postHog.getAnonymousId()) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + integration.updateSurveyCache([survey], events: [:]) + #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "New identity") + integration.uninstall(postHog) } - #expect(store.load(survey) == nil) - } - @Test("invalid persisted progress is discarded", arguments: ["version", "questionIndex", "questionOrder", "responses"]) - func invalidSavedProgress(field: String) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("reset during a progress read cannot restore or remove another identity's state", arguments: ["load", "save", "remove", "reconcile"], [false, true]) + func resetDuringProgressRead(operation: String, startNewAttempt: Bool) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let store = SurveyProgressStore(storage: storage) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + let oldProgress = try #require(store.load(survey)) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + postHog.reset() + if startNewAttempt { + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity")) + } + } + switch operation { + case "load": #expect(store.load(survey) == nil) + case "save": store.save(oldProgress, for: survey) + case "remove": store.remove(survey) + default: integration.updateSurveyCache([survey], events: [:]) + } + #expect(storage.testOnSurveyProgressRead == nil) + let progress = store.load(survey) + if startNewAttempt { + let progress = try #require(progress) + #expect(progress.submissionId != oldProgress.submissionId) + #expect(progress.responses["$survey_response_first"]?.text == "New identity") + } else { + #expect(progress == nil) + } + integration.uninstall(postHog) } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - let storage = try #require(postHog.storage) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - var records = try #require(storage.getDictionary(forKey: .surveyProgress)) - var record = try #require(records["partial-survey/0"] as? [String: Any]) - let invalidValues: [String: Any] = [ - "version": 99, "questionIndex": 9, - "questionOrder": ["open:second", "open:first"], - "responses": ["first": ["type": 99]], - ] - record[field] = invalidValues[field] - records["partial-survey/0"] = record - storage.setDictionary(forKey: .surveyProgress, contents: records) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(storage.getDictionary(forKey: .surveyProgress)?["partial-survey/0"] == nil) - } - #if os(iOS) - @Test("an invalidated attempt closes the displayed survey") - @MainActor - func invalidatedDisplayQuestion() throws { - let survey = try partialResponseSurvey(enabled: true).toDisplaySurvey(initialQuestionIndex: 1) - let controller = SurveyDisplayController() - controller.onSurveyResponse = { _, _, _ in nil } - controller.showSurvey(survey) - controller.onNextQuestion(index: 1, response: .openEnded("Stale")) - #expect(controller.displayedSurvey == nil) + @Test("reset during response validation invalidates the pending answer") + func resetDuringResponseValidation() throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old identity")) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + postHog.reset() + } + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale response")) == nil) + #expect(events.count == 1) + #expect(integration.testActiveSubmissionId == nil) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + integration.uninstall(postHog) } - #endif - @Test("unfinished surveys remain eligible after partial answers mark them seen") - func resumeEligibility() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("dismissal and reset remove saved progress", arguments: [false, true]) + func clearSavedProgress(reset: Bool) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + #expect(store.load(survey)?.questionIndex == 1) + if reset { + postHog.reset() + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) + } else { + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + } + #expect(store.load(survey) == nil) } - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - let integration = try getSurveyIntegration(postHog) - integration.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == 1) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - #expect(postHog.storage?.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) - integration.uninstall(postHog) - let resumed = try getSurveyIntegration(postHog) - let withInternalFlag = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) - resumed.setSurveys([withInternalFlag]) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == 1) - let gated = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "linked_flag_key": "disabled-product-flag"]) - resumed.setSurveys([gated]) - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - } - @Test("next branching on the last question completes the survey") - func nextBranchingOnLastQuestionCompletesSurvey() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + @Test("invalid persisted progress is discarded", arguments: ["version", "questionIndex", "questionOrder", "responses"]) + func invalidSavedProgress(field: String) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + let storage = try #require(postHog.storage) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + var records = try #require(storage.getDictionary(forKey: .surveyProgress)) + var record = try #require(records["partial-survey/0"] as? [String: Any]) + let invalidValues: [String: Any] = [ + "version": 99, "questionIndex": 9, + "questionOrder": ["open:second", "open:first"], + "responses": ["first": ["type": 99]], + ] + record[field] = invalidValues[field] + records["partial-survey/0"] = record + storage.setDictionary(forKey: .surveyProgress, contents: records) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(storage.getDictionary(forKey: .surveyProgress)?["partial-survey/0"] == nil) } - let survey = try surveyJSON(questions: [[ - "id": "only", - "type": "open", - "question": "Only?", - "branching": ["type": "next_question"], - ]]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) - #expect(next == (0, true)) - } - @Test("next branching before the last question advances") - func nextBranchingBeforeLastQuestionAdvances() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + #if os(iOS) + @Test("an invalidated attempt closes the displayed survey") + @MainActor + func invalidatedDisplayQuestion() throws { + let survey = try partialResponseSurvey(enabled: true).toDisplaySurvey(initialQuestionIndex: 1) + let controller = SurveyDisplayController() + controller.onSurveyResponse = { _, _, _ in nil } + controller.showSurvey(survey) + controller.onNextQuestion(index: 1, response: .openEnded("Stale")) + #expect(controller.displayedSurvey == nil) + } + #endif + + @Test("unfinished surveys remain eligible after partial answers mark them seen") + func resumeEligibility() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + let integration = try getSurveyIntegration(postHog) + integration.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == 1) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + #expect(postHog.storage?.getDictionary(forKey: .surveySeen)?["seenSurvey_partial-survey"] as? Bool == true) + integration.uninstall(postHog) + let resumed = try getSurveyIntegration(postHog) + let withInternalFlag = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) + resumed.setSurveys([withInternalFlag]) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == 1) + let gated = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "linked_flag_key": "disabled-product-flag"]) + resumed.setSurveys([gated]) + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) } - let survey = try surveyJSON(questions: [ - [ - "id": "first", + + @Test("next branching on the last question completes the survey") + func nextBranchingOnLastQuestionCompletesSurvey() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [[ + "id": "only", "type": "open", - "question": "First?", + "question": "Only?", "branching": ["type": "next_question"], - ], - ["id": "second", "type": "open", "question": "Second?"], - ]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) - #expect(next == (1, false)) - } + ]]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) - @Test("unmapped response branching on the last question completes the survey") - func unmappedResponseBranchingOnLastQuestionCompletesSurvey() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) + #expect(next == (0, true)) } - let survey = try surveyJSON(questions: [[ - "id": "only", - "type": "single_choice", - "question": "Pick", - "choices": ["Yes", "No"], - "branching": ["type": "response_based", "responseValues": ["0": "end"]], - ]]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) - #expect(next == (0, true)) - } - @Test("unmapped response branching before the last question advances") - func unmappedResponseBranchingBeforeLastQuestionAdvances() throws { - let postHog = getSut() - postHog.config.setBeforeSend { _ in nil } - defer { - postHog.close() - postHog.reset() + @Test("next branching before the last question advances") + func nextBranchingBeforeLastQuestionAdvances() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [ + [ + "id": "first", + "type": "open", + "question": "First?", + "branching": ["type": "next_question"], + ], + ["id": "second", "type": "open", "question": "Second?"], + ]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded("ok"))) + #expect(next == (1, false)) } - let survey = try surveyJSON(questions: [ - [ - "id": "first", + + @Test("unmapped response branching on the last question completes the survey") + func unmappedResponseBranchingOnLastQuestionCompletesSurvey() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [[ + "id": "only", "type": "single_choice", "question": "Pick", "choices": ["Yes", "No"], "branching": ["type": "response_based", "responseValues": ["0": "end"]], - ], - ["id": "second", "type": "open", "question": "Second?"], - ]) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - - let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) - #expect(next == (1, false)) - } + ]]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) - @Test("custom delegates resume only when opted in", arguments: ["legacy", "disabled", "enabled"]) - func customDelegateResume(capability: String) throws { - let postHog = getSut() - defer { postHog.reset() - postHog.close() - } - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { events.append($0) - return nil - } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) - let first = try getSurveyIntegration(postHog) - first.setShownSurvey(survey) - _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) - let originalId = try #require(first.testActiveSubmissionId) - first.uninstall(postHog) - - let supportsResume = capability == "enabled" - postHog.config._surveysConfig.surveysDelegate = capability == "legacy" - ? LegacySurveyDelegate() : ResumeSurveyDelegate(supportsResume) - let resumed = try getSurveyIntegration(postHog) - defer { resumed.uninstall(postHog) } - resumed.setSurveys([survey]) - var matching: [PostHogSurvey] = [] - resumed.getActiveMatchingSurveys { matching = $0 } - #expect(matching.count == (supportsResume ? 1 : 0)) - - resumed.setShownSurvey(survey) - #expect(resumed.testActiveQuestionIndex == (supportsResume ? 1 : 0)) - #expect((resumed.testActiveSubmissionId == originalId) == supportsResume) - let callbacks = resumed.testSurveyCallbacks() - if !supportsResume { - let next = try #require(callbacks.response(survey.toDisplaySurvey(), 0, .openEnded(nil))) - #expect(next.questionIndex == 1) + let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) + #expect(next == (0, true)) } - let completed = try #require(callbacks.response(survey.toDisplaySurvey(), 1, .openEnded("Final"))) - #expect(completed.isSurveyCompleted) - let sent = try #require(events.last { $0.event == "survey sent" }) - #expect(sent.properties["$survey_response_first"] as? String == (supportsResume ? "Saved" : nil)) - } - @Test("new iterations and ended or removed surveys discard old progress", arguments: [false, true]) - func staleSurveyProgress(ended: Bool) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("unmapped response branching before the last question advances") + func unmappedResponseBranchingBeforeLastQuestionAdvances() throws { + let postHog = getSut() + postHog.config.setBeforeSend { _ in nil } + defer { + postHog.close() + postHog.reset() + } + let survey = try surveyJSON(questions: [ + [ + "id": "first", + "type": "single_choice", + "question": "Pick", + "choices": ["Yes", "No"], + "branching": ["type": "response_based", "responseValues": ["0": "end"]], + ], + ["id": "second", "type": "open", "question": "Second?"], + ]) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + + let next = try #require(integration.getNextQuestion(index: 0, response: .singleChoice("No"))) + #expect(next == (1, false)) } - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 1]) - let nextIteration = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 2]) - let integration = try getSurveyIntegration(postHog) - postHog.config.setBeforeSend { _ in nil } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - #expect(store.load(survey) != nil) - #expect(store.load(nextIteration) == nil) - store.reconcile([nextIteration]) - #expect(store.load(survey) == nil) - integration.setShownSurvey(nextIteration) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let endedSurvey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "end_date": 1, "current_iteration": 2]) - store.reconcile(ended ? [endedSurvey] : []) - #expect(store.load(nextIteration) == nil) - } - @Test("unavailable config preserves progress while an authoritative empty list removes it") - func unavailableConfigPreservesProgress() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - postHog.config.setBeforeSend { _ in nil } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) - integration.setSurveys([survey]) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved before going offline")) - let store = SurveyProgressStore(storage: try #require(postHog.storage)) - let saved = try #require(store.load(survey)) - var callbacks = 0 - integration.decodeAndSetSurveys(remoteConfig: nil) { - callbacks += 1 - #expect($0.isEmpty) - } - #expect(callbacks == 1) - #expect(store.load(survey)?.submissionId == saved.submissionId) - #expect(store.load(survey)?.responses["$survey_response_first"]?.text == "Saved before going offline") - var matching: [PostHogSurvey] = [] - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.map(\.id) == [survey.id]) - - integration.decodeAndSetSurveys(remoteConfig: ["surveys": []]) { - callbacks += 1 - #expect($0.isEmpty) + @Test("custom delegates resume only when opted in", arguments: ["legacy", "disabled", "enabled"]) + func customDelegateResume(capability: String) throws { + let postHog = getSut() + defer { postHog.reset() + postHog.close() + } + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { events.append($0) + return nil + } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "internal_targeting_flag_key": "already-answered"]) + let first = try getSurveyIntegration(postHog) + first.setShownSurvey(survey) + _ = first.getNextQuestion(index: 0, response: .openEnded("Saved")) + let originalId = try #require(first.testActiveSubmissionId) + first.uninstall(postHog) + + let supportsResume = capability == "enabled" + postHog.config._surveysConfig.surveysDelegate = capability == "legacy" + ? LegacySurveyDelegate() : ResumeSurveyDelegate(supportsResume) + let resumed = try getSurveyIntegration(postHog) + defer { resumed.uninstall(postHog) } + resumed.setSurveys([survey]) + var matching: [PostHogSurvey] = [] + resumed.getActiveMatchingSurveys { matching = $0 } + #expect(matching.count == (supportsResume ? 1 : 0)) + + resumed.setShownSurvey(survey) + #expect(resumed.testActiveQuestionIndex == (supportsResume ? 1 : 0)) + #expect((resumed.testActiveSubmissionId == originalId) == supportsResume) + let callbacks = resumed.testSurveyCallbacks() + if !supportsResume { + let next = try #require(callbacks.response(survey.toDisplaySurvey(), 0, .openEnded(nil))) + #expect(next.questionIndex == 1) + } + let completed = try #require(callbacks.response(survey.toDisplaySurvey(), 1, .openEnded("Final"))) + #expect(completed.isSurveyCompleted) + let sent = try #require(events.last { $0.event == "survey sent" }) + #expect(sent.properties["$survey_response_first"] as? String == (supportsResume ? "Saved" : nil)) } - #expect(callbacks == 2) - #expect(store.load(survey) == nil) - integration.getActiveMatchingSurveys { matching = $0 } - #expect(matching.isEmpty) - } - @Test("showing a survey alone does not create resumable progress") - func noProgressBeforeAnswer() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() + @Test("new iterations and ended or removed surveys discard old progress", arguments: [false, true]) + func staleSurveyProgress(ended: Bool) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 1]) + let nextIteration = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "current_iteration": 2]) + let integration = try getSurveyIntegration(postHog) + postHog.config.setBeforeSend { _ in nil } + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + #expect(store.load(survey) != nil) + #expect(store.load(nextIteration) == nil) + store.reconcile([nextIteration]) + #expect(store.load(survey) == nil) + integration.setShownSurvey(nextIteration) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let endedSurvey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0, "end_date": 1, "current_iteration": 2]) + store.reconcile(ended ? [endedSurvey] : []) + #expect(store.load(nextIteration) == nil) } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - integration.setShownSurvey(survey) - #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) - } - @Test("partial responses emit cumulative answers with one submission id", arguments: [true, false, nil] as [Bool?]) - func partialResponses(enabled: Bool?) throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: enabled) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil + @Test("unavailable config preserves progress while an authoritative empty list removes it") + func unavailableConfigPreservesProgress() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + postHog.config.setBeforeSend { _ in nil } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true, properties: ["start_date": 0]) + integration.setSurveys([survey]) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved before going offline")) + let store = SurveyProgressStore(storage: try #require(postHog.storage)) + let saved = try #require(store.load(survey)) + var callbacks = 0 + integration.decodeAndSetSurveys(remoteConfig: nil) { + callbacks += 1 + #expect($0.isEmpty) + } + #expect(callbacks == 1) + #expect(store.load(survey)?.submissionId == saved.submissionId) + #expect(store.load(survey)?.responses["$survey_response_first"]?.text == "Saved before going offline") + var matching: [PostHogSurvey] = [] + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.map(\.id) == [survey.id]) + + integration.decodeAndSetSurveys(remoteConfig: ["surveys": []]) { + callbacks += 1 + #expect($0.isEmpty) + } + #expect(callbacks == 2) + #expect(store.load(survey) == nil) + integration.getActiveMatchingSurveys { matching = $0 } + #expect(matching.isEmpty) } - integration.setShownSurvey(survey) - let first = try #require(integration.getNextQuestion(index: 0, response: .openEnded("First answer"))) - #expect(!first.1) - #expect(!integration.canShowNextSurvey()) - #expect(events.count == (enabled == true ? 1 : 0)) - if enabled == true { - let partial = try #require(events.first) - #expect(partial.event == "survey sent") - #expect(partial.properties["$survey_completed"] as? Bool == false) - #expect(partial.properties["$survey_response_first"] as? String == "First answer") - #expect(partial.properties["$survey_response_second"] == nil) + @Test("showing a survey alone does not create resumable progress") + func noProgressBeforeAnswer() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + integration.setShownSurvey(survey) + #expect(SurveyProgressStore(storage: try #require(postHog.storage)).load(survey) == nil) } - _ = integration.getNextQuestion(index: 1, response: .openEnded("Second answer")) - #expect(events.count == (enabled == true ? 2 : 1)) - let completed = try #require(events.last) - #expect(completed.event == "survey sent") - #expect(completed.properties["$survey_completed"] as? Bool == true) - #expect(completed.properties["$survey_response_first"] as? String == "First answer") - #expect(completed.properties["$survey_response_second"] as? String == "Second answer") - let submissionId = try #require(completed.properties["$survey_submission_id"] as? String) - #expect(UUID(uuidString: submissionId) != nil) - #expect(events.allSatisfy { $0.properties["$survey_submission_id"] as? String == submissionId }) - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - #expect(events.count == (enabled == true ? 2 : 1)) - } - - @Test("dismissed partial response keeps submission id and next attempt gets a new id") - func partialResponseDismissal() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil - } + @Test("partial responses emit cumulative answers with one submission id", arguments: [true, false, nil] as [Bool?]) + func partialResponses(enabled: Bool?) throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: enabled) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let sent = try #require(events.last) - #expect(sent.event == "survey sent") - let submissionId = try #require(sent.properties["$survey_submission_id"] as? String) - integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) - let dismissed = try #require(events.last) - #expect(dismissed.event == "survey dismissed") - #expect(dismissed.properties["$survey_submission_id"] as? String == submissionId) - #expect(dismissed.properties["$survey_partially_completed"] as? Bool == true) - #expect(dismissed.properties["$survey_response_first"] as? String == "Saved") - - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New answer")) - let nextId = try #require(events.last?.properties["$survey_submission_id"] as? String) - #expect(nextId != submissionId) - #expect(events.count == 3) - } + integration.setShownSurvey(survey) + let first = try #require(integration.getNextQuestion(index: 0, response: .openEnded("First answer"))) + #expect(!first.1) + #expect(!integration.canShowNextSurvey()) + #expect(events.count == (enabled == true ? 1 : 0)) + if enabled == true { + let partial = try #require(events.first) + #expect(partial.event == "survey sent") + #expect(partial.properties["$survey_completed"] as? Bool == false) + #expect(partial.properties["$survey_response_first"] as? String == "First answer") + #expect(partial.properties["$survey_response_second"] == nil) + } - @Test("branching to end marks a partial-enabled survey complete") - func partialResponseBranching() throws { - let postHog = getSut() - defer { postHog.close() - postHog.reset() - } - let integration = try getSurveyIntegration(postHog) - let survey = try partialResponseSurvey(enabled: true, branching: ["type": "end"]) - var events: [PostHogEvent] = [] - postHog.config.setBeforeSend { event in - events.append(event) - return nil + _ = integration.getNextQuestion(index: 1, response: .openEnded("Second answer")) + #expect(events.count == (enabled == true ? 2 : 1)) + let completed = try #require(events.last) + #expect(completed.event == "survey sent") + #expect(completed.properties["$survey_completed"] as? Bool == true) + #expect(completed.properties["$survey_response_first"] as? String == "First answer") + #expect(completed.properties["$survey_response_second"] as? String == "Second answer") + let submissionId = try #require(completed.properties["$survey_submission_id"] as? String) + #expect(UUID(uuidString: submissionId) != nil) + #expect(events.allSatisfy { $0.properties["$survey_submission_id"] as? String == submissionId }) + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + #expect(events.count == (enabled == true ? 2 : 1)) } - integration.setShownSurvey(survey) - let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded(nil))) - #expect(next.1) - #expect(events.count == 1) - #expect(events.first?.properties["$survey_completed"] as? Bool == true) - #expect(events.first?.properties["$survey_response_second"] == nil) - } - - // MARK: - Survey Shown Event Tests - - @Test("survey shown event has correct event name and properties") - func surveyShownEventHasCorrectNameAndBaseProperties() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let survey = getTestSurvey( - id: "survey-123", - name: "Test Survey Name", - questions: defaultQuestions, - currentIteration: 2, - currentIterationStartDate: Date(timeIntervalSince1970: 1609459200) // 2021-01-01 - ) - - integration.testSendSurveyShownEvent(survey: survey) - - let events = try await getServerEvents(server) - - #expect(events.count == 1) - let event = events[0] - - #expect(event.event == "survey shown") - #expect(event.properties["$survey_name"] as? String == "Test Survey Name") - #expect(event.properties["$survey_id"] as? String == "survey-123") - #expect(event.properties["$survey_iteration"] as? Int == 2) - #expect(event.properties["$survey_iteration_start_date"] as? String == "2021-01-01T00:00:00.000Z") - postHog.close() - postHog.reset() - } - - @Test("survey shown event without iteration has correct properties") - func surveyShownEventWithoutIterationHasCorrectProperties() async throws { - let postHog = getSut() + @Test("dismissed partial response keeps submission id and next attempt gets a new id") + func partialResponseDismissal() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } - let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let sent = try #require(events.last) + #expect(sent.event == "survey sent") + let submissionId = try #require(sent.properties["$survey_submission_id"] as? String) + integration.testHandleSurveyClosed(survey: survey.toDisplaySurvey()) + let dismissed = try #require(events.last) + #expect(dismissed.event == "survey dismissed") + #expect(dismissed.properties["$survey_submission_id"] as? String == submissionId) + #expect(dismissed.properties["$survey_partially_completed"] as? Bool == true) + #expect(dismissed.properties["$survey_response_first"] as? String == "Saved") + + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New answer")) + let nextId = try #require(events.last?.properties["$survey_submission_id"] as? String) + #expect(nextId != submissionId) + #expect(events.count == 3) + } - let survey = getTestSurvey( - id: "survey-id-456", - name: "Some Simple Survey", - questions: defaultQuestions - ) + @Test("branching to end marks a partial-enabled survey complete") + func partialResponseBranching() throws { + let postHog = getSut() + defer { postHog.close() + postHog.reset() + } + let integration = try getSurveyIntegration(postHog) + let survey = try partialResponseSurvey(enabled: true, branching: ["type": "end"]) + var events: [PostHogEvent] = [] + postHog.config.setBeforeSend { event in + events.append(event) + return nil + } + integration.setShownSurvey(survey) + let next = try #require(integration.getNextQuestion(index: 0, response: .openEnded(nil))) + #expect(next.1) + #expect(events.count == 1) + #expect(events.first?.properties["$survey_completed"] as? Bool == true) + #expect(events.first?.properties["$survey_response_second"] == nil) + } - integration.testSendSurveyShownEvent(survey: survey) + // MARK: - Survey Shown Event Tests - let events = try await getServerEvents(server) + @Test("survey shown event has correct event name and properties") + func surveyShownEventHasCorrectNameAndBaseProperties() async throws { + let postHog = getSut() - #expect(events.count == 1) - let event = events[0] + let integration = try getSurveyIntegration(postHog) - #expect(event.event == "survey shown") - #expect(event.properties["$survey_name"] as? String == "Some Simple Survey") - #expect(event.properties["$survey_id"] as? String == "survey-id-456") - #expect(event.properties["$survey_iteration"] == nil) - #expect(event.properties["$survey_iteration_start_date"] == nil) + let survey = getTestSurvey( + id: "survey-123", + name: "Test Survey Name", + questions: defaultQuestions, + currentIteration: 2, + currentIterationStartDate: Date(timeIntervalSince1970: 1609459200) // 2021-01-01 + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyShownEvent(survey: survey) - // MARK: - Survey Sent Event Tests + let events = try await getServerEvents(server) - @Test("survey sent event has correct event name and response properties") - func surveySentEventHasCorrectNameAndResponseProperties() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey shown") + #expect(event.properties["$survey_name"] as? String == "Test Survey Name") + #expect(event.properties["$survey_id"] as? String == "survey-123") + #expect(event.properties["$survey_iteration"] as? Int == 2) + #expect(event.properties["$survey_iteration_start_date"] as? String == "2021-01-01T00:00:00.000Z") - let survey = getTestSurvey(questions: [ - .open(PostHogOpenSurveyQuestion( - id: "qID1", - question: "What do you think about our product?", - description: "Please share your thoughts", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 0, - branching: nil, - translations: nil - )), - .singleChoice(PostHogMultipleSurveyQuestion( - id: "qID2", - question: "How likely are you to recommend us?", - description: "Please select one option", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 1, - branching: nil, - translations: nil, - choices: ["Very likely", "Somewhat likely", "Not likely"], - hasOpenChoice: false, - shuffleOptions: false - )), - .rating(PostHogRatingSurveyQuestion( - id: "qID3", - question: "Rate your experience", - description: "1 = Poor, 5 = Excellent", - descriptionContentType: .text, - optional: false, - buttonText: nil, - originalQuestionIndex: 2, - branching: nil, - translations: nil, - display: .number, - scale: .fivePoint, - lowerBoundLabel: "Poor", - upperBoundLabel: "Excellent" - )), - ]) + postHog.close() + postHog.reset() + } - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), - integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), - integration.testGetResponseKey(questionId: "qID3"): .rating(4), - ] + @Test("survey shown event without iteration has correct properties") + func surveyShownEventWithoutIterationHasCorrectProperties() async throws { + let postHog = getSut() - integration.testSendSurveySentEvent(survey: survey, responses: responses) + let integration = try getSurveyIntegration(postHog) - let events = try await getServerEvents(server) + let survey = getTestSurvey( + id: "survey-id-456", + name: "Some Simple Survey", + questions: defaultQuestions + ) - #expect(events.count == 1) - let event = events[0] + integration.testSendSurveyShownEvent(survey: survey) - #expect(event.event == "survey sent") - #expect(event.properties["$survey_name"] as? String == survey.name) - #expect(event.properties["$survey_id"] as? String == survey.id) + let events = try await getServerEvents(server) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_responded/\(survey.id)"] as? Bool == true) + #expect(events.count == 1) + let event = events[0] - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) + #expect(event.event == "survey shown") + #expect(event.properties["$survey_name"] as? String == "Some Simple Survey") + #expect(event.properties["$survey_id"] as? String == "survey-id-456") + #expect(event.properties["$survey_iteration"] == nil) + #expect(event.properties["$survey_iteration_start_date"] == nil) - #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") - #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") - #expect(event.properties["$survey_response_qID3"] as? String == "4") + postHog.close() + postHog.reset() + } - postHog.close() - postHog.reset() - } + // MARK: - Survey Sent Event Tests - @Test("survey sent event with a single response") - func surveySentEventWithSingleResponse() async throws { - let postHog = getSut() + @Test("survey sent event has correct event name and response properties") + func surveySentEventHasCorrectNameAndResponseProperties() async throws { + let postHog = getSut() - let integration = try getSurveyIntegration(postHog) + let integration = try getSurveyIntegration(postHog) - let survey = getTestSurvey( - id: "single-response-survey", - name: "Single Response Survey", - questions: [ + let survey = getTestSurvey(questions: [ .open(PostHogOpenSurveyQuestion( id: "qID1", question: "What do you think about our product?", @@ -972,272 +890,356 @@ class PostHogSurveyEventsTest { branching: nil, translations: nil )), + .singleChoice(PostHogMultipleSurveyQuestion( + id: "qID2", + question: "How likely are you to recommend us?", + description: "Please select one option", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 1, + branching: nil, + translations: nil, + choices: ["Very likely", "Somewhat likely", "Not likely"], + hasOpenChoice: false, + shuffleOptions: false + )), + .rating(PostHogRatingSurveyQuestion( + id: "qID3", + question: "Rate your experience", + description: "1 = Poor, 5 = Excellent", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 2, + branching: nil, + translations: nil, + display: .number, + scale: .fivePoint, + lowerBoundLabel: "Poor", + upperBoundLabel: "Excellent" + )), + ]) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), + integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), + integration.testGetResponseKey(questionId: "qID3"): .rating(4), ] - ) - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Excellent product!"), - ] + integration.testSendSurveySentEvent(survey: survey, responses: responses) - integration.testSendSurveySentEvent(survey: survey, responses: responses) + let events = try await getServerEvents(server) - let events = try await getServerEvents(server) + #expect(events.count == 1) + let event = events[0] - #expect(events.count == 1) - let event = events[0] + #expect(event.event == "survey sent") + #expect(event.properties["$survey_name"] as? String == survey.name) + #expect(event.properties["$survey_id"] as? String == survey.id) - #expect(event.event == "survey sent") - #expect(event.properties["$survey_name"] as? String == "Single Response Survey") - #expect(event.properties["$survey_id"] as? String == "single-response-survey") - #expect(event.properties["$survey_response_qID1"] as? String == "Excellent product!") + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_responded/\(survey.id)"] as? Bool == true) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_responded/single-response-survey"] as? Bool == true) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) - postHog.close() - postHog.reset() - } + #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") + #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") + #expect(event.properties["$survey_response_qID3"] as? String == "4") + + postHog.close() + postHog.reset() + } + + @Test("survey sent event with a single response") + func surveySentEventWithSingleResponse() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let survey = getTestSurvey( + id: "single-response-survey", + name: "Single Response Survey", + questions: [ + .open(PostHogOpenSurveyQuestion( + id: "qID1", + question: "What do you think about our product?", + description: "Please share your thoughts", + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil + )), + ] + ) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Excellent product!"), + ] + + integration.testSendSurveySentEvent(survey: survey, responses: responses) - // MARK: - Survey Dismissed Event Tests + let events = try await getServerEvents(server) - @Test("survey dismissed event has correct name and properties") - func surveyDismissedEventHasCorrectNameAndProperties() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey sent") + #expect(event.properties["$survey_name"] as? String == "Single Response Survey") + #expect(event.properties["$survey_id"] as? String == "single-response-survey") + #expect(event.properties["$survey_response_qID1"] as? String == "Excellent product!") - let survey = getTestSurvey( - id: "dismissed-survey", - name: "Dismissed Survey", - questions: defaultQuestions - ) + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_responded/single-response-survey"] as? Bool == true) - integration.testSendSurveyDismissedEvent(survey: survey) + postHog.close() + postHog.reset() + } - let events = try await getServerEvents(server) + // MARK: - Survey Dismissed Event Tests - #expect(events.count == 1) - let event = events[0] + @Test("survey dismissed event has correct name and properties") + func surveyDismissedEventHasCorrectNameAndProperties() async throws { + let postHog = getSut() - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_name"] as? String == "Dismissed Survey") - #expect(event.properties["$survey_id"] as? String == "dismissed-survey") + let integration = try getSurveyIntegration(postHog) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_dismissed/dismissed-survey"] as? Bool == true) + let survey = getTestSurvey( + id: "dismissed-survey", + name: "Dismissed Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey) - @Test("survey dismissed event includes responses and partial completion when there are answers") - func surveyDismissedEventIncludesResponsesWhenThereAreAnswers() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let survey = getTestSurvey( - id: "dismissed-responses-survey", - name: "Dismissed Responses Survey", - questions: defaultQuestions - ) - - let responses: [String: PostHogSurveyResponse] = [ - integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), - integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), - integration.testGetResponseKey(questionId: "qID3"): .rating(4), - "$survey_response": .openEnded("Great product!"), - "$survey_response_1": .singleChoice("Very likely"), - "$survey_response_2": .rating(4), - ] + let events = try await getServerEvents(server) - integration.testSendSurveyDismissedEvent(survey: survey, responses: responses) + #expect(events.count == 1) + let event = events[0] - let events = try await getServerEvents(server) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_name"] as? String == "Dismissed Survey") + #expect(event.properties["$survey_id"] as? String == "dismissed-survey") - #expect(events.count == 1) - let event = events[0] + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_dismissed/dismissed-survey"] as? Bool == true) - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_partially_completed"] as? Bool == true) - #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") - #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") - #expect(event.properties["$survey_response_qID3"] as? String == "4") - #expect(event.properties["$survey_response"] as? String == "Great product!") - #expect(event.properties["$survey_response_1"] as? String == "Very likely") - #expect(event.properties["$survey_response_2"] as? String == "4") + postHog.close() + postHog.reset() + } - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) - #expect(questions?[0]["response"] as? String == "Great product!") - #expect(questions?[1]["response"] as? String == "Very likely") - #expect(questions?[2]["response"] as? String == "4") + @Test("survey dismissed event includes responses and partial completion when there are answers") + func surveyDismissedEventIncludesResponsesWhenThereAreAnswers() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let survey = getTestSurvey( + id: "dismissed-responses-survey", + name: "Dismissed Responses Survey", + questions: defaultQuestions + ) + + let responses: [String: PostHogSurveyResponse] = [ + integration.testGetResponseKey(questionId: "qID1"): .openEnded("Great product!"), + integration.testGetResponseKey(questionId: "qID2"): .singleChoice("Very likely"), + integration.testGetResponseKey(questionId: "qID3"): .rating(4), + "$survey_response": .openEnded("Great product!"), + "$survey_response_1": .singleChoice("Very likely"), + "$survey_response_2": .rating(4), + ] - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey, responses: responses) - @Test("survey dismissed event marks partial completion false when there are no answers") - func surveyDismissedEventMarksPartialCompletionFalseWhenThereAreNoAnswers() async throws { - let postHog = getSut() + let events = try await getServerEvents(server) - let integration = try getSurveyIntegration(postHog) + #expect(events.count == 1) + let event = events[0] - let survey = getTestSurvey( - id: "dismissed-empty-survey", - name: "Dismissed Empty Survey", - questions: defaultQuestions - ) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_partially_completed"] as? Bool == true) + #expect(event.properties["$survey_response_qID1"] as? String == "Great product!") + #expect(event.properties["$survey_response_qID2"] as? String == "Very likely") + #expect(event.properties["$survey_response_qID3"] as? String == "4") + #expect(event.properties["$survey_response"] as? String == "Great product!") + #expect(event.properties["$survey_response_1"] as? String == "Very likely") + #expect(event.properties["$survey_response_2"] as? String == "4") - integration.testSendSurveyDismissedEvent(survey: survey, responses: [:]) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) + #expect(questions?[0]["response"] as? String == "Great product!") + #expect(questions?[1]["response"] as? String == "Very likely") + #expect(questions?[2]["response"] as? String == "4") - let events = try await getServerEvents(server) + postHog.close() + postHog.reset() + } - #expect(events.count == 1) - let event = events[0] + @Test("survey dismissed event marks partial completion false when there are no answers") + func surveyDismissedEventMarksPartialCompletionFalseWhenThereAreNoAnswers() async throws { + let postHog = getSut() - #expect(event.event == "survey dismissed") - #expect(event.properties["$survey_partially_completed"] as? Bool == false) + let integration = try getSurveyIntegration(postHog) - let questions = event.properties["$survey_questions"] as? [[String: Any]] - #expect(questions?.count == 3) - #expect(questions?[0]["response"] == nil) - #expect(questions?[1]["response"] == nil) - #expect(questions?[2]["response"] == nil) + let survey = getTestSurvey( + id: "dismissed-empty-survey", + name: "Dismissed Empty Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey, responses: [:]) + + let events = try await getServerEvents(server) - @Test("survey dismissed event with iteration has correct interaction property") - func surveyDismissedEventWithIterationHasCorrectInteractionProperty() async throws { - let postHog = getSut() + #expect(events.count == 1) + let event = events[0] - let integration = try getSurveyIntegration(postHog) + #expect(event.event == "survey dismissed") + #expect(event.properties["$survey_partially_completed"] as? Bool == false) - let survey = getTestSurvey( - id: "iter-dismissed-survey", - name: "Iteration Dismissed Survey", - questions: defaultQuestions, - currentIteration: 2 - ) + let questions = event.properties["$survey_questions"] as? [[String: Any]] + #expect(questions?.count == 3) + #expect(questions?[0]["response"] == nil) + #expect(questions?[1]["response"] == nil) + #expect(questions?[2]["response"] == nil) - integration.testSendSurveyDismissedEvent(survey: survey) + postHog.close() + postHog.reset() + } - let events = try await getServerEvents(server) + @Test("survey dismissed event with iteration has correct interaction property") + func surveyDismissedEventWithIterationHasCorrectInteractionProperty() async throws { + let postHog = getSut() - #expect(events.count == 1) - let event = events[0] + let integration = try getSurveyIntegration(postHog) - let setProperties = event.properties["$set"] as? [String: Any] - #expect(setProperties?["$survey_dismissed/iter-dismissed-survey/2"] as? Bool == true) + let survey = getTestSurvey( + id: "iter-dismissed-survey", + name: "Iteration Dismissed Survey", + questions: defaultQuestions, + currentIteration: 2 + ) - postHog.close() - postHog.reset() - } + integration.testSendSurveyDismissedEvent(survey: survey) + + let events = try await getServerEvents(server) - // MARK: - Base Survey Event Properties Tests + #expect(events.count == 1) + let event = events[0] - @Test("base survey event properties include all required fields") - func baseSurveyEventPropertiesIncludeAllRequiredFields() async throws { - let postHog = getSut() + let setProperties = event.properties["$set"] as? [String: Any] + #expect(setProperties?["$survey_dismissed/iter-dismissed-survey/2"] as? Bool == true) - let integration = try getSurveyIntegration(postHog) + postHog.close() + postHog.reset() + } - let survey = getTestSurvey( - id: "complete-survey-id", - name: "Complete Survey", - questions: defaultQuestions, - currentIteration: 5, - currentIterationStartDate: Date(timeIntervalSince1970: 1640995200) // 2022-01-01 - ) + // MARK: - Base Survey Event Properties Tests - let properties = integration.testGetBaseSurveyEventProperties(for: survey) + @Test("base survey event properties include all required fields") + func baseSurveyEventPropertiesIncludeAllRequiredFields() async throws { + let postHog = getSut() - #expect(properties["$survey_name"] as? String == "Complete Survey") - #expect(properties["$survey_id"] as? String == "complete-survey-id") - #expect(properties["$survey_iteration"] as? Int == 5) - #expect(properties["$survey_iteration_start_date"] as? String == "2022-01-01T00:00:00.000Z") + let integration = try getSurveyIntegration(postHog) - postHog.close() - postHog.reset() - } + let survey = getTestSurvey( + id: "complete-survey-id", + name: "Complete Survey", + questions: defaultQuestions, + currentIteration: 5, + currentIterationStartDate: Date(timeIntervalSince1970: 1640995200) // 2022-01-01 + ) + + let properties = integration.testGetBaseSurveyEventProperties(for: survey) - @Test("base survey event properties exclude nil values") - func baseSurveyEventPropertiesExcludeNilValues() async throws { - let postHog = getSut() + #expect(properties["$survey_name"] as? String == "Complete Survey") + #expect(properties["$survey_id"] as? String == "complete-survey-id") + #expect(properties["$survey_iteration"] as? Int == 5) + #expect(properties["$survey_iteration_start_date"] as? String == "2022-01-01T00:00:00.000Z") - let integration = try getSurveyIntegration(postHog) + postHog.close() + postHog.reset() + } - let survey = getTestSurvey( - id: "minimal-survey-id", - name: "Minimal Survey", - questions: defaultQuestions - ) + @Test("base survey event properties exclude nil values") + func baseSurveyEventPropertiesExcludeNilValues() async throws { + let postHog = getSut() - let properties = integration.testGetBaseSurveyEventProperties(for: survey) + let integration = try getSurveyIntegration(postHog) - #expect(properties["$survey_name"] as? String == "Minimal Survey") - #expect(properties["$survey_id"] as? String == "minimal-survey-id") - #expect(properties["$survey_iteration"] == nil) - #expect(properties["$survey_iteration_start_date"] == nil) + let survey = getTestSurvey( + id: "minimal-survey-id", + name: "Minimal Survey", + questions: defaultQuestions + ) - postHog.close() - postHog.reset() - } + let properties = integration.testGetBaseSurveyEventProperties(for: survey) + + #expect(properties["$survey_name"] as? String == "Minimal Survey") + #expect(properties["$survey_id"] as? String == "minimal-survey-id") + #expect(properties["$survey_iteration"] == nil) + #expect(properties["$survey_iteration_start_date"] == nil) - @Test("survey interaction property formats correctly") - func surveyInteractionPropertyFormatsCorrectly() async throws { - let postHog = getSut() - - let integration = try getSurveyIntegration(postHog) - - let surveyWithoutIteration = getTestSurvey( - id: "test-survey", - name: "Test Survey", - questions: defaultQuestions - ) - - let propertyWithoutIteration = integration.testGetSurveyInteractionProperty( - survey: surveyWithoutIteration, - property: "responded" - ) - #expect(propertyWithoutIteration == "$survey_responded/test-survey") - - let surveyWithIteration = getTestSurvey( - id: "test-survey", - name: "Test Survey", - questions: defaultQuestions, - currentIteration: 3 - ) - - let propertyWithIteration = integration.testGetSurveyInteractionProperty( - survey: surveyWithIteration, - property: "dismissed" - ) - #expect(propertyWithIteration == "$survey_dismissed/test-survey/3") - - postHog.close() - postHog.reset() + postHog.close() + postHog.reset() + } + + @Test("survey interaction property formats correctly") + func surveyInteractionPropertyFormatsCorrectly() async throws { + let postHog = getSut() + + let integration = try getSurveyIntegration(postHog) + + let surveyWithoutIteration = getTestSurvey( + id: "test-survey", + name: "Test Survey", + questions: defaultQuestions + ) + + let propertyWithoutIteration = integration.testGetSurveyInteractionProperty( + survey: surveyWithoutIteration, + property: "responded" + ) + #expect(propertyWithoutIteration == "$survey_responded/test-survey") + + let surveyWithIteration = getTestSurvey( + id: "test-survey", + name: "Test Survey", + questions: defaultQuestions, + currentIteration: 3 + ) + + let propertyWithIteration = integration.testGetSurveyInteractionProperty( + survey: surveyWithIteration, + property: "dismissed" + ) + #expect(propertyWithIteration == "$survey_dismissed/test-survey/3") + + postHog.close() + postHog.reset() + } } -} -private class LegacySurveyDelegate: NSObject, PostHogSurveysDelegate { - func renderSurvey( - _: PostHogDisplaySurvey, - onSurveyShown _: @escaping OnPostHogSurveyShown, - onSurveyResponse _: @escaping OnPostHogSurveyResponse, - onSurveyClosed _: @escaping OnPostHogSurveyClosed - ) {} + private class LegacySurveyDelegate: NSObject, PostHogSurveysDelegate { + func renderSurvey( + _: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) {} - func cleanupSurveys() {} -} + func cleanupSurveys() {} + } -private final class ResumeSurveyDelegate: LegacySurveyDelegate { - @objc let supportsSurveyResume: Bool + private final class ResumeSurveyDelegate: LegacySurveyDelegate { + @objc let supportsSurveyResume: Bool - init(_ supportsSurveyResume: Bool) { - self.supportsSurveyResume = supportsSurveyResume - super.init() + init(_ supportsSurveyResume: Bool) { + self.supportsSurveyResume = supportsSurveyResume + super.init() + } } -} +#endif diff --git a/PostHogTests/PostHogSurveyResumeTest.swift b/PostHogTests/PostHogSurveyResumeTest.swift index 7da10dbeac..550dc25247 100644 --- a/PostHogTests/PostHogSurveyResumeTest.swift +++ b/PostHogTests/PostHogSurveyResumeTest.swift @@ -2,7 +2,7 @@ import Foundation @testable import PostHog import Testing -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) extension PostHogSurveyEventsTest { @MainActor private func renderingSDK(resetStorage: Bool, language: String) -> (PostHogSDK, PostHogSurveyIntegration, SurveyDisplayController) { diff --git a/PostHogTests/PostHogSurveySharedStorageTest.swift b/PostHogTests/PostHogSurveySharedStorageTest.swift index ba28c10c85..c3c1c80351 100644 --- a/PostHogTests/PostHogSurveySharedStorageTest.swift +++ b/PostHogTests/PostHogSurveySharedStorageTest.swift @@ -1,272 +1,274 @@ -import Foundation -@testable import PostHog -import Testing +#if !SWIFT_PACKAGE || Surveys + import Foundation + @testable import PostHog + import Testing -extension PostHogSurveyEventsTest { - @Test("a failed epoch rotation revokes progress for every storage instance") - func immutableEpochRevokesSharedProgress() throws { - let first = getSut() - defer { first.close() } - let second = getSut(resetStorage: false) - defer { second.close() } - second.config.setBeforeSend { _ in nil } - let storage = try #require(first.storage) - let epochFile = storage.url(forKey: .surveyResetEpoch) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - integration.setShownSurvey(survey) - var events: [PostHogEvent] = [] - first.config.setBeforeSend { events.append($0) - return nil - } - try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: epochFile.path) - defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + extension PostHogSurveyEventsTest { + @Test("a failed epoch rotation revokes progress for every storage instance") + func immutableEpochRevokesSharedProgress() throws { + let first = getSut() + defer { first.close() } + let second = getSut(resetStorage: false) + defer { second.close() } + second.config.setBeforeSend { _ in nil } + let storage = try #require(first.storage) + let epochFile = storage.url(forKey: .surveyResetEpoch) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + integration.setShownSurvey(survey) + var events: [PostHogEvent] = [] + first.config.setBeforeSend { events.append($0) + return nil + } + try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: epochFile.path) + defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + second.reset() + } second.reset() + #expect(integration.getNextQuestion(index: 0, response: .openEnded("Old process")) == nil) + #expect(events.isEmpty) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) + first.reset() + let recovered = try getSurveyIntegration(second) + recovered.setShownSurvey(survey) + _ = recovered.getNextQuestion(index: 0, response: .openEnded("Recovered")) + #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "Recovered") } - second.reset() - #expect(integration.getNextQuestion(index: 0, response: .openEnded("Old process")) == nil) - #expect(events.isEmpty) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: epochFile.path) - first.reset() - let recovered = try getSurveyIntegration(second) - recovered.setShownSurvey(survey) - _ = recovered.getNextQuestion(index: 0, response: .openEnded("Recovered")) - #expect(SurveyProgressStore(storage: storage).load(survey)?.responses["$survey_response_first"]?.text == "Recovered") - } - @Test("a new shared-storage attempt captures events before resetting SDK warms its identity") - func surveyEventsDuringResetIdentityGap() throws { - let first = getSut() - defer { first.close() } - let storage = try #require(first.storage) - var generateDuringReset: (() -> Void)? - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - config.disableQueueTimerForTesting = true - config.getAnonymousId = { uuid in - generateDuringReset?() - // Preserve the identity created by the other process during this callback. - return storage.getString(forKey: .anonymousId).flatMap(UUID.init(uuidString:)) ?? uuid - } - let second = PostHogSDK.with(config) - defer { second.close() } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - var events: [PostHogEvent] = [] - first.config.setBeforeSend { events.append($0) - return nil + @Test("a new shared-storage attempt captures events before resetting SDK warms its identity") + func surveyEventsDuringResetIdentityGap() throws { + let first = getSut() + defer { first.close() } + let storage = try #require(first.storage) + var generateDuringReset: (() -> Void)? + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false + config.disableQueueTimerForTesting = true + config.getAnonymousId = { uuid in + generateDuringReset?() + // Preserve the identity created by the other process during this callback. + return storage.getString(forKey: .anonymousId).flatMap(UUID.init(uuidString:)) ?? uuid + } + let second = PostHogSDK.with(config) + defer { second.close() } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + var events: [PostHogEvent] = [] + first.config.setBeforeSend { events.append($0) + return nil + } + var enteredGap = false + generateDuringReset = { + enteredGap = true + #expect(storage.getString(forKey: .anonymousId) == nil) + #expect(storage.getString(forKey: .distinctId) == nil) + integration.setShownSurvey(survey) + integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) + _ = integration.getNextQuestion(index: 0, response: .openEnded("First")) + _ = integration.getNextQuestion(index: 1, response: .openEnded("Final")) + } + defer { generateDuringReset = nil } + second.reset() + try #require(enteredGap) + #expect(events.map(\.event) == ["survey shown", "survey sent", "survey sent"]) + #expect(events.allSatisfy { $0.distinctId == second.getDistinctId() }) + #expect(events.last?.properties["$survey_response_first"] as? String == "First") } - var enteredGap = false - generateDuringReset = { - enteredGap = true - #expect(storage.getString(forKey: .anonymousId) == nil) - #expect(storage.getString(forKey: .distinctId) == nil) + + @Test("fresh SDK shown events use the newly persisted anonymous identity") + func freshSurveyEventIdentity() throws { + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false + config.disableQueueTimerForTesting = true + let storage = PostHogStorage(config) + storage.reset() + try #require(storage.getString(forKey: .anonymousId) == nil) + try #require(storage.getString(forKey: .distinctId) == nil) + let postHog = PostHogSDK.with(config) + defer { postHog.close() } + var captured: PostHogEvent? + config.setBeforeSend { captured = $0 + return nil + } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(survey) integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - _ = integration.getNextQuestion(index: 0, response: .openEnded("First")) - _ = integration.getNextQuestion(index: 1, response: .openEnded("Final")) + let event = try #require(captured) + #expect(event.event == "survey shown") + #expect(event.distinctId == storage.getString(forKey: .anonymousId)) } - defer { generateDuringReset = nil } - second.reset() - try #require(enteredGap) - #expect(events.map(\.event) == ["survey shown", "survey sent", "survey sent"]) - #expect(events.allSatisfy { $0.distinctId == second.getDistinctId() }) - #expect(events.last?.properties["$survey_response_first"] as? String == "First") - } - @Test("fresh SDK shown events use the newly persisted anonymous identity") - func freshSurveyEventIdentity() throws { - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - config.disableQueueTimerForTesting = true - let storage = PostHogStorage(config) - storage.reset() - try #require(storage.getString(forKey: .anonymousId) == nil) - try #require(storage.getString(forKey: .distinctId) == nil) - let postHog = PostHogSDK.with(config) - defer { postHog.close() } - var captured: PostHogEvent? - config.setBeforeSend { captured = $0 - return nil + @Test("reset atomically replaces a read-only epoch file") + func resetReplacesReadOnlyEpoch() throws { + let postHog = getSut() + defer { postHog.close() } + let storage = try #require(postHog.storage) + let oldEpoch = storage.withSurveyState { $0 } + let epochFile = storage.url(forKey: .surveyResetEpoch) + try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: epochFile.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: epochFile.path) } + postHog.reset() + #expect(storage.getString(forKey: .surveyResetEpoch) != oldEpoch) + #expect(storage.isSurveyGenerationCurrent(oldEpoch) == false) + postHog.config.setBeforeSend { _ in nil } + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity answer")) + let progress = try #require(SurveyProgressStore(storage: PostHogStorage(postHog.config)).load(survey)) + #expect(progress.questionIndex == 1) + #expect(progress.responses["$survey_response_first"]?.text == "New identity answer") } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - integration.testHandleSurveyShown(survey: survey.toDisplaySurvey()) - let event = try #require(captured) - #expect(event.event == "survey shown") - #expect(event.distinctId == storage.getString(forKey: .anonymousId)) - } - - @Test("reset atomically replaces a read-only epoch file") - func resetReplacesReadOnlyEpoch() throws { - let postHog = getSut() - defer { postHog.close() } - let storage = try #require(postHog.storage) - let oldEpoch = storage.withSurveyState { $0 } - let epochFile = storage.url(forKey: .surveyResetEpoch) - try FileManager.default.setAttributes([.posixPermissions: 0o400], ofItemAtPath: epochFile.path) - defer { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: epochFile.path) } - postHog.reset() - #expect(storage.getString(forKey: .surveyResetEpoch) != oldEpoch) - #expect(storage.isSurveyGenerationCurrent(oldEpoch) == false) - postHog.config.setBeforeSend { _ in nil } - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("New identity answer")) - let progress = try #require(SurveyProgressStore(storage: PostHogStorage(postHog.config)).load(survey)) - #expect(progress.questionIndex == 1) - #expect(progress.responses["$survey_response_first"]?.text == "New identity answer") - } - @Test("progress without a reset epoch is discarded") - func ownerlessProgressIsDiscarded() throws { - let postHog = getSut() - defer { postHog.close() } - postHog.config.setBeforeSend { _ in nil } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Ownerless")) - var records = try #require(storage.getDictionary(forKey: .surveyProgress)) - let key = try #require(records.keys.first) - var record = try #require(records[key] as? [String: Any]) - record.removeValue(forKey: "resetEpoch") - record["version"] = 1 - records[key] = record - storage.setDictionary(forKey: .surveyProgress, contents: records) - #expect(SurveyProgressStore(storage: storage).load(survey) == nil) - #expect(storage.getDictionary(forKey: .surveyProgress)?.isEmpty == true) - } + @Test("progress without a reset epoch is discarded") + func ownerlessProgressIsDiscarded() throws { + let postHog = getSut() + defer { postHog.close() } + postHog.config.setBeforeSend { _ in nil } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Ownerless")) + var records = try #require(storage.getDictionary(forKey: .surveyProgress)) + let key = try #require(records.keys.first) + var record = try #require(records[key] as? [String: Any]) + record.removeValue(forKey: "resetEpoch") + record["version"] = 1 + records[key] = record + storage.setDictionary(forKey: .surveyProgress, contents: records) + #expect(SurveyProgressStore(storage: storage).load(survey) == nil) + #expect(storage.getDictionary(forKey: .surveyProgress)?.isEmpty == true) + } - @Test("survey progress fails closed when the shared lock cannot be opened") - func unavailableSurveyCoordination() throws { - let postHog = getSut() - defer { postHog.close() } - postHog.config.setBeforeSend { _ in nil } - let storage = try #require(postHog.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(postHog) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) - let store = SurveyProgressStore(storage: storage) - let progress = try #require(store.load(survey)) - let lockFile = storage.appFolderUrl.appendingPathComponent("posthog.surveyState.lock") - try FileManager.default.removeItem(at: lockFile) - try FileManager.default.createDirectory(at: lockFile, withIntermediateDirectories: false) - defer { try? FileManager.default.removeItem(at: lockFile) } - #expect(store.load(survey) == nil) - store.save(progress, for: survey) - #expect(integration.getNextQuestion(index: 1, response: .openEnded("Rejected")) == nil) - } + @Test("survey progress fails closed when the shared lock cannot be opened") + func unavailableSurveyCoordination() throws { + let postHog = getSut() + defer { postHog.close() } + postHog.config.setBeforeSend { _ in nil } + let storage = try #require(postHog.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(postHog) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Saved")) + let store = SurveyProgressStore(storage: storage) + let progress = try #require(store.load(survey)) + let lockFile = storage.appFolderUrl.appendingPathComponent("posthog.surveyState.lock") + try FileManager.default.removeItem(at: lockFile) + try FileManager.default.createDirectory(at: lockFile, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(at: lockFile) } + #expect(store.load(survey) == nil) + store.save(progress, for: survey) + #expect(integration.getNextQuestion(index: 1, response: .openEnded("Rejected")) == nil) + } - @Test("independent storage instances initialize one durable survey epoch") - func sharedSurveyEpochInitialization() async throws { - let postHog = getSut() - defer { postHog.close() } - let storage = try #require(postHog.storage) - storage.remove(key: .surveyResetEpoch) - let instances = (0 ..< 8).map { _ in PostHogStorage(postHog.config) } - let epochs = await withTaskGroup(of: String.self, returning: [String].self) { group in - for instance in instances { - group.addTask { instance.withSurveyState { $0 } } - } - var epochs: [String] = [] - for await epoch in group { - epochs.append(epoch) + @Test("independent storage instances initialize one durable survey epoch") + func sharedSurveyEpochInitialization() async throws { + let postHog = getSut() + defer { postHog.close() } + let storage = try #require(postHog.storage) + storage.remove(key: .surveyResetEpoch) + let instances = (0 ..< 8).map { _ in PostHogStorage(postHog.config) } + let epochs = await withTaskGroup(of: String.self, returning: [String].self) { group in + for instance in instances { + group.addTask { instance.withSurveyState { $0 } } + } + var epochs: [String] = [] + for await epoch in group { + epochs.append(epoch) + } + return epochs } - return epochs + #expect(Set(epochs).count == 1) + #expect(epochs.first == storage.getString(forKey: .surveyResetEpoch)) } - #expect(Set(epochs).count == 1) - #expect(epochs.first == storage.getString(forKey: .surveyResetEpoch)) - } - @Test("shared storage rejects an old process's progress after reset", arguments: [false, true]) - func sharedStorageResetRejectsStaleProgress(keepAnonymousId: Bool) throws { - let first = getSut() - defer { first.close() } - first.config.setBeforeSend { _ in nil } - if !keepAnonymousId { first.identify("old-process-user") } - let second = getSut(resetStorage: false) - defer { second.reset() - second.close() - } - second.config.reuseAnonymousId = keepAnonymousId - second.config.setBeforeSend { _ in nil } - let firstStorage = try #require(first.storage) - let secondStorage = try #require(second.storage) - try #require(firstStorage !== secondStorage) - try #require(firstStorage.appFolderUrl == secondStorage.appFolderUrl) - let oldIdentity = first.getDistinctId() - try #require(second.getDistinctId() == oldIdentity) - let survey = try partialResponseSurvey(enabled: true) - let oldIntegration = try getSurveyIntegration(first) - oldIntegration.setShownSurvey(survey) - _ = oldIntegration.getNextQuestion(index: 0, response: .openEnded("Old process")) - let firstStore = SurveyProgressStore(storage: firstStorage) - let oldProgress = try #require(firstStore.load(survey)) - second.reset() - #expect((second.getDistinctId() == oldIdentity) == keepAnonymousId) - firstStore.save(oldProgress, for: survey) - #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) - let freshIntegration = try getSurveyIntegration(second) - freshIntegration.setShownSurvey(survey) - _ = freshIntegration.getNextQuestion(index: 0, response: .openEnded("New process")) - let freshSubmission = freshIntegration.testActiveSubmissionId - firstStore.save(oldProgress, for: survey) - #expect(firstStore.load(survey)?.submissionId == freshSubmission) - #expect(oldIntegration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) - let resumed = try getSurveyIntegration(first) - resumed.setShownSurvey(survey) - #expect(resumed.testActiveSubmissionId == freshSubmission) - var captured: PostHogEvent? - first.config.setBeforeSend { captured = $0 - return nil + @Test("shared storage rejects an old process's progress after reset", arguments: [false, true]) + func sharedStorageResetRejectsStaleProgress(keepAnonymousId: Bool) throws { + let first = getSut() + defer { first.close() } + first.config.setBeforeSend { _ in nil } + if !keepAnonymousId { first.identify("old-process-user") } + let second = getSut(resetStorage: false) + defer { second.reset() + second.close() + } + second.config.reuseAnonymousId = keepAnonymousId + second.config.setBeforeSend { _ in nil } + let firstStorage = try #require(first.storage) + let secondStorage = try #require(second.storage) + try #require(firstStorage !== secondStorage) + try #require(firstStorage.appFolderUrl == secondStorage.appFolderUrl) + let oldIdentity = first.getDistinctId() + try #require(second.getDistinctId() == oldIdentity) + let survey = try partialResponseSurvey(enabled: true) + let oldIntegration = try getSurveyIntegration(first) + oldIntegration.setShownSurvey(survey) + _ = oldIntegration.getNextQuestion(index: 0, response: .openEnded("Old process")) + let firstStore = SurveyProgressStore(storage: firstStorage) + let oldProgress = try #require(firstStore.load(survey)) + second.reset() + #expect((second.getDistinctId() == oldIdentity) == keepAnonymousId) + firstStore.save(oldProgress, for: survey) + #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) + let freshIntegration = try getSurveyIntegration(second) + freshIntegration.setShownSurvey(survey) + _ = freshIntegration.getNextQuestion(index: 0, response: .openEnded("New process")) + let freshSubmission = freshIntegration.testActiveSubmissionId + firstStore.save(oldProgress, for: survey) + #expect(firstStore.load(survey)?.submissionId == freshSubmission) + #expect(oldIntegration.getNextQuestion(index: 1, response: .openEnded("Stale callback")) == nil) + let resumed = try getSurveyIntegration(first) + resumed.setShownSurvey(survey) + #expect(resumed.testActiveSubmissionId == freshSubmission) + var captured: PostHogEvent? + first.config.setBeforeSend { captured = $0 + return nil + } + _ = resumed.getNextQuestion(index: 1, response: .openEnded("Fresh response")) + let event = try #require(captured) + #expect(event.distinctId == second.getDistinctId()) + #expect(event.properties["$survey_response_first"] as? String == "New process") } - _ = resumed.getNextQuestion(index: 1, response: .openEnded("Fresh response")) - let event = try #require(captured) - #expect(event.distinctId == second.getDistinctId()) - #expect(event.properties["$survey_response_first"] as? String == "New process") - } - @Test("reset in another storage instance waits for an in-flight progress write") - func sharedStorageResetWaitsForWrite() throws { - let first = getSut() - defer { first.close() } - first.config.setBeforeSend { _ in nil } - let second = getSut(resetStorage: false) - defer { second.reset() - second.close() - } - let storage = try #require(first.storage) - let secondStorage = try #require(second.storage) - let survey = try partialResponseSurvey(enabled: true) - let integration = try getSurveyIntegration(first) - integration.setShownSurvey(survey) - _ = integration.getNextQuestion(index: 0, response: .openEnded("Old process")) - let store = SurveyProgressStore(storage: storage) - let progress = try #require(store.load(survey)) - let resetStarted = DispatchSemaphore(value: 0) - let resetFinished = DispatchSemaphore(value: 0) - storage.testOnSurveyProgressRead = { - storage.testOnSurveyProgressRead = nil - DispatchQueue.global().async { - resetStarted.signal() - second.reset() - resetFinished.signal() + @Test("reset in another storage instance waits for an in-flight progress write") + func sharedStorageResetWaitsForWrite() throws { + let first = getSut() + defer { first.close() } + first.config.setBeforeSend { _ in nil } + let second = getSut(resetStorage: false) + defer { second.reset() + second.close() + } + let storage = try #require(first.storage) + let secondStorage = try #require(second.storage) + let survey = try partialResponseSurvey(enabled: true) + let integration = try getSurveyIntegration(first) + integration.setShownSurvey(survey) + _ = integration.getNextQuestion(index: 0, response: .openEnded("Old process")) + let store = SurveyProgressStore(storage: storage) + let progress = try #require(store.load(survey)) + let resetStarted = DispatchSemaphore(value: 0) + let resetFinished = DispatchSemaphore(value: 0) + storage.testOnSurveyProgressRead = { + storage.testOnSurveyProgressRead = nil + DispatchQueue.global().async { + resetStarted.signal() + second.reset() + resetFinished.signal() + } + #expect(resetStarted.wait(timeout: .now() + 5) == .success) + #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) } - #expect(resetStarted.wait(timeout: .now() + 5) == .success) - #expect(resetFinished.wait(timeout: .now() + 0.05) == .timedOut) + store.save(progress, for: survey) + try #require(resetFinished.wait(timeout: .now() + 5) == .success) + #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) } - store.save(progress, for: survey) - try #require(resetFinished.wait(timeout: .now() + 5) == .success) - #expect(SurveyProgressStore(storage: secondStorage).load(survey) == nil) } -} +#endif diff --git a/PostHogTests/PostHogSurveySheetDetentsTest.swift b/PostHogTests/PostHogSurveySheetDetentsTest.swift index f20b12a15c..6a2ca3d2e4 100644 --- a/PostHogTests/PostHogSurveySheetDetentsTest.swift +++ b/PostHogTests/PostHogSurveySheetDetentsTest.swift @@ -5,20 +5,18 @@ // Created by Anna Garcia on 28/09/2026. // -#if os(iOS) +#if os(iOS) && (!SWIFT_PACKAGE || Surveys) import Foundation @testable import PostHog import Testing @Suite("Survey sheet detents") struct PostHogSurveySheetDetentsTest { - @available(iOS 15.0, *) @Test("a sheet shorter than the window gets a detent of its own height") func shortSheetGetsItsOwnHeight() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 400, availableHeight: 844) == [.height(400)]) } - @available(iOS 15.0, *) @Test("a sheet taller than the window can be expanded") func tallSheetIsExpandable() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 900, availableHeight: 844) == [.medium, .large]) @@ -29,7 +27,6 @@ /// `UIScreen.main` still reports the 678pt outer display. A sheet between the two doesn't fit. /// Unfolded in portrait the window is 951pt tall, so sheets between 678 and 951pt used to be /// shown at half height even though they fit. - @available(iOS 15.0, *) @Test("a sheet taller than the window but shorter than the main screen can be expanded") func sheetBetweenWindowAndMainScreenIsExpandable() { #expect(SurveyPresentationDetentsRepresentable.detents(forSheetHeight: 672, availableHeight: 669) == [.medium, .large]) diff --git a/PostHogTests/PostHogSurveyTranslationsTest.swift b/PostHogTests/PostHogSurveyTranslationsTest.swift index 45d31c753d..89b89094dc 100644 --- a/PostHogTests/PostHogSurveyTranslationsTest.swift +++ b/PostHogTests/PostHogSurveyTranslationsTest.swift @@ -5,7 +5,7 @@ // Created by PostHog Code on 2026-05-13. // -#if os(iOS) || TESTING +#if (os(iOS) || TESTING) && (!SWIFT_PACKAGE || Surveys) import Foundation @testable import PostHog @@ -566,9 +566,7 @@ @Test("changing the language person property re-translates the active survey") func languageChangeRetranslatesActiveSurvey() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -584,9 +582,7 @@ @Test("re-resolving the same language does not push an update") func sameLanguageIsNoop() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) // Already showing the French translation @@ -613,9 +609,7 @@ @Test("delegate without updateSurvey keeps the rendered language") func delegateWithoutUpdateSurveyKeepsState() async throws { - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = NoLiveUpdateSurveysDelegate() + postHog.config.surveysConfig.surveysDelegate = NoLiveUpdateSurveysDelegate() let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -629,9 +623,7 @@ @Test("resetting person properties reverts the active survey language") func resetRevertsActiveSurveyLanguage() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy let integration = try getSurveyIntegration(postHog) integration.setShownSurvey(translatedSurvey(), language: nil) @@ -650,9 +642,7 @@ @Test("no active survey means no update is pushed") func noActiveSurveyIsNoop() async throws { let spy = SpySurveysDelegate() - // Use the backing property directly: the public `surveysConfig` accessor is - // gated to iOS 15+, but the delegate it exposes is not version-specific. - postHog.config._surveysConfig.surveysDelegate = spy + postHog.config.surveysConfig.surveysDelegate = spy _ = try getSurveyIntegration(postHog) postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) diff --git a/PostHogTests/PostHogSurveysTest.swift b/PostHogTests/PostHogSurveysTest.swift index 7eb79c8152..9668023bdc 100644 --- a/PostHogTests/PostHogSurveysTest.swift +++ b/PostHogTests/PostHogSurveysTest.swift @@ -5,2557 +5,2557 @@ // Created by Yiannis Josephides on 21/01/2025. // -import Foundation -import OHHTTPStubs -import OHHTTPStubsSwift -@testable import PostHog -import Testing - -@Suite("Test Surveys") -enum PostHogSurveysTest { - @Suite("Auto-submit on selection") - struct AutoSubmit { - private func displayQuestion(type: String, skipSubmit: Bool?, hasOpenChoice: Bool = false, display: String = "number") throws -> PostHogDisplaySurveyQuestion { - var json: [String: Any] = [ - "id": "auto-submit", "type": type, "question": "Choose", - "choices": ["First", "Other"], "hasOpenChoice": hasOpenChoice, - "display": display, "scale": 5, - ] - json["skipSubmitButton"] = skipSubmit - let question = try PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: JSONSerialization.data(withJSONObject: json)) - return try #require(question.toDisplayQuestion()) - } - - #if os(iOS) - @Test("rating selection emits one answer immediately and clearing emits none", arguments: [true, false], ["number", "emoji"]) - @available(iOS 15.0, *) - @MainActor - func ratingSelection(skipSubmit: Bool, display: String) throws { - let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) - var responses: [Int?] = [] - let view = RatingQuestionView(question: question, onNextQuestion: { responses.append($0) }) - view.selection.wrappedValue = 4 - #expect(responses == (skipSubmit ? [4] : [])) - view.selection.wrappedValue = nil - #expect(responses == (skipSubmit ? [4] : [])) - } - - @Test("single-choice selection emits the answer only when eligible", arguments: [true, false], [false, true]) - @available(iOS 15.0, *) - @MainActor - func choiceSelection(skipSubmit: Bool, hasOpenChoice: Bool) throws { - let question = try #require(displayQuestion(type: "single_choice", skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) - var responses: [String?] = [] - let view = SingleChoiceQuestionView(question: question, onNextQuestion: { responses.append($0) }) - view.selection.wrappedValue = [0] - let expected: [String?] = skipSubmit && !hasOpenChoice ? ["First"] : [] - #expect(responses == expected) - view.selection.wrappedValue = [] - #expect(responses == expected) - } - #endif - - @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) - func rating(skipSubmit: Bool?, display: String) throws { - let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) - #expect(question.skipSubmitButton == (skipSubmit == true)) - } - - @Test("only single choice without an open choice auto-submits", arguments: [true, false, nil] as [Bool?], [false, true]) - func choices(skipSubmit: Bool?, hasOpenChoice: Bool) throws { - for type in ["single_choice", "multiple_choice"] { - let question = try #require(displayQuestion(type: type, skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) - #expect(question.skipSubmitButton == (skipSubmit == true)) - #expect(question.shouldAutoSubmit == (skipSubmit == true && type == "single_choice" && !hasOpenChoice)) +#if !SWIFT_PACKAGE || Surveys + import Foundation + import OHHTTPStubs + import OHHTTPStubsSwift + @testable import PostHog + import Testing + + @Suite("Test Surveys") + enum PostHogSurveysTest { + @Suite("Auto-submit on selection") + struct AutoSubmit { + private func displayQuestion(type: String, skipSubmit: Bool?, hasOpenChoice: Bool = false, display: String = "number") throws -> PostHogDisplaySurveyQuestion { + var json: [String: Any] = [ + "id": "auto-submit", "type": type, "question": "Choose", + "choices": ["First", "Other"], "hasOpenChoice": hasOpenChoice, + "display": display, "scale": 5, + ] + json["skipSubmitButton"] = skipSubmit + let question = try PostHogApi.jsonDecoder.decode(PostHogSurveyQuestion.self, from: JSONSerialization.data(withJSONObject: json)) + return try #require(question.toDisplayQuestion()) } - } - } - - @Suite("Test decoding surveys from remote config") - struct TestDecodingSurveys { - @Test("survey decodes correctly") - func surveyDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_basic") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") - #expect(sut.type == .popover) - #expect(sut.featureFlagKeys == [ - PostHogSurveyFeatureFlagKeyValue(key: "flag1", value: "linked-flag-key"), - PostHogSurveyFeatureFlagKeyValue(key: "flag2", value: "survey-targeting-flag-key"), - ]) - #expect(sut.targetingFlagKey == "survey-targeting-flag-key") - #expect(sut.internalTargetingFlagKey == "survey-targeting-88af4df282-custom") - #expect(sut.questions.count == 1) - - if case let .open(question) = sut.questions[0] { - #expect(question.id == "300") - #expect(question.question == "What would you like to see in our Core Web Vitals product?") - #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) - } else { - throw TestError("Expected basic question type") - } - - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) - #expect(sut.appearance?.fontFamily == "system-ui") - #expect(sut.appearance?.whiteLabel == false) - #expect(sut.appearance?.borderColor == "#c9c6c6") - #expect(sut.appearance?.placeholder == "Start typing...") - #expect(sut.appearance?.backgroundColor == "#eeeded") - #expect(sut.appearance?.ratingButtonColor == "white") - #expect(sut.appearance?.submitButtonColor == "black") - #expect(sut.appearance?.submitButtonTextColor == "white") - #expect(sut.appearance?.thankYouMessageHeader == "Thank you for your feedback!") - #expect(sut.appearance?.displayThankYouMessage == true) - #expect(sut.appearance?.ratingButtonActiveColor == "black") - #expect(sut.appearance?.surveyPopupDelaySeconds == 5) - #expect(sut.startDate.map(ISO8601DateFormatter().string) == "2025-01-16T22:23:38Z") - #expect(sut.endDate == nil) - #expect(sut.currentIteration == 1) - #expect(sut.currentIterationStartDate.map(ISO8601DateFormatter().string) == "2024-12-12T18:58:22Z") - } - @Test("survey with unknown type decodes correctly") - func surveyWithUnknownTypeDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_unknown_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + #if os(iOS) + @Test("rating selection emits one answer immediately and clearing emits none", arguments: [true, false], ["number", "emoji"]) + @MainActor + func ratingSelection(skipSubmit: Bool, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + var responses: [Int?] = [] + let view = RatingQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = 4 + #expect(responses == (skipSubmit ? [4] : [])) + view.selection.wrappedValue = nil + #expect(responses == (skipSubmit ? [4] : [])) + } - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") + @Test("single-choice selection emits the answer only when eligible", arguments: [true, false], [false, true]) + @MainActor + func choiceSelection(skipSubmit: Bool, hasOpenChoice: Bool) throws { + let question = try #require(displayQuestion(type: "single_choice", skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + var responses: [String?] = [] + let view = SingleChoiceQuestionView(question: question, onNextQuestion: { responses.append($0) }) + view.selection.wrappedValue = [0] + let expected: [String?] = skipSubmit && !hasOpenChoice ? ["First"] : [] + #expect(responses == expected) + view.selection.wrappedValue = [] + #expect(responses == expected) + } + #endif - // Verify that an unknown survey type is correctly captured - if case let .unknown(type) = sut.type { - #expect(type == "future_type_not_yet_supported") - } else { - throw TestError("Expected unknown survey type") + @Test("rating auto-submit survives decoding and display mapping", arguments: [true, false, nil] as [Bool?], ["number", "emoji"]) + func rating(skipSubmit: Bool?, display: String) throws { + let question = try #require(displayQuestion(type: "rating", skipSubmit: skipSubmit, display: display) as? PostHogDisplayRatingQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) } - // Verify that other properties are still correctly decoded - #expect(sut.questions.count == 1) - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) - } - - @Test("survey with unknown question type decodes correctly") - func surveyWithUnknownQuestionTypeDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_unknown_question_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - - #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") - #expect(sut.name == "Core Web Vitals feature request") - #expect(sut.type == .popover) - #expect(sut.questions.count == 1) - - // Verify that an unknown question type is correctly captured - if case let .unknown(type) = sut.questions[0] { - #expect(type == "future_question_type") - } else { - throw TestError("Expected unknown question type") + @Test("only single choice without an open choice auto-submits", arguments: [true, false, nil] as [Bool?], [false, true]) + func choices(skipSubmit: Bool?, hasOpenChoice: Bool) throws { + for type in ["single_choice", "multiple_choice"] { + let question = try #require(displayQuestion(type: type, skipSubmit: skipSubmit, hasOpenChoice: hasOpenChoice) as? PostHogDisplayChoiceQuestion) + #expect(question.skipSubmitButton == (skipSubmit == true)) + #expect(question.shouldAutoSubmit == (skipSubmit == true && type == "single_choice" && !hasOpenChoice)) + } } - - // Verify that other properties are still correctly decoded - #expect(sut.conditions?.url == "core-web-vitals") - #expect(sut.appearance?.position == .right) } - @Suite("Survey question types decode correctly") - struct SurveyQuestionTypeDecodeTests { - @Test("basic question decodes correctly") - func basicQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_basic") + @Suite("Test decoding surveys from remote config") + struct TestDecodingSurveys { + @Test("survey decodes correctly") + func surveyDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_basic") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + #expect(sut.type == .popover) + #expect(sut.featureFlagKeys == [ + PostHogSurveyFeatureFlagKeyValue(key: "flag1", value: "linked-flag-key"), + PostHogSurveyFeatureFlagKeyValue(key: "flag2", value: "survey-targeting-flag-key"), + ]) + #expect(sut.targetingFlagKey == "survey-targeting-flag-key") + #expect(sut.internalTargetingFlagKey == "survey-targeting-88af4df282-custom") + #expect(sut.questions.count == 1) + if case let .open(question) = sut.questions[0] { + #expect(question.id == "300") #expect(question.question == "What would you like to see in our Core Web Vitals product?") #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") - #expect(question.descriptionContentType == .text) #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) } else { - throw TestError("Expected a basic question") + throw TestError("Expected basic question type") } - } - @Test("single-choice question decodes correctly") - func singleChoiceQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_single_choice") + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) + #expect(sut.appearance?.fontFamily == "system-ui") + #expect(sut.appearance?.whiteLabel == false) + #expect(sut.appearance?.borderColor == "#c9c6c6") + #expect(sut.appearance?.placeholder == "Start typing...") + #expect(sut.appearance?.backgroundColor == "#eeeded") + #expect(sut.appearance?.ratingButtonColor == "white") + #expect(sut.appearance?.submitButtonColor == "black") + #expect(sut.appearance?.submitButtonTextColor == "white") + #expect(sut.appearance?.thankYouMessageHeader == "Thank you for your feedback!") + #expect(sut.appearance?.displayThankYouMessage == true) + #expect(sut.appearance?.ratingButtonActiveColor == "black") + #expect(sut.appearance?.surveyPopupDelaySeconds == 5) + #expect(sut.startDate.map(ISO8601DateFormatter().string) == "2025-01-16T22:23:38Z") + #expect(sut.endDate == nil) + #expect(sut.currentIteration == 1) + #expect(sut.currentIterationStartDate.map(ISO8601DateFormatter().string) == "2024-12-12T18:58:22Z") + } + + @Test("survey with unknown type decodes correctly") + func surveyWithUnknownTypeDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_unknown_type") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .singleChoice(question) = sut.questions[0] { - #expect(question.choices == ["Heck yeah", "Nope, I'm good"]) - #expect(question.question == "Would you be interested in being part of a PostHog community event in your city?") - #expect(question.buttonText == "Submit") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + + // Verify that an unknown survey type is correctly captured + if case let .unknown(type) = sut.type { + #expect(type == "future_type_not_yet_supported") } else { - throw TestError("Expected a single choice question") + throw TestError("Expected unknown survey type") } + + // Verify that other properties are still correctly decoded + #expect(sut.questions.count == 1) + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) } - @Test("multiple-choice question decodes correctly") - func multipleChoiceQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_multiple_choice") + @Test("survey with unknown question type decodes correctly") + func surveyWithUnknownQuestionTypeDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_unknown_question_type") let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .multipleChoice(question) = sut.questions[0] { - #expect(question.choices == [ - "Meet new people", - "Build stuff (hackathon style)", - "Product growth hack (evolve your existing product, not start from scratch)", - "Learn things (talks, discussions)", - "Eat, drink and vibe (chill)", - "Something else", - ]) - #expect(question.question == "Let's say there's a meetup. What would you hope to get out of it? ") - #expect(question.buttonText == "Submit") - #expect(question.hasOpenChoice == true) - #expect(question.originalQuestionIndex == 2) - #expect(question.descriptionContentType == .text) + #expect(sut.id == "01947134-8a35-0000-549a-193b86fa2e44") + #expect(sut.name == "Core Web Vitals feature request") + #expect(sut.type == .popover) + #expect(sut.questions.count == 1) + + // Verify that an unknown question type is correctly captured + if case let .unknown(type) = sut.questions[0] { + #expect(type == "future_question_type") } else { - throw TestError("Expected a multiple choice question") + throw TestError("Expected unknown question type") } - } - @Test("link question decodes correctly") - func linkQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_link") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + // Verify that other properties are still correctly decoded + #expect(sut.conditions?.url == "core-web-vitals") + #expect(sut.appearance?.position == .right) + } + + @Suite("Survey question types decode correctly") + struct SurveyQuestionTypeDecodeTests { + @Test("basic question decodes correctly") + func basicQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_basic") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .open(question) = sut.questions[0] { + #expect(question.question == "What would you like to see in our Core Web Vitals product?") + #expect(question.description == "Core Web Vitals just launched and we're interested in hearing (reading?) from you on what you think we're missing with this product") + #expect(question.descriptionContentType == .text) + #expect(question.originalQuestionIndex == 0) + } else { + throw TestError("Expected a basic question") + } + } - if case let .link(question) = sut.questions[0] { - #expect(question.link == "https://cal.com/david-posthog/user-interview") - #expect(question.question == "Share your thoughts") - #expect(question.buttonText == "Schedule") - #expect(question.description == "We'd love to get your first impressions of the feature on a video call") - } else { - throw TestError("Expected a link question") + @Test("single-choice question decodes correctly") + func singleChoiceQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_single_choice") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .singleChoice(question) = sut.questions[0] { + #expect(question.choices == ["Heck yeah", "Nope, I'm good"]) + #expect(question.question == "Would you be interested in being part of a PostHog community event in your city?") + #expect(question.buttonText == "Submit") + #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a single choice question") + } } - } - @Test("rating question decodes correctly") - func ratingQuestionDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_question_rating") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("multiple-choice question decodes correctly") + func multipleChoiceQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_multiple_choice") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .multipleChoice(question) = sut.questions[0] { + #expect(question.choices == [ + "Meet new people", + "Build stuff (hackathon style)", + "Product growth hack (evolve your existing product, not start from scratch)", + "Learn things (talks, discussions)", + "Eat, drink and vibe (chill)", + "Something else", + ]) + #expect(question.question == "Let's say there's a meetup. What would you hope to get out of it? ") + #expect(question.buttonText == "Submit") + #expect(question.hasOpenChoice == true) + #expect(question.originalQuestionIndex == 2) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a multiple choice question") + } + } - if case let .rating(question) = sut.questions[0] { - #expect(question.scale == .tenPoint) - #expect(question.display == .number) - #expect(question.question == "How likely are you to recommend us to a friend?") - #expect(question.lowerBoundLabel == "Unlikely") - #expect(question.upperBoundLabel == "Very likely") - #expect(question.originalQuestionIndex == 0) - #expect(question.descriptionContentType == .text) - } else { - throw TestError("Expected a rating question") + @Test("link question decodes correctly") + func linkQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_link") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .link(question) = sut.questions[0] { + #expect(question.link == "https://cal.com/david-posthog/user-interview") + #expect(question.question == "Share your thoughts") + #expect(question.buttonText == "Schedule") + #expect(question.description == "We'd love to get your first impressions of the feature on a video call") + } else { + throw TestError("Expected a link question") + } } - } - @Test("rating question decodes without bound labels") - func ratingQuestionDecodesWithoutBoundLabels() throws { - let data = try loadFixture("fixture_survey_question_rating_no_bounds") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("rating question decodes correctly") + func ratingQuestionDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_question_rating") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .rating(question) = sut.questions[0] { + #expect(question.scale == .tenPoint) + #expect(question.display == .number) + #expect(question.question == "How likely are you to recommend us to a friend?") + #expect(question.lowerBoundLabel == "Unlikely") + #expect(question.upperBoundLabel == "Very likely") + #expect(question.originalQuestionIndex == 0) + #expect(question.descriptionContentType == .text) + } else { + throw TestError("Expected a rating question") + } + } - if case let .rating(question) = sut.questions[0] { - #expect(question.scale == .tenPoint) - #expect(question.display == .number) - #expect(question.lowerBoundLabel == nil) - #expect(question.upperBoundLabel == nil) - } else { - throw TestError("Expected a rating question") + @Test("rating question decodes without bound labels") + func ratingQuestionDecodesWithoutBoundLabels() throws { + let data = try loadFixture("fixture_survey_question_rating_no_bounds") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .rating(question) = sut.questions[0] { + #expect(question.scale == .tenPoint) + #expect(question.display == .number) + #expect(question.lowerBoundLabel == nil) + #expect(question.upperBoundLabel == nil) + } else { + throw TestError("Expected a rating question") + } } } - } - @Suite("Question branching decodes correctly") - struct QuestionBranchingDecodesCorrectly { - @Test("next branching decodes correctly") - func nextBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_next") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Question branching decodes correctly") + struct QuestionBranchingDecodesCorrectly { + @Test("next branching decodes correctly") + func nextBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_next") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case .next = sut.questions[0].branching { - // nothing - } else { - throw TestError("Expected next branching") + if case .next = sut.questions[0].branching { + // nothing + } else { + throw TestError("Expected next branching") + } } - } - @Test("end branching decodes correctly") - func endBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_end") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("end branching decodes correctly") + func endBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_end") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case .end = sut.questions[0].branching { - // nothing - } else { - throw TestError("Expected end branching") + if case .end = sut.questions[0].branching { + // nothing + } else { + throw TestError("Expected end branching") + } } - } - @Test("specific question branching decodes correctly") - func specificQuestionBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_specific") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("specific question branching decodes correctly") + func specificQuestionBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_specific") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .specificQuestion(index: index) = sut.questions[0].branching { - #expect(index == 2) - } else { - throw TestError("Expected specific question branching") + if case let .specificQuestion(index: index) = sut.questions[0].branching { + #expect(index == 2) + } else { + throw TestError("Expected specific question branching") + } } - } - @Test("response-based branching decodes correctly") - func responseBasedBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("response-based branching decodes correctly") + func responseBasedBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values["0"] as? Int == 1) - #expect(values["1"] as? String == "end") - } else { - throw TestError("Expected response based branching") + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values["0"] as? Int == 1) + #expect(values["1"] as? String == "end") + } else { + throw TestError("Expected response based branching") + } } - } - - @Test("response-based with linkert branching decodes correctly") - func responseBasedWithLinkertBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based_linkert") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values["negative"] as? Int == 1) - #expect(values["neutral"] as? Int == 2) - #expect(values["positive"] as? Int == 3) - } else { - throw TestError("Expected response based branching") + @Test("response-based with linkert branching decodes correctly") + func responseBasedWithLinkertBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based_linkert") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values["negative"] as? Int == 1) + #expect(values["neutral"] as? Int == 2) + #expect(values["positive"] as? Int == 3) + } else { + throw TestError("Expected response based branching") + } } - } - @Test("response-based with empty values branching decodes correctly") - func responseBasedWithEmptyValuesBranchingDecodesCorrectly() throws { - let data = try loadFixture("fixture_survey_branching_response_based_empty") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("response-based with empty values branching decodes correctly") + func responseBasedWithEmptyValuesBranchingDecodesCorrectly() throws { + let data = try loadFixture("fixture_survey_branching_response_based_empty") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if case let .responseBased(values) = sut.questions[0].branching { - #expect(values.isEmpty) - } else { - throw TestError("Expected response based branching") + if case let .responseBased(values) = sut.questions[0].branching { + #expect(values.isEmpty) + } else { + throw TestError("Expected response based branching") + } } } - } - @Suite("Question display conditions decodes correctly") - struct DisplayConditionsDecodesCorrectly { - @Test("event condition decodes correctly") - func eventConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Question display conditions decodes correctly") + struct DisplayConditionsDecodesCorrectly { + @Test("event condition decodes correctly") + func eventConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let events = sut.conditions?.events { - #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) - } else { - throw TestError("Expected event display condition") + if let events = sut.conditions?.events { + #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) + } else { + throw TestError("Expected event display condition") + } } - } - @Test("repeated event condition decodes correctly") - func repeatedEventConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event_repeated") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("repeated event condition decodes correctly") + func repeatedEventConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event_repeated") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let events = sut.conditions?.events { - #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) - #expect(events.repeatedActivation == true) - } else { - throw TestError("Expected repeated event display condition") + if let events = sut.conditions?.events { + #expect(events.values == [PostHogEventCondition(name: "dashboard loading time")]) + #expect(events.repeatedActivation == true) + } else { + throw TestError("Expected repeated event display condition") + } } - } - @Test("event condition with property filters decodes correctly") - func eventConditionWithPropertyFiltersDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_event_property_filters") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("event condition with property filters decodes correctly") + func eventConditionWithPropertyFiltersDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_event_property_filters") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + + if let events = sut.conditions?.events { + #expect(events.values.count == 1) + #expect(events.values[0].name == "purchase_completed") + + let filters = events.values[0].propertyFilters + #expect(filters != nil) + #expect(filters?["amount"]?.matchOperator == .gt) + #expect(filters?["amount"]?.values == ["100"]) + #expect(filters?["category"]?.matchOperator == .exact) + #expect(filters?["category"]?.values == ["electronics"]) + } else { + throw TestError("Expected event display condition with property filters") + } + } - if let events = sut.conditions?.events { - #expect(events.values.count == 1) - #expect(events.values[0].name == "purchase_completed") + @Test("device type condition decodes correctly") + func deviceTypeConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_device_type") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - let filters = events.values[0].propertyFilters - #expect(filters != nil) - #expect(filters?["amount"]?.matchOperator == .gt) - #expect(filters?["amount"]?.values == ["100"]) - #expect(filters?["category"]?.matchOperator == .exact) - #expect(filters?["category"]?.values == ["electronics"]) - } else { - throw TestError("Expected event display condition with property filters") + if let types = sut.conditions?.deviceTypes { + #expect(types == ["Mobile"]) + } else { + throw TestError("Expected event display condition") + } } - } - @Test("device type condition decodes correctly") - func deviceTypeConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_device_type") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("url condition decodes correctly") + func urlConditionDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_conditions_url") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let types = sut.conditions?.deviceTypes { - #expect(types == ["Mobile"]) - } else { - throw TestError("Expected event display condition") + if let url = sut.conditions?.url { + #expect(url == "ScreenName") + } else { + throw TestError("Expected url display condition") + } } } - @Test("url condition decodes correctly") - func urlConditionDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_conditions_url") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Url match types decode correctly") + struct UrlMatchTypeDecodesCorrectly { + @Test("exact url match type decodes correctly") + func exactUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_exact") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url { - #expect(url == "ScreenName") - } else { - throw TestError("Expected url display condition") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .exact) + } else { + throw TestError("Expected url match type") + } } - } - } - @Suite("Url match types decode correctly") - struct UrlMatchTypeDecodesCorrectly { - @Test("exact url match type decodes correctly") - func exactUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_exact") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("is_not url match type decodes correctly") + func isNotRegexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_is_not") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .exact) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .isNot) + } else { + throw TestError("Expected url match type") + } } - } - @Test("is_not url match type decodes correctly") - func isNotRegexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_is_not") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("icontains url match type decodes correctly") + func iContainsUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_icontains") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .isNot) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .iContains) + } else { + throw TestError("Expected url match type") + } } - } - @Test("icontains url match type decodes correctly") - func iContainsUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_icontains") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("not_icontains url match type decodes correctly") + func notIContainsUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_not_icontains") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .iContains) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .notIContains) + } else { + throw TestError("Expected url match type") + } } - } - @Test("not_icontains url match type decodes correctly") - func notIContainsUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_not_icontains") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("regex url match type decodes correctly") + func regexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_regex") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .notIContains) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .regex) + } else { + throw TestError("Expected url match type") + } } - } - @Test("regex url match type decodes correctly") - func regexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_regex") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Test("not_regex url match type decodes correctly") + func notRegexUrlMatchTypeDecodesCorrectly() async throws { + let data = try loadFixture("fixture_survey_url_match_type_not_regex") + let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .regex) - } else { - throw TestError("Expected url match type") + if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { + #expect(url == "ScreenName") + #expect(matchType == .notRegex) + } else { + throw TestError("Expected url match type") + } } } + } - @Test("not_regex url match type decodes correctly") - func notRegexUrlMatchTypeDecodesCorrectly() async throws { - let data = try loadFixture("fixture_survey_url_match_type_not_regex") - let sut = try PostHogApi.jsonDecoder.decode(PostHogSurvey.self, from: data) + @Suite("Test SurveyUrlMatchType match function") + struct TestSurveyUrlMatching { + static let regexMap: [(url: String, regex: String, shouldMatch: Bool)] = [ + // url + (url: "https://example.com/survey/123", regex: "https://example.com/survey/(\\d+)", shouldMatch: true), + // screen name + (url: "MyScreenName", regex: "^My", shouldMatch: true), + // case sensitivity + (url: "myScreenName", regex: "^My", shouldMatch: false), + // special characters + (url: "User.Profile-Page/123", regex: "\\.[A-Za-z]+-[A-Za-z]+/\\d+", shouldMatch: true), + // empty strings + (url: "", regex: ".*", shouldMatch: true), + // word boundaries + (url: "UserProfile", regex: "\\bProfile\\b", shouldMatch: false), + (url: "User Profile", regex: "\\bProfile\\b", shouldMatch: true), + ] - if let url = sut.conditions?.url, let matchType = sut.conditions?.urlMatchType { - #expect(url == "ScreenName") - #expect(matchType == .notRegex) - } else { - throw TestError("Expected url match type") - } + @Test("matches regex correctly", arguments: regexMap) + func matchesRegex(url: String, regex: String, shouldMatch: Bool) { + let sut: PostHogSurveyMatchType = .regex + let matches = sut.matchFunction + + #expect(matches([regex], url) == shouldMatch) } - } - } - @Suite("Test SurveyUrlMatchType match function") - struct TestSurveyUrlMatching { - static let regexMap: [(url: String, regex: String, shouldMatch: Bool)] = [ - // url - (url: "https://example.com/survey/123", regex: "https://example.com/survey/(\\d+)", shouldMatch: true), - // screen name - (url: "MyScreenName", regex: "^My", shouldMatch: true), - // case sensitivity - (url: "myScreenName", regex: "^My", shouldMatch: false), - // special characters - (url: "User.Profile-Page/123", regex: "\\.[A-Za-z]+-[A-Za-z]+/\\d+", shouldMatch: true), - // empty strings - (url: "", regex: ".*", shouldMatch: true), - // word boundaries - (url: "UserProfile", regex: "\\bProfile\\b", shouldMatch: false), - (url: "User Profile", regex: "\\bProfile\\b", shouldMatch: true), - ] - - @Test("matches regex correctly", arguments: regexMap) - func matchesRegex(url: String, regex: String, shouldMatch: Bool) { - let sut: PostHogSurveyMatchType = .regex - let matches = sut.matchFunction - - #expect(matches([regex], url) == shouldMatch) - } + @Test("matches not_regex correctly", arguments: regexMap) + func matchesNotRegex(url: String, regex: String, shouldMatch: Bool) { + let sut: PostHogSurveyMatchType = .notRegex + let matches = sut.matchFunction - @Test("matches not_regex correctly", arguments: regexMap) - func matchesNotRegex(url: String, regex: String, shouldMatch: Bool) { - let sut: PostHogSurveyMatchType = .notRegex - let matches = sut.matchFunction + #expect(matches([regex], url) != shouldMatch) + } - #expect(matches([regex], url) != shouldMatch) - } + @Test("matches icontains correctly") + func matchesIcontains() { + let sut: PostHogSurveyMatchType = .iContains + let matches = sut.matchFunction - @Test("matches icontains correctly") - func matchesIcontains() { - let sut: PostHogSurveyMatchType = .iContains - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == true) + #expect(matches(["Hello"], "HeLLo") == true) + #expect(matches(["Hello"], "heLLo") == true) + #expect(matches(["PostHog"], "PostHogTest") == true) + #expect(matches(["Hello"], "PostHog") == false) + } - #expect(matches(["Hello"], "hello") == true) - #expect(matches(["Hello"], "HeLLo") == true) - #expect(matches(["Hello"], "heLLo") == true) - #expect(matches(["PostHog"], "PostHogTest") == true) - #expect(matches(["Hello"], "PostHog") == false) - } + @Test("matches not_icontains correctly") + func matchesNotIcontains() { + let sut: PostHogSurveyMatchType = .notIContains + let matches = sut.matchFunction - @Test("matches not_icontains correctly") - func matchesNotIcontains() { - let sut: PostHogSurveyMatchType = .notIContains - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == false) + #expect(matches(["Hello"], "HeLLo") == false) + #expect(matches(["Hello"], "heLLo") == false) + #expect(matches(["PostHog"], "PostHogTest") == false) + #expect(matches(["Hello"], "PostHog") == true) + } - #expect(matches(["Hello"], "hello") == false) - #expect(matches(["Hello"], "HeLLo") == false) - #expect(matches(["Hello"], "heLLo") == false) - #expect(matches(["PostHog"], "PostHogTest") == false) - #expect(matches(["Hello"], "PostHog") == true) - } + @Test("matches exact correctly") + func matchesExact() { + let sut: PostHogSurveyMatchType = .exact + let matches = sut.matchFunction - @Test("matches exact correctly") - func matchesExact() { - let sut: PostHogSurveyMatchType = .exact - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == false) + #expect(matches(["HeLLo"], "hello") == false) + #expect(matches(["HeLlo"], "hello") == false) + #expect(matches(["Hello"], "Hello") == true) + #expect(matches(["Hello", "PostHog"], "Hello") == true) + } - #expect(matches(["Hello"], "hello") == false) - #expect(matches(["HeLLo"], "hello") == false) - #expect(matches(["HeLlo"], "hello") == false) - #expect(matches(["Hello"], "Hello") == true) - #expect(matches(["Hello", "PostHog"], "Hello") == true) - } + @Test("matches is_not correctly") + func matchesIsNot() { + let sut: PostHogSurveyMatchType = .isNot + let matches = sut.matchFunction - @Test("matches is_not correctly") - func matchesIsNot() { - let sut: PostHogSurveyMatchType = .isNot - let matches = sut.matchFunction + #expect(matches(["Hello"], "hello") == true) + #expect(matches(["Hello"], "HeLLo") == true) + #expect(matches(["Hello"], "heLLo") == true) + #expect(matches(["Hello"], "Hello") == false) + #expect(matches(["Hello", "PostHog"], "Hello") == false) + } - #expect(matches(["Hello"], "hello") == true) - #expect(matches(["Hello"], "HeLLo") == true) - #expect(matches(["Hello"], "heLLo") == true) - #expect(matches(["Hello"], "Hello") == false) - #expect(matches(["Hello", "PostHog"], "Hello") == false) + @Test("matches gt and lt correctly") + func matchesGtLt() { + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "150") == true) + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "50") == false) + #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "50") == true) + #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "150") == false) + #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "not_a_number") == false) + } } - @Test("matches gt and lt correctly") - func matchesGtLt() { - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "150") == true) - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "50") == false) - #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "50") == true) - #expect(PostHogSurveyMatchType.lt.matchFunction(["100"], "150") == false) - #expect(PostHogSurveyMatchType.gt.matchFunction(["100"], "not_a_number") == false) - } - } + @Suite("Test matchPropertyFilters") + class TestMatchPropertyFilters { + let server: MockPostHogServer + let integration: PostHogSurveyIntegration + let postHog: PostHogSDK - @Suite("Test matchPropertyFilters") - class TestMatchPropertyFilters { - let server: MockPostHogServer - let integration: PostHogSurveyIntegration - let postHog: PostHogSDK + init() throws { + server = MockPostHogServer() + server.start() - init() throws { - server = MockPostHogServer() - server.start() + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.captureApplicationLifecycleEvents = false - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.captureApplicationLifecycleEvents = false + let storage = PostHogStorage(config) + storage.reset() - let storage = PostHogStorage(config) - storage.reset() + postHog = PostHogSDK.with(config) - postHog = PostHogSDK.with(config) + PostHogSurveyIntegration.clearInstalls() + integration = PostHogSurveyIntegration() + let installOutcome = integration.install(postHog) + try #require(installOutcome == .installed) + } - PostHogSurveyIntegration.clearInstalls() - integration = PostHogSurveyIntegration() - let installOutcome = integration.install(postHog) - try #require(installOutcome == .installed) - } + deinit { + server.stop() + postHog.close() + postHog.reset() + } - deinit { - server.stop() - postHog.close() - postHog.reset() - } + @Test("returns true when no filters, false when property is missing") + func edgeCases() { + #expect(integration.testMatchPropertyFilters(nil, eventProperties: [:]) == true) + let filters = ["category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact)] + #expect(integration.testMatchPropertyFilters(filters, eventProperties: [:]) == false) + } - @Test("returns true when no filters, false when property is missing") - func edgeCases() { - #expect(integration.testMatchPropertyFilters(nil, eventProperties: [:]) == true) - let filters = ["category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact)] - #expect(integration.testMatchPropertyFilters(filters, eventProperties: [:]) == false) + @Test("icontains matches value against filter (case-insensitive)") + func icontainsOperatorWorks() { + let filters = ["query": PostHogPropertyFilter(values: ["product"], matchOperator: .iContains)] + #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "new PRODUCT features"]) == true) + #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "nothing here"]) == false) + } } - @Test("icontains matches value against filter (case-insensitive)") - func icontainsOperatorWorks() { - let filters = ["query": PostHogPropertyFilter(values: ["product"], matchOperator: .iContains)] - #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "new PRODUCT features"]) == true) - #expect(integration.testMatchPropertyFilters(filters, eventProperties: ["query": "nothing here"]) == false) - } - } + @Suite("Test onEvent with property filters") + final class TestOnEventPropertyFilters { + let server: MockPostHogServer + let integration: PostHogSurveyIntegration + let postHog: PostHogSDK - @Suite("Test onEvent with property filters") - final class TestOnEventPropertyFilters { - let server: MockPostHogServer - let integration: PostHogSurveyIntegration - let postHog: PostHogSDK - - init() throws { - server = MockPostHogServer() - server.start() - - let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") - config._surveys = true - config.flushAt = 1 - config.disableReachabilityForTesting = true - config.disableQueueTimerForTesting = true - config.disableRemoteConfigForTesting = true - config.captureApplicationLifecycleEvents = false - - let storage = PostHogStorage(config) - storage.reset() - - postHog = PostHogSDK.with(config) - - PostHogSurveyIntegration.clearInstalls() - integration = PostHogSurveyIntegration() - let installOutcome = integration.install(postHog) - try #require(installOutcome == .installed) - } + init() throws { + server = MockPostHogServer() + server.start() - deinit { - integration.uninstall(postHog) - postHog.close() - postHog.reset() - server.stop() - } + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableRemoteConfigForTesting = true + config.captureApplicationLifecycleEvents = false - @Test("activates survey when event name and properties match") - func eventWithPropertiesActivatesSurvey() { - let condition = PostHogEventCondition( - name: "purchase", - propertyFilters: ["amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt)] - ) - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + let storage = PostHogStorage(config) + storage.reset() - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) - } + postHog = PostHogSDK.with(config) - @Test("activates survey when event has no property filters (backward compat)") - func eventWithNoFiltersActivatesSurvey() { - let condition = PostHogEventCondition(name: "purchase") - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + PostHogSurveyIntegration.clearInstalls() + integration = PostHogSurveyIntegration() + let installOutcome = integration.install(postHog) + try #require(installOutcome == .installed) + } - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: [:], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) - } + deinit { + integration.uninstall(postHog) + postHog.close() + postHog.reset() + server.stop() + } - @Test("does not activate survey when one of multiple property filters fails") - func partialFilterMismatchPreventsActivation() { - let condition = PostHogEventCondition( - name: "purchase", - propertyFilters: [ - "amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt), - "category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact), - ] - ) - integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + @Test("activates survey when event name and properties match") + func eventWithPropertiesActivatesSurvey() { + let condition = PostHogEventCondition( + name: "purchase", + propertyFilters: ["amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt)] + ) + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) - integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150, "category": "clothing"], timestamp: Date())) - #expect(integration.testIsEventActivated(surveyId: "survey-1") == false) - } - } + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) + } - @Suite("Test canActivateRepeatedly") - struct TestCanActivateRepeatedly { - private func getSut( - repeatedActivation: Bool?, - values: [PostHogEventCondition], - schedule: PostHogSurveySchedule? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: "id", - name: "name", - type: .popover, - questions: [], - featureFlagKeys: nil, - linkedFlagKey: nil, - targetingFlagKey: nil, - internalTargetingFlagKey: nil, - conditions: PostHogSurveyConditions( - url: nil, - urlMatchType: nil, - selector: nil, - deviceTypes: nil, - deviceTypesMatchType: nil, - seenSurveyWaitPeriodInDays: nil, - events: PostHogSurveyEventConditions( - repeatedActivation: repeatedActivation, - values: values + @Test("activates survey when event has no property filters (backward compat)") + func eventWithNoFiltersActivatesSurvey() { + let condition = PostHogEventCondition(name: "purchase") + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: [:], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == true) + } + + @Test("does not activate survey when one of multiple property filters fails") + func partialFilterMismatchPreventsActivation() { + let condition = PostHogEventCondition( + name: "purchase", + propertyFilters: [ + "amount": PostHogPropertyFilter(values: ["100"], matchOperator: .gt), + "category": PostHogPropertyFilter(values: ["electronics"], matchOperator: .exact), + ] + ) + integration.testSetEventsToSurveys(["purchase": [(surveyId: "survey-1", condition: condition)]]) + + integration.testOnEvent(event: PostHogEvent(event: "purchase", distinctId: "test", properties: ["amount": 150, "category": "clothing"], timestamp: Date())) + #expect(integration.testIsEventActivated(surveyId: "survey-1") == false) + } + } + + @Suite("Test canActivateRepeatedly") + struct TestCanActivateRepeatedly { + private func getSut( + repeatedActivation: Bool?, + values: [PostHogEventCondition], + schedule: PostHogSurveySchedule? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: "id", + name: "name", + type: .popover, + questions: [], + featureFlagKeys: nil, + linkedFlagKey: nil, + targetingFlagKey: nil, + internalTargetingFlagKey: nil, + conditions: PostHogSurveyConditions( + url: nil, + urlMatchType: nil, + selector: nil, + deviceTypes: nil, + deviceTypesMatchType: nil, + seenSurveyWaitPeriodInDays: nil, + events: PostHogSurveyEventConditions( + repeatedActivation: repeatedActivation, + values: values + ), + linkedFlagVariant: nil, + actions: nil ), - linkedFlagVariant: nil, - actions: nil - ), - appearance: nil, - currentIteration: nil, - currentIterationStartDate: nil, - startDate: nil, - endDate: nil, - schedule: schedule, - translations: nil - ) - } + appearance: nil, + currentIteration: nil, + currentIterationStartDate: nil, + startDate: nil, + endDate: nil, + schedule: schedule, + translations: nil + ) + } - @Test("returns false when survey has no events") - func returnsFalseWhenSurveyHasNoEvents() { - let sut = getSut( - repeatedActivation: true, - values: [] - ) + @Test("returns false when survey has no events") + func returnsFalseWhenSurveyHasNoEvents() { + let sut = getSut( + repeatedActivation: true, + values: [] + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when survey has events and repeatedActivation is true") - func returnsTrueWhenSurveyHasEventsAndRepeatedActivationIsAlsoTrue() { - let sut = getSut( - repeatedActivation: true, - values: [ - PostHogEventCondition(name: "first event"), - PostHogEventCondition(name: "second event"), - ] - ) + @Test("returns true when survey has events and repeatedActivation is true") + func returnsTrueWhenSurveyHasEventsAndRepeatedActivationIsAlsoTrue() { + let sut = getSut( + repeatedActivation: true, + values: [ + PostHogEventCondition(name: "first event"), + PostHogEventCondition(name: "second event"), + ] + ) - #expect(sut.canActivateRepeatedly == true) - } + #expect(sut.canActivateRepeatedly == true) + } - @Test("returns false when survey has events but repeatedActivation is false") - func returnsFalseWhenSurveyHasEventsButRepeatedActivationIsFalse() { - let sut = getSut( - repeatedActivation: false, - values: [ - PostHogEventCondition(name: "first event"), - PostHogEventCondition(name: "second event"), - ] - ) + @Test("returns false when survey has events but repeatedActivation is false") + func returnsFalseWhenSurveyHasEventsButRepeatedActivationIsFalse() { + let sut = getSut( + repeatedActivation: false, + values: [ + PostHogEventCondition(name: "first event"), + PostHogEventCondition(name: "second event"), + ] + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when schedule is always, even without events") - func returnsTrueWhenScheduleIsAlways() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .always - ) + @Test("returns true when schedule is always, even without events") + func returnsTrueWhenScheduleIsAlways() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .always + ) - #expect(sut.canActivateRepeatedly == true) - } + #expect(sut.canActivateRepeatedly == true) + } - @Test("returns false when schedule is once") - func returnsFalseWhenScheduleIsOnce() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .once - ) + @Test("returns false when schedule is once") + func returnsFalseWhenScheduleIsOnce() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .once + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns false when schedule is recurring") - func returnsFalseWhenScheduleIsRecurring() { - let sut = getSut( - repeatedActivation: nil, - values: [], - schedule: .recurring - ) + @Test("returns false when schedule is recurring") + func returnsFalseWhenScheduleIsRecurring() { + let sut = getSut( + repeatedActivation: nil, + values: [], + schedule: .recurring + ) - #expect(sut.canActivateRepeatedly == false) - } + #expect(sut.canActivateRepeatedly == false) + } - @Test("returns true when schedule is always, even with repeatedActivation false") - func returnsTrueWhenScheduleIsAlwaysRegardlessOfRepeatedActivation() { - let sut = getSut( - repeatedActivation: false, - values: [ - PostHogEventCondition(name: "first event"), - ], - schedule: .always - ) + @Test("returns true when schedule is always, even with repeatedActivation false") + func returnsTrueWhenScheduleIsAlwaysRegardlessOfRepeatedActivation() { + let sut = getSut( + repeatedActivation: false, + values: [ + PostHogEventCondition(name: "first event"), + ], + schedule: .always + ) - #expect(sut.canActivateRepeatedly == true) + #expect(sut.canActivateRepeatedly == true) + } } - } - @Suite("Test getActiveMatchingSurveys", .serialized) - class TestGetActiveSurveys { - let server: MockPostHogServer - let postHog: PostHogSDK + @Suite("Test getActiveMatchingSurveys", .serialized) + class TestGetActiveSurveys { + let server: MockPostHogServer + let postHog: PostHogSDK - #if os(iOS) - final class SpySurveysDelegate: NSObject, PostHogSurveysDelegate { - var renderedSurveyIds: [String] = [] - var onRender: (() -> Void)? + #if os(iOS) + final class SpySurveysDelegate: NSObject, PostHogSurveysDelegate { + var renderedSurveyIds: [String] = [] + var onRender: (() -> Void)? + + func renderSurvey( + _ survey: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) { + renderedSurveyIds.append(survey.id) + onRender?() + } - func renderSurvey( - _ survey: PostHogDisplaySurvey, - onSurveyShown _: @escaping OnPostHogSurveyShown, - onSurveyResponse _: @escaping OnPostHogSurveyResponse, - onSurveyClosed _: @escaping OnPostHogSurveyClosed - ) { - renderedSurveyIds.append(survey.id) - onRender?() + func cleanupSurveys() {} } - - func cleanupSurveys() {} - } - #endif - - init() { - let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9090") - config._surveys = true - #if os(iOS) - config._surveysConfig.surveysDelegate = SpySurveysDelegate() #endif - config.enableSwizzling = false - config.disableFlushOnBackgroundForTesting = true - config.disableRemoteConfigForTesting = true - let storage = PostHogStorage(config) - storage.reset() - postHog = PostHogSDK.with(config) - server = MockPostHogServer() - server.featureFlags = [ - "linked-flag-enabled": true, - "linked-flag-disabled": false, - "survey-targeting-flag-enabled": true, - "survey-targeting-flag-disabled": false, - "internal-targeting-flag-enabled": true, - "internal-targeting-flag-disabled": false, - ] - server.start() - } - deinit { - let storage = PostHogStorage(postHog.config) - postHog.close() - deleteSafely(storage.appFolderUrl) - server.stop() - } - - let draftSurvey = - """ - { - "id": "draft_id", - "name": "Draft Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } + init() { + let config = PostHogConfig(projectToken: UUID().uuidString, host: "http://localhost:9090") + config._surveys = true + #if os(iOS) + config._surveysConfig.surveysDelegate = SpySurveysDelegate() + #endif + config.enableSwizzling = false + config.disableFlushOnBackgroundForTesting = true + config.disableRemoteConfigForTesting = true + let storage = PostHogStorage(config) + storage.reset() + postHog = PostHogSDK.with(config) + server = MockPostHogServer() + server.featureFlags = [ + "linked-flag-enabled": true, + "linked-flag-disabled": false, + "survey-targeting-flag-enabled": true, + "survey-targeting-flag-disabled": false, + "internal-targeting-flag-enabled": true, + "internal-targeting-flag-disabled": false, ] + server.start() } - """ - let activeSurvey = - """ - { - "id": "active_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let completedSurvey = - """ - { - "id": "completed_id", - "name": "Completed Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z", - "end_date": "2025-03-03T09:18:18.376000Z" - } - """ - - let surveyWithDesktopDevice = - """ - { - "id": "desktop_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "deviceTypes": ["Desktop"], - "deviceTypesMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithMobileDevice = - """ - { - "id": "mobile_id", - "name": "Active Survey", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "deviceTypes": ["Mobile"], - "deviceTypesMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" + deinit { + let storage = PostHogStorage(postHog.config) + postHog.close() + deleteSafely(storage.appFolderUrl) + server.stop() } - """ - let surveyWithSelectorCondition = - """ - { - "id": "selector_id", - "name": "Web-only Survey (selector)", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "selector": "#my-button" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ + let draftSurvey = + """ + { + "id": "draft_id", + "name": "Draft Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ] + } + """ - let surveyWithUrlCondition = - """ - { - "id": "url_id", - "name": "Web-only Survey (url)", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What is a completed survey?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "url": "https://example.com/pricing", - "urlMatchType": "icontains" - }, - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveysWithFeatureFlags = - """ - { - "name": "survey with feature flags", - "id": "survey-with-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-enabled" }, - { "key": "flag2", "value": "survey-targeting-flag-enabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey with disabled feature flags", - "id": "survey-with-disabled-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-disabled" }, - { "key": "flag2", "value": "survey-targeting-flag-disabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey without feature flags", - "id": "survey-without-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveysWithMissingKeysAndValues = - """ - { - "name": "survey with missing keys", - "id": "survey-with-missing-keys", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "", "value": "linked-flag-enabled" }, - { "key": "", "value": "" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - }, - { - "name": "survey with missing values", - "id": "survey-with-missing-values", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "" }, - { "key": "flag2", "value": "" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithEnabledAndDisabledFlags = - """ - { - "name": "survey with disabled and enabled feature flags", - "id": "survey-with-mixed-flags", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "feature_flag_keys": [ - { "key": "flag1", "value": "linked-flag-disabled" }, - { "key": "flag2", "value": "linked-flag-enabled" }, - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithEnabledInternalTargetingFlag = - """ - { - "name": "survey with internal flag enabled", - "id": "survey-with-internal-flag-enabled", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "internal_targeting_flag_key": "internal-targeting-flag-enabled", - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let surveyWithDisabledInternalTargetingFlag = - """ - { - "name": "survey with internal flag disabled", - "id": "survey-with-internal-flag-disabled", - "description": "survey with feature flags description", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "what do you think?", - "originalQuestionIndex": 0 - } - ], - "internal_targeting_flag_key": "internal-targeting-flag-disabled", - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - private func getSut(surveys: [String]) -> PostHogSurveyIntegration { - server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - let installOutcome = sut.install(postHog) - #expect(installOutcome == .installed) - return sut - } - - private func parseSurveys(_ surveys: String) throws -> [[String: Any]] { - try #require(JSONSerialization.jsonObject(with: Data("[\(surveys)]".utf8)) as? [[String: Any]]) - } - - @Test("returns surveys that are active") - func returnsActiveSurveys() async { - let surveys: [String] = [ - draftSurvey, - activeSurvey, - completedSurvey, - ] + let activeSurvey = + """ + { + "id": "active_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - let sut = getSut(surveys: surveys) + let completedSurvey = + """ + { + "id": "completed_id", + "name": "Completed Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z", + "end_date": "2025-03-03T09:18:18.376000Z" + } + """ - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let surveyWithDesktopDevice = + """ + { + "id": "desktop_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "deviceTypes": ["Desktop"], + "deviceTypesMatchType": "icontains" + }, + "start_date": "2024-07-23T09:18:18.376000Z" } - } + """ - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } + let surveyWithMobileDevice = + """ + { + "id": "mobile_id", + "name": "Active Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "deviceTypes": ["Mobile"], + "deviceTypesMatchType": "icontains" + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - @Test("refreshes cached surveys when remote config loads") - func refreshesCachedSurveysWhenRemoteConfigLoads() async throws { - let refreshedSurvey = + let surveyWithSelectorCondition = """ { - "id": "active_id", - "name": "Refreshed Survey", + "id": "selector_id", + "name": "Web-only Survey (selector)", "type": "popover", "questions": [ { "id": "1", "type": "open", - "question": "What do you think?", + "question": "What is a completed survey?", "originalQuestionIndex": 0 } ], "conditions": { - "events": { - "values": [ - { "name": "new-trigger" } - ] + "selector": "#my-button" + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ + + let surveyWithUrlCondition = + """ + { + "id": "url_id", + "name": "Web-only Survey (url)", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What is a completed survey?", + "originalQuestionIndex": 0 } + ], + "conditions": { + "url": "https://example.com/pricing", + "urlMatchType": "icontains" }, "start_date": "2024-07-23T09:18:18.376000Z" } """ - let initialSurvey = refreshedSurvey - .replacingOccurrences(of: "Refreshed Survey", with: "Initial Survey") - .replacingOccurrences(of: "new-trigger", with: "old-trigger") - let sut = getSut(surveys: [initialSurvey]) - let initialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(returning: $0) + let surveysWithFeatureFlags = + """ + { + "name": "survey with feature flags", + "id": "survey-with-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-enabled" }, + { "key": "flag2", "value": "survey-targeting-flag-enabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey with disabled feature flags", + "id": "survey-with-disabled-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-disabled" }, + { "key": "flag2", "value": "survey-targeting-flag-disabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey without feature flags", + "id": "survey-without-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" } - } - #expect(initialSurveys.isEmpty) + """ - // Keep the test from presenting survey UI after activating an event. - sut.setShownSurvey(.testInstance(name: "Placeholder")) - sut.testOnEvent(event: PostHogEvent( - event: "old-trigger", - distinctId: "test", - properties: [:], - timestamp: Date() - )) - let activatedInitialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + let surveysWithMissingKeysAndValues = + """ + { + "name": "survey with missing keys", + "id": "survey-with-missing-keys", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "", "value": "linked-flag-enabled" }, + { "key": "", "value": "" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + }, + { + "name": "survey with missing values", + "id": "survey-with-missing-values", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "" }, + { "key": "flag2", "value": "" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" } - } - #expect(activatedInitialSurveys.map(\.name) == ["Initial Survey"]) + """ + + let surveyWithEnabledAndDisabledFlags = + """ + { + "name": "survey with disabled and enabled feature flags", + "id": "survey-with-mixed-flags", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "feature_flag_keys": [ + { "key": "flag1", "value": "linked-flag-disabled" }, + { "key": "flag2", "value": "linked-flag-enabled" }, + ], + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ - server.remoteConfigSurveys = "[\(refreshedSurvey)]" - let remoteConfigLoaded = AsyncLatch() - let token = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in - DispatchQueue.main.async { - remoteConfigLoaded.signal() + let surveyWithEnabledInternalTargetingFlag = + """ + { + "name": "survey with internal flag enabled", + "id": "survey-with-internal-flag-enabled", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "internal_targeting_flag_key": "internal-targeting-flag-enabled", + "start_date": "2024-07-23T09:18:18.376000Z" } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await remoteConfigLoaded.wait() + """ - let surveysBeforeNewTrigger: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + let surveyWithDisabledInternalTargetingFlag = + """ + { + "name": "survey with internal flag disabled", + "id": "survey-with-internal-flag-disabled", + "description": "survey with feature flags description", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "what do you think?", + "originalQuestionIndex": 0 + } + ], + "internal_targeting_flag_key": "internal-targeting-flag-disabled", + "start_date": "2024-07-23T09:18:18.376000Z" } + """ + + private func getSut(surveys: [String]) -> PostHogSurveyIntegration { + server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + let installOutcome = sut.install(postHog) + #expect(installOutcome == .installed) + return sut + } + + private func parseSurveys(_ surveys: String) throws -> [[String: Any]] { + try #require(JSONSerialization.jsonObject(with: Data("[\(surveys)]".utf8)) as? [[String: Any]]) } - #expect(surveysBeforeNewTrigger.isEmpty) - sut.testOnEvent(event: PostHogEvent( - event: "new-trigger", - distinctId: "test", - properties: [:], - timestamp: Date() - )) - let refreshedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(returning: $0) + @Test("returns surveys that are active") + func returnsActiveSurveys() async { + let surveys: [String] = [ + draftSurvey, + activeSurvey, + completedSurvey, + ] + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) } - #expect(refreshedSurveys.map(\.name) == ["Refreshed Survey"]) - _ = token - } - #if os(iOS) - @Test("defers showing surveys until a foreground window exists") - func defersShowingSurveysUntilForegroundWindowExists() async { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: [activeSurvey]) - sut.hasActiveSurveyWindow = { false } + @Test("refreshes cached surveys when remote config loads") + func refreshesCachedSurveysWhenRemoteConfigLoads() async throws { + let refreshedSurvey = + """ + { + "id": "active_id", + "name": "Refreshed Survey", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "events": { + "values": [ + { "name": "new-trigger" } + ] + } + }, + "start_date": "2024-07-23T09:18:18.376000Z" + } + """ + let initialSurvey = refreshedSurvey + .replacingOccurrences(of: "Refreshed Survey", with: "Initial Survey") + .replacingOccurrences(of: "new-trigger", with: "old-trigger") - let matchingSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + let sut = getSut(surveys: [initialSurvey]) + let initialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in sut.getActiveMatchingSurveys(forceReload: true) { continuation.resume(returning: $0) } } - await withCheckedContinuation { continuation in - DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + #expect(initialSurveys.isEmpty) + + // Keep the test from presenting survey UI after activating an event. + sut.setShownSurvey(.testInstance(name: "Placeholder")) + sut.testOnEvent(event: PostHogEvent( + event: "old-trigger", + distinctId: "test", + properties: [:], + timestamp: Date() + )) + let activatedInitialSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } } + #expect(activatedInitialSurveys.map(\.name) == ["Initial Survey"]) - #expect(matchingSurveys.map(\.id) == ["active_id"]) - #expect(delegate.renderedSurveyIds.isEmpty) - - let rendered = AsyncLatch() - delegate.onRender = { rendered.signal() } - sut.hasActiveSurveyWindow = { true } - DI.main.appLifecyclePublisher.onDidBecomeActive.invoke(()) - await rendered.wait() + server.remoteConfigSurveys = "[\(refreshedSurvey)]" + let remoteConfigLoaded = AsyncLatch() + let token = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in + DispatchQueue.main.async { + remoteConfigLoaded.signal() + } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await remoteConfigLoaded.wait() - #expect(delegate.renderedSurveyIds == ["active_id"]) + let surveysBeforeNewTrigger: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } + } + #expect(surveysBeforeNewTrigger.isEmpty) + + sut.testOnEvent(event: PostHogEvent( + event: "new-trigger", + distinctId: "test", + properties: [:], + timestamp: Date() + )) + let refreshedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(returning: $0) + } + } + #expect(refreshedSurveys.map(\.name) == ["Refreshed Survey"]) + _ = token } - @Test("waits for fresh feature flags before showing refreshed surveys") - func waitsForFreshFeatureFlagsBeforeShowingRefreshedSurveys() async throws { - server.featureFlags = ["survey-flag": true] - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + #if os(iOS) + @Test("defers showing surveys until a foreground window exists") + func defersShowingSurveysUntilForegroundWindowExists() async { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: [activeSurvey]) + sut.hasActiveSurveyWindow = { false } + + let matchingSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(returning: $0) + } + } + await withCheckedContinuation { continuation in + DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + } + + #expect(matchingSurveys.map(\.id) == ["active_id"]) + #expect(delegate.renderedSurveyIds.isEmpty) + + let rendered = AsyncLatch() + delegate.onRender = { rendered.signal() } + sut.hasActiveSurveyWindow = { true } + DI.main.appLifecyclePublisher.onDidBecomeActive.invoke(()) + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["active_id"]) } - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - postHog.config.preloadFeatureFlags = false - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - server.remoteConfigSurveys = - """ - [{ + @Test("waits for fresh feature flags before showing refreshed surveys") + func waitsForFreshFeatureFlagsBeforeShowingRefreshedSurveys() async throws { + server.featureFlags = ["survey-flag": true] + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + } + + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + postHog.config.preloadFeatureFlags = false + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + server.remoteConfigSurveys = + """ + [{ + "id": "flagged_survey", + "name": "Flagged Survey", + "type": "popover", + "questions": [{ + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + }], + "linked_flag_key": "survey-flag", + "start_date": "2024-07-23T09:18:18.376000Z" + }] + """ + + server.featureFlags = ["survey-flag": false] + let featureFlagsLoaded = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { _ in + featureFlagsLoaded.signal() + }) + let remoteConfigLoaded = AsyncLatch() + let remoteToken = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in + DispatchQueue.main.async { DispatchQueue.main.async { remoteConfigLoaded.signal() } } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await remoteConfigLoaded.wait() + try #require(remoteConfigLoaded.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + + await featureFlagsLoaded.wait() + try #require(featureFlagsLoaded.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + _ = (sut, remoteToken, flagsToken) + } + + @Test("does not use an older flag refresh for newer surveys") + func doesNotUseOlderFlagRefreshForNewerSurveys() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + + let survey: [String: Any] = [ "id": "flagged_survey", "name": "Flagged Survey", "type": "popover", - "questions": [{ + "questions": [[ "id": "1", "type": "open", "question": "What do you think?", - "originalQuestionIndex": 0 - }], + "originalQuestionIndex": 0, + ]], "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z" - }] - """ + "start_date": "2024-07-23T09:18:18.376000Z", + ] + let requestLock = NSLock() + var requestCount = 0 + server.flagsResponseHandler = { _ in + let index = requestLock.withLock { + requestCount += 1 + return requestCount + } + let response = HTTPStubsResponse( + jsonObject: [ + "featureFlags": ["survey-flag": index == 1], + "featureFlagPayloads": [:], + "flags": [:], + ], + statusCode: 200, + headers: nil + ) + if index == 1 { response.responseTime = 0.1 } + return response + } - server.featureFlags = ["survey-flag": false] - let featureFlagsLoaded = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { _ in - featureFlagsLoaded.signal() - }) - let remoteConfigLoaded = AsyncLatch() - let remoteToken = try #require(postHog.remoteConfig?.onRemoteConfigLoaded.subscribe { _ in - DispatchQueue.main.async { DispatchQueue.main.async { remoteConfigLoaded.signal() } } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await remoteConfigLoaded.wait() - try #require(remoteConfigLoaded.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) + let flagsLoaded = AsyncLatch() + let loadedLock = NSLock() + var loadedCount = 0 + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + guard flags != nil else { return } + let isSecondLoad = loadedLock.withLock { + loadedCount += 1 + return loadedCount == 2 + } + if isSecondLoad { flagsLoaded.signal() } + }) + + postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) + postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) + postHog.remoteConfig?.reloadFeatureFlags() + await flagsLoaded.wait() + await withCheckedContinuation { continuation in + DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + } + + #expect(requestLock.withLock { requestCount } == 2) + #expect(loadedLock.withLock { loadedCount } == 2) + #expect(delegate.renderedSurveyIds.isEmpty) + _ = (sut, flagsToken) + } + + @Test("renders surveys without flag conditions while flags load") + func rendersSurveysWithoutFlagsWhileFlagsLoad() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + + server.flagsResponseHandler = { _ in + let response = HTTPStubsResponse( + jsonObject: [ + "featureFlags": ["survey-flag": true], + "featureFlagPayloads": [:], + "flags": [:], + ], + statusCode: 200, + headers: nil + ) + response.responseTime = 0.2 + return response + } + + let stateLock = NSLock() + var featureFlagsLoaded = false + var renderedBeforeFlagsLoaded = false + let flagsLoaded = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + guard flags != nil else { return } + stateLock.withLock { featureFlagsLoaded = true } + flagsLoaded.signal() + }) + let rendered = AsyncLatch() + delegate.onRender = { + stateLock.withLock { renderedBeforeFlagsLoaded = !featureFlagsLoaded } + rendered.signal() + } + + let flaggedSurvey = activeSurvey + .replacingOccurrences(of: "active_id", with: "flagged_survey") + .replacingOccurrences( + of: "\"start_date\"", + with: "\"linked_flag_key\": \"survey-flag\", \"start_date\"" + ) + postHog.remoteConfig?.onRemoteConfigLoaded.invoke([ + "surveys": try parseSurveys("\(flaggedSurvey),\(activeSurvey)"), + ]) + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["active_id"]) + #expect(stateLock.withLock { renderedBeforeFlagsLoaded }) + await flagsLoaded.wait() + _ = (sut, flagsToken) + } - await featureFlagsLoaded.wait() - try #require(featureFlagsLoaded.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) - _ = (sut, remoteToken, flagsToken) + @Test("recovers survey rendering after a feature flag refresh failure") + func recoversSurveyRenderingAfterFeatureFlagRefreshFailure() async throws { + let delegate = SpySurveysDelegate() + postHog.config._surveysConfig.surveysDelegate = delegate + postHog.config.preloadFeatureFlags = false + let sut = getSut(surveys: []) + sut.hasActiveSurveyWindow = { true } + server.remoteConfigSurveys = + """ + [{ + "id": "flagged_survey", + "name": "Flagged Survey", + "type": "popover", + "questions": [{ + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + }], + "linked_flag_key": "survey-flag", + "start_date": "2024-07-23T09:18:18.376000Z" + }] + """ + server.flagsResponseHandler = { _ in + HTTPStubsResponse(jsonObject: [:], statusCode: 200, headers: nil) + } + + let failed = AsyncLatch() + let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in + if flags == nil { failed.signal() } + }) + postHog.remoteConfig?.reloadRemoteConfig() + await failed.wait() + try #require(failed.isSignaled) + #expect(delegate.renderedSurveyIds.isEmpty) + + server.flagsResponseHandler = nil + server.featureFlags = ["survey-flag": true] + let rendered = AsyncLatch() + delegate.onRender = { rendered.signal() } + postHog.remoteConfig?.reloadFeatureFlags() + await rendered.wait() + + #expect(delegate.renderedSurveyIds == ["flagged_survey"]) + _ = (sut, flagsToken) + } + #endif + + @Test("returns surveys that match device type") + func returnsSurveysThatMatchDeviceType() async { + let surveys: [String] = [ + draftSurvey, + activeSurvey, + completedSurvey, + surveyWithDesktopDevice, + surveyWithMobileDevice, + ] + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + if let currentDeviceType = PostHogContext.deviceType?.lowercased() { + #expect(matchedSurveys.map(\.id) == ["active_id", "\(currentDeviceType)_id"]) + } else { + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } } - @Test("does not use an older flag refresh for newer surveys") - func doesNotUseOlderFlagRefreshForNewerSurveys() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } + @Test("does not return web-only surveys with a CSS selector condition") + func doesNotReturnSurveysWithSelectorCondition() async { + let surveys: [String] = [ + activeSurvey, + surveyWithSelectorCondition, + ] - let survey: [String: Any] = [ - "id": "flagged_survey", - "name": "Flagged Survey", - "type": "popover", - "questions": [[ - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0, - ]], - "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z", + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } + + @Test("does not return web-only surveys with a URL condition") + func doesNotReturnSurveysWithUrlCondition() async { + let surveys: [String] = [ + activeSurvey, + surveyWithUrlCondition, ] - let requestLock = NSLock() - var requestCount = 0 - server.flagsResponseHandler = { _ in - let index = requestLock.withLock { - requestCount += 1 - return requestCount + + let sut = getSut(surveys: surveys) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) } - let response = HTTPStubsResponse( - jsonObject: [ - "featureFlags": ["survey-flag": index == 1], - "featureFlagPayloads": [:], - "flags": [:], - ], - statusCode: 200, - headers: nil - ) - if index == 1 { response.responseTime = 0.1 } - return response - } - - let flagsLoaded = AsyncLatch() - let loadedLock = NSLock() - var loadedCount = 0 - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - guard flags != nil else { return } - let isSecondLoad = loadedLock.withLock { - loadedCount += 1 - return loadedCount == 2 + } + + #expect(matchedSurveys.map(\.id) == ["active_id"]) + } + + @Test("returns only surveys with enabled feature flags") + func returnsOnlySurveysWithEnabledFeatureFlags() async throws { + let sut = getSut(surveys: [surveysWithFeatureFlags]) + // Seed surveys separately so this matching test does not race the remote-config listener's own flag refresh. + let surveys = sut.decodeSurveys(from: [ + "surveys": try parseSurveys(surveysWithFeatureFlags), + ]) + sut.updateSurveyCache(surveys, events: [:]) + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in + continuation.resume() } - if isSecondLoad { flagsLoaded.signal() } - }) + } - postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) - postHog.remoteConfig?.onRemoteConfigLoaded.invoke(["surveys": [survey]]) - postHog.remoteConfig?.reloadFeatureFlags() - await flagsLoaded.wait() + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.map(\.id).contains("survey-with-flags")) + #expect(matchedSurveys.map(\.id).contains("survey-without-flags")) + #expect(!matchedSurveys.map(\.id).contains("survey-with-disabled-flags")) + } + + @Test("matches the linked flag variant without bypassing other targeting", arguments: [ + ("linked-blue", "blue", true), + ("linked-blue", "red", false), + ("linked-blue", "Blue", false), + ("linked-flag-enabled", "blue", false), + ("linked-flag-disabled", "blue", false), + ("missing-flag", "blue", false), + ("linked-blue", "any", true), + ("linked-flag-enabled", "any", true), + ("linked-flag-disabled", "any", false), + ("missing-flag", "any", false), + ("linked-blue", nil, true), + ("linked-blue", "", true), + (nil, "blue", true), + ("", "blue", true), + ] as [(String?, String?, Bool)]) + func matchesLinkedFlagVariant(linkedKey: String?, variant: String?, matches: Bool) async throws { + server.featureFlags?["linked-blue"] = "blue" + var survey = try #require(try parseSurveys(activeSurvey).first) + survey["linked_flag_key"] = linkedKey + survey["conditions"] = variant.map { ["linkedFlagVariant": $0] } ?? [:] + var blockedSurvey = survey + blockedSurvey["id"] = "blocked" + blockedSurvey["targeting_flag_key"] = "survey-targeting-flag-disabled" + + let sut = getSut(surveys: []) + let surveys = sut.decodeSurveys(from: ["surveys": [survey, blockedSurvey]]) + sut.updateSurveyCache(surveys, events: [:]) await withCheckedContinuation { continuation in - DispatchQueue.main.async { DispatchQueue.main.async { continuation.resume() } } + postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } + } + let matched: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { continuation.resume(returning: $0) } } + #expect(matched.map(\.id) == (matches ? ["active_id"] : [])) + } - #expect(requestLock.withLock { requestCount } == 2) - #expect(loadedLock.withLock { loadedCount } == 2) - #expect(delegate.renderedSurveyIds.isEmpty) - _ = (sut, flagsToken) + @Test("Should not return surveys when any feature flag is disabled") + func shouldFilterOutSurveysWhenAnyFlagIsDisabled() async { + let sut = getSut(surveys: [surveyWithEnabledAndDisabledFlags]) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } + } + + #expect(matchedSurveys.isEmpty) } - @Test("renders surveys without flag conditions while flags load") - func rendersSurveysWithoutFlagsWhileFlagsLoad() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - - server.flagsResponseHandler = { _ in - let response = HTTPStubsResponse( - jsonObject: [ - "featureFlags": ["survey-flag": true], - "featureFlagPayloads": [:], - "flags": [:], - ], - statusCode: 200, - headers: nil - ) - response.responseTime = 0.2 - return response - } - - let stateLock = NSLock() - var featureFlagsLoaded = false - var renderedBeforeFlagsLoaded = false - let flagsLoaded = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - guard flags != nil else { return } - stateLock.withLock { featureFlagsLoaded = true } - flagsLoaded.signal() - }) - let rendered = AsyncLatch() - delegate.onRender = { - stateLock.withLock { renderedBeforeFlagsLoaded = !featureFlagsLoaded } - rendered.signal() + @Test("does not require flag evaluation for surveys with missing keys or values") + func doesNotRequireFlagsForMissingKeysOrValues() throws { + let surveys = PostHogSurveyIntegration().decodeSurveys(from: [ + "surveys": try parseSurveys(surveysWithMissingKeysAndValues), + ]) + + try #require(surveys.count == 2) + #expect(surveys.allSatisfy { !$0.requiresFeatureFlagEvaluation }) + } + + @Test("skips checking flags for surveys with missing keys or values ") + func shouldIgnoreSurveysWithMissingFeatureFlagsKeysOrValues() async { + let sut = getSut(surveys: [surveysWithMissingKeysAndValues]) + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - let flaggedSurvey = activeSurvey - .replacingOccurrences(of: "active_id", with: "flagged_survey") - .replacingOccurrences( - of: "\"start_date\"", - with: "\"linked_flag_key\": \"survey-flag\", \"start_date\"" - ) - postHog.remoteConfig?.onRemoteConfigLoaded.invoke([ - "surveys": try parseSurveys("\(flaggedSurvey),\(activeSurvey)"), + #expect(matchedSurveys.map(\.id).contains("survey-with-missing-keys")) + #expect(matchedSurveys.map(\.id).contains("survey-with-missing-values")) + } + + @Test("returns surveys that match internal targeting flags") + func returnsSurveysThatMatchInternalTargetingFlags() async throws { + let sut = getSut(surveys: [surveyWithEnabledInternalTargetingFlag]) + let surveys = sut.decodeSurveys(from: [ + "surveys": try parseSurveys(surveyWithEnabledInternalTargetingFlag), ]) - await rendered.wait() + sut.updateSurveyCache(surveys, events: [:]) + await withCheckedContinuation { continuation in + postHog.remoteConfig?.reloadFeatureFlags { _ in + continuation.resume() + } + } + + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys { + continuation.resume(with: .success($0)) + } + } - #expect(delegate.renderedSurveyIds == ["active_id"]) - #expect(stateLock.withLock { renderedBeforeFlagsLoaded }) - await flagsLoaded.wait() - _ = (sut, flagsToken) + #expect(matchedSurveys.map(\.id) == ["survey-with-internal-flag-enabled"]) } + } - @Test("recovers survey rendering after a feature flag refresh failure") - func recoversSurveyRenderingAfterFeatureFlagRefreshFailure() async throws { - let delegate = SpySurveysDelegate() - postHog.config._surveysConfig.surveysDelegate = delegate - postHog.config.preloadFeatureFlags = false - let sut = getSut(surveys: []) - sut.hasActiveSurveyWindow = { true } - server.remoteConfigSurveys = - """ - [{ - "id": "flagged_survey", - "name": "Flagged Survey", - "type": "popover", - "questions": [{ + @Suite("Test survey wait period", .serialized) + class TestSurveyWaitPeriod { + let server: MockPostHogServer + let postHog: PostHogSDK + let storage: PostHogStorage + + init() { + let config = PostHogConfig(projectToken: "test_survey_wait_period", host: "http://localhost:9090") + config._surveys = true + #if os(iOS) + config._surveysConfig.surveysDelegate = TestGetActiveSurveys.SpySurveysDelegate() + #endif + config.enableSwizzling = false + config.disableFlushOnBackgroundForTesting = true + postHog = PostHogSDK.with(config) + storage = PostHogStorage(config) + storage.reset() + server = MockPostHogServer() + server.start() + } + + deinit { + server.stop() + postHog.close() + postHog.reset() + } + + let activeSurveyNoWaitPeriod = + """ + { + "id": "no-wait-period", + "name": "Survey without wait period", + "type": "popover", + "questions": [ + { "id": "1", "type": "open", "question": "What do you think?", "originalQuestionIndex": 0 - }], - "linked_flag_key": "survey-flag", - "start_date": "2024-07-23T09:18:18.376000Z" - }] - """ - server.flagsResponseHandler = { _ in - HTTPStubsResponse(jsonObject: [:], statusCode: 200, headers: nil) + } + ], + "start_date": "2024-07-23T09:18:18.376000Z" } + """ - let failed = AsyncLatch() - let flagsToken = try #require(postHog.remoteConfig?.onFeatureFlagsLoaded.subscribe { flags in - if flags == nil { failed.signal() } - }) - postHog.remoteConfig?.reloadRemoteConfig() - await failed.wait() - try #require(failed.isSignaled) - #expect(delegate.renderedSurveyIds.isEmpty) - - server.flagsResponseHandler = nil - server.featureFlags = ["survey-flag": true] - let rendered = AsyncLatch() - delegate.onRender = { rendered.signal() } - postHog.remoteConfig?.reloadFeatureFlags() - await rendered.wait() - - #expect(delegate.renderedSurveyIds == ["flagged_survey"]) - _ = (sut, flagsToken) - } - #endif - - @Test("returns surveys that match device type") - func returnsSurveysThatMatchDeviceType() async { - let surveys: [String] = [ - draftSurvey, - activeSurvey, - completedSurvey, - surveyWithDesktopDevice, - surveyWithMobileDevice, - ] - - let sut = getSut(surveys: surveys) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let activeSurveyWithWaitPeriod = + """ + { + "id": "with-wait-period", + "name": "Survey with wait period", + "type": "popover", + "questions": [ + { + "id": "1", + "type": "open", + "question": "What do you think?", + "originalQuestionIndex": 0 + } + ], + "conditions": { + "seenSurveyWaitPeriodInDays": 7 + }, + "start_date": "2024-07-23T09:18:18.376000Z" } - } + """ - if let currentDeviceType = PostHogContext.deviceType?.lowercased() { - #expect(matchedSurveys.map(\.id) == ["active_id", "\(currentDeviceType)_id"]) - } else { - #expect(matchedSurveys.map(\.id) == ["active_id"]) + private func getSut(surveys: [String]) -> PostHogSurveyIntegration { + server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + let installOutcome = sut.install(postHog) + #expect(installOutcome == .installed) + return sut } - } - @Test("does not return web-only surveys with a CSS selector condition") - func doesNotReturnSurveysWithSelectorCondition() async { - let surveys: [String] = [ - activeSurvey, - surveyWithSelectorCondition, - ] + @Test("survey without wait period is not filtered") + func surveyWithoutWaitPeriodIsNotFiltered() async { + let sut = getSut(surveys: [activeSurveyNoWaitPeriod]) - let sut = getSut(surveys: surveys) + // Set last seen date to recently + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(Date())) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } - @Test("does not return web-only surveys with a URL condition") - func doesNotReturnSurveysWithUrlCondition() async { - let surveys: [String] = [ - activeSurvey, - surveyWithUrlCondition, - ] - - let sut = getSut(surveys: surveys) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) - } + #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) } - #expect(matchedSurveys.map(\.id) == ["active_id"]) - } + @Test("survey with wait period passes when no survey was previously seen") + func surveyWithWaitPeriodPassesWhenNoPreviouslySeen() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("returns only surveys with enabled feature flags") - func returnsOnlySurveysWithEnabledFeatureFlags() async throws { - let sut = getSut(surveys: [surveysWithFeatureFlags]) - // Seed surveys separately so this matching test does not race the remote-config listener's own flag refresh. - let surveys = sut.decodeSurveys(from: [ - "surveys": try parseSurveys(surveysWithFeatureFlags), - ]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in - continuation.resume() + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(with: .success($0)) - } + #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) } - #expect(matchedSurveys.map(\.id).contains("survey-with-flags")) - #expect(matchedSurveys.map(\.id).contains("survey-without-flags")) - #expect(!matchedSurveys.map(\.id).contains("survey-with-disabled-flags")) - } - - @Test("matches the linked flag variant without bypassing other targeting", arguments: [ - ("linked-blue", "blue", true), - ("linked-blue", "red", false), - ("linked-blue", "Blue", false), - ("linked-flag-enabled", "blue", false), - ("linked-flag-disabled", "blue", false), - ("missing-flag", "blue", false), - ("linked-blue", "any", true), - ("linked-flag-enabled", "any", true), - ("linked-flag-disabled", "any", false), - ("missing-flag", "any", false), - ("linked-blue", nil, true), - ("linked-blue", "", true), - (nil, "blue", true), - ("", "blue", true), - ] as [(String?, String?, Bool)]) - func matchesLinkedFlagVariant(linkedKey: String?, variant: String?, matches: Bool) async throws { - server.featureFlags?["linked-blue"] = "blue" - var survey = try #require(try parseSurveys(activeSurvey).first) - survey["linked_flag_key"] = linkedKey - survey["conditions"] = variant.map { ["linkedFlagVariant": $0] } ?? [:] - var blockedSurvey = survey - blockedSurvey["id"] = "blocked" - blockedSurvey["targeting_flag_key"] = "survey-targeting-flag-disabled" - - let sut = getSut(surveys: []) - let surveys = sut.decodeSurveys(from: ["surveys": [survey, blockedSurvey]]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in continuation.resume() } - } - let matched: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { continuation.resume(returning: $0) } - } - #expect(matched.map(\.id) == (matches ? ["active_id"] : [])) - } + @Test("survey with wait period is filtered when period has not elapsed") + func surveyWithWaitPeriodIsFilteredWhenNotElapsed() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("Should not return surveys when any feature flag is disabled") - func shouldFilterOutSurveysWhenAnyFlagIsDisabled() async { - let sut = getSut(surveys: [surveyWithEnabledAndDisabledFlags]) + // Set last seen date to 1 day ago (wait period is 7 days) + let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - - #expect(matchedSurveys.isEmpty) - } - @Test("does not require flag evaluation for surveys with missing keys or values") - func doesNotRequireFlagsForMissingKeysOrValues() throws { - let surveys = PostHogSurveyIntegration().decodeSurveys(from: [ - "surveys": try parseSurveys(surveysWithMissingKeysAndValues), - ]) + #expect(matchedSurveys.isEmpty) + } - try #require(surveys.count == 2) - #expect(surveys.allSatisfy { !$0.requiresFeatureFlagEvaluation }) - } + @Test("survey with wait period passes when period has elapsed") + func surveyWithWaitPeriodPassesWhenElapsed() async { + let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - @Test("skips checking flags for surveys with missing keys or values ") - func shouldIgnoreSurveysWithMissingFeatureFlagsKeysOrValues() async { - let sut = getSut(surveys: [surveysWithMissingKeysAndValues]) + // Set last seen date to 10 days ago (wait period is 7 days) + let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(tenDaysAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } + + #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) } - #expect(matchedSurveys.map(\.id).contains("survey-with-missing-keys")) - #expect(matchedSurveys.map(\.id).contains("survey-with-missing-values")) - } + @Test("survey without wait period is not affected by last seen date") + func surveyWithoutWaitPeriodNotAffectedByLastSeenDate() async { + let sut = getSut(surveys: [activeSurveyNoWaitPeriod, activeSurveyWithWaitPeriod]) - @Test("returns surveys that match internal targeting flags") - func returnsSurveysThatMatchInternalTargetingFlags() async throws { - let sut = getSut(surveys: [surveyWithEnabledInternalTargetingFlag]) - let surveys = sut.decodeSurveys(from: [ - "surveys": try parseSurveys(surveyWithEnabledInternalTargetingFlag), - ]) - sut.updateSurveyCache(surveys, events: [:]) - await withCheckedContinuation { continuation in - postHog.remoteConfig?.reloadFeatureFlags { _ in - continuation.resume() - } - } + // Set last seen date to 1 day ago (wait period is 7 days) + let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys { - continuation.resume(with: .success($0)) + let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in + sut.getActiveMatchingSurveys(forceReload: true) { + continuation.resume(with: .success($0)) + } } - } - #expect(matchedSurveys.map(\.id) == ["survey-with-internal-flag-enabled"]) + // Only the survey without wait period should pass + #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) + } } - } - @Suite("Test survey wait period", .serialized) - class TestSurveyWaitPeriod { - let server: MockPostHogServer - let postHog: PostHogSDK - let storage: PostHogStorage + @Suite("Test conditional branching", .serialized) + class TestConfitionalBranchingLogic { + let postHog: PostHogSDK + let server = MockPostHogServer() - init() { - let config = PostHogConfig(projectToken: "test_survey_wait_period", host: "http://localhost:9090") - config._surveys = true - #if os(iOS) - config._surveysConfig.surveysDelegate = TestGetActiveSurveys.SpySurveysDelegate() - #endif - config.enableSwizzling = false - config.disableFlushOnBackgroundForTesting = true - postHog = PostHogSDK.with(config) - storage = PostHogStorage(config) - storage.reset() - server = MockPostHogServer() - server.start() - } + init() { + server.start() + let config = PostHogConfig(projectToken: "test_survey_branching", host: "http://localhost:9090") + config._surveys = true + config.enableSwizzling = false + config.disableRemoteConfigForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + config.setBeforeSend { _ in nil } + PostHogStorage(config).reset() + postHog = PostHogSDK.with(config) + } - deinit { - server.stop() - postHog.close() - postHog.reset() - } + deinit { + postHog.close() + server.stop() + } - let activeSurveyNoWaitPeriod = - """ - { - "id": "no-wait-period", - "name": "Survey without wait period", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0 - } - ], - "start_date": "2024-07-23T09:18:18.376000Z" - } - """ - - let activeSurveyWithWaitPeriod = - """ - { - "id": "with-wait-period", - "name": "Survey with wait period", - "type": "popover", - "questions": [ - { - "id": "1", - "type": "open", - "question": "What do you think?", - "originalQuestionIndex": 0 - } - ], - "conditions": { - "seenSurveyWaitPeriodInDays": 7 - }, - "start_date": "2024-07-23T09:18:18.376000Z" + private func getSut() -> PostHogSurveyIntegration { + let sut = PostHogSurveyIntegration() + PostHogSurveyIntegration.clearInstalls() + #expect(sut.install(postHog) == .installed) + sut.stop() + return sut } - """ - private func getSut(surveys: [String]) -> PostHogSurveyIntegration { - server.remoteConfigSurveys = "[\(surveys.joined(separator: ","))]" - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - let installOutcome = sut.install(postHog) - #expect(installOutcome == .installed) - return sut - } + @Test("returns next question index when no branching") + func returnsNextQuestionIndexWhenNoBranching() throws { + let sut = getSut() - @Test("survey without wait period is not filtered") - func surveyWithoutWaitPeriodIsNotFiltered() async { - let sut = getSut(surveys: [activeSurveyNoWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1")), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + ] + ) - // Set last seen date to recently - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(Date())) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { + #expect(nextIndex == 1) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") } - } - - #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) - } - @Test("survey with wait period passes when no survey was previously seen") - func surveyWithWaitPeriodPassesWhenNoPreviouslySeen() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 1, response: .openEnded("response 2")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") } - } - - #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) - } - - @Test("survey with wait period is filtered when period has not elapsed") - func surveyWithWaitPeriodIsFilteredWhenNotElapsed() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) - // Set last seen date to 1 day ago (wait period is 7 days) - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) - - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 3")) { + #expect(nextIndex == 2) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - #expect(matchedSurveys.isEmpty) - } + @Test("completes survey with single question") + func completesSurveyWithSingleQuestion() throws { + let sut = getSut() - @Test("survey with wait period passes when period has elapsed") - func surveyWithWaitPeriodPassesWhenElapsed() async { - let sut = getSut(surveys: [activeSurveyWithWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1")), + ] + ) - // Set last seen date to 10 days ago (wait period is 7 days) - let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(tenDaysAgo)) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { + #expect(nextIndex == 0) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - #expect(matchedSurveys.map(\.id) == ["with-wait-period"]) - } + @Test("ends survey when branching is end") + func endsSurveyWhenBranchingIsEnd() throws { + let sut = getSut() - @Test("survey without wait period is not affected by last seen date") - func surveyWithoutWaitPeriodNotAffectedByLastSeenDate() async { - let sut = getSut(surveys: [activeSurveyNoWaitPeriod, activeSurveyWithWaitPeriod]) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1", branching: .end)), + .open(.testInstance(question: "q2")), + ] + ) - // Set last seen date to 1 day ago (wait period is 7 days) - let oneDayAgo = Calendar.current.date(byAdding: .day, value: -1, to: Date())! - storage.setString(forKey: .lastSeenSurveyDate, contents: toISO8601String(oneDayAgo)) + sut.setShownSurvey(survey) - let matchedSurveys: [PostHogSurvey] = await withCheckedContinuation { continuation in - sut.getActiveMatchingSurveys(forceReload: true) { - continuation.resume(with: .success($0)) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { + #expect(nextIndex == 0) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") } } - // Only the survey without wait period should pass - #expect(matchedSurveys.map(\.id) == ["no-wait-period"]) - } - } + @Test("jumps to specific question when branching to specific question") + func jumpsToSpecificQuestionWhenBranchingToSpecificQuestion() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "q1", + display: .number, + scale: .tenPoint, + branching: .specificQuestion(index: 2) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - @Suite("Test conditional branching", .serialized) - class TestConfitionalBranchingLogic { - let postHog: PostHogSDK - let server = MockPostHogServer() - - init() { - server.start() - let config = PostHogConfig(projectToken: "test_survey_branching", host: "http://localhost:9090") - config._surveys = true - config.enableSwizzling = false - config.disableRemoteConfigForTesting = true - config.disableQueueTimerForTesting = true - config.disableFlushOnBackgroundForTesting = true - config.captureApplicationLifecycleEvents = false - config.setBeforeSend { _ in nil } - PostHogStorage(config).reset() - postHog = PostHogSDK.with(config) - } + sut.setShownSurvey(survey) - deinit { - postHog.close() - server.stop() - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .rating(7)) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - private func getSut() -> PostHogSurveyIntegration { - let sut = PostHogSurveyIntegration() - PostHogSurveyIntegration.clearInstalls() - #expect(sut.install(postHog) == .installed) - sut.stop() - return sut - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { + #expect(nextIndex == 3) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - @Test("returns next question index when no branching") - func returnsNextQuestionIndexWhenNoBranching() throws { - let sut = getSut() + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("response 3")) { + #expect(nextIndex == 3) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") + } + } - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1")), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - ] - ) + @Test("jumps to last question when branching is out of bounds") + func jumpsToLastQuestionWhenBranchingOutOfBounds() throws { + let sut = getSut() - sut.setShownSurvey(survey) + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .open(.testInstance(question: "q1", branching: .specificQuestion(index: 5))), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { - #expect(nextIndex == 1) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 1, response: .openEnded("response 2")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 3")) { - #expect(nextIndex == 2) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { + #expect(nextIndex == 2) + #expect(isCompleted == true) + } else { + throw TestError("Expected next question state") + } } - } - @Test("completes survey with single question") - func completesSurveyWithSingleQuestion() throws { - let sut = getSut() + @Test("handles single choice response based branching") + func handlesSingleChoiceResponseBasedBranching() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .singleChoice(.testInstance( + question: "How satisfied are you with our product?", + choices: ["Very Dissatisfied", "Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"], + branching: .responseBased(responseValues: [ + "0": 1, // Very Dissatisfied -> Detractor path + "1": 1, // Dissatisfied -> Detractor path + "2": 2, // Neutral -> Neutral path + "3": 3, // Satisfied -> Promoter path + "4": 3, // Very Satisfied -> Promoter path + ]) + )), + .open(.testInstance(question: "detractor", branching: .specificQuestion(index: 4))), // Detractor path + .open(.testInstance(question: "neutral", branching: .specificQuestion(index: 4))), // Neutral path + .open(.testInstance(question: "promoter", branching: .specificQuestion(index: 4))), // Promoter path + .open(.testInstance(question: "Final")), // Final question + ] + ) - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1")), - ] - ) + // Test Very Satisfied/Satisfied path (promoter) + sut.setShownSurvey(survey) - sut.setShownSurvey(survey) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Very Satisfied")) { + #expect(nextIndex == 3) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { - #expect(nextIndex == 0) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") - } - } + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("Great product!")) { + #expect(nextIndex == 4) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - @Test("ends survey when branching is end") - func endsSurveyWhenBranchingIsEnd() throws { - let sut = getSut() + // Test Neutral path + sut.setShownSurvey(survey) - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1", branching: .end)), - .open(.testInstance(question: "q2")), - ] - ) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Neutral")) { + #expect(nextIndex == 2) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - sut.setShownSurvey(survey) + if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("It's okay")) { + #expect(nextIndex == 4) + #expect(isCompleted == false) + } else { + throw TestError("Expected next question state") + } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response")) { - #expect(nextIndex == 0) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") - } - } + // Test Dissatisfied/Very Dissatisfied path (detractor) + sut.setShownSurvey(survey) - @Test("jumps to specific question when branching to specific question") - func jumpsToSpecificQuestionWhenBranchingToSpecificQuestion() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "q1", - display: .number, - scale: .tenPoint, - branching: .specificQuestion(index: 2) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + let detractor = try #require(sut.getNextQuestion(index: 0, response: .singleChoice("Very Dissatisfied"))) + #expect(detractor.0 == 1) + #expect(detractor.1 == false) + + let final = try #require(sut.getNextQuestion(index: 1, response: .openEnded("Needs work"))) + #expect(final.0 == 4) + #expect(final.1 == false) + + let completed = try #require(sut.getNextQuestion(index: 4, response: .openEnded("Yes"))) + #expect(completed.0 == 4) + #expect(completed.1 == true) + } + + @Test("handles rating response based branching for scale 3") + func handlesRatingResponseBasedBranchingForScale3() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .emoji, + scale: .threePoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - sut.setShownSurvey(survey) + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .rating(7)) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + for rating in 1 ... 3 { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == rating) + #expect(isCompleted == false) + } } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { - #expect(nextIndex == 3) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + @Test("handles rating response based branching for scale 5") + func handlesRatingResponseBasedBranchingForScale5() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .emoji, + scale: .fivePoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("response 3")) { - #expect(nextIndex == 3) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 2), (4, 3), (5, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } - } - - @Test("jumps to last question when branching is out of bounds") - func jumpsToLastQuestionWhenBranchingOutOfBounds() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .open(.testInstance(question: "q1", branching: .specificQuestion(index: 5))), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - ] - ) - sut.setShownSurvey(survey) + @Test("handles rating response based branching for scale 7") + func handlesRatingResponseBasedBranchingForScale7() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "rating question", + display: .number, + scale: .sevenPoint, + branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) + )), + .open(.testInstance(question: "q2")), + .open(.testInstance(question: "q3")), + .open(.testInstance(question: "q4")), + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .openEnded("response 1")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("response 2")) { - #expect(nextIndex == 2) - #expect(isCompleted == true) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3), (7, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } - } - - @Test("handles single choice response based branching") - func handlesSingleChoiceResponseBasedBranching() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .singleChoice(.testInstance( - question: "How satisfied are you with our product?", - choices: ["Very Dissatisfied", "Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"], - branching: .responseBased(responseValues: [ - "0": 1, // Very Dissatisfied -> Detractor path - "1": 1, // Dissatisfied -> Detractor path - "2": 2, // Neutral -> Neutral path - "3": 3, // Satisfied -> Promoter path - "4": 3, // Very Satisfied -> Promoter path - ]) - )), - .open(.testInstance(question: "detractor", branching: .specificQuestion(index: 4))), // Detractor path - .open(.testInstance(question: "neutral", branching: .specificQuestion(index: 4))), // Neutral path - .open(.testInstance(question: "promoter", branching: .specificQuestion(index: 4))), // Promoter path - .open(.testInstance(question: "Final")), // Final question - ] - ) - // Test Very Satisfied/Satisfied path (promoter) - sut.setShownSurvey(survey) + @Test("handles NPS rating response based branching for scale 10") + func handlesNPSRatingResponseBasedBranchingForScale10() throws { + let sut = getSut() + + let survey = PostHogSurvey.testInstance( + name: "test survey", + questions: [ + .rating(.testInstance( + question: "q1", + display: .number, + scale: .tenPoint, + branching: .responseBased(responseValues: ["detractors": 1, "passives": 2, "promoters": 3]) + )), + .open(.testInstance(question: "question_detractors", branching: .end)), // Detractors path + .open(.testInstance(question: "question_passives", branching: .end)), // Passives path + .open(.testInstance(question: "question_promoters", branching: .end)), // Promoters path + ] + ) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Very Satisfied")) { - #expect(nextIndex == 3) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") - } + sut.setShownSurvey(survey) - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 3, response: .openEnded("Great product!")) { - #expect(nextIndex == 4) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + for (rating, expectedIndex) in [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 2), (8, 2), (9, 3), (10, 3)] { + sut.setShownSurvey(survey) + let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) + #expect(nextIndex == expectedIndex) + #expect(isCompleted == false) + } } + } - // Test Neutral path - sut.setShownSurvey(survey) + @Suite("Test resilient survey list decoding") + struct TestResilientSurveyListDecoding { + @Test("atomic array decode fails when one survey is malformed") + func atomicArrayDecodeFailsOnMalformedEntry() throws { + let data = try loadFixture("fixture_surveys_array_with_malformed") - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 0, response: .singleChoice("Neutral")) { - #expect(nextIndex == 2) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + // Documents Swift's atomic [Decodable] behavior: a single malformed + // element throws and drops the entire array. This is the root cause + // of issue #611 and the reason decodeSurveys decodes per-element. + #expect(throws: DecodingError.self) { + _ = try PostHogApi.jsonDecoder.decode([PostHogSurvey].self, from: data) + } } - if let (nextIndex, isCompleted) = sut.getNextQuestion(index: 2, response: .openEnded("It's okay")) { - #expect(nextIndex == 4) - #expect(isCompleted == false) - } else { - throw TestError("Expected next question state") + @Test("per-element decoding skips malformed entries and keeps valid ones") + func perElementDecodingSkipsMalformedEntries() throws { + let data = try loadFixture("fixture_surveys_array_with_malformed") + let rawArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] + let arrayItems = try #require(rawArray) + + let decoded = PostHogSurveyIntegration().decodeSurveys(from: ["surveys": arrayItems]) + + #expect(decoded.count == 1) + #expect(decoded.first?.id == "valid-survey-id") + } + } + + @Suite("Test survey to display model mapping") + struct TestSurveyToDisplayMapping { + private func makeAppearance( + delay: TimeInterval?, + displayIntroScreen: Bool? = nil, + introScreenHeader: String? = nil, + introScreenDescription: String? = nil, + introScreenDescriptionContentType: PostHogSurveyTextContentType? = nil, + introScreenButtonText: String? = nil + ) -> PostHogSurveyAppearance { + PostHogSurveyAppearance( + position: nil, fontFamily: nil, backgroundColor: nil, + submitButtonColor: nil, submitButtonText: nil, submitButtonTextColor: nil, + textColor: nil, descriptionTextColor: nil, ratingButtonColor: nil, + ratingButtonActiveColor: nil, ratingButtonHoverColor: nil, + inputBackground: nil, inputTextColor: nil, whiteLabel: nil, + autoDisappear: nil, displayThankYouMessage: nil, + thankYouMessageHeader: nil, thankYouMessageDescription: nil, + thankYouMessageDescriptionContentType: nil, + thankYouMessageCloseButtonText: nil, + displayIntroScreen: displayIntroScreen, + introScreenHeader: introScreenHeader, + introScreenDescription: introScreenDescription, + introScreenDescriptionContentType: introScreenDescriptionContentType, + introScreenButtonText: introScreenButtonText, + borderColor: nil, + placeholder: nil, shuffleQuestions: nil, + surveyPopupDelaySeconds: delay, + widgetType: nil, widgetSelector: nil, widgetLabel: nil, widgetColor: nil + ) } - // Test Dissatisfied/Very Dissatisfied path (detractor) - sut.setShownSurvey(survey) - - let detractor = try #require(sut.getNextQuestion(index: 0, response: .singleChoice("Very Dissatisfied"))) - #expect(detractor.0 == 1) - #expect(detractor.1 == false) + @Test("maps surveyPopupDelaySeconds to display survey appearance") + func mapsPopupDelaySecondsToDisplayAppearance() { + let survey = PostHogSurvey.testInstance( + name: "delay-mapped", + appearance: makeAppearance(delay: 7) + ) - let final = try #require(sut.getNextQuestion(index: 1, response: .openEnded("Needs work"))) - #expect(final.0 == 4) - #expect(final.1 == false) + let displaySurvey = survey.toDisplaySurvey() - let completed = try #require(sut.getNextQuestion(index: 4, response: .openEnded("Yes"))) - #expect(completed.0 == 4) - #expect(completed.1 == true) - } + #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == 7) + } - @Test("handles rating response based branching for scale 3") - func handlesRatingResponseBasedBranchingForScale3() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .emoji, - scale: .threePoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("keeps popup delay nil on display survey appearance when missing") + func keepsPopupDelayNilWhenMissing() { + let survey = PostHogSurvey.testInstance( + name: "delay-missing", + appearance: makeAppearance(delay: nil) + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() - for rating in 1 ... 3 { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == rating) - #expect(isCompleted == false) + #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == nil) } - } - @Test("handles rating response based branching for scale 5") - func handlesRatingResponseBasedBranchingForScale5() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .emoji, - scale: .fivePoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("maps intro screen fields to display survey appearance") + func mapsIntroScreenFieldsToDisplayAppearance() { + let survey = PostHogSurvey.testInstance( + name: "intro-mapped", + appearance: makeAppearance( + delay: nil, + displayIntroScreen: true, + introScreenHeader: "Welcome!", + introScreenDescription: "Two quick questions.", + introScreenDescriptionContentType: .text, + introScreenButtonText: "Get started" + ) + ) - for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 2), (4, 3), (5, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + let displaySurvey = survey.toDisplaySurvey() + + #expect(displaySurvey.appearance?.displayIntroScreen == true) + #expect(displaySurvey.appearance?.introScreenHeader == "Welcome!") + #expect(displaySurvey.appearance?.introScreenDescription == "Two quick questions.") + #expect(displaySurvey.appearance?.introScreenDescriptionContentType == .text) + #expect(displaySurvey.appearance?.introScreenButtonText == "Get started") } - } - @Test("handles rating response based branching for scale 7") - func handlesRatingResponseBasedBranchingForScale7() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "rating question", - display: .number, - scale: .sevenPoint, - branching: .responseBased(responseValues: ["negative": 1, "neutral": 2, "positive": 3]) - )), - .open(.testInstance(question: "q2")), - .open(.testInstance(question: "q3")), - .open(.testInstance(question: "q4")), - ] - ) + @Test("defaults displayIntroScreen to false on display survey appearance when missing") + func defaultsDisplayIntroScreenToFalseWhenMissing() { + let survey = PostHogSurvey.testInstance( + name: "intro-missing", + appearance: makeAppearance(delay: nil) + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() - for (rating, expectedIndex) in [(1, 1), (2, 1), (3, 1), (4, 2), (5, 3), (6, 3), (7, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + #expect(displaySurvey.appearance?.displayIntroScreen == false) + #expect(displaySurvey.appearance?.introScreenHeader == nil) + #expect(displaySurvey.appearance?.introScreenButtonText == nil) } - } - @Test("handles NPS rating response based branching for scale 10") - func handlesNPSRatingResponseBasedBranchingForScale10() throws { - let sut = getSut() - - let survey = PostHogSurvey.testInstance( - name: "test survey", - questions: [ - .rating(.testInstance( - question: "q1", - display: .number, - scale: .tenPoint, - branching: .responseBased(responseValues: ["detractors": 1, "passives": 2, "promoters": 3]) - )), - .open(.testInstance(question: "question_detractors", branching: .end)), // Detractors path - .open(.testInstance(question: "question_passives", branching: .end)), // Passives path - .open(.testInstance(question: "question_promoters", branching: .end)), // Promoters path + @Test( + "rating display nil-coalesces missing bound labels to empty string", + arguments: [ + (lower: String?.none, upper: String?.none, expectedLower: "", expectedUpper: ""), + (lower: String?.none, upper: "Very likely", expectedLower: "", expectedUpper: "Very likely"), + (lower: "Not likely", upper: String?.none, expectedLower: "Not likely", expectedUpper: ""), ] ) + func ratingDisplayDefaultsMissingBoundLabelsToEmptyString( + lower: String?, + upper: String?, + expectedLower: String, + expectedUpper: String + ) throws { + let ratingQuestion = PostHogRatingSurveyQuestion( + id: "r-1", + question: "How likely?", + description: nil, + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: nil, + display: .number, + scale: .tenPoint, + lowerBoundLabel: lower, + upperBoundLabel: upper + ) + let survey = PostHogSurvey.testInstance( + name: "rating-no-bounds", + questions: [.rating(ratingQuestion)] + ) - sut.setShownSurvey(survey) + let displaySurvey = survey.toDisplaySurvey() + let displayQuestion = try #require(displaySurvey.questions.first as? PostHogDisplayRatingQuestion) - for (rating, expectedIndex) in [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 2), (8, 2), (9, 3), (10, 3)] { - sut.setShownSurvey(survey) - let (nextIndex, isCompleted) = try #require(sut.getNextQuestion(index: 0, response: .rating(rating))) - #expect(nextIndex == expectedIndex) - #expect(isCompleted == false) + #expect(displayQuestion.lowerBoundLabel == expectedLower) + #expect(displayQuestion.upperBoundLabel == expectedUpper) } } } - @Suite("Test resilient survey list decoding") - struct TestResilientSurveyListDecoding { - @Test("atomic array decode fails when one survey is malformed") - func atomicArrayDecodeFailsOnMalformedEntry() throws { - let data = try loadFixture("fixture_surveys_array_with_malformed") - - // Documents Swift's atomic [Decodable] behavior: a single malformed - // element throws and drops the entire array. This is the root cause - // of issue #611 and the reason decodeSurveys decodes per-element. - #expect(throws: DecodingError.self) { - _ = try PostHogApi.jsonDecoder.decode([PostHogSurvey].self, from: data) - } - } - - @Test("per-element decoding skips malformed entries and keeps valid ones") - func perElementDecodingSkipsMalformedEntries() throws { - let data = try loadFixture("fixture_surveys_array_with_malformed") - let rawArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] - let arrayItems = try #require(rawArray) - - let decoded = PostHogSurveyIntegration().decodeSurveys(from: ["surveys": arrayItems]) - - #expect(decoded.count == 1) - #expect(decoded.first?.id == "valid-survey-id") - } + func loadFixture(_ name: String) throws -> Data { + let url = Bundle.test.url(forResource: name, withExtension: "json") + return try Data(contentsOf: url!) } - @Suite("Test survey to display model mapping") - struct TestSurveyToDisplayMapping { - private func makeAppearance( - delay: TimeInterval?, - displayIntroScreen: Bool? = nil, - introScreenHeader: String? = nil, - introScreenDescription: String? = nil, - introScreenDescriptionContentType: PostHogSurveyTextContentType? = nil, - introScreenButtonText: String? = nil - ) -> PostHogSurveyAppearance { - PostHogSurveyAppearance( - position: nil, fontFamily: nil, backgroundColor: nil, - submitButtonColor: nil, submitButtonText: nil, submitButtonTextColor: nil, - textColor: nil, descriptionTextColor: nil, ratingButtonColor: nil, - ratingButtonActiveColor: nil, ratingButtonHoverColor: nil, - inputBackground: nil, inputTextColor: nil, whiteLabel: nil, - autoDisappear: nil, displayThankYouMessage: nil, - thankYouMessageHeader: nil, thankYouMessageDescription: nil, - thankYouMessageDescriptionContentType: nil, - thankYouMessageCloseButtonText: nil, - displayIntroScreen: displayIntroScreen, - introScreenHeader: introScreenHeader, - introScreenDescription: introScreenDescription, - introScreenDescriptionContentType: introScreenDescriptionContentType, - introScreenButtonText: introScreenButtonText, - borderColor: nil, - placeholder: nil, shuffleQuestions: nil, - surveyPopupDelaySeconds: delay, - widgetType: nil, widgetSelector: nil, widgetLabel: nil, widgetColor: nil - ) - } - - @Test("maps surveyPopupDelaySeconds to display survey appearance") - func mapsPopupDelaySecondsToDisplayAppearance() { - let survey = PostHogSurvey.testInstance( - name: "delay-mapped", - appearance: makeAppearance(delay: 7) - ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == 7) - } - - @Test("keeps popup delay nil on display survey appearance when missing") - func keepsPopupDelayNilWhenMissing() { - let survey = PostHogSurvey.testInstance( - name: "delay-missing", - appearance: makeAppearance(delay: nil) + private extension PostHogSurvey { + static func testInstance( + id: String = UUID().uuidString, + name: String, + type: PostHogSurveyType = .popover, + questions: [PostHogSurveyQuestion] = [ + .open( + PostHogOpenSurveyQuestion( + id: "", + question: "Some question", + description: "Some description", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: nil, + translations: nil + ) + ), + ], + featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]? = nil, + linkedFlagKey: String? = nil, + targetingFlagKey: String? = nil, + internalTargetingFlagKey: String? = nil, + conditions: PostHogSurveyConditions? = nil, + appearance: PostHogSurveyAppearance? = nil, + currentIteration: Int? = nil, + currentIterationStartDate: Date? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + schedule: PostHogSurveySchedule? = nil + ) -> PostHogSurvey { + PostHogSurvey( + id: id, + name: name, + type: type, + questions: questions, + featureFlagKeys: featureFlagKeys, + linkedFlagKey: linkedFlagKey, + targetingFlagKey: targetingFlagKey, + internalTargetingFlagKey: internalTargetingFlagKey, + conditions: conditions, + appearance: appearance, + currentIteration: currentIteration, + currentIterationStartDate: currentIterationStartDate, + startDate: startDate, + endDate: endDate, + schedule: schedule, + translations: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.surveyPopupDelaySeconds == nil) } + } - @Test("maps intro screen fields to display survey appearance") - func mapsIntroScreenFieldsToDisplayAppearance() { - let survey = PostHogSurvey.testInstance( - name: "intro-mapped", - appearance: makeAppearance( - delay: nil, - displayIntroScreen: true, - introScreenHeader: "Welcome!", - introScreenDescription: "Two quick questions.", - introScreenDescriptionContentType: .text, - introScreenButtonText: "Get started" - ) + private extension PostHogOpenSurveyQuestion { + static func testInstance( + question: String, + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogOpenSurveyQuestion { + PostHogOpenSurveyQuestion( + id: "", + question: question, + description: "", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: branching, + translations: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.displayIntroScreen == true) - #expect(displaySurvey.appearance?.introScreenHeader == "Welcome!") - #expect(displaySurvey.appearance?.introScreenDescription == "Two quick questions.") - #expect(displaySurvey.appearance?.introScreenDescriptionContentType == .text) - #expect(displaySurvey.appearance?.introScreenButtonText == "Get started") } + } - @Test("defaults displayIntroScreen to false on display survey appearance when missing") - func defaultsDisplayIntroScreenToFalseWhenMissing() { - let survey = PostHogSurvey.testInstance( - name: "intro-missing", - appearance: makeAppearance(delay: nil) + private extension PostHogMultipleSurveyQuestion { + static func testInstance( + question: String, + choices: [String], + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogMultipleSurveyQuestion { + PostHogMultipleSurveyQuestion( + id: "", + question: question, + description: "", + descriptionContentType: nil, + optional: nil, + buttonText: nil, + originalQuestionIndex: nil, + branching: branching, + translations: nil, + choices: choices, + hasOpenChoice: false, + shuffleOptions: nil ) - - let displaySurvey = survey.toDisplaySurvey() - - #expect(displaySurvey.appearance?.displayIntroScreen == false) - #expect(displaySurvey.appearance?.introScreenHeader == nil) - #expect(displaySurvey.appearance?.introScreenButtonText == nil) } + } - @Test( - "rating display nil-coalesces missing bound labels to empty string", - arguments: [ - (lower: String?.none, upper: String?.none, expectedLower: "", expectedUpper: ""), - (lower: String?.none, upper: "Very likely", expectedLower: "", expectedUpper: "Very likely"), - (lower: "Not likely", upper: String?.none, expectedLower: "Not likely", expectedUpper: ""), - ] - ) - func ratingDisplayDefaultsMissingBoundLabelsToEmptyString( - lower: String?, - upper: String?, - expectedLower: String, - expectedUpper: String - ) throws { - let ratingQuestion = PostHogRatingSurveyQuestion( - id: "r-1", - question: "How likely?", - description: nil, + private extension PostHogRatingSurveyQuestion { + static func testInstance( + question: String, + display: PostHogSurveyRatingDisplayType, + scale: PostHogSurveyRatingScale, + branching: PostHogSurveyQuestionBranching? = nil + ) -> PostHogRatingSurveyQuestion { + PostHogRatingSurveyQuestion( + id: "", + question: question, + description: "", descriptionContentType: nil, optional: nil, buttonText: nil, - originalQuestionIndex: 0, - branching: nil, + originalQuestionIndex: nil, + branching: branching, translations: nil, - display: .number, - scale: .tenPoint, - lowerBoundLabel: lower, - upperBoundLabel: upper - ) - let survey = PostHogSurvey.testInstance( - name: "rating-no-bounds", - questions: [.rating(ratingQuestion)] + display: display, + scale: scale, + lowerBoundLabel: "", + upperBoundLabel: "" ) - - let displaySurvey = survey.toDisplaySurvey() - let displayQuestion = try #require(displaySurvey.questions.first as? PostHogDisplayRatingQuestion) - - #expect(displayQuestion.lowerBoundLabel == expectedLower) - #expect(displayQuestion.upperBoundLabel == expectedUpper) } } -} - -func loadFixture(_ name: String) throws -> Data { - let url = Bundle.test.url(forResource: name, withExtension: "json") - return try Data(contentsOf: url!) -} - -private extension PostHogSurvey { - static func testInstance( - id: String = UUID().uuidString, - name: String, - type: PostHogSurveyType = .popover, - questions: [PostHogSurveyQuestion] = [ - .open( - PostHogOpenSurveyQuestion( - id: "", - question: "Some question", - description: "Some description", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: nil, - translations: nil - ) - ), - ], - featureFlagKeys: [PostHogSurveyFeatureFlagKeyValue]? = nil, - linkedFlagKey: String? = nil, - targetingFlagKey: String? = nil, - internalTargetingFlagKey: String? = nil, - conditions: PostHogSurveyConditions? = nil, - appearance: PostHogSurveyAppearance? = nil, - currentIteration: Int? = nil, - currentIterationStartDate: Date? = nil, - startDate: Date? = nil, - endDate: Date? = nil, - schedule: PostHogSurveySchedule? = nil - ) -> PostHogSurvey { - PostHogSurvey( - id: id, - name: name, - type: type, - questions: questions, - featureFlagKeys: featureFlagKeys, - linkedFlagKey: linkedFlagKey, - targetingFlagKey: targetingFlagKey, - internalTargetingFlagKey: internalTargetingFlagKey, - conditions: conditions, - appearance: appearance, - currentIteration: currentIteration, - currentIterationStartDate: currentIterationStartDate, - startDate: startDate, - endDate: endDate, - schedule: schedule, - translations: nil - ) - } -} - -private extension PostHogOpenSurveyQuestion { - static func testInstance( - question: String, - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogOpenSurveyQuestion { - PostHogOpenSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil - ) - } -} - -private extension PostHogMultipleSurveyQuestion { - static func testInstance( - question: String, - choices: [String], - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogMultipleSurveyQuestion { - PostHogMultipleSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil, - choices: choices, - hasOpenChoice: false, - shuffleOptions: nil - ) - } -} - -private extension PostHogRatingSurveyQuestion { - static func testInstance( - question: String, - display: PostHogSurveyRatingDisplayType, - scale: PostHogSurveyRatingScale, - branching: PostHogSurveyQuestionBranching? = nil - ) -> PostHogRatingSurveyQuestion { - PostHogRatingSurveyQuestion( - id: "", - question: question, - description: "", - descriptionContentType: nil, - optional: nil, - buttonText: nil, - originalQuestionIndex: nil, - branching: branching, - translations: nil, - display: display, - scale: scale, - lowerBoundLabel: "", - upperBoundLabel: "" - ) - } -} +#endif diff --git a/PostHogTests/PostHogTracingHeadersTest.swift b/PostHogTests/PostHogTracingHeadersTest.swift index 82cfbbad93..f57bf1fcea 100644 --- a/PostHogTests/PostHogTracingHeadersTest.swift +++ b/PostHogTests/PostHogTracingHeadersTest.swift @@ -133,10 +133,6 @@ @Test("adds distinct and session tracing headers to listed hosts for async await URLSession APIs", arguments: asyncCases) func addsHeadersToListedHostsForAsyncAwaitURLSessionAPIs(_ testCase: AsyncTracingCase) async throws { - guard #available(iOS 15.0, *) else { - return - } - try await withTracingSut(tracingHeaders: [Self.primaryHost]) { sut in let capture = CapturedRequest() stubRequest(host: Self.primaryHost, capture: capture) @@ -151,10 +147,6 @@ @Test("adds distinct and session tracing headers to PostHog URLSession wrapper APIs", arguments: wrapperCases) func addsHeadersToPostHogURLSessionWrapperAPIs(_ testCase: WrapperTracingCase) async throws { - guard #available(iOS 15.0, *) else { - return - } - try await withTracingSut(tracingHeaders: [Self.primaryHost]) { sut in let capture = CapturedRequest() stubRequest(host: Self.primaryHost, capture: capture) @@ -313,7 +305,6 @@ } } - @available(iOS 15.0, *) private func invokeAsyncTracingCase(_ testCase: AsyncTracingCase) async throws { let url = try makeURL(host: Self.primaryHost) let request = URLRequest(url: url) @@ -342,7 +333,6 @@ } } - @available(iOS 15.0, *) private func invokeWrapperTracingCase(_ testCase: WrapperTracingCase) async throws { let url = try makeURL(host: Self.primaryHost) let request = URLRequest(url: url) @@ -365,7 +355,6 @@ } } - @available(iOS 15.0, *) private func consumeFirstByte(from bytes: URLSession.AsyncBytes) async throws { var iterator = bytes.makeAsyncIterator() _ = try await iterator.next() diff --git a/PostHogTests/PostHogViewControllerTraversalTest.swift b/PostHogTests/PostHogViewControllerTraversalTest.swift index 7e3ebd5f99..ac6411391f 100644 --- a/PostHogTests/PostHogViewControllerTraversalTest.swift +++ b/PostHogTests/PostHogViewControllerTraversalTest.swift @@ -451,7 +451,7 @@ let split: SplitViewController let navigation = UINavigationController(rootViewController: OneViewController()) var wrappedDetail: UIViewController? - if setup != "classic", #available(iOS 14.0, tvOS 14.0, *) { + if setup != "classic" { split = SplitViewController(style: .doubleColumn) split.setViewController(ThreeViewController(), for: .primary) if setup == "wrapped column" { diff --git a/PostHogTests/PostHogWebPTest.swift b/PostHogTests/PostHogWebPTest.swift index 96a6b9fe63..d297286d76 100644 --- a/PostHogTests/PostHogWebPTest.swift +++ b/PostHogTests/PostHogWebPTest.swift @@ -5,7 +5,35 @@ // Created by Yiannis Josephides on 09/12/2024. // -#if canImport(UIKit) || targetEnvironment(macCatalyst) +#if os(iOS) + import Foundation + @testable import PostHog + import Testing + import UIKit + + @Suite("Replay image encoding") + struct PostHogReplayImageEncodingTests { + @Test("encodes as WebP when libwebp is linked, JPEG otherwise") + func encodesWithExpectedFormat() throws { + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 4)) + } + #if !SWIFT_PACKAGE || SessionReplay + let mimeType = "webp" + #else + let mimeType = "jpeg" + #endif + + let encoded = try #require(image.toBase64()) + + #expect(encoded.hasPrefix("data:image/\(mimeType);base64,")) + } + } +#endif + +// Mirrors the gate on `UIImage.webpData`, which SwiftPM compiles out when the `SessionReplay` trait is off. +#if os(iOS) && (!SWIFT_PACKAGE || SessionReplay) import Foundation @testable import PostHog import Testing diff --git a/PostHogTests/RRStyleTest.swift b/PostHogTests/RRStyleTest.swift deleted file mode 100644 index 260aa1d7f8..0000000000 --- a/PostHogTests/RRStyleTest.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// RRStyleTest.swift -// PostHogTests -// - -import Foundation -@testable import PostHog -import Testing - -@Suite("RRStyle Tests") -struct RRStyleTest { - @Suite("toDict") - struct ToDictTests { - @Test("returns an empty dictionary when nothing is set") - func emptyWhenNothingSet() { - #expect(RRStyle().toDict().isEmpty) - } - - @Test("includes all fields when everything is set") - func includesAllFields() { - let style = RRStyle() - style.color = "#FFFFFF" - style.backgroundColor = "#1C1C1E" - style.backgroundImage = "url(https://example.com/background.png)" - style.borderWidth = 1 - style.borderRadius = 8 - style.borderColor = "#D1D1D6" - style.fontSize = 17 - style.fontFamily = "Helvetica Neue" - style.horizontalAlign = "center" - style.verticalAlign = "center" - style.paddingTop = 8 - style.paddingBottom = 8 - style.paddingLeft = 16 - style.paddingRight = 16 - style.bar = "horizontal" - - let dict = style.toDict() - - #expect(dict["color"] as? String == "#FFFFFF") - #expect(dict["backgroundColor"] as? String == "#1C1C1E") - #expect(dict["backgroundImage"] as? String == "url(https://example.com/background.png)") - #expect(dict["borderWidth"] as? Int == 1) - #expect(dict["borderRadius"] as? Int == 8) - #expect(dict["borderColor"] as? String == "#D1D1D6") - #expect(dict["fontSize"] as? Int == 17) - #expect(dict["fontFamily"] as? String == "Helvetica Neue") - #expect(dict["horizontalAlign"] as? String == "center") - #expect(dict["verticalAlign"] as? String == "center") - #expect(dict["paddingTop"] as? Int == 8) - #expect(dict["paddingBottom"] as? Int == 8) - #expect(dict["paddingLeft"] as? Int == 16) - #expect(dict["paddingRight"] as? Int == 16) - #expect(dict["bar"] as? String == "horizontal") - #expect(dict.count == 15) - } - - @Test("a single field only produces its own key", arguments: [ - ("color", { (s: RRStyle) in s.color = "#000000" }, "#000000"), - ("backgroundColor", { (s: RRStyle) in s.backgroundColor = "#F2F2F7" }, "#F2F2F7"), - ("backgroundImage", { (s: RRStyle) in s.backgroundImage = "url(https://example.com/background.png)" }, "url(https://example.com/background.png)"), - ("borderWidth", { (s: RRStyle) in s.borderWidth = 1 }, "1"), - ("borderRadius", { (s: RRStyle) in s.borderRadius = 8 }, "8"), - ("borderColor", { (s: RRStyle) in s.borderColor = "#D1D1D6" }, "#D1D1D6"), - ("fontSize", { (s: RRStyle) in s.fontSize = 17 }, "17"), - ("fontFamily", { (s: RRStyle) in s.fontFamily = "Helvetica Neue" }, "Helvetica Neue"), - ("horizontalAlign", { (s: RRStyle) in s.horizontalAlign = "left" }, "left"), - ("verticalAlign", { (s: RRStyle) in s.verticalAlign = "center" }, "center"), - ("paddingTop", { (s: RRStyle) in s.paddingTop = 8 }, "8"), - ("paddingBottom", { (s: RRStyle) in s.paddingBottom = 8 }, "8"), - ("paddingLeft", { (s: RRStyle) in s.paddingLeft = 16 }, "16"), - ("paddingRight", { (s: RRStyle) in s.paddingRight = 16 }, "16"), - ("bar", { (s: RRStyle) in s.bar = "circular" }, "circular"), - ]) - func singleFieldOnlyProducesItsOwnKey(key: String, set: (RRStyle) -> Void, expected: String) { - let style = RRStyle() - set(style) - - let dict = style.toDict() - - #expect(dict.count == 1) - #expect(dict[key].map { String(describing: $0) } == expected) - } - - @Test("omits keys for nil fields in a partially set style") - func omitsNilFields() { - let style = RRStyle() - style.color = "#FFFFFF" - style.fontSize = 17 - - let dict = style.toDict() - - #expect(Set(dict.keys) == ["color", "fontSize"]) - #expect(dict["color"] as? String == "#FFFFFF") - #expect(dict["fontSize"] as? Int == 17) - #expect(!dict.values.contains { $0 is NSNull }) - } - - @Test("keeps zero values") - func keepsZeroValues() { - let style = RRStyle() - style.borderWidth = 0 - style.borderRadius = 0 - style.paddingTop = 0 - - let dict = style.toDict() - - #expect(dict["borderWidth"] as? Int == 0) - #expect(dict["borderRadius"] as? Int == 0) - #expect(dict["paddingTop"] as? Int == 0) - #expect(dict.count == 3) - } - } -} diff --git a/PostHogTests/RRWireframeTest.swift b/PostHogTests/RRWireframeTest.swift index 2ca2a38ef9..daba391cab 100644 --- a/PostHogTests/RRWireframeTest.swift +++ b/PostHogTests/RRWireframeTest.swift @@ -51,67 +51,9 @@ struct RRWireframeTest { #expect(dict.count == 5) } - @Test("includes all fields when everything is set") - func includesAllFields() { - let wireframe = RRWireframeTest.makeWireframe() - let child = RRWireframe() - child.id = 43 - child.type = "text" - child.text = "Sign in" - wireframe.childWireframes = [child] - wireframe.type = "input" - wireframe.inputType = "select" - wireframe.text = "Country" - wireframe.label = "Country" - wireframe.value = "Austria" - wireframe.base64 = RRWireframeTest.base64Image - let style = RRStyle() - style.color = "#000000" - style.backgroundColor = "#FFFFFF" - wireframe.style = style - wireframe.disabled = false - wireframe.checked = true - wireframe.options = ["Austria", "Germany", "Portugal"] - wireframe.max = 100 - - let dict = wireframe.toDict() - - #expect(dict["id"] as? Int == 42) - let children = dict["childWireframes"] as? [[String: Any]] - #expect(children?.count == 1) - #expect(children?.first?["id"] as? Int == 43) - #expect(children?.first?["type"] as? String == "text") - #expect(children?.first?["text"] as? String == "Sign in") - #expect(dict["type"] as? String == "input") - #expect(dict["inputType"] as? String == "select") - #expect(dict["text"] as? String == "Country") - #expect(dict["label"] as? String == "Country") - #expect(dict["value"] as? String == "Austria") - #expect(dict["base64"] as? String == RRWireframeTest.base64Image) - let styleDict = dict["style"] as? [String: Any] - #expect(styleDict?["color"] as? String == "#000000") - #expect(styleDict?["backgroundColor"] as? String == "#FFFFFF") - #expect(styleDict?.count == 2) - #expect(dict["disabled"] as? Bool == false) - #expect(dict["checked"] as? Bool == true) - #expect(dict["options"] as? [String] == ["Austria", "Germany", "Portugal"]) - #expect(dict["max"] as? Int == 100) - #expect(dict.count == 5 + 12) - } - @Test("a single optional field only produces its own key", arguments: [ - ("childWireframes", { (w: RRWireframe) in w.childWireframes = [] }, "[]"), - ("type", { (w: RRWireframe) in w.type = "div" }, "div"), - ("inputType", { (w: RRWireframe) in w.inputType = "text_area" }, "text_area"), - ("text", { (w: RRWireframe) in w.text = "Welcome back" }, "Welcome back"), - ("label", { (w: RRWireframe) in w.label = "Email" }, "Email"), - ("value", { (w: RRWireframe) in w.value = "1" }, "1"), + ("type", { (w: RRWireframe) in w.type = "screenshot" }, "screenshot"), ("base64", { (w: RRWireframe) in w.base64 = RRWireframeTest.base64Image }, RRWireframeTest.base64Image), - ("style", { (w: RRWireframe) in w.style = RRStyle() }, "[:]"), - ("disabled", { (w: RRWireframe) in w.disabled = true }, "true"), - ("checked", { (w: RRWireframe) in w.checked = true }, "true"), - ("options", { (w: RRWireframe) in w.options = ["Small", "Large"] }, #"["Small", "Large"]"#), - ("max", { (w: RRWireframe) in w.max = 1 }, "1"), ]) func singleFieldOnlyProducesItsOwnKey(key: String, set: (RRWireframe) -> Void, expected: String) { let wireframe = RRWireframe() @@ -124,86 +66,19 @@ struct RRWireframeTest { #expect(dict["id"] as? Int == 0) } - @Test("omits keys for nil fields in a partially set wireframe") - func omitsNilFields() { + @Test("includes the screenshot type and image") + func includesScreenshotFields() { let wireframe = RRWireframeTest.makeWireframe() - wireframe.type = "text" - wireframe.text = "Add to cart" + wireframe.type = "screenshot" + wireframe.base64 = RRWireframeTest.base64Image let dict = wireframe.toDict() - #expect(Set(dict.keys) == ["id", "x", "y", "width", "height", "type", "text"]) - #expect(dict["id"] as? Int == 42) - #expect(dict["x"] as? Int == 16) - #expect(dict["y"] as? Int == 120) - #expect(dict["width"] as? Int == 343) - #expect(dict["height"] as? Int == 44) - #expect(dict["type"] as? String == "text") - #expect(dict["text"] as? String == "Add to cart") + #expect(Set(dict.keys) == ["id", "x", "y", "width", "height", "type", "base64"]) + #expect(dict["type"] as? String == "screenshot") + #expect(dict["base64"] as? String == RRWireframeTest.base64Image) #expect(!dict.values.contains { $0 is NSNull }) } - - @Test("keeps zero and false values") - func keepsZeroAndFalseValues() { - let wireframe = RRWireframe() - wireframe.disabled = false - wireframe.checked = false - wireframe.max = 0 - wireframe.text = "" - wireframe.value = 0 - wireframe.options = [] - - let dict = wireframe.toDict() - - #expect(dict["disabled"] as? Bool == false) - #expect(dict["checked"] as? Bool == false) - #expect(dict["max"] as? Int == 0) - #expect(dict["text"] as? String == "") - #expect(dict["value"] as? Int == 0) - #expect((dict["options"] as? [String])?.isEmpty == true) - #expect(dict.count == 5 + 6) - } - - @Test("keeps numeric and string values as-is") - func keepsValueTypes() { - let numeric = RRWireframe() - numeric.value = 0.75 - let string = RRWireframe() - string.value = "Hello" - - #expect(numeric.toDict()["value"] as? Double == 0.75) - #expect(string.toDict()["value"] as? String == "Hello") - } - - @Test("converts nested child wireframes to dictionaries") - func convertsChildren() { - let parent = RRWireframe() - parent.type = "radio_group" - let first = RRWireframe() - first.id = 1 - first.type = "div" - let second = RRWireframe() - second.id = 2 - second.type = "div" - second.checked = true - parent.childWireframes = [first, second] - - let children = parent.toDict()["childWireframes"] as? [[String: Any]] - - #expect(children?.count == 2) - #expect(children?[0]["id"] as? Int == 1) - #expect(children?[0]["checked"] == nil) - #expect(children?[1]["id"] as? Int == 2) - #expect(children?[1]["checked"] as? Bool == true) - } - - @Test("uses realistic wireframe types", arguments: ["text", "input", "image", "div", "radio_group", "screenshot"]) - func realisticTypes(type: String) { - let wireframe = RRWireframeTest.makeWireframe() - wireframe.type = type - - #expect(wireframe.toDict()["type"] as? String == type) - } } #if os(iOS) diff --git a/PostHogTests/StoredSurveyResponseTest.swift b/PostHogTests/StoredSurveyResponseTest.swift index 1b8d64d58c..d31728a473 100644 --- a/PostHogTests/StoredSurveyResponseTest.swift +++ b/PostHogTests/StoredSurveyResponseTest.swift @@ -1,18 +1,20 @@ -import Foundation -@testable import PostHog -import Testing +#if !SWIFT_PACKAGE || Surveys + import Foundation + @testable import PostHog + import Testing -struct StoredSurveyResponseTest { - @Test("stored response values survive JSON encoding and decoding", arguments: [ - PostHogSurveyResponse.rating(nil), .rating(5), .openEnded("hello"), .openEnded(nil), - .singleChoice("A"), .multipleChoice(["A", "B"]), .multipleChoice(nil), .link(true), .link(false), - ]) - func storedResponseRoundTrip(response: PostHogSurveyResponse) throws { - let restored = try #require(JSONDecoder().decode(StoredSurveyResponse.self, from: JSONEncoder().encode(StoredSurveyResponse(response))).response) - #expect(restored.type == response.type) - #expect(restored.textValue == response.textValue) - #expect(restored.ratingValue == response.ratingValue) - #expect(restored.selectedOptions == response.selectedOptions) - #expect(restored.linkClicked == response.linkClicked) + struct StoredSurveyResponseTest { + @Test("stored response values survive JSON encoding and decoding", arguments: [ + PostHogSurveyResponse.rating(nil), .rating(5), .openEnded("hello"), .openEnded(nil), + .singleChoice("A"), .multipleChoice(["A", "B"]), .multipleChoice(nil), .link(true), .link(false), + ]) + func storedResponseRoundTrip(response: PostHogSurveyResponse) throws { + let restored = try #require(JSONDecoder().decode(StoredSurveyResponse.self, from: JSONEncoder().encode(StoredSurveyResponse(response))).response) + #expect(restored.type == response.type) + #expect(restored.textValue == response.textValue) + #expect(restored.ratingValue == response.ratingValue) + #expect(restored.selectedOptions == response.selectedOptions) + #expect(restored.linkClicked == response.linkClicked) + } } -} +#endif diff --git a/PostHogTests/SwiftUITapAutocaptureTests.swift b/PostHogTests/SwiftUITapAutocaptureTests.swift index 6c4cc53969..c193b2b7fe 100644 --- a/PostHogTests/SwiftUITapAutocaptureTests.swift +++ b/PostHogTests/SwiftUITapAutocaptureTests.swift @@ -25,7 +25,6 @@ @Test(arguments: [false, true], [false, true]) func independentCaptureOptions(legacy: Bool, swiftUI: Bool) throws { - guard #available(iOS 13.4, *) else { return } PostHogAutocaptureEventTracker.eventProcessor = nil let server = MockPostHogServer() server.start() @@ -153,7 +152,6 @@ } @Test func genuineSwiftUIHostingViewCapturesOnlyThroughTouchObserver() { - guard #available(iOS 13.4, *) else { return } let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 640)) let controller = UIHostingController(rootView: Color.clear) window.rootViewController = controller @@ -412,7 +410,6 @@ } @Test func edgeReleaseOutsideMarkerDoesNotCapture() { - guard #available(iOS 13.4, *) else { return } let (window, host) = fixture() let marker = PostHogLabelTaggerView(label: "edge-target") marker.frame = CGRect(x: 0, y: 0, width: 20, height: 40) @@ -451,7 +448,6 @@ } @Test func pointerTapUsesTheSameCaptureRoute() { - guard #available(iOS 13.4, *) else { return } let (window, host) = fixture() host.accessibilityIdentifier = "pointer-target" let publisher = TapTestPublisher() @@ -530,7 +526,6 @@ } } - @available(iOS 13.4, *) private final class PointerTestTouch: UITouch { let target: UIView let targetWindow: UIWindow diff --git a/PostHogTests/TestUtils/TestPostHog.swift b/PostHogTests/TestUtils/TestPostHog.swift index 6fb8735ba3..9aa36142fa 100644 --- a/PostHogTests/TestUtils/TestPostHog.swift +++ b/PostHogTests/TestUtils/TestPostHog.swift @@ -6,7 +6,7 @@ // import Foundation -@testable import PostHog +@_spi(PostHogInternal) @testable import PostHog import Testing import XCTest @@ -42,8 +42,7 @@ func waitFlagsRequest(_ server: MockPostHogServer) { // Waits for *this* SDK's feature flags to finish loading. `lastRequestId` can already be non-nil // (restored from disk or an earlier load), so waiting on it would pass before the fresh response lands. -// The global `didReceiveFeatureFlags` notification (posted object: nil for every load) could likewise -// be satisfied by a prior, still-draining SUT. So observe this instance's `onFeatureFlagsLoaded` — +// So observe this instance's `onFeatureFlagsLoaded` — // which fires only after the response is stored — subscribed before the request so it can't be missed. func waitForFeatureFlagsLoaded(_ server: MockPostHogServer, _ sut: PostHogSDK) { let flagsLoaded = XCTestExpectation(description: "feature flags loaded") diff --git a/PostHogTests/UtilsTest.swift b/PostHogTests/UtilsTest.swift index 620842ab03..969def647b 100644 --- a/PostHogTests/UtilsTest.swift +++ b/PostHogTests/UtilsTest.swift @@ -96,7 +96,7 @@ struct UtilsTest { } } - #if os(iOS) + #if os(iOS) && (!SWIFT_PACKAGE || Surveys) @Suite("Survey color tests") struct SurveyColorTests { @Test( diff --git a/README.md b/README.md index 2be037b204..42fc6ca2a0 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,34 @@ [![Build](https://img.shields.io/github/actions/workflow/status/PostHog/posthog-ios/build.yml?branch=main)](https://github.com/PostHog/posthog-ios/actions/workflows/build.yml?query=branch%3Amain) -[![CocoaPods compadible](https://img.shields.io/cocoapods/v/PostHog.svg)](https://cocoapods.org/pods/PostHog) [![SwiftPM compatible](https://img.shields.io/badge/spm-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager) -![platforms](https://img.shields.io/cocoapods/p/PostHog.svg?style=flat) +[![Swift Package Index platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FPostHog%2Fposthog-ios%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/PostHog/posthog-ios) [![Swift Package Index](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FPostHog%2Fposthog-ios%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/PostHog/posthog-ios) # PostHog iOS +Upgrading from 3.x? See the [migration guide](MIGRATION.md). + Please see the main [PostHog docs](https://posthog.com/docs). Specifically, the [iOS docs](https://posthog.com/docs/libraries/ios) details. +## Installation + +Install PostHog with [Swift Package Manager](https://www.swift.org/documentation/package-manager/). In Xcode, choose **File > Add Package Dependencies…** and enter `https://github.com/PostHog/posthog-ios`, or add it to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/PostHog/posthog-ios.git", from: "4.0.0") +] +``` + +### Moving from CocoaPods + +PostHog 4.0 and later is only published to Swift Package Manager. Versions 3.x stay on CocoaPods, but no new versions will be pushed there: [CocoaPods trunk becomes read-only on December 2, 2026](https://blog.cocoapods.org/CocoaPods-Specs-Repo/). + +To move an app from CocoaPods, remove `pod 'PostHog'` from your `Podfile`, run `pod install`, then add the package as shown above. + +React Native and Flutter apps get this SDK through their PostHog package; follow that package's installation docs instead. + ## Questions? ### [Check out our community page.](https://posthog.com/posts) diff --git a/RELEASING.md b/RELEASING.md index a8652804f9..61b3532d12 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -30,12 +30,12 @@ When the PR is merged to `main`, the release workflow will automatically: 2. Notify the client libraries team in Slack for approval 3. Wait for approval from a maintainer (via GitHub environment protection) 4. Once approved: - - Apply changesets and bump the version (in `package.json`, `PostHogVersion.swift`, and `PostHog.podspec`) + - Apply changesets and bump the version (in `package.json` and `PostHogVersion.swift`) - Update the `CHANGELOG.md` - Commit the version bump to `main` - Create a git tag and GitHub release - - Publish the pod to CocoaPods - - SPM uses the tag name to determine the version, directly from the repo + +Swift Package Manager reads the version from the git tag, so the tag is the release. 4.x is distributed only through Swift Package Manager; see [CocoaPods](#cocoapods). ### Manual Trigger @@ -74,134 +74,12 @@ If the release workflow fails with "No changesets found", ensure your PR include Make sure the PR includes a changeset file and was merged to `main`, or trigger the workflow manually from the Actions tab. -### Manual CocoaPods publish (emergency only) - -In case of automation failure, you can manually publish: - -```bash -make releaseCocoaPods -``` - -You'll need to be authenticated with CocoaPods trunk and have push access to the `PostHog` pod. - -# CocoaPods Management - -The PostHog iOS SDK is published to CocoaPods automatically via GitHub Actions when a release is created. This section covers the management of CocoaPods permissions and tokens. - -### Check Your Current Session - -```bash -# Check if you're logged in and your permissions -pod trunk me -``` - -Expected output should show: - -```text -- Name: Your Name -- Email: your.email@posthog.com -- Since: Date -- Pods: - - PostHog -- Sessions: - - Session details... -``` - -### Check Pod Ownership - -```bash -# Check who owns the PostHog pod -pod trunk info PostHog -``` - -This will show: - -- Current version -- All owners with push permissions - -### Adding a co-owner +## CocoaPods -1. **With an existing owner**: +PostHog 4.0 and later is not published to CocoaPods. The release workflow has no CocoaPods step, and the repository no longer has a podspec or a `make releaseCocoaPods` target. - ```bash - # Run this as an existing owner - pod trunk add-owner PostHog dev.email@posthog.com - ``` +[CocoaPods trunk becomes read-only on December 2, 2026](https://blog.cocoapods.org/CocoaPods-Specs-Repo/). After that date nobody can push a new version of the `PostHog` pod: -2. **Verify**: - ```bash - # Check that the new owner was added - pod trunk info PostHog - ``` - -### Removing a co-owner - -1. **With an existing owner**: - - ```bash - # Run this as an existing owner - pod trunk remove-owner PostHog dev.email@posthog.com - ``` - -2. **Verify**: - ```bash - # Check that the new owner was removed - pod trunk info PostHog - ``` - -### Rotating COCOAPODS_TRUNK_TOKEN - -The `COCOAPODS_TRUNK_TOKEN` is used in GitHub Actions for automated releases. Here's how to rotate it: - -The token is stored as a GitHub secret and used in `.github/workflows/release.yml`: - -```yaml -env: - COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} -``` - -#### Step 1: Create a new session - -1. **Login to CocoaPods Trunk** - As the account that will own the token, create a new session with: - ```bash - pod trunk register your.email@posthog.com 'Your Name' - # Verify email if needed - ``` - -2. **Extract the token**: - The token is stored in `~/.netrc` file: - ```bash - # View your .netrc file - grep -A2 'trunk.cocoapods.org' ~/.netrc - # or look for the line with `trunk.cocoapods.org` in ~/.netrc - cat ~/.netrc - ``` - -#### Step 2: Update GitHub Secret - -1. **Go to GitHub repository settings**: - - Request temp access if needed - - Navigate to `https://github.com/organizations/PostHog/settings/secrets/actions` - - Or: Org → Settings → Secrets and variables → Actions - -2. **Update the secret**: - - Find `COCOAPODS_TRUNK_TOKEN` in the list - - Update the secret - -#### Step 3: Revoking Old Sessions - -1. **Revoke old sessions**: - ```bash - # Clear current sessions (from the user that owned the previous token) - pod trunk me clean-sessions - ``` - -#### Step 4: Update workflow docs - -Update `./.github/workflows/release.yml` with a comment on the new token owner. - -```yaml -env: - COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} # Using @username token -``` +- 3.x versions already on trunk stay installable with `pod 'PostHog', '~> 3.0'`, but they are frozen. No new 3.x pod can be published after the lock. +- Before the lock, a 3.x fix can only reach CocoaPods from a branch cut from the latest 3.x tag, because the release workflow runs on `main`, which is 4.x. That branch still has `PostHog.podspec`, `make releaseCocoaPods` and the pod-ownership and `COCOAPODS_TRUNK_TOKEN` docs (see [RELEASING.md at 3.92.5](https://github.com/PostHog/posthog-ios/blob/3.92.5/RELEASING.md#cocoapods-management)). Get maintainer approval before publishing from it. +- Once no further 3.x pod is planned, and at the latest after the lock, delete the `COCOAPODS_TRUNK_TOKEN` secret and revoke its trunk session with `pod trunk me clean-sessions`. diff --git a/api/posthog-ios.public-api.no-traits.txt b/api/posthog-ios.public-api.no-traits.txt new file mode 100644 index 0000000000..6fec67dd27 --- /dev/null +++ b/api/posthog-ios.public-api.no-traits.txt @@ -0,0 +1,269 @@ +# This file is generated by `scripts/check-public-api.sh --update --no-traits`. +# Do not edit manually. +# Public API scope: public/open symbols emitted for the PostHog iOS symbol graphs, Swift Package Manager build with --disable-default-traits. + +PostHog | BeforeSendBlock | typealias | typealias BeforeSendBlock = (PostHogEvent) -> PostHogEvent? | s:7PostHog15BeforeSendBlocka +PostHog | BoxedBeforeSendBlock | class | @objc final class BoxedBeforeSendBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendBlock +PostHog | BoxedBeforeSendBlock.block | property | @objc let block: BeforeSendBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendBlock(py)block +PostHog | BoxedBeforeSendBlock.init(block:) | init | @objc(block:) init(block: @escaping BeforeSendBlock) | c:@M@PostHog@objc(cs)BoxedBeforeSendBlock(im)block: +PostHog | BoxedBeforeSendLogBlock | class | @objc final class BoxedBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock +PostHog | BoxedBeforeSendLogBlock.block | property | @objc let block: PostHogBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(py)block +PostHog | BoxedBeforeSendLogBlock.init(block:) | init | @objc(block:) init(block: @escaping PostHogBeforeSendLogBlock) | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(im)block: +PostHog | PostHogBeforeSendLogBlock | typealias | typealias PostHogBeforeSendLogBlock = (PostHogLogRecord) -> PostHogLogRecord? | s:7PostHog0aB18BeforeSendLogBlocka +PostHog | PostHogBootstrapConfig | class | @objc(PostHogBootstrapConfig) class PostHogBootstrapConfig | c:@M@PostHog@objc(cs)PostHogBootstrapConfig +PostHog | PostHogBootstrapConfig.distinctId | property | @objc var distinctId: String? | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(py)distinctId +PostHog | PostHogBootstrapConfig.featureFlagPayloads | property | @objc var featureFlagPayloads: [String : Any]? | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(py)featureFlagPayloads +PostHog | PostHogBootstrapConfig.featureFlags | property | @objc var featureFlags: [String : Any]? | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(py)featureFlags +PostHog | PostHogBootstrapConfig.init() | init | override init() | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(im)init +PostHog | PostHogBootstrapConfig.init(anonymousId:) | init | @objc convenience init(anonymousId: String) | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(im)initWithAnonymousId: +PostHog | PostHogBootstrapConfig.init(distinctId:isIdentifiedId:) | init | @objc convenience init(distinctId: String, isIdentifiedId: Bool) | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(im)initWithDistinctId:isIdentifiedId: +PostHog | PostHogBootstrapConfig.init(distinctId:isIdentifiedId:featureFlags:featureFlagPayloads:) | init | @objc init(distinctId: String?, isIdentifiedId: Bool = false, featureFlags: [String : Any]?, featureFlagPayloads: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(im)initWithDistinctId:isIdentifiedId:featureFlags:featureFlagPayloads: +PostHog | PostHogBootstrapConfig.init(featureFlags:featureFlagPayloads:) | init | @objc convenience init(featureFlags: [String : Any], featureFlagPayloads: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(im)initWithFeatureFlags:featureFlagPayloads: +PostHog | PostHogBootstrapConfig.isIdentifiedId | property | @objc var isIdentifiedId: Bool | c:@M@PostHog@objc(cs)PostHogBootstrapConfig(py)isIdentifiedId +PostHog | PostHogCompression | enum | @objc(PostHogCompression) enum PostHogCompression | c:@M@PostHog@E@PostHogCompression +PostHog | PostHogCompression.gzip | enum.case | case gzip | c:@M@PostHog@E@PostHogCompression@PostHogCompressionGzip +PostHog | PostHogCompression.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB11CompressionO8rawValueACSgSi_tcfc +PostHog | PostHogCompression.none | enum.case | case none | c:@M@PostHog@E@PostHogCompression@PostHogCompressionNone +PostHog | PostHogConfig | class | @objc(PostHogConfig) class PostHogConfig | c:@M@PostHog@objc(cs)PostHogConfig +PostHog | PostHogConfig.PostHogDataMode | enum | @frozen @objc(PostHogDataMode) enum PostHogDataMode | s:7PostHog0aB6ConfigC0aB8DataModeO +PostHog | PostHogConfig.PostHogDataMode.any | enum.case | case any | s:7PostHog0aB6ConfigC0aB8DataModeO3anyyA2EmF +PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB6ConfigC0aB8DataModeO8rawValueAESgSi_tcfc +PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF +PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier +PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap +PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents +PostHog | PostHogConfig.captureAutocaptureElementText | property | @objc var captureAutocaptureElementText: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureAutocaptureElementText +PostHog | PostHogConfig.captureElementInteractions | property | @objc var captureElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureElementInteractions +PostHog | PostHogConfig.capturePushNotificationOpened | property | @objc var capturePushNotificationOpened: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)capturePushNotificationOpened +PostHog | PostHogConfig.capturePushNotificationSubscriptions | property | @objc var capturePushNotificationSubscriptions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)capturePushNotificationSubscriptions +PostHog | PostHogConfig.captureScreenViews | property | @objc var captureScreenViews: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureScreenViews +PostHog | PostHogConfig.captureSwiftUIElementInteractions | property | @objc var captureSwiftUIElementInteractions: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureSwiftUIElementInteractions +PostHog | PostHogConfig.compression | property | @objc var compression: PostHogCompression | c:@M@PostHog@objc(cs)PostHogConfig(py)compression +PostHog | PostHogConfig.dataMode | property | @objc var dataMode: PostHogConfig.PostHogDataMode | c:@M@PostHog@objc(cs)PostHogConfig(py)dataMode +PostHog | PostHogConfig.debug | property | @objc var debug: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)debug +PostHog | PostHogConfig.defaultHost | type.property | static let defaultHost: String | s:7PostHog0aB6ConfigC11defaultHostSSvpZ +PostHog | PostHogConfig.disableGeoIp | property | @objc var disableGeoIp: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)disableGeoIp +PostHog | PostHogConfig.enableSwizzling | property | @objc var enableSwizzling: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)enableSwizzling +PostHog | PostHogConfig.errorTrackingConfig | property | @objc let errorTrackingConfig: PostHogErrorTrackingConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)errorTrackingConfig +PostHog | PostHogConfig.evaluationContexts | property | @objc var evaluationContexts: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)evaluationContexts +PostHog | PostHogConfig.featureFlagRequestMaxRetries | property | @objc var featureFlagRequestMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)featureFlagRequestMaxRetries +PostHog | PostHogConfig.flushAt | property | @objc var flushAt: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)flushAt +PostHog | PostHogConfig.flushIntervalSeconds | property | @objc var flushIntervalSeconds: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)flushIntervalSeconds +PostHog | PostHogConfig.getAnonymousId | property | @objc var getAnonymousId: (UUID) -> UUID | c:@M@PostHog@objc(cs)PostHogConfig(py)getAnonymousId +PostHog | PostHogConfig.host | property | @objc let host: URL | c:@M@PostHog@objc(cs)PostHogConfig(py)host +PostHog | PostHogConfig.init(projectToken:) | init | @objc(projectToken:) init(projectToken: String) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken: +PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:host:) init(projectToken: String, host: String = defaultHost) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken:host: +PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs +PostHog | PostHogConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxBatchSize +PostHog | PostHogConfig.maxQueueSize | property | @objc var maxQueueSize: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxQueueSize +PostHog | PostHogConfig.maxRetries | property | @objc var maxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)maxRetries +PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)optOut +PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles +PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags +PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken +PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider +PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig +PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders +PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId +PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureFlagEvent: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sendFeatureFlagEvent +PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogEvent) -> PostHogEvent?]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF +PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties +PostHog | PostHogConfig.tracingHeaders | property | @objc var tracingHeaders: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)tracingHeaders +PostHog | PostHogConfig.urlSessionConfiguration | property | @objc var urlSessionConfiguration: URLSessionConfiguration? | c:@M@PostHog@objc(cs)PostHogConfig(py)urlSessionConfiguration +PostHog | PostHogErrorTrackingConfig | class | @objc class PostHogErrorTrackingConfig | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig +PostHog | PostHogErrorTrackingConfig.exceptionSteps | property | @objc var exceptionSteps: PostHogExceptionStepsConfig | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(py)exceptionSteps +PostHog | PostHogErrorTrackingConfig.ignoredExceptionTypes | property | @objc var ignoredExceptionTypes: [String] | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(py)ignoredExceptionTypes +PostHog | PostHogErrorTrackingConfig.inAppByDefault | property | @objc var inAppByDefault: Bool | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(py)inAppByDefault +PostHog | PostHogErrorTrackingConfig.inAppExcludes | property | @objc var inAppExcludes: [String] | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(py)inAppExcludes +PostHog | PostHogErrorTrackingConfig.inAppIncludes | property | @objc var inAppIncludes: [String] | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(py)inAppIncludes +PostHog | PostHogErrorTrackingConfig.init() | init | override init() | c:@M@PostHog@objc(cs)PostHogErrorTrackingConfig(im)init +PostHog | PostHogEvent | class | @objc(PostHogEvent) class PostHogEvent | c:@M@PostHog@objc(cs)PostHogEvent +PostHog | PostHogEvent.distinctId | property | @objc var distinctId: String | c:@M@PostHog@objc(cs)PostHogEvent(py)distinctId +PostHog | PostHogEvent.event | property | @objc var event: String | c:@M@PostHog@objc(cs)PostHogEvent(py)event +PostHog | PostHogEvent.properties | property | @objc var properties: [String : Any] | c:@M@PostHog@objc(cs)PostHogEvent(py)properties +PostHog | PostHogEvent.timestamp | property | @objc var timestamp: Date | c:@M@PostHog@objc(cs)PostHogEvent(py)timestamp +PostHog | PostHogEvent.uuid | property | @objc var uuid: UUID { get } | c:@M@PostHog@objc(cs)PostHogEvent(py)uuid +PostHog | PostHogExceptionStepsConfig | class | @objc class PostHogExceptionStepsConfig | c:@M@PostHog@objc(cs)PostHogExceptionStepsConfig +PostHog | PostHogExceptionStepsConfig.enabled | property | @objc var enabled: Bool | c:@M@PostHog@objc(cs)PostHogExceptionStepsConfig(py)enabled +PostHog | PostHogExceptionStepsConfig.maxBytes | property | @objc var maxBytes: Int | c:@M@PostHog@objc(cs)PostHogExceptionStepsConfig(py)maxBytes +PostHog | PostHogFeatureFlagResult | class | @objc(PostHogFeatureFlagResult) class PostHogFeatureFlagResult | c:@M@PostHog@objc(cs)PostHogFeatureFlagResult +PostHog | PostHogFeatureFlagResult.enabled | property | @objc let enabled: Bool | c:@M@PostHog@objc(cs)PostHogFeatureFlagResult(py)enabled +PostHog | PostHogFeatureFlagResult.key | property | @objc let key: String | c:@M@PostHog@objc(cs)PostHogFeatureFlagResult(py)key +PostHog | PostHogFeatureFlagResult.payload | property | @objc let payload: Any? | c:@M@PostHog@objc(cs)PostHogFeatureFlagResult(py)payload +PostHog | PostHogFeatureFlagResult.payloadAs(_:) | method | func payloadAs(_ type: T.Type) -> T? where T : Decodable | s:7PostHog0aB17FeatureFlagResultC9payloadAsyxSgxmSeRzlF +PostHog | PostHogFeatureFlagResult.variant | property | @objc let variant: String? | c:@M@PostHog@objc(cs)PostHogFeatureFlagResult(py)variant +PostHog | PostHogFeatureFlagsLoaded | class | @objc(PostHogFeatureFlagsLoaded) final class PostHogFeatureFlagsLoaded | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded +PostHog | PostHogFeatureFlagsLoaded.errorsLoading | property | @objc let errorsLoading: Bool | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded(py)errorsLoading +PostHog | PostHogFeatureFlagsLoaded.flags | property | @objc let flags: [String] | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded(py)flags +PostHog | PostHogFeatureFlagsLoaded.variants | property | @objc let variants: [String : Any] | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded(py)variants +PostHog | PostHogFeatureFlagsSubscription | class | @objc(PostHogFeatureFlagsSubscription) final class PostHogFeatureFlagsSubscription | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription +PostHog | PostHogFeatureFlagsSubscription.unsubscribe() | method | @objc func unsubscribe() | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription(im)unsubscribe +PostHog | PostHogLogRecord | class | @objc final class PostHogLogRecord | c:@M@PostHog@objc(cs)PostHogLogRecord +PostHog | PostHogLogRecord.attributes | property | @objc var attributes: [String : Any] | c:@M@PostHog@objc(cs)PostHogLogRecord(py)attributes +PostHog | PostHogLogRecord.body | property | @objc var body: String | c:@M@PostHog@objc(cs)PostHogLogRecord(py)body +PostHog | PostHogLogRecord.distinctId | property | @objc var distinctId: String? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)distinctId +PostHog | PostHogLogRecord.featureFlagKeys | property | @objc var featureFlagKeys: [String] | c:@M@PostHog@objc(cs)PostHogLogRecord(py)featureFlagKeys +PostHog | PostHogLogRecord.level | property | @objc var level: PostHogLogSeverity | c:@M@PostHog@objc(cs)PostHogLogRecord(py)level +PostHog | PostHogLogRecord.screenName | property | @objc var screenName: String? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)screenName +PostHog | PostHogLogRecord.sessionId | property | @objc var sessionId: String? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)sessionId +PostHog | PostHogLogRecord.spanId | property | @objc var spanId: String? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)spanId +PostHog | PostHogLogRecord.traceFlags | property | @objc var traceFlags: NSNumber? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)traceFlags +PostHog | PostHogLogRecord.traceId | property | @objc var traceId: String? | c:@M@PostHog@objc(cs)PostHogLogRecord(py)traceId +PostHog | PostHogLogSeverity | enum | @objc enum PostHogLogSeverity | c:@M@PostHog@E@PostHogLogSeverity +PostHog | PostHogLogSeverity.debug | enum.case | case debug | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityDebug +PostHog | PostHogLogSeverity.error | enum.case | case error | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityError +PostHog | PostHogLogSeverity.fatal | enum.case | case fatal | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityFatal +PostHog | PostHogLogSeverity.info | enum.case | case info | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityInfo +PostHog | PostHogLogSeverity.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB11LogSeverityO8rawValueACSgSi_tcfc +PostHog | PostHogLogSeverity.trace | enum.case | case trace | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityTrace +PostHog | PostHogLogSeverity.warn | enum.case | case warn | c:@M@PostHog@E@PostHogLogSeverity@PostHogLogSeverityWarn +PostHog | PostHogLogger | class | @objc final class PostHogLogger | c:@M@PostHog@objc(cs)PostHogLogger +PostHog | PostHogLogger.debug(_:) | method | @objc(debugWithBody:) func debug(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)debugWithBody: +PostHog | PostHogLogger.debug(_:attributes:) | method | @objc(debugWithBody:attributes:) func debug(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)debugWithBody:attributes: +PostHog | PostHogLogger.error(_:) | method | @objc(errorWithBody:) func error(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)errorWithBody: +PostHog | PostHogLogger.error(_:attributes:) | method | @objc(errorWithBody:attributes:) func error(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)errorWithBody:attributes: +PostHog | PostHogLogger.fatal(_:) | method | @objc(fatalWithBody:) func fatal(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)fatalWithBody: +PostHog | PostHogLogger.fatal(_:attributes:) | method | @objc(fatalWithBody:attributes:) func fatal(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)fatalWithBody:attributes: +PostHog | PostHogLogger.info(_:) | method | @objc(infoWithBody:) func info(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)infoWithBody: +PostHog | PostHogLogger.info(_:attributes:) | method | @objc(infoWithBody:attributes:) func info(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)infoWithBody:attributes: +PostHog | PostHogLogger.trace(_:) | method | @objc(traceWithBody:) func trace(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)traceWithBody: +PostHog | PostHogLogger.trace(_:attributes:) | method | @objc(traceWithBody:attributes:) func trace(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)traceWithBody:attributes: +PostHog | PostHogLogger.warn(_:) | method | @objc(warnWithBody:) func warn(_ body: String) | c:@M@PostHog@objc(cs)PostHogLogger(im)warnWithBody: +PostHog | PostHogLogger.warn(_:attributes:) | method | @objc(warnWithBody:attributes:) func warn(_ body: String, attributes: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogLogger(im)warnWithBody:attributes: +PostHog | PostHogLogsConfig | class | @objc final class PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogLogsConfig +PostHog | PostHogLogsConfig.environment | property | @objc var environment: String? | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)environment +PostHog | PostHogLogsConfig.flushAt | property | @objc var flushAt: Int | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)flushAt +PostHog | PostHogLogsConfig.flushIntervalSeconds | property | @objc var flushIntervalSeconds: TimeInterval | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)flushIntervalSeconds +PostHog | PostHogLogsConfig.init() | init | override init() | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)init +PostHog | PostHogLogsConfig.maxBatchSize | property | @objc var maxBatchSize: Int | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)maxBatchSize +PostHog | PostHogLogsConfig.maxBufferSize | property | @objc var maxBufferSize: Int | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)maxBufferSize +PostHog | PostHogLogsConfig.rateCapMaxLogs | property | @objc var rateCapMaxLogs: Int | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)rateCapMaxLogs +PostHog | PostHogLogsConfig.rateCapWindowSeconds | property | @objc var rateCapWindowSeconds: TimeInterval | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)rateCapWindowSeconds +PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAttributes: [String : Any] | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)resourceAttributes +PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName +PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogLogRecord) -> PostHogLogRecord?]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF +PostHog | PostHogPersonProfiles | enum | @objc(PostHogPersonProfiles) enum PostHogPersonProfiles | c:@M@PostHog@E@PostHogPersonProfiles +PostHog | PostHogPersonProfiles.always | enum.case | case always | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesAlways +PostHog | PostHogPersonProfiles.identifiedOnly | enum.case | case identifiedOnly | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesIdentifiedOnly +PostHog | PostHogPersonProfiles.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB14PersonProfilesO8rawValueACSgSi_tcfc +PostHog | PostHogPersonProfiles.never | enum.case | case never | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesNever +PostHog | PostHogRageClickConfig | class | @objc(PostHogRageClickConfig) class PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogRageClickConfig +PostHog | PostHogRageClickConfig.enabled | property | @objc var enabled: Bool | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)enabled +PostHog | PostHogRageClickConfig.minimumTapCount | property | @objc var minimumTapCount: Int | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)minimumTapCount +PostHog | PostHogRageClickConfig.thresholdPoints | property | @objc var thresholdPoints: CGFloat | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)thresholdPoints +PostHog | PostHogRageClickConfig.timeoutInterval | property | @objc var timeoutInterval: TimeInterval | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)timeoutInterval +PostHog | PostHogSDK | class | @objc class PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK +PostHog | PostHogSDK.addExceptionStep(_:) | method | @objc(addExceptionStep:) func addExceptionStep(_ message: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep: +PostHog | PostHogSDK.addExceptionStep(_:properties:) | method | @objc(addExceptionStep:properties:) func addExceptionStep(_ message: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)addExceptionStep:properties: +PostHog | PostHogSDK.alias(_:) | method | @objc func alias(_ alias: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)alias: +PostHog | PostHogSDK.capture(_:) | method | @objc func capture(_ event: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)capture: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.capture(_:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) | method | @objc(captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp:) func capture(_ event: String, distinctId: String? = nil, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil, timestamp: Date? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:distinctId:properties:userProperties:userPropertiesSetOnce:groups:timestamp: +PostHog | PostHogSDK.capture(_:properties:) | method | @objc(captureWithEvent:properties:) func capture(_ event: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties: +PostHog | PostHogSDK.capture(_:properties:userProperties:) | method | @objc(captureWithEvent:properties:userProperties:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties: +PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.capture(_:properties:userProperties:userPropertiesSetOnce:groups:) | method | @objc(captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups:) func capture(_ event: String, properties: [String : Any]? = nil, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil, groups: [String : String]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureWithEvent:properties:userProperties:userPropertiesSetOnce:groups: +PostHog | PostHogSDK.captureDeepLink(openURLContexts:) | method | @objc func captureDeepLink(openURLContexts: Set) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithOpenURLContexts: +PostHog | PostHogSDK.captureDeepLink(url:) | method | @objc func captureDeepLink(url: URL) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl: +PostHog | PostHogSDK.captureDeepLink(url:options:) | method | @objc func captureDeepLink(url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrl:options: +PostHog | PostHogSDK.captureDeepLink(urls:) | method | @objc func captureDeepLink(urls: [URL]) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUrls: +PostHog | PostHogSDK.captureDeepLink(userActivity:) | method | @objc func captureDeepLink(userActivity: NSUserActivity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureDeepLinkWithUserActivity: +PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithError:) func captureException(_ error: any Error) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError: +PostHog | PostHogSDK.captureException(_:) | method | @objc(captureExceptionWithNSException:) func captureException(_ exception: NSException) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException: +PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithError:properties:) func captureException(_ error: any Error, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithError:properties: +PostHog | PostHogSDK.captureException(_:properties:) | method | @objc(captureExceptionWithNSException:properties:) func captureException(_ exception: NSException, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureExceptionWithNSException:properties: +PostHog | PostHogSDK.captureFeatureInteraction(flag:flagVariant:) | method | @objc func captureFeatureInteraction(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureInteractionWithFlag:flagVariant: +PostHog | PostHogSDK.captureFeatureView(flag:flagVariant:) | method | @objc func captureFeatureView(flag: String, flagVariant: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureFeatureViewWithFlag:flagVariant: +PostHog | PostHogSDK.captureLog(_:) | method | @objc(captureLogWithBody:) func captureLog(_ body: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody: +PostHog | PostHogSDK.captureLog(_:level:) | method | @objc(captureLogWithBody:level:) func captureLog(_ body: String, level: PostHogLogSeverity) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level: +PostHog | PostHogSDK.captureLog(_:level:attributes:) | method | @objc(captureLogWithBody:level:attributes:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes: +PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | @objc(captureLogWithBody:level:attributes:traceId:spanId:traceFlags:) func captureLog(_ body: String, level: PostHogLogSeverity, attributes: [String : Any]?, traceId: String?, spanId: String?, traceFlags: NSNumber?) | c:@M@PostHog@objc(cs)PostHogSDK(im)captureLogWithBody:level:attributes:traceId:spanId:traceFlags: +PostHog | PostHogSDK.captureLog(_:level:attributes:traceId:spanId:traceFlags:) | method | func captureLog(_ body: String, level: PostHogLogSeverity = .info, attributes: [String : Any]? = nil, traceId: String? = nil, spanId: String? = nil, traceFlags: Int? = nil) | s:7PostHog0aB3SDKC10captureLog_5level10attributes7traceId04spanI00H5FlagsySS_AA0abE8SeverityOSDySSypGSgSSSgANSiSgtF +PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc func capturePushNotificationOpened(response: UNNotificationResponse) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithResponse: +PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: +PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close +PostHog | PostHogSDK.debug(_:) | method | @objc func debug(_ enabled: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)debug: +PostHog | PostHogSDK.didStartNotification | type.property | @objc static let didStartNotification: Notification.Name | c:@CM@PostHog@objc(cs)PostHogSDK(cpy)didStartNotification +PostHog | PostHogSDK.endSession() | method | @objc func endSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)endSession +PostHog | PostHogSDK.flush() | method | @objc func flush() | c:@M@PostHog@objc(cs)PostHogSDK(im)flush +PostHog | PostHogSDK.getAllFeatureFlags() | method | @objc func getAllFeatureFlags() -> [PostHogFeatureFlagResult]? | c:@M@PostHog@objc(cs)PostHogSDK(im)getAllFeatureFlags +PostHog | PostHogSDK.getAnonymousId() | method | @objc func getAnonymousId() -> String | c:@M@PostHog@objc(cs)PostHogSDK(im)getAnonymousId +PostHog | PostHogSDK.getDeviceId() | method | @objc func getDeviceId() -> String | c:@M@PostHog@objc(cs)PostHogSDK(im)getDeviceId +PostHog | PostHogSDK.getDistinctId() | method | @objc func getDistinctId() -> String | c:@M@PostHog@objc(cs)PostHogSDK(im)getDistinctId +PostHog | PostHogSDK.getFeatureFlag(_:) | method | @objc func getFeatureFlag(_ key: String) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlag: +PostHog | PostHogSDK.getFeatureFlag(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagWithKey:sendFeatureFlagEvent:) func getFeatureFlag(_ key: String, sendFeatureFlagEvent: Bool) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagWithKey:sendFeatureFlagEvent: +PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFlagResult(_ key: String) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResult: +PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: +PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId +PostHog | PostHogSDK.group(type:key:) | method | @objc(groupWithType:key:) func group(type: String, key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key: +PostHog | PostHogSDK.group(type:key:groupProperties:) | method | @objc(groupWithType:key:groupProperties:) func group(type: String, key: String, groupProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)groupWithType:key:groupProperties: +PostHog | PostHogSDK.identify(_:) | method | @objc func identify(_ distinctId: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)identify: +PostHog | PostHogSDK.identify(_:userProperties:) | method | @objc(identifyWithDistinctId:userProperties:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties: +PostHog | PostHogSDK.identify(_:userProperties:userPropertiesSetOnce:) | method | @objc(identifyWithDistinctId:userProperties:userPropertiesSetOnce:) func identify(_ distinctId: String, userProperties: [String : Any]? = nil, userPropertiesSetOnce: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)identifyWithDistinctId:userProperties:userPropertiesSetOnce: +PostHog | PostHogSDK.isAutocaptureActive() | method | @objc func isAutocaptureActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isAutocaptureActive +PostHog | PostHogSDK.isFeatureEnabled(_:) | method | @objc func isFeatureEnabled(_ key: String) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabled: +PostHog | PostHogSDK.isFeatureEnabled(_:sendFeatureFlagEvent:) | method | @objc(isFeatureEnabledWithKey:sendFeatureFlagEvent:) func isFeatureEnabled(_ key: String, sendFeatureFlagEvent: Bool) -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isFeatureEnabledWithKey:sendFeatureFlagEvent: +PostHog | PostHogSDK.isOptOut() | method | @objc func isOptOut() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isOptOut +PostHog | PostHogSDK.isRageClickActive() | method | @objc func isRageClickActive() -> Bool | c:@M@PostHog@objc(cs)PostHogSDK(im)isRageClickActive +PostHog | PostHogSDK.logger | property | @objc var logger: PostHogLogger? { get } | c:@M@PostHog@objc(cs)PostHogSDK(py)logger +PostHog | PostHogSDK.onFeatureFlags(_:) | method | @discardableResult @objc(onFeatureFlags:) func onFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) -> PostHogFeatureFlagsSubscription | c:@M@PostHog@objc(cs)PostHogSDK(im)onFeatureFlags: +PostHog | PostHogSDK.optIn() | method | @objc func optIn() | c:@M@PostHog@objc(cs)PostHogSDK(im)optIn +PostHog | PostHogSDK.optOut() | method | @objc func optOut() | c:@M@PostHog@objc(cs)PostHogSDK(im)optOut +PostHog | PostHogSDK.prewarmPushNotificationOpenCapture() | type.method | @objc static func prewarmPushNotificationOpenCapture() | c:@M@PostHog@objc(cs)PostHogSDK(cm)prewarmPushNotificationOpenCapture +PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func register(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerProperties: +PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: +PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: +PostHog | PostHogSDK.reloadFeatureFlags() | method | @objc func reloadFeatureFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlags +PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: +PostHog | PostHogSDK.reset() | method | @objc func reset() | c:@M@PostHog@objc(cs)PostHogSDK(im)reset +PostHog | PostHogSDK.resetGroupPropertiesForFlags() | method | @objc func resetGroupPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlags +PostHog | PostHogSDK.resetGroupPropertiesForFlags(_:) | method | @objc(resetGroupPropertiesForFlagsWithGroupType:) func resetGroupPropertiesForFlags(_ groupType: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithGroupType: +PostHog | PostHogSDK.resetGroupPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(resetGroupPropertiesForFlagsWithGroupType:reloadFeatureFlags:) func resetGroupPropertiesForFlags(_ groupType: String, reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithGroupType:reloadFeatureFlags: +PostHog | PostHogSDK.resetGroupPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetGroupPropertiesForFlagsWithReloadFeatureFlags:) func resetGroupPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithReloadFeatureFlags: +PostHog | PostHogSDK.resetPersonPropertiesForFlags() | method | @objc func resetPersonPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlags +PostHog | PostHogSDK.resetPersonPropertiesForFlags(reloadFeatureFlags:) | method | @objc(resetPersonPropertiesForFlagsWithReloadFeatureFlags:) func resetPersonPropertiesForFlags(reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetPersonPropertiesForFlagsWithReloadFeatureFlags: +PostHog | PostHogSDK.screen(_:) | method | @objc func screen(_ screenTitle: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)screen: +PostHog | PostHogSDK.screen(_:properties:) | method | @objc(screenWithTitle:properties:) func screen(_ screenTitle: String, properties: [String : Any]? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)screenWithTitle:properties: +PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:) | method | @objc(setGroupPropertiesForFlags:properties:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties: +PostHog | PostHogSDK.setGroupPropertiesForFlags(_:properties:reloadFeatureFlags:) | method | @objc(setGroupPropertiesForFlags:properties:reloadFeatureFlags:) func setGroupPropertiesForFlags(_ groupType: String, properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setGroupPropertiesForFlags:properties:reloadFeatureFlags: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:) func setPersonProperties(userPropertiesToSet: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet: +PostHog | PostHogSDK.setPersonProperties(userPropertiesToSet:userPropertiesToSetOnce:) | method | @objc(setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce:) func setPersonProperties(userPropertiesToSet: [String : Any]?, userPropertiesToSetOnce: [String : Any]?) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesWithUserPropertiesToSet:userPropertiesToSetOnce: +PostHog | PostHogSDK.setPersonPropertiesForFlags(_:) | method | @objc func setPersonPropertiesForFlags(_ properties: [String : Any]) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlags: +PostHog | PostHogSDK.setPersonPropertiesForFlags(_:reloadFeatureFlags:) | method | @objc(setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags:) func setPersonPropertiesForFlags(_ properties: [String : Any], reloadFeatureFlags: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)setPersonPropertiesForFlagsWithProperties:reloadFeatureFlags: +PostHog | PostHogSDK.setup(_:) | method | @objc func setup(_ config: PostHogConfig) | c:@M@PostHog@objc(cs)PostHogSDK(im)setup: +PostHog | PostHogSDK.shared | type.property | @objc nonisolated(unsafe) static let shared: PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cpy)shared +PostHog | PostHogSDK.startSession() | method | @objc func startSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)startSession +PostHog | PostHogSDK.unregister(_:) | method | @objc(unregisterProperties:) func unregister(_ key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterProperties: +PostHog | PostHogSDK.unregisterPushNotificationToken() | method | @objc func unregisterPushNotificationToken() | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterPushNotificationToken +PostHog | PostHogSDK.with(_:) | type.method | @objc static func with(_ config: PostHogConfig) -> PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cm)with: +PostHog | UIView.postHogLabel | property | @MainActor var postHogLabel: String? { get set } | s:So6UIViewC7PostHogE04postC5LabelSSSgvp +PostHog | URLSession.postHogData(for:delegate:postHog:) | method | func postHogData(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for8delegate0dC010Foundation0E0V_So13NSURLResponseCtAH10URLRequestV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogData(for:postHog:) | method | func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for0dC010Foundation0E0V_So13NSURLResponseCtAG10URLRequestV_AC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogData(from:delegate:postHog:) | method | func postHogData(from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data4from8delegate0dC010Foundation0E0V_So13NSURLResponseCtAH3URLV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogData(from:postHog:) | method | func postHogData(from url: URL, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data4from0dC010Foundation0E0V_So13NSURLResponseCtAG3URLV_AC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogDownload(for:delegate:postHog:) | method | func postHogDownload(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (URL, URLResponse) | s:So12NSURLSessionC7PostHogE04postC8Download3for8delegate0dC010Foundation3URLV_So13NSURLResponseCtAH10URLRequestV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogDownload(from:delegate:postHog:) | method | func postHogDownload(from url: URL, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (URL, URLResponse) | s:So12NSURLSessionC7PostHogE04postC8Download4from8delegate0dC010Foundation3URLV_So13NSURLResponseCtAJ_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogDownload(resumeFrom:delegate:postHog:) | method | func postHogDownload(resumeFrom resumeData: Data, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (URL, URLResponse) | s:So12NSURLSessionC7PostHogE04postC8Download10resumeFrom8delegate0dC010Foundation3URLV_So13NSURLResponseCtAH4DataV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogUpload(for:from:delegate:postHog:) | method | func postHogUpload(for request: URLRequest, from bodyData: Data, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC6Upload3for4from8delegate0dC010Foundation4DataV_So13NSURLResponseCtAI10URLRequestV_AKSo0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogUpload(for:from:postHog:) | method | func postHogUpload(for request: URLRequest, from bodyData: Data, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC6Upload3for4from0dC010Foundation4DataV_So13NSURLResponseCtAH10URLRequestV_AjC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogUpload(for:fromFile:delegate:postHog:) | method | func postHogUpload(for request: URLRequest, fromFile fileURL: URL, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC6Upload3for8fromFile8delegate0dC010Foundation4DataV_So13NSURLResponseCtAI10URLRequestV_AI3URLVSo0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF +PostHog | URLSession.postHogUpload(for:fromFile:postHog:) | method | func postHogUpload(for request: URLRequest, fromFile fileURL: URL, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC6Upload3for8fromFile0dC010Foundation4DataV_So13NSURLResponseCtAH10URLRequestV_AH3URLVAC0bC3SDKCSgtYaKF +PostHog | View.postHogDeepLinkListener(_:) | method | @MainActor func postHogDeepLinkListener(_ posthog: PostHogSDK? = nil) -> some View + | s:7SwiftUI4ViewP7PostHogE04postE16DeepLinkListeneryQrAD0dE3SDKCSgF +PostHog | View.postHogLabel(_:) | method | @MainActor func postHogLabel(_ label: String?) -> some View + | s:7SwiftUI4ViewP7PostHogE04postE5LabelyQrSSSgF +PostHog | View.postHogMask(_:) | method | @MainActor func postHogMask(_ isEnabled: Bool = true) -> some View + | s:7SwiftUI4ViewP7PostHogE04postE4MaskyQrSbF +PostHog | View.postHogNoRageClick(_:) | method | @MainActor func postHogNoRageClick(_ isEnabled: Bool = true) -> some View + | s:7SwiftUI4ViewP7PostHogE04postE11NoRageClickyQrSbF +PostHog | View.postHogScreenView(_:_:postHog:) | method | @MainActor func postHogScreenView(_ screenName: String? = nil, _ properties: [String : Any]? = nil, postHog: PostHogSDK? = nil) -> some View + | s:7SwiftUI4ViewP7PostHogE04poste6ScreenC0__0fE0QrSSSg_SDySSypGSgAD0dE3SDKCSgtF +PostHog | View.postHogViewSeen(_:_:postHog:) | method | @MainActor func postHogViewSeen(_ event: String, _ properties: [String : Any]? = nil, postHog: PostHogSDK? = nil) -> some View + | s:7SwiftUI4ViewP7PostHogE04posteC4Seen__0fE0QrSS_SDySSypGSgAD0dE3SDKCSgtF diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index a56b8a97a4..a01b4135d2 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -9,27 +9,6 @@ PostHog | BoxedBeforeSendBlock.init(block:) | init | @objc(block:) init(block: @ PostHog | BoxedBeforeSendLogBlock | class | @objc final class BoxedBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock PostHog | BoxedBeforeSendLogBlock.block | property | @objc let block: PostHogBeforeSendLogBlock | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(py)block PostHog | BoxedBeforeSendLogBlock.init(block:) | init | @objc(block:) init(block: @escaping PostHogBeforeSendLogBlock) | c:@M@PostHog@objc(cs)BoxedBeforeSendLogBlock(im)block: -PostHog | CompressionLevel | struct | struct CompressionLevel | s:7PostHog16CompressionLevelV -PostHog | CompressionLevel.bestCompression | type.property | static let bestCompression: CompressionLevel | s:7PostHog16CompressionLevelV04bestC0ACvpZ -PostHog | CompressionLevel.bestSpeed | type.property | static let bestSpeed: CompressionLevel | s:7PostHog16CompressionLevelV9bestSpeedACvpZ -PostHog | CompressionLevel.defaultCompression | type.property | static let defaultCompression: CompressionLevel | s:7PostHog16CompressionLevelV07defaultC0ACvpZ -PostHog | CompressionLevel.init(_:) | init | init(_ rawValue: Int32) | s:7PostHog16CompressionLevelVyACs5Int32Vcfc -PostHog | CompressionLevel.init(rawValue:) | init | init(rawValue: Int32) | s:7PostHog16CompressionLevelV8rawValueACs5Int32V_tcfc -PostHog | CompressionLevel.noCompression | type.property | static let noCompression: CompressionLevel | s:7PostHog16CompressionLevelV02noC0ACvpZ -PostHog | CompressionLevel.rawValue | property | let rawValue: Int32 | s:7PostHog16CompressionLevelV8rawValues5Int32Vvp -PostHog | Gzip | enum | enum Gzip | s:7PostHog4GzipO -PostHog | Gzip.maxWindowBits | type.property | static let maxWindowBits: Int32 | s:7PostHog4GzipO13maxWindowBitss5Int32VvpZ -PostHog | GzipError | struct | struct GzipError | s:7PostHog9GzipErrorV -PostHog | GzipError.Kind | enum | enum Kind | s:7PostHog9GzipErrorV4KindO -PostHog | GzipError.Kind.buffer | enum.case | case buffer | s:7PostHog9GzipErrorV4KindO6bufferyA2EmF -PostHog | GzipError.Kind.data | enum.case | case data | s:7PostHog9GzipErrorV4KindO4datayA2EmF -PostHog | GzipError.Kind.memory | enum.case | case memory | s:7PostHog9GzipErrorV4KindO6memoryyA2EmF -PostHog | GzipError.Kind.stream | enum.case | case stream | s:7PostHog9GzipErrorV4KindO6streamyA2EmF -PostHog | GzipError.Kind.unknown(code:) | enum.case | case unknown(code: Int) | s:7PostHog9GzipErrorV4KindO7unknownyAESi_tcAEmF -PostHog | GzipError.Kind.version | enum.case | case version | s:7PostHog9GzipErrorV4KindO7versionyA2EmF -PostHog | GzipError.kind | property | let kind: GzipError.Kind | s:7PostHog9GzipErrorV4kindAC4KindOvp -PostHog | GzipError.localizedDescription | property | var localizedDescription: String { get } | s:7PostHog9GzipErrorV20localizedDescriptionSSvp -PostHog | GzipError.message | property | let message: String | s:7PostHog9GzipErrorV7messageSSvp PostHog | OnPostHogSurveyClosed | typealias | typealias OnPostHogSurveyClosed = (PostHogDisplaySurvey) -> Void | s:7PostHog02OnaB12SurveyCloseda PostHog | OnPostHogSurveyResponse | typealias | typealias OnPostHogSurveyResponse = (PostHogDisplaySurvey, Int, PostHogSurveyResponse) -> PostHogNextSurveyQuestion? | s:7PostHog02OnaB14SurveyResponsea PostHog | OnPostHogSurveyShown | typealias | typealias OnPostHogSurveyShown = (PostHogDisplaySurvey) -> Void | s:7PostHog02OnaB11SurveyShowna @@ -51,10 +30,8 @@ PostHog | PostHogCompression.none | enum.case | case none | c:@M@PostHog@E@PostH PostHog | PostHogConfig | class | @objc(PostHogConfig) class PostHogConfig | c:@M@PostHog@objc(cs)PostHogConfig PostHog | PostHogConfig.PostHogDataMode | enum | @frozen @objc(PostHogDataMode) enum PostHogDataMode | s:7PostHog0aB6ConfigC0aB8DataModeO PostHog | PostHogConfig.PostHogDataMode.any | enum.case | case any | s:7PostHog0aB6ConfigC0aB8DataModeO3anyyA2EmF -PostHog | PostHogConfig.PostHogDataMode.cellular | enum.case | case cellular | s:7PostHog0aB6ConfigC0aB8DataModeO8cellularyA2EmF PostHog | PostHogConfig.PostHogDataMode.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB6ConfigC0aB8DataModeO8rawValueAESgSi_tcfc PostHog | PostHogConfig.PostHogDataMode.wifi | enum.case | case wifi | s:7PostHog0aB6ConfigC0aB8DataModeO4wifiyA2EmF -PostHog | PostHogConfig.apiKey | property | @objc var apiKey: String { get } | c:@M@PostHog@objc(cs)PostHogConfig(py)apiKey PostHog | PostHogConfig.appGroupIdentifier | property | @objc var appGroupIdentifier: String? | c:@M@PostHog@objc(cs)PostHogConfig(py)appGroupIdentifier PostHog | PostHogConfig.bootstrap | property | @objc var bootstrap: PostHogBootstrapConfig? | c:@M@PostHog@objc(cs)PostHogConfig(py)bootstrap PostHog | PostHogConfig.captureApplicationLifecycleEvents | property | @objc var captureApplicationLifecycleEvents: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)captureApplicationLifecycleEvents @@ -72,14 +49,11 @@ PostHog | PostHogConfig.disableGeoIp | property | @objc var disableGeoIp: Bool | PostHog | PostHogConfig.enableSwizzling | property | @objc var enableSwizzling: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)enableSwizzling PostHog | PostHogConfig.errorTrackingConfig | property | @objc let errorTrackingConfig: PostHogErrorTrackingConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)errorTrackingConfig PostHog | PostHogConfig.evaluationContexts | property | @objc var evaluationContexts: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)evaluationContexts -PostHog | PostHogConfig.evaluationEnvironments | property | @objc var evaluationEnvironments: [String]? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)evaluationEnvironments PostHog | PostHogConfig.featureFlagRequestMaxRetries | property | @objc var featureFlagRequestMaxRetries: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)featureFlagRequestMaxRetries PostHog | PostHogConfig.flushAt | property | @objc var flushAt: Int | c:@M@PostHog@objc(cs)PostHogConfig(py)flushAt PostHog | PostHogConfig.flushIntervalSeconds | property | @objc var flushIntervalSeconds: TimeInterval | c:@M@PostHog@objc(cs)PostHogConfig(py)flushIntervalSeconds PostHog | PostHogConfig.getAnonymousId | property | @objc var getAnonymousId: (UUID) -> UUID | c:@M@PostHog@objc(cs)PostHogConfig(py)getAnonymousId PostHog | PostHogConfig.host | property | @objc let host: URL | c:@M@PostHog@objc(cs)PostHogConfig(py)host -PostHog | PostHogConfig.init(apiKey:) | init | @objc(apiKey:) convenience init(apiKey: String) | c:@M@PostHog@objc(cs)PostHogConfig(im)apiKey: -PostHog | PostHogConfig.init(apiKey:host:) | init | @objc(apiKey:host:) convenience init(apiKey: String, host: String = defaultHost) | c:@M@PostHog@objc(cs)PostHogConfig(im)apiKey:host: PostHog | PostHogConfig.init(projectToken:) | init | @objc(projectToken:) init(projectToken: String) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken: PostHog | PostHogConfig.init(projectToken:host:) | init | @objc(projectToken:host:) init(projectToken: String, host: String = defaultHost) | c:@M@PostHog@objc(cs)PostHogConfig(im)projectToken:host: PostHog | PostHogConfig.logs | property | @objc let logs: PostHogLogsConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)logs @@ -90,28 +64,33 @@ PostHog | PostHogConfig.optOut | property | @objc var optOut: Bool | c:@M@PostHo PostHog | PostHogConfig.personProfiles | property | @objc var personProfiles: PostHogPersonProfiles | c:@M@PostHog@objc(cs)PostHogConfig(py)personProfiles PostHog | PostHogConfig.preloadFeatureFlags | property | @objc var preloadFeatureFlags: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)preloadFeatureFlags PostHog | PostHogConfig.projectToken | property | @objc let projectToken: String | c:@M@PostHog@objc(cs)PostHogConfig(py)projectToken -PostHog | PostHogConfig.propertiesSanitizer | property | @objc var propertiesSanitizer: (any PostHogPropertiesSanitizer)? { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)propertiesSanitizer PostHog | PostHogConfig.pushIdentityProvider | property | @objc var pushIdentityProvider: ((String, String, @escaping (String?) -> Void) -> Void)? | c:@M@PostHog@objc(cs)PostHogConfig(py)pushIdentityProvider PostHog | PostHogConfig.rageClickConfig | property | @objc let rageClickConfig: PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)rageClickConfig -PostHog | PostHogConfig.remoteConfig | property | @objc var remoteConfig: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)remoteConfig PostHog | PostHogConfig.requestHeaders | property | @objc var requestHeaders: [String : String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)requestHeaders PostHog | PostHogConfig.reuseAnonymousId | property | @objc var reuseAnonymousId: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)reuseAnonymousId PostHog | PostHogConfig.sendFeatureFlagEvent | property | @objc var sendFeatureFlagEvent: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sendFeatureFlagEvent PostHog | PostHogConfig.sessionReplay | property | @objc var sessionReplay: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplay PostHog | PostHogConfig.sessionReplayConfig | property | @objc let sessionReplayConfig: PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogConfig(py)sessionReplayConfig PostHog | PostHogConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendBlock]) | c:@M@PostHog@objc(cs)PostHogConfig(im)setBeforeSend: -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFcd_tF -PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: BeforeSendBlock...) | s:7PostHog0aB6ConfigC13setBeforeSendyyAA0aB5EventCSgAFYbcd_tF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogEvent) -> PostHogEvent?]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFcGF +PostHog | PostHogConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [BeforeSendBlock]) | s:7PostHog0aB6ConfigC13setBeforeSendyySayAA0aB5EventCSgAFYbcGF PostHog | PostHogConfig.setDefaultPersonProperties | property | @objc var setDefaultPersonProperties: Bool | c:@M@PostHog@objc(cs)PostHogConfig(py)setDefaultPersonProperties -PostHog | PostHogConfig.snapshotEndpoint | property | @objc var snapshotEndpoint: String | c:@M@PostHog@objc(cs)PostHogConfig(py)snapshotEndpoint -PostHog | PostHogConfig.storageManager | property | var storageManager: PostHogStorageManager? | s:7PostHog0aB6ConfigC14storageManagerAA0ab7StorageE0CSgvp PostHog | PostHogConfig.surveys | property | @objc var surveys: Bool { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveys PostHog | PostHogConfig.surveysConfig | property | @objc var surveysConfig: PostHogSurveysConfig { get set } | c:@M@PostHog@objc(cs)PostHogConfig(py)surveysConfig PostHog | PostHogConfig.tracingHeaders | property | @objc var tracingHeaders: [String]? | c:@M@PostHog@objc(cs)PostHogConfig(py)tracingHeaders PostHog | PostHogConfig.urlSessionConfiguration | property | @objc var urlSessionConfiguration: URLSessionConfiguration? | c:@M@PostHog@objc(cs)PostHogConfig(py)urlSessionConfiguration +PostHog | PostHogConsoleLogEntry | class | @objc class PostHogConsoleLogEntry | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry +PostHog | PostHogConsoleLogEntry.init(level:message:) | init | @objc init(level: PostHogConsoleLogLevel, message: String) | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(im)initWithLevel:message: +PostHog | PostHogConsoleLogEntry.level | property | @objc let level: PostHogConsoleLogLevel | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(py)level +PostHog | PostHogConsoleLogEntry.message | property | @objc let message: String | c:@M@PostHog@objc(cs)PostHogConsoleLogEntry(py)message +PostHog | PostHogConsoleLogLevel | enum | @objc enum PostHogConsoleLogLevel | c:@M@PostHog@E@PostHogConsoleLogLevel +PostHog | PostHogConsoleLogLevel.error | enum.case | case error | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelError +PostHog | PostHogConsoleLogLevel.info | enum.case | case info | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelInfo +PostHog | PostHogConsoleLogLevel.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB15ConsoleLogLevelO8rawValueACSgSi_tcfc +PostHog | PostHogConsoleLogLevel.warn | enum.case | case warn | c:@M@PostHog@E@PostHogConsoleLogLevel@PostHogConsoleLogLevelWarn PostHog | PostHogDataMode | enum | @frozen enum PostHogDataMode | c:@M@PostHog@E@PostHogDataMode PostHog | PostHogDataMode.any | enum.case | case any | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeAny -PostHog | PostHogDataMode.cellular | enum.case | case cellular | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeCellular PostHog | PostHogDataMode.init(rawValue:) | init | init?(rawValue: Int) | s:So15PostHogDataModeV8rawValueABSgSi_tcfc PostHog | PostHogDataMode.wifi | enum.case | case wifi | c:@M@PostHog@E@PostHogDataMode@PostHogDataModeWifi PostHog | PostHogDisplayChoiceQuestion | class | @objc class PostHogDisplayChoiceQuestion | c:@M@PostHog@objc(cs)PostHogDisplayChoiceQuestion @@ -207,15 +186,6 @@ PostHog | PostHogFeatureFlagsLoaded.flags | property | @objc let flags: [String] PostHog | PostHogFeatureFlagsLoaded.variants | property | @objc let variants: [String : Any] | c:@M@PostHog@objc(cs)PostHogFeatureFlagsLoaded(py)variants PostHog | PostHogFeatureFlagsSubscription | class | @objc(PostHogFeatureFlagsSubscription) final class PostHogFeatureFlagsSubscription | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription PostHog | PostHogFeatureFlagsSubscription.unsubscribe() | method | @objc func unsubscribe() | c:@M@PostHog@objc(cs)PostHogFeatureFlagsSubscription(im)unsubscribe -PostHog | PostHogLogEntry | class | @objc class PostHogLogEntry | c:@M@PostHog@objc(cs)PostHogLogEntry -PostHog | PostHogLogEntry.init(level:message:) | init | @objc init(level: PostHogLogLevel, message: String) | c:@M@PostHog@objc(cs)PostHogLogEntry(im)initWithLevel:message: -PostHog | PostHogLogEntry.level | property | @objc let level: PostHogLogLevel | c:@M@PostHog@objc(cs)PostHogLogEntry(py)level -PostHog | PostHogLogEntry.message | property | @objc let message: String | c:@M@PostHog@objc(cs)PostHogLogEntry(py)message -PostHog | PostHogLogLevel | enum | @objc enum PostHogLogLevel | c:@M@PostHog@E@PostHogLogLevel -PostHog | PostHogLogLevel.error | enum.case | case error | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelError -PostHog | PostHogLogLevel.info | enum.case | case info | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelInfo -PostHog | PostHogLogLevel.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB8LogLevelO8rawValueACSgSi_tcfc -PostHog | PostHogLogLevel.warn | enum.case | case warn | c:@M@PostHog@E@PostHogLogLevel@PostHogLogLevelWarn PostHog | PostHogLogRecord | class | @objc final class PostHogLogRecord | c:@M@PostHog@objc(cs)PostHogLogRecord PostHog | PostHogLogRecord.attributes | property | @objc var attributes: [String : Any] | c:@M@PostHog@objc(cs)PostHogLogRecord(py)attributes PostHog | PostHogLogRecord.body | property | @objc var body: String | c:@M@PostHog@objc(cs)PostHogLogRecord(py)body @@ -261,8 +231,9 @@ PostHog | PostHogLogsConfig.resourceAttributes | property | @objc var resourceAt PostHog | PostHogLogsConfig.serviceName | property | @objc var serviceName: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceName PostHog | PostHogLogsConfig.serviceVersion | property | @objc var serviceVersion: String | c:@M@PostHog@objc(cs)PostHogLogsConfig(py)serviceVersion PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | @objc func setBeforeSend(_ blocks: [BoxedBeforeSendLogBlock]) | c:@M@PostHog@objc(cs)PostHogLogsConfig(im)setBeforeSend: -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFcd_tF -PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: PostHogBeforeSendLogBlock...) | s:7PostHog0aB10LogsConfigC13setBeforeSendyyAA0aB9LogRecordCSgAFYbcd_tF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [(PostHogLogRecord) -> PostHogLogRecord?]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFcGF +PostHog | PostHogLogsConfig.setBeforeSend(_:) | method | func setBeforeSend(_ blocks: [PostHogBeforeSendLogBlock]) | s:7PostHog0aB10LogsConfigC13setBeforeSendyySayAA0aB9LogRecordCSgAFYbcGF PostHog | PostHogNextSurveyQuestion | class | @objc class PostHogNextSurveyQuestion | c:@M@PostHog@objc(cs)PostHogNextSurveyQuestion PostHog | PostHogNextSurveyQuestion.isSurveyCompleted | property | let isSurveyCompleted: Bool | s:7PostHog0aB18NextSurveyQuestionC02isD9CompletedSbvp PostHog | PostHogNextSurveyQuestion.questionIndex | property | let questionIndex: Int | s:7PostHog0aB18NextSurveyQuestionC13questionIndexSivp @@ -271,8 +242,6 @@ PostHog | PostHogPersonProfiles.always | enum.case | case always | c:@M@PostHog@ PostHog | PostHogPersonProfiles.identifiedOnly | enum.case | case identifiedOnly | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesIdentifiedOnly PostHog | PostHogPersonProfiles.init(rawValue:) | init | init?(rawValue: Int) | s:7PostHog0aB14PersonProfilesO8rawValueACSgSi_tcfc PostHog | PostHogPersonProfiles.never | enum.case | case never | c:@M@PostHog@E@PostHogPersonProfiles@PostHogPersonProfilesNever -PostHog | PostHogPropertiesSanitizer | protocol | @objc(PostHogPropertiesSanitizer) protocol PostHogPropertiesSanitizer | c:@M@PostHog@objc(pl)PostHogPropertiesSanitizer -PostHog | PostHogPropertiesSanitizer.sanitize(_:) | method | @objc func sanitize(_ properties: [String : Any]) -> [String : Any] | c:@M@PostHog@objc(pl)PostHogPropertiesSanitizer(im)sanitize: PostHog | PostHogRageClickConfig | class | @objc(PostHogRageClickConfig) class PostHogRageClickConfig | c:@M@PostHog@objc(cs)PostHogRageClickConfig PostHog | PostHogRageClickConfig.enabled | property | @objc var enabled: Bool | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)enabled PostHog | PostHogRageClickConfig.minimumTapCount | property | @objc var minimumTapCount: Int | c:@M@PostHog@objc(cs)PostHogRageClickConfig(py)minimumTapCount @@ -309,7 +278,6 @@ PostHog | PostHogSDK.capturePushNotificationOpened(response:) | method | @objc f PostHog | PostHogSDK.capturePushNotificationOpened(title:subtitle:body:payload:action:) | method | @objc func capturePushNotificationOpened(title: String? = nil, subtitle: String? = nil, body: String? = nil, payload: [AnyHashable : Any]? = nil, action: String? = nil) | c:@M@PostHog@objc(cs)PostHogSDK(im)capturePushNotificationOpenedWithTitle:subtitle:body:payload:action: PostHog | PostHogSDK.close() | method | @objc func close() | c:@M@PostHog@objc(cs)PostHogSDK(im)close PostHog | PostHogSDK.debug(_:) | method | @objc func debug(_ enabled: Bool = true) | c:@M@PostHog@objc(cs)PostHogSDK(im)debug: -PostHog | PostHogSDK.didReceiveFeatureFlags | type.property | @objc static let didReceiveFeatureFlags: Notification.Name | c:@CM@PostHog@objc(cs)PostHogSDK(cpy)didReceiveFeatureFlags PostHog | PostHogSDK.didStartNotification | type.property | @objc static let didStartNotification: Notification.Name | c:@CM@PostHog@objc(cs)PostHogSDK(cpy)didStartNotification PostHog | PostHogSDK.endSession() | method | @objc func endSession() | c:@M@PostHog@objc(cs)PostHogSDK(im)endSession PostHog | PostHogSDK.flush() | method | @objc func flush() | c:@M@PostHog@objc(cs)PostHogSDK(im)flush @@ -319,7 +287,6 @@ PostHog | PostHogSDK.getDeviceId() | method | @objc func getDeviceId() -> String PostHog | PostHogSDK.getDistinctId() | method | @objc func getDistinctId() -> String | c:@M@PostHog@objc(cs)PostHogSDK(im)getDistinctId PostHog | PostHogSDK.getFeatureFlag(_:) | method | @objc func getFeatureFlag(_ key: String) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlag: PostHog | PostHogSDK.getFeatureFlag(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagWithKey:sendFeatureFlagEvent:) func getFeatureFlag(_ key: String, sendFeatureFlagEvent: Bool) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagWithKey:sendFeatureFlagEvent: -PostHog | PostHogSDK.getFeatureFlagPayload(_:) | method | @objc func getFeatureFlagPayload(_ key: String) -> Any? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagPayload: PostHog | PostHogSDK.getFeatureFlagResult(_:) | method | @objc func getFeatureFlagResult(_ key: String) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResult: PostHog | PostHogSDK.getFeatureFlagResult(_:sendFeatureFlagEvent:) | method | @objc(getFeatureFlagResultWithKey:sendFeatureFlagEvent:) func getFeatureFlagResult(_ key: String, sendFeatureFlagEvent: Bool) -> PostHogFeatureFlagResult? | c:@M@PostHog@objc(cs)PostHogSDK(im)getFeatureFlagResultWithKey:sendFeatureFlagEvent: PostHog | PostHogSDK.getSessionId() | method | @objc func getSessionId() -> String? | c:@M@PostHog@objc(cs)PostHogSDK(im)getSessionId @@ -343,7 +310,7 @@ PostHog | PostHogSDK.register(_:) | method | @objc(registerProperties:) func reg PostHog | PostHogSDK.registerPushNotificationToken(_:) | method | @objc func registerPushNotificationToken(_ deviceToken: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken: PostHog | PostHogSDK.registerPushNotificationToken(_:appId:) | method | @objc func registerPushNotificationToken(_ deviceToken: String, appId: String?) | c:@M@PostHog@objc(cs)PostHogSDK(im)registerPushNotificationToken:appId: PostHog | PostHogSDK.reloadFeatureFlags() | method | @objc func reloadFeatureFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlags -PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping () -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: +PostHog | PostHogSDK.reloadFeatureFlags(_:) | method | @objc(reloadFeatureFlagsWithCallback:) func reloadFeatureFlags(_ callback: @escaping (PostHogFeatureFlagsLoaded) -> Void) | c:@M@PostHog@objc(cs)PostHogSDK(im)reloadFeatureFlagsWithCallback: PostHog | PostHogSDK.reset() | method | @objc func reset() | c:@M@PostHog@objc(cs)PostHogSDK(im)reset PostHog | PostHogSDK.resetGroupPropertiesForFlags() | method | @objc func resetGroupPropertiesForFlags() | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlags PostHog | PostHogSDK.resetGroupPropertiesForFlags(_:) | method | @objc(resetGroupPropertiesForFlagsWithGroupType:) func resetGroupPropertiesForFlags(_ groupType: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)resetGroupPropertiesForFlagsWithGroupType: @@ -368,38 +335,20 @@ PostHog | PostHogSDK.stopSessionRecording() | method | @objc func stopSessionRec PostHog | PostHogSDK.unregister(_:) | method | @objc(unregisterProperties:) func unregister(_ key: String) | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterProperties: PostHog | PostHogSDK.unregisterPushNotificationToken() | method | @objc func unregisterPushNotificationToken() | c:@M@PostHog@objc(cs)PostHogSDK(im)unregisterPushNotificationToken PostHog | PostHogSDK.with(_:) | type.method | @objc static func with(_ config: PostHogConfig) -> PostHogSDK | c:@M@PostHog@objc(cs)PostHogSDK(cm)with: -PostHog | PostHogSessionManager | class | @objc class PostHogSessionManager | c:@M@PostHog@objc(cs)PostHogSessionManager -PostHog | PostHogSessionManager.setSessionId(_:) | method | @objc func setSessionId(_ sessionId: String) | c:@M@PostHog@objc(cs)PostHogSessionManager(im)setSessionId: -PostHog | PostHogSessionManager.shared | type.property | @objc static var shared: PostHogSessionManager { get } | c:@M@PostHog@objc(cs)PostHogSessionManager(cpy)shared PostHog | PostHogSessionReplayConfig | class | @objc(PostHogSessionReplayConfig) class PostHogSessionReplayConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig PostHog | PostHogSessionReplayConfig.captureLogs | property | @objc var captureLogs: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureLogs PostHog | PostHogSessionReplayConfig.captureLogsConfig | property | @objc var captureLogsConfig: PostHogSessionReplayConsoleLogConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureLogsConfig PostHog | PostHogSessionReplayConfig.captureNetworkTelemetry | property | @objc var captureNetworkTelemetry: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureNetworkTelemetry PostHog | PostHogSessionReplayConfig.captureTouches | property | @objc var captureTouches: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)captureTouches -PostHog | PostHogSessionReplayConfig.debouncerDelay | property | @objc var debouncerDelay: TimeInterval { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)debouncerDelay PostHog | PostHogSessionReplayConfig.maskAllImages | property | @objc var maskAllImages: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllImages PostHog | PostHogSessionReplayConfig.maskAllSandboxedViews | property | @objc var maskAllSandboxedViews: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllSandboxedViews PostHog | PostHogSessionReplayConfig.maskAllTextInputs | property | @objc var maskAllTextInputs: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskAllTextInputs -PostHog | PostHogSessionReplayConfig.maskPhotoLibraryImages | property | @objc var maskPhotoLibraryImages: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)maskPhotoLibraryImages PostHog | PostHogSessionReplayConfig.sampleRate | property | @objc var sampleRate: NSNumber? { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)sampleRate -PostHog | PostHogSessionReplayConfig.screenshotMode | property | @objc var screenshotMode: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotMode -PostHog | PostHogSessionReplayConfig.screenshotModeBackgroundCapture | property | @objc var screenshotModeBackgroundCapture: Bool | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotModeBackgroundCapture PostHog | PostHogSessionReplayConfig.screenshotScale | property | @objc var screenshotScale: NSNumber? { get set } | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)screenshotScale PostHog | PostHogSessionReplayConfig.throttleDelay | property | @objc var throttleDelay: TimeInterval | c:@M@PostHog@objc(cs)PostHogSessionReplayConfig(py)throttleDelay PostHog | PostHogSessionReplayConsoleLogConfig | class | @objc class PostHogSessionReplayConsoleLogConfig | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig -PostHog | PostHogSessionReplayConsoleLogConfig.logSanitizer | property | @objc var logSanitizer: (String) -> PostHogLogEntry? | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)logSanitizer -PostHog | PostHogSessionReplayConsoleLogConfig.minLogLevel | property | @objc var minLogLevel: PostHogLogLevel | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)minLogLevel -PostHog | PostHogStorageManager | class | class PostHogStorageManager | s:7PostHog0aB14StorageManagerC -PostHog | PostHogStorageManager.getAnonymousId() | method | func getAnonymousId() -> String | s:7PostHog0aB14StorageManagerC14getAnonymousIdSSyF -PostHog | PostHogStorageManager.getDeviceId() | method | func getDeviceId() -> String | s:7PostHog0aB14StorageManagerC11getDeviceIdSSyF -PostHog | PostHogStorageManager.getDistinctId() | method | func getDistinctId() -> String | s:7PostHog0aB14StorageManagerC13getDistinctIdSSyF -PostHog | PostHogStorageManager.isIdentified() | method | func isIdentified() -> Bool | s:7PostHog0aB14StorageManagerC12isIdentifiedSbyF -PostHog | PostHogStorageManager.isPersonProcessing() | method | func isPersonProcessing() -> Bool | s:7PostHog0aB14StorageManagerC18isPersonProcessingSbyF -PostHog | PostHogStorageManager.reset(keepAnonymousId:_:) | method | func reset(keepAnonymousId: Bool = false, _ resetStorage: Bool = false) | s:7PostHog0aB14StorageManagerC5reset15keepAnonymousId_ySb_SbtF -PostHog | PostHogStorageManager.setAnonymousId(_:) | method | func setAnonymousId(_ id: String) | s:7PostHog0aB14StorageManagerC14setAnonymousIdyySSF -PostHog | PostHogStorageManager.setDistinctId(_:) | method | func setDistinctId(_ id: String) | s:7PostHog0aB14StorageManagerC13setDistinctIdyySSF -PostHog | PostHogStorageManager.setIdentified(_:) | method | func setIdentified(_ isIdentified: Bool) | s:7PostHog0aB14StorageManagerC13setIdentifiedyySbF -PostHog | PostHogStorageManager.setPersonProcessing(_:) | method | func setPersonProcessing(_ enable: Bool) | s:7PostHog0aB14StorageManagerC19setPersonProcessingyySbF +PostHog | PostHogSessionReplayConsoleLogConfig.logSanitizer | property | @objc var logSanitizer: (String) -> PostHogConsoleLogEntry? | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)logSanitizer +PostHog | PostHogSessionReplayConsoleLogConfig.minLogLevel | property | @objc var minLogLevel: PostHogConsoleLogLevel | c:@M@PostHog@objc(cs)PostHogSessionReplayConsoleLogConfig(py)minLogLevel PostHog | PostHogSurveyResponse | class | @objc @objcMembers class PostHogSurveyResponse | c:@M@PostHog@objc(cs)PostHogSurveyResponse PostHog | PostHogSurveyResponse.link(_:) | type.method | static func link(_ clicked: Bool) -> PostHogSurveyResponse | c:@M@PostHog@objc(cs)PostHogSurveyResponse(cm)link: PostHog | PostHogSurveyResponse.linkClicked | property | let linkClicked: Bool? | s:7PostHog0aB14SurveyResponseC11linkClickedSbSgvp @@ -426,14 +375,6 @@ PostHog | PostHogSurveysDelegate.cleanupSurveys() | method | @objc func cleanupS PostHog | PostHogSurveysDelegate.renderSurvey(_:onSurveyShown:onSurveyResponse:onSurveyClosed:) | method | @objc func renderSurvey(_ survey: PostHogDisplaySurvey, onSurveyShown: @escaping OnPostHogSurveyShown, onSurveyResponse: @escaping OnPostHogSurveyResponse, onSurveyClosed: @escaping OnPostHogSurveyClosed) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)renderSurvey:onSurveyShown:onSurveyResponse:onSurveyClosed: PostHog | PostHogSurveysDelegate.supportsSurveyResume | property | @objc optional var supportsSurveyResume: Bool { get } | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(py)supportsSurveyResume PostHog | PostHogSurveysDelegate.updateSurvey(_:) | method | @objc optional func updateSurvey(_ survey: PostHogDisplaySurvey) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)updateSurvey: -PostHog | ReachabilityError | enum | enum ReachabilityError | s:7PostHog17ReachabilityErrorO -PostHog | ReachabilityError.failedToCreateWithAddress(_:_:) | enum.case | case failedToCreateWithAddress(sockaddr, Int32) | s:7PostHog17ReachabilityErrorO25failedToCreateWithAddressyACSo8sockaddrV_s5Int32VtcACmF -PostHog | ReachabilityError.failedToCreateWithHostname(_:_:) | enum.case | case failedToCreateWithHostname(String, Int32) | s:7PostHog17ReachabilityErrorO26failedToCreateWithHostnameyACSS_s5Int32VtcACmF -PostHog | ReachabilityError.unableToGetFlags(_:) | enum.case | case unableToGetFlags(Int32) | s:7PostHog17ReachabilityErrorO16unableToGetFlagsyACs5Int32VcACmF -PostHog | ReachabilityError.unableToSetCallback(_:) | enum.case | case unableToSetCallback(Int32) | s:7PostHog17ReachabilityErrorO19unableToSetCallbackyACs5Int32VcACmF -PostHog | ReachabilityError.unableToSetDispatchQueue(_:) | enum.case | case unableToSetDispatchQueue(Int32) | s:7PostHog17ReachabilityErrorO24unableToSetDispatchQueueyACs5Int32VcACmF -PostHog | UIColor.hexDescription(_:) | method | func hexDescription(_ includeAlpha: Bool = false) -> String | s:So7UIColorC7PostHogE14hexDescriptionySSSbF -PostHog | UIColor.init(hex:) | init | convenience init(hex: String?) | s:So7UIColorC7PostHogE3hexABSSSg_tcfc PostHog | UIView.postHogLabel | property | @MainActor var postHogLabel: String? { get set } | s:So6UIViewC7PostHogE04postC5LabelSSSgvp PostHog | URLSession.postHogData(for:delegate:postHog:) | method | func postHogData(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for8delegate0dC010Foundation0E0V_So13NSURLResponseCtAH10URLRequestV_So0A12TaskDelegate_pSgAC0bC3SDKCSgtYaKF PostHog | URLSession.postHogData(for:postHog:) | method | func postHogData(for request: URLRequest, postHog: PostHogSDK? = nil) async throws -> (Data, URLResponse) | s:So12NSURLSessionC7PostHogE04postC4Data3for0dC010Foundation0E0V_So13NSURLResponseCtAG10URLRequestV_AC0bC3SDKCSgtYaKF @@ -460,12 +401,3 @@ PostHog | View.postHogScreenView(_:_:postHog:) | method | @MainActor func postHo | s:7SwiftUI4ViewP7PostHogE04poste6ScreenC0__0fE0QrSSSg_SDySSypGSgAD0dE3SDKCSgtF PostHog | View.postHogViewSeen(_:_:postHog:) | method | @MainActor func postHogViewSeen(_ event: String, _ properties: [String : Any]? = nil, postHog: PostHogSDK? = nil) -> some View | s:7SwiftUI4ViewP7PostHogE04posteC4Seen__0fE0QrSS_SDySSypGSgAD0dE3SDKCSgtF -PostHog | dateToMillis(_:) | func | func dateToMillis(_ date: Date) -> Int64 | s:7PostHog12dateToMillisys5Int64V10Foundation4DateVF -PostHog | deleteSafely(_:) | func | func deleteSafely(_ file: URL) | s:7PostHog12deleteSafelyyy10Foundation3URLVF -PostHog | imageToBase64(_:_:) | func | func imageToBase64(_ image: UIImage, _ compressionQuality: CGFloat = 0.3) -> String? | s:7PostHog13imageToBase64ySSSgSo7UIImageC_14CoreFoundation7CGFloatVtF -PostHog | postHogSdkName | var | var postHogSdkName: String | s:7PostHog04postB7SdkNameSSvp -PostHog | postHogVersion | var | var postHogVersion: String | s:7PostHog04postB7VersionSSvp -PostHog | postHogiOSSdkName | var | let postHogiOSSdkName: String | s:7PostHog17postHogiOSSdkNameSSvp -PostHog | sanitizeDictionary(_:) | func | func sanitizeDictionary(_ dict: [String : Any]?) -> [String : Any]? | s:7PostHog18sanitizeDictionaryySDySSypGSgADF -PostHog | toISO8601Date(_:) | func | func toISO8601Date(_ date: String) -> Date? | s:7PostHog13toISO8601Datey10Foundation0E0VSgSSF -PostHog | toISO8601String(_:) | func | func toISO8601String(_ date: Date) -> String | s:7PostHog15toISO8601StringySS10Foundation4DateVF diff --git a/build-tools/upload-symbols.sh b/build-tools/upload-symbols.sh index 449c6b1e0f..ca4ae016b6 100755 --- a/build-tools/upload-symbols.sh +++ b/build-tools/upload-symbols.sh @@ -5,14 +5,13 @@ # # Xcode Build Phase Setup: # SPM: "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh" -# CocoaPods: "${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh" # # # Usage Examples: -# Basic: "${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh" -# With source: POSTHOG_INCLUDE_SOURCE=1 "${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh" -# Skip conflicts: POSTHOG_SKIP_ON_CONFLICT=1 "${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh" -# Overwrite: POSTHOG_FORCE=1 "${PODS_ROOT}/PostHog/build-tools/upload-symbols.sh" +# Basic: "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh" +# With source: POSTHOG_INCLUDE_SOURCE=1 "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh" +# Skip conflicts: POSTHOG_SKIP_ON_CONFLICT=1 "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh" +# Overwrite: POSTHOG_FORCE=1 "${BUILD_DIR%/Build/*}/SourcePackages/checkouts/posthog-ios/build-tools/upload-symbols.sh" # # Build Settings (required): # DEBUG_INFORMATION_FORMAT = DWARF with dSYM File diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 7ef882a815..8471e45895 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -12,6 +12,3 @@ NEW_VERSION="$1" # Replace `postHogVersion` with the given version perl -pi -e "s/postHogVersion = \".*\"/postHogVersion = \"$NEW_VERSION\"/" PostHog/PostHogVersion.swift - -# Replace `s.version` with the given version -perl -pi -e "s/s.version = \".*\"/s.version = \"$NEW_VERSION\"/" PostHog.podspec diff --git a/scripts/check-public-api.sh b/scripts/check-public-api.sh index 89b1d74fb1..e31e223350 100755 --- a/scripts/check-public-api.sh +++ b/scripts/check-public-api.sh @@ -2,8 +2,8 @@ set -euo pipefail ROOT_DIR="$(git rev-parse --show-toplevel)" -SNAPSHOT_FILE="$ROOT_DIR/api/posthog-ios.public-api.txt" UPDATE=false +NO_TRAITS=false while [[ $# -gt 0 ]]; do case "$1" in @@ -11,12 +11,18 @@ while [[ $# -gt 0 ]]; do UPDATE=true shift ;; + --no-traits) + NO_TRAITS=true + shift + ;; -\?|-h|--help) cat <<'EOF' -Usage: scripts/check-public-api.sh [--update] +Usage: scripts/check-public-api.sh [--update] [--no-traits] Checks the PostHog Swift public API snapshot. Use --update after intentional public API changes. +Use --no-traits to check the Swift Package Manager build with every package trait +disabled against api/posthog-ios.public-api.no-traits.txt. EOF exit ;; @@ -27,46 +33,83 @@ EOF esac done +if $NO_TRAITS; then + SNAPSHOT_FILE="$ROOT_DIR/api/posthog-ios.public-api.no-traits.txt" + UPDATE_COMMAND="scripts/check-public-api.sh --update --no-traits" + SCOPE="public/open symbols emitted for the PostHog iOS symbol graphs, Swift Package Manager build with --disable-default-traits." +else + SNAPSHOT_FILE="$ROOT_DIR/api/posthog-ios.public-api.txt" + UPDATE_COMMAND="scripts/check-public-api.sh --update" + SCOPE="public/open symbols emitted for the PostHog iOS symbol graphs." +fi + TMP_FILE="$(mktemp)" -DERIVED_DATA_DIR="$(mktemp -d)" +BUILD_DIR="$(mktemp -d)" SYMBOLGRAPH_DIR="$(mktemp -d)" cleanup() { rm -f "$TMP_FILE" - rm -rf "$DERIVED_DATA_DIR" "$SYMBOLGRAPH_DIR" + rm -rf "$BUILD_DIR" "$SYMBOLGRAPH_DIR" } trap cleanup EXIT cd "$ROOT_DIR" set -o pipefail -xcrun xcodebuild build \ - -scheme PostHog \ - -destination generic/platform=ios \ - -derivedDataPath "$DERIVED_DATA_DIR" \ - | xcpretty >/dev/null - -PRODUCTS_DIR="$(find "$DERIVED_DATA_DIR/Build/Products" -maxdepth 1 -type d -name '*iphoneos' | head -n 1)" -if [[ -z "$PRODUCTS_DIR" ]]; then - echo "No iOS build products found under $DERIVED_DATA_DIR/Build/Products" >&2 +IOS_SDK="$(xcrun --sdk iphoneos --show-sdk-path)" +EXTRA_EXTRACT_ARGS=() + +if $NO_TRAITS; then + xcrun swift build \ + --target PostHog \ + --disable-default-traits \ + --triple arm64-apple-ios15.0 \ + --sdk "$IOS_SDK" \ + --build-path "$BUILD_DIR" \ + >/dev/null + + PRODUCTS_DIR="$BUILD_DIR/arm64-apple-ios/debug" + MODULES_DIR="$PRODUCTS_DIR/Modules" + if [[ ! -d "$MODULES_DIR" ]]; then + MODULES_DIR="$PRODUCTS_DIR" + fi + EXTRA_EXTRACT_ARGS=( + -I "$MODULES_DIR" + -Xcc -fmodule-map-file="$PRODUCTS_DIR/PostHogObjCExceptionSupport.build/module.modulemap" + ) +else + xcrun xcodebuild build \ + -scheme PostHog \ + -destination generic/platform=ios \ + -derivedDataPath "$BUILD_DIR" \ + | xcpretty >/dev/null + + PRODUCTS_DIR="$(find "$BUILD_DIR/Build/Products" -maxdepth 1 -type d -name '*iphoneos' | head -n 1)" + EXTRA_EXTRACT_ARGS=(-F "$PRODUCTS_DIR") +fi + +if [[ -z "$PRODUCTS_DIR" || ! -d "$PRODUCTS_DIR" ]]; then + echo "No iOS build products found under $BUILD_DIR" >&2 exit 1 fi xcrun swift-symbolgraph-extract \ -module-name PostHog \ - -target arm64-apple-ios13.0 \ - -sdk "$(xcrun --sdk iphoneos --show-sdk-path)" \ + -target arm64-apple-ios15.0 \ + -sdk "$IOS_SDK" \ -I "$PRODUCTS_DIR" \ - -F "$PRODUCTS_DIR" \ + "${EXTRA_EXTRACT_ARGS[@]}" \ -minimum-access-level public \ -skip-synthesized-members \ -output-dir "$SYMBOLGRAPH_DIR" -python3 - "$SYMBOLGRAPH_DIR" "$TMP_FILE" <<'PY' +python3 - "$SYMBOLGRAPH_DIR" "$TMP_FILE" "$UPDATE_COMMAND" "$SCOPE" <<'PY' import json import sys from pathlib import Path symbolgraph_dir = Path(sys.argv[1]) out = Path(sys.argv[2]) +update_command = sys.argv[3] +scope = sys.argv[4] if not symbolgraph_dir.exists(): raise SystemExit(f'No Swift symbol graph output found under {symbolgraph_dir}') @@ -90,9 +133,9 @@ if not records: raise SystemExit('No public PostHog symbols found in generated symbol graphs') lines = [ - '# This file is generated by `scripts/check-public-api.sh --update`.', + f'# This file is generated by `{update_command}`.', '# Do not edit manually.', - '# Public API scope: public/open symbols emitted for the PostHog iOS symbol graphs.', + f'# Public API scope: {scope}', '', ] for module, path_components, kind, declaration, precise in sorted(set(records)): @@ -110,13 +153,13 @@ fi if [[ ! -f "$SNAPSHOT_FILE" ]]; then echo "Missing public API snapshot: $SNAPSHOT_FILE" >&2 - echo "Run scripts/check-public-api.sh --update to create it." >&2 + echo "Run $UPDATE_COMMAND to create it." >&2 exit 1 fi if diff -u "$SNAPSHOT_FILE" "$TMP_FILE"; then - echo "Swift public API snapshot is up to date." + echo "Swift public API snapshot $(basename "$SNAPSHOT_FILE") is up to date." else - echo "Swift public API changed. Run scripts/check-public-api.sh --update if intentional." >&2 + echo "Swift public API changed. Run $UPDATE_COMMAND if intentional." >&2 exit 1 fi diff --git a/scripts/downgrade-compatibility-smoke/Package.swift b/scripts/downgrade-compatibility-smoke/Package.swift index dc6d46841c..84b66b1f3f 100644 --- a/scripts/downgrade-compatibility-smoke/Package.swift +++ b/scripts/downgrade-compatibility-smoke/Package.swift @@ -14,7 +14,7 @@ let currentWriterSwiftSettings: [SwiftSetting] = environment["POSTHOG_DOWNGRADE_ let package = Package( name: "DowngradeCompatibilitySmoke", - platforms: [.macOS(.v10_15)], + platforms: [.macOS(.v11)], products: [ .executable(name: "DowngradeCompatibilitySmoke", targets: ["DowngradeCompatibilitySmoke"]), ], diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index 30b4051976..343bb83c7f 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -113,7 +113,7 @@ Reset the SDK and adapter state. 1. **macOS environment** (local Mac or GitHub Actions `macos-latest`) 2. **Docker Desktop** installed and running -3. **Swift 5.9+** installed +3. **Swift 6.2+** installed ### Steps diff --git a/sdk_compliance_adapter/Sources/main.swift b/sdk_compliance_adapter/Sources/main.swift index 6fb1becfbc..1f74f01e26 100644 --- a/sdk_compliance_adapter/Sources/main.swift +++ b/sdk_compliance_adapter/Sources/main.swift @@ -1,5 +1,5 @@ import Foundation -import PostHog +@_spi(PostHogInternal) import PostHog import Vapor // Redirect all SDK on-disk storage into a private sandbox this adapter fully owns, so a @@ -50,7 +50,7 @@ app.middleware.use(RouteLoggingMiddleware()) // Health endpoint app.get("health") { req async throws -> Response in let health: [String: Any] = [ - "sdk_name": postHogiOSSdkName, + "sdk_name": postHogSdkName, "sdk_version": postHogVersion, "adapter_version": "1.0.0", // Declares which test suites apply. The iOS SDK posts events to /batch @@ -126,9 +126,7 @@ app.post("init") { req async throws -> Response in #if os(iOS) config.sessionReplay = false - if #available(iOS 15.0, *) { - config.surveys = false - } + config.surveys = false #endif // Configure custom URLSession with our RequestInterceptor diff --git a/sdk_compliance_adapter/run_tests.sh b/sdk_compliance_adapter/run_tests.sh index 5a245aa6d2..fde1667fe2 100755 --- a/sdk_compliance_adapter/run_tests.sh +++ b/sdk_compliance_adapter/run_tests.sh @@ -10,7 +10,7 @@ echo "" # Check prerequisites if ! command -v swift &> /dev/null; then - echo "❌ Error: Swift not found. Please install Swift 5.9+" + echo "❌ Error: Swift not found. Please install Swift 6.2+" exit 1 fi diff --git a/test-audit/replay.md b/test-audit/replay.md index d4b230ebcd..cdbf18c236 100644 --- a/test-audit/replay.md +++ b/test-audit/replay.md @@ -383,14 +383,6 @@ Production owner: presentation-aware mask collection. The harness requires visib | 450 | `emptyArraysStill` | R — legitimate no-mask geometry settles. | | 457 | `driftInflationCoversSweepInAfterOrder` | R — swept coverage and owner ordering; catches wrong pairing/under-inflation. | | 486 | `driftInflationCoversTrailBehindBefore` | R — displayed-pixel lag requires trailing inflation. | -| 502 | `sweptRectsCoversBothSamplesInAfterOrder` | R — union and owner ordering independently specified. | -| 526 | `sweptRectsNilBefore` | R — missing sample cannot manufacture a safe union. | -| 533 | `sweptRectsNilAfter` | R — same later-sample guard. | -| 540 | `sweptRectsCountMismatch` | R — newly appearing owner remains covered. | -| 558 | `sweptRectsDuplicateOwnerInBefore` | R — ambiguous duplicate pairing returns nil. | -| 573 | `sweptRectsDuplicateOwnerInAfter` | R — symmetric ambiguity guard. | -| 588 | `sweptRectsDisjointOwnerSet` | R — recycled owners cover both samples instead of losing disappeared content. | -| 603 | `sweptRectsStationaryOwnerUnchanged` | R — stationary union does not distort mask. | ### 5. `PostHogMaskSnapshotTest.swift` @@ -883,10 +875,10 @@ These protect separate privacy and availability risks; no consolidation proposed 1. Equal image hashes, no pending metadata → skip. 2. Different hashes → send. 3. Equal hashes with pending snapshot data → send. -4. Nil image hash/wireframe mode → send. +4. Nil image hash → send. 5. No previous hash → send. -Credible regressions: duplicate traffic, lost metadata, wireframe suppression, or missing first frame. +Credible regressions: duplicate traffic, lost metadata, or missing first frame. ### 21. `PostHogSDKPersonProfilesTest.swift`