Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.15.4
## 4.16.0

### Enhancements

- Adds support for annual subscriptions that are billed monthly.
- Added `EventTrackingBehavior` enum and `SuperwallOptions.eventTrackingBehavior` property for GDPR-compliant event collection control. Use `.all` (default) to track everything, `.superwallOnly` to suppress user-initiated tracking, trigger fires, and user-attribute updates while keeping internal SDK events, or `.none` to stop all event collection entirely. The behavior can also be changed at runtime via `Superwall.shared.eventTrackingBehavior`.
- Deprecated `SuperwallOptions.isExternalDataCollectionEnabled`. Setting it to `false` now maps to `.superwallOnly`; setting it back to `true` maps to `.all`.

### Fixes

Expand Down
41 changes: 41 additions & 0 deletions Sources/SuperwallKit/Config/Options/EventTrackingBehavior.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// EventTrackingBehavior.swift
//
//
// Created by Yusuf Tör on 22/06/2026.
//

import Foundation

/// Controls which events are sent to the Superwall servers.
///
/// Use ``SuperwallOptions/eventTrackingBehavior`` or set ``Superwall/eventTrackingBehavior``
/// at runtime to change event collection at any time.
///
/// - `.all`: All events are tracked (default).
/// - `.superwallOnly`: Only internal Superwall events are tracked. User-initiated
/// ``Superwall/track(event:params:)`` calls, trigger fires, and user-attribute updates
/// are suppressed. Equivalent to the deprecated `isExternalDataCollectionEnabled = false`.
/// - `.none`: No events are sent to the Superwall servers.
@objc(SWKEventTrackingBehavior)
public enum EventTrackingBehavior: Int, CustomStringConvertible, Encodable, Sendable {
/// All events are tracked. This is the default.
case all = 0

/// Only internal Superwall events are tracked.
///
/// User-initiated tracking calls, trigger-fire events, and user-attribute
/// updates are suppressed. All other internal SDK events continue to be sent.
case superwallOnly = 1

/// No events are sent to the Superwall servers.
case none = 2

public var description: String {
switch self {
case .all: return "all"
case .superwallOnly: return "superwallOnly"
case .none: return "none"
}
}
}
33 changes: 28 additions & 5 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,35 @@ public final class SuperwallOptions: NSObject, Encodable {
/// - Note: You cannot use ``Superwall/purchase(_:)`` while this is `true`.
public var shouldObservePurchases = false

/// Controls which events are sent to the Superwall servers.
///
/// Defaults to ``EventTrackingBehavior/all``. Set this to ``EventTrackingBehavior/superwallOnly``
/// to suppress user-initiated tracking, trigger fires, and user-attribute updates, or to
/// ``EventTrackingBehavior/none`` to stop all event collection (e.g. for GDPR compliance).
///
/// You can also change this at runtime via ``Superwall/eventTrackingBehavior``.
public var eventTrackingBehavior: EventTrackingBehavior = .all

/// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers.
/// Defaults to `true`.
///
/// Set this to `false` to stop external data collection. This will not affect
/// your ability to create placements based on properties.
public var isExternalDataCollectionEnabled = true
/// - Warning: Deprecated. Use ``eventTrackingBehavior`` instead.
/// Setting this to `false` maps to ``EventTrackingBehavior/superwallOnly`` unless the current
/// value is already ``EventTrackingBehavior/none``, in which case `.none` is preserved.
/// Setting it back to `true` maps to ``EventTrackingBehavior/all``.
@available(*, deprecated, renamed: "eventTrackingBehavior")
public var isExternalDataCollectionEnabled: Bool {
get {
return eventTrackingBehavior == .all
}
set {
if newValue {
eventTrackingBehavior = .all
} else if eventTrackingBehavior != .none {
eventTrackingBehavior = .superwallOnly
}
}
}
Comment thread
yusuftor marked this conversation as resolved.

/// Sets the device locale identifier to use when evaluating audience filters.
///
Expand Down Expand Up @@ -374,7 +397,7 @@ public final class SuperwallOptions: NSObject, Encodable {
public var logging = Logging()

private enum CodingKeys: String, CodingKey {
case isExternalDataCollectionEnabled
case eventTrackingBehavior
case localeIdentifier
case isGameControllerEnabled
case storeKitVersion
Expand Down Expand Up @@ -409,7 +432,7 @@ public final class SuperwallOptions: NSObject, Encodable {
try networkEnvironment.encode(to: encoder)
try logging.encode(to: encoder)

try container.encode(isExternalDataCollectionEnabled, forKey: .isExternalDataCollectionEnabled)
try container.encode(eventTrackingBehavior.description, forKey: .eventTrackingBehavior)
try container.encode(localeIdentifier, forKey: .localeIdentifier)
try container.encode(isGameControllerEnabled, forKey: .isGameControllerEnabled)
try container.encode(storeKitVersion.description, forKey: .storeKitVersion)
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.15.4
4.16.0
"""
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,17 @@
await passMessageToWebView(base64Templates)
}

func passEventTrackingBehaviorToWebView(_ behavior: EventTrackingBehavior) {
let event: [String: Any] = [
"event_name": "event_tracking_behavior",
"behavior": behavior.description
]
guard let jsonData = try? JSONSerialization.data(withJSONObject: [event]) else {
return
}
passMessageToWebView(jsonData.base64EncodedString())
}

private func passMessageToWebView(_ base64String: String) {
let messageScript = """
window.paywall.accept64('\(base64String)');
Expand Down Expand Up @@ -372,6 +383,9 @@
paywallInfo: paywallInfo
)
await Superwall.shared.track(webViewLoad)

let behavior = await Superwall.shared.eventTrackingBehavior
await self.passEventTrackingBehaviorToWebView(behavior)
}

let htmlSubstitutions = paywall.htmlSubstitutions
Expand Down Expand Up @@ -424,7 +438,7 @@
// block selection
let selectionString =
// swiftlint:disable:next line_length
"var css = '*{-webkit-touch-callout:none;-webkit-user-select:none} .w-webflow-badge { display: none !important; }'; "

Check warning on line 441 in Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift

View workflow job for this annotation

GitHub Actions / Package-SwiftLint

Superfluous Disable Command Violation: SwiftLint rule 'line_length' did not trigger a violation in the disabled region; remove the disable command (superfluous_disable_command)
+ "var head = document.head || document.getElementsByTagName('head')[0]; "
+ "var style = document.createElement('style'); style.type = 'text/css'; "
+ "style.appendChild(document.createTextNode(css)); head.appendChild(style); "
Expand Down
55 changes: 36 additions & 19 deletions Sources/SuperwallKit/Storage/PlacementsQueue.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//
// File.swift
//
//
//
// Created by brian on 8/16/21.
//
Expand All @@ -16,7 +16,8 @@ actor PlacementsQueue {
private var elements: [JSON] = []
private var timer: Timer?
private unowned let network: Network
private unowned let configManager: ConfigManager
private var trackingBehavior: EventTrackingBehavior
private let timerInterval: Double

@MainActor
private var resignActiveObserver: AnyCancellable?
Expand All @@ -31,23 +32,23 @@ actor PlacementsQueue {
configManager: ConfigManager
) {
self.network = network
self.configManager = configManager
// Capture synchronously while configManager is guaranteed alive.
self.trackingBehavior = configManager.options.eventTrackingBehavior
switch configManager.options.networkEnvironment {
case .release:
self.timerInterval = 20.0
default:
self.timerInterval = 1.0
}
Task { [weak self] in
await self?.setupTimer()
await self?.addObserver()
}
}

private func setupTimer() {
let timeInterval: Double
switch configManager.options.networkEnvironment {
case .release:
timeInterval = 20.0
default:
timeInterval = 1.0
}
let timer = Timer(
timeInterval: timeInterval,
timeInterval: timerInterval,
repeats: true
) { [weak self] _ in
guard let self = self else {
Expand Down Expand Up @@ -76,25 +77,41 @@ actor PlacementsQueue {
data: JSON,
from placement: Trackable
) {
guard externalDataCollectionAllowed(from: placement) else {
guard trackingAllowed(from: placement) else {
return
}
elements.append(data)
}

private func externalDataCollectionAllowed(from placement: Trackable) -> Bool {
if Superwall.shared.options.isExternalDataCollectionEnabled {
return true
func setTrackingBehavior(_ behavior: EventTrackingBehavior) {
trackingBehavior = behavior
if behavior != .all {
elements.removeAll()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ This eager clear closes the .all.superwallOnly leak, but it inherits the runtime flush race that was fixed for .none in 4b645d9. The Superwall.shared.eventTrackingBehavior setter writes options synchronously and then dispatches setTrackingBehavior via an unordered Task; a flushInternal already scheduled on the actor (timer tick / willResignActive) reads the still-.all cached trackingBehavior, passes the != .none guard, and drains buffered user-initiated events before this clear runs. For .none, flushInternal's trackingBehavior == .none early-return catches that window; .superwallOnly has no analogous flush-time guard, so the eager clear alone can lose the race.

Technical details
# `.superwallOnly` runtime switch has the same flush race that was closed for `.none`

## Affected sites
- `Sources/SuperwallKit/Superwall.swift:78-83` — setter writes `options.eventTrackingBehavior` synchronously, then dispatches `Task { await placementsQueue.setTrackingBehavior(newValue) }` (unordered).
- `Sources/SuperwallKit/Storage/PlacementsQueue.swift:86-91``setTrackingBehavior` clears `elements` on `behavior != .all`, but only when the Task actually runs.
- `Sources/SuperwallKit/Storage/PlacementsQueue.swift:109-113``flushInternal` only early-returns for `.none`; under a stale `.all` cache it drains and transmits user-initiated events.

## Required outcome
- Under a runtime switch to `.superwallOnly`, user-initiated events buffered before the switch must not be transmitted, regardless of how the `setTrackingBehavior` Task and an in-flight `flushInternal` interleave on the actor.

## Suggested approach
- The new test exercises `setTrackingBehavior``flushInternal` in deterministic order, so it does not surface this race. The gap is narrow (requires a flush already scheduled in the window between the option write and the Task) and may be acceptable for the GDPR use case, where `.none` is the consent-decline path and `.superwallOnly` is typically set at init. If runtime `.superwallOnly` opt-out is a supported flow, consider whether `flushInternal` should re-evaluate per-event gating against the current `trackingBehavior` rather than draining whatever was buffered.

## Open questions for the human
- Is runtime switching to `.superwallOnly` (as opposed to init-time) a supported flow that needs the same atomicity guarantee as `.none`? If `.superwallOnly` is only ever set at configure time, this race cannot occur and no change is needed.

}
if placement is InternalSuperwallEvent.TriggerFire
|| placement is InternalSuperwallEvent.UserAttributes
|| placement is UserInitiatedPlacement.Track {
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

private func trackingAllowed(from placement: Trackable) -> Bool {
switch trackingBehavior {
case .all:
return true
case .superwallOnly:
if placement is InternalSuperwallEvent.TriggerFire
|| placement is InternalSuperwallEvent.UserAttributes
|| placement is UserInitiatedPlacement.Track {
return false
}
return true
case .none:
return false
}
return true
}
Comment thread
yusuftor marked this conversation as resolved.

func flushInternal(depth: Int = 10) {
if trackingBehavior == .none {
elements.removeAll()
return
}

var eventsToSend: [JSON] = []

var i = 0
Expand Down
40 changes: 40 additions & 0 deletions Sources/SuperwallKit/Superwall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@ public final class Superwall: NSObject, ObservableObject {
}
}

/// Controls which events are sent to the Superwall servers at runtime.
///
/// Defaults to ``EventTrackingBehavior/all``. Update this at any point after
/// ``configure(apiKey:purchaseController:options:completion:)-52tke`` to change event
/// collection dynamically — for example, toggling to ``EventTrackingBehavior/none``
/// after the user declines data collection in a GDPR consent flow.
///
/// You can also set the initial value via ``SuperwallOptions/eventTrackingBehavior``
/// before calling `configure`.
public var eventTrackingBehavior: EventTrackingBehavior {
get {
return options.eventTrackingBehavior
}
set {
options.eventTrackingBehavior = newValue

Task {
await dependencyContainer.placementsQueue.setTrackingBehavior(newValue)
}

let behavior = newValue
Task { @MainActor [weak self] in
self?.paywallViewController?.webView.messageHandler
.passEventTrackingBehaviorToWebView(behavior)
}

// When opting out entirely, don't emit the config-attributes event. It
// races the `setTrackingBehavior(.none)` Task above, so a flush could
// transmit it before the queue's opt-out takes effect.
if newValue == .none {
return
}

let configAttributes = dependencyContainer.makeConfigAttributes()
Task {
await track(configAttributes)
}
}
Comment thread
yusuftor marked this conversation as resolved.
}

/// Defines the products to override on any paywall by product name.
///
/// You can override one or more products of your choosing. For example, this is how you would override the first and third product on a paywall:
Expand Down
2 changes: 1 addition & 1 deletion SuperwallKit.podspec
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Pod::Spec.new do |s|

s.name = "SuperwallKit"
s.version = "4.15.4"
s.version = "4.16.0"
s.summary = "Superwall: In-App Paywalls Made Easy"
s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com"

Expand Down
8 changes: 8 additions & 0 deletions SuperwallKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@
3CF2307C2CB994D00A35FADD /* LoadingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866F99509EDFBE8BAE10E575 /* LoadingModel.swift */; };
3DCE95BAC148CCC7E6E7F608 /* DeviceTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3E5C31CEEDC9C2853D91C50 /* DeviceTemplate.swift */; };
3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.swift */; };
3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */; };
3F3774A066285BB0DFE61B61 /* JSONToDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09C238ADC0B019047FAB1DF /* JSONToDict.swift */; };
3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 124F219E38F8398A65A7EB32 /* DependencyContainer.swift */; };
3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9D685A5912892EF9C2931B1 /* V4Migrator.swift */; };
Expand Down Expand Up @@ -517,6 +518,7 @@
E9F80BA7BCAB71270E0E1CC1 /* AttributionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */; };
E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; };
EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; };
EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; };
EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E522F5BCABB3A95B97549E /* PurchaseError.swift */; };
EB6540A8E1ECC3548C5E6368 /* PaywallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */; };
ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */; };
Expand Down Expand Up @@ -807,6 +809,7 @@
67602AF9B2543CAD0B42F3CF /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = "<group>"; };
67C4FC41FEE0B47EA402D738 /* LocalizationConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfig.swift; sourceTree = "<group>"; };
67D9C2B45E15025BF7D783AD /* zh_Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hans; path = zh_Hans.lproj/Localizable.strings; sourceTree = "<group>"; };
682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacementsQueueTests.swift; sourceTree = "<group>"; };
68363EE16B2DE5C1C5361657 /* Enrichment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Enrichment.swift; sourceTree = "<group>"; };
6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogicTests.swift; sourceTree = "<group>"; };
69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -911,6 +914,7 @@
93604964336C40E763A7BFAF /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
937CAD765EA00E4A0FC86037 /* V2ProductsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2ProductsResponse.swift; sourceTree = "<group>"; };
938EB5121B1D9EA6B2EAE9EC /* Task+Retrying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Retrying.swift"; sourceTree = "<group>"; };
93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTrackingBehavior.swift; sourceTree = "<group>"; };
93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroOfferEligibility.swift; sourceTree = "<group>"; };
94125DB9AC8A2EA66F983EDA /* PresentationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationResult.swift; sourceTree = "<group>"; };
9423D7BA0604D88E30161BFB /* PaywallCacheLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallCacheLogic.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2481,6 +2485,7 @@
A1EF6DDE57A911501DFB2B4D /* Storage */ = {
isa = PBXGroup;
children = (
682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */,
6D1887F247BF6F770122F257 /* StorageMock.swift */,
787764B249892BBCA1088235 /* StorageTests.swift */,
0E582EAD2A75C1D7A72F2E52 /* Cache */,
Expand Down Expand Up @@ -2941,6 +2946,7 @@
isa = PBXGroup;
children = (
23307BBFD80385233DDD4C43 /* AssetResource.swift */,
93D8033AAF5549E30ACDA3EA /* EventTrackingBehavior.swift */,
B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */,
CFDA311A030FDFC45AEE248A /* SuperwallOptions.swift */,
);
Expand Down Expand Up @@ -3289,6 +3295,7 @@
5F1F480AA12C8D17B179D96B /* PaywallViewControllerMock.swift in Sources */,
ED246150DA2747AA42D6009C /* PermissionStatusTests.swift in Sources */,
4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */,
EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */,
A3A0961A4A230C10B8896400 /* PopupTransitionTests.swift in Sources */,
3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */,
3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */,
Expand Down Expand Up @@ -3439,6 +3446,7 @@
DEF83BEF1ED06921BD55F55A /* EvaluationContext.swift in Sources */,
2653909358966BE9AC9894F1 /* EvaluationResult.swift in Sources */,
35597883CB038DBEE63E162B /* EventData.swift in Sources */,
3EE4C1C4EC45718C2EED34E5 /* EventTrackingBehavior.swift in Sources */,
F297363F2C4BFEE9D051D684 /* EventsRequest.swift in Sources */,
5FB78EB52A12BA704AD3AE31 /* EventsResponse.swift in Sources */,
412C74624BB8933162DAAC5F /* Experiment.swift in Sources */,
Expand Down
5 changes: 5 additions & 0 deletions Tests/SuperwallKitTests/Network/NetworkMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Combine

final class NetworkMock: Network {
var sentSessionEvents: SessionEventsRequest?
var sentEvents: [EventsRequest] = []
var getConfigCalled = false
var assignmentsConfirmed = false
var assignments: [PostbackAssignment] = []
Expand All @@ -29,6 +30,10 @@ final class NetworkMock: Network {
var onRedeemEntitlements: (() -> Void)?
var onPollRedemptionResult: (() -> Void)?

override func sendEvents(events: EventsRequest) async {
sentEvents.append(events)
}

override func sendSessionEvents(_ session: SessionEventsRequest) async {
sentSessionEvents = session
}
Expand Down
Loading
Loading