From 639cfd8f5235a51aa7468136638f457c03cf1c2b Mon Sep 17 00:00:00 2001 From: Brian Anglin Date: Mon, 4 May 2026 18:56:42 -0700 Subject: [PATCH 01/25] Switch SwiftPM Superscript dependency to superscript-ios-next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates from `superwall/Superscript-iOS` (which committed the ~250 MB libcel.xcframework into git, ballooning history past 1.2 GB and making SPM clones painfully slow) to the new slim repo `superwall/superscript-ios-next`, which distributes the xcframework as a GitHub Release asset and uses SPM's binaryTarget(url:checksum:). The Superscript Swift module name is unchanged, so source files don't need edits. CocoaPods consumers are intentionally NOT touched in this PR — the `Superscript` pod on Trunk is still published from the legacy repo's pipeline. SuperwallKit.podspec keeps depending on `Superscript`, '1.0.13'. We can flip the pod over later once the new repo's COCOAPODS_TRUNK_TOKEN is configured and a release is published. --- CLAUDE.md | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- Package.resolved | 6 +++--- Package.swift | 4 ++-- SuperwallKit.xcodeproj/project.pbxproj | 10 +++++----- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- project.yml | 4 ++-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f414dee131..419b8b5f7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Package Management - Swift Package Manager: Primary dependency management via `Package.swift` - CocoaPods: Also supported via `SuperwallKit.podspec` -- Dependencies: `Superscript-iOS` at exact version 1.0.4 +- Dependencies: `superscript-ios-next` at exact version 1.0.14 (slim binary-target distribution; replaces the legacy `Superscript-iOS` repo whose committed xcframework bloated clones) ## Architecture Overview diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c9e34050a5..b898197654 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -12,11 +12,11 @@ }, { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 251dbbdc99..5da674204b 100644 --- a/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -3,11 +3,11 @@ "pins": [ { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Package.resolved b/Package.resolved index 251dbbdc99..5da674204b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -3,11 +3,11 @@ "pins": [ { "package": "Superscript", - "repositoryURL": "https://github.com/superwall/Superscript-iOS", + "repositoryURL": "https://github.com/superwall/superscript-ios-next", "state": { "branch": null, - "revision": "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version": "1.0.13" + "revision": "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version": "1.0.14" } } ] diff --git a/Package.swift b/Package.swift index ff4a62e430..c1fe431dc8 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/superwall/Superscript-iOS", .exact("1.0.13")) + .package(url: "https://github.com/superwall/superscript-ios-next", .exact("1.0.14")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -27,7 +27,7 @@ let package = Package( .target( name: "SuperwallKit", dependencies: [ - .product(name: "Superscript", package: "Superscript-iOS") + .product(name: "Superscript", package: "superscript-ios-next") ], exclude: ["Resources/BundleHelper.swift"], resources: [ diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 6501ada07d..ed9c757d65 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -3149,7 +3149,7 @@ ); mainGroup = 5CE8CEF97A892FFF3D0D8F06; packageReferences = ( - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */, + 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); projectDirPath = ""; projectRoot = ""; @@ -4019,12 +4019,12 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */ = { + 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/superwall/Superscript-iOS"; + repositoryURL = "https://github.com/superwall/superscript-ios-next"; requirement = { kind = exactVersion; - version = 1.0.13; + version = 1.0.14; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -4032,7 +4032,7 @@ /* Begin XCSwiftPackageProductDependency section */ 721C720FA8360B9851DE843D /* Superscript */ = { isa = XCSwiftPackageProductDependency; - package = 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "Superscript-iOS" */; + package = 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */; productName = Superscript; }; /* End XCSwiftPackageProductDependency section */ diff --git a/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fb1b66dcba..8434c257b6 100644 --- a/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SuperwallKit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -2,12 +2,12 @@ "originHash" : "db362fdf36eaf1c479870b40c7a6c2e2165345c7210ebe36257ee38523125878", "pins" : [ { - "identity" : "superscript-ios", + "identity" : "superscript-ios-next", "kind" : "remoteSourceControl", - "location" : "https://github.com/superwall/Superscript-iOS", + "location" : "https://github.com/superwall/superscript-ios-next", "state" : { - "revision" : "711866edcb62dbd237c24ed3e5fa39fad0db639f", - "version" : "1.0.13" + "revision" : "abb2c8c96e958aabe11a84df67860ad7bbef3b68", + "version" : "1.0.14" } } ], diff --git a/project.yml b/project.yml index 1ba3470851..efd42e923c 100644 --- a/project.yml +++ b/project.yml @@ -4,8 +4,8 @@ options: packages: Superscript: - url: https://github.com/superwall/Superscript-iOS - exactVersion: "1.0.13" + url: https://github.com/superwall/superscript-ios-next + exactVersion: "1.0.14" targets: SuperwallKit: From 571bf6ebb26e0fb5c47eb759f2b4a9726cb02a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Mon, 11 May 2026 14:39:05 +0200 Subject: [PATCH 02/25] Add changelog entry --- CHANGELOG.md | 6 ++++++ Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d8441dda..b19cf2820d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.15.2 + +### Fixes + +- Changes the Superscript spm package repo source to a new lightweight repo meaning that the download of the package is way faster. + ## 4.15.1 ### Enhancements diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 03b9d70246..9e400afba7 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.15.1 +4.15.2 """ diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 8b1f2be8b1..cdf48a52df 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.15.1" + s.version = "4.15.2" 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" From 9bef2a0a0d2cb64c2627424ccdf03e7f16ad4c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 14:52:08 +0200 Subject: [PATCH 03/25] Improve Apple Search Ads attribution capture Make the AdServices integration retry resiliently and only mark the token as posted after the backend confirms. Previously a single transient failure (Apple's attribution endpoint isn't ready yet, brief network blip, etc.) permanently lost attribution for that install because the token was saved to storage before the backend round-trip. - AdServicesResponse now decodes top-level `eligible` / `error` so the backend can signal "this user wasn't from Search Ads" distinctly from a transient failure. - Network.sendToken throws instead of swallowing into [:]. - AttributionPoster: in-session backoff for both AAAttribution and the backend post; cross-launch retry budget (8 attempts within 48h of first attempt); permanent AAAttribution errors don't burn the budget; thread-safe single-flight via serial queue; foreground retry priority bumped from .background to .utility; listenToConfig no longer uses .first so toggling the dashboard flag mid-session still triggers a fetch; cancelInFlight() called from reset. - AttributionFetcher.identifierForAdvertisers filters the all-zeros UUID sentinel iOS returns when ATT is denied, so we stop polluting attribution payloads with a junk IDFA. - Drop the dead getAdServicesTokenIfNeeded() kickoff from configure() that always bailed at the config-enabled guard. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + .../AdServicesAttributionAttempts.swift | 16 ++ .../Attribution/AttributionFetcher.swift | 9 + .../Attribution/AttributionPoster.swift | 241 +++++++++++++++--- .../Models/AdServicesResponse.swift | 7 + Sources/SuperwallKit/Network/Network.swift | 9 +- .../Storage/Cache/CacheKeys.swift | 15 ++ Sources/SuperwallKit/Superwall.swift | 15 +- SuperwallKit.xcodeproj/project.pbxproj | 14 +- .../AdServicesAttributionTests.swift | 136 ++++++++++ 10 files changed, 416 insertions(+), 52 deletions(-) create mode 100644 Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift create mode 100644 Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b19cf2820d..8e19660d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 4.15.2 +### Enhancements + +- Improves Apple Search Ads attribution capture rate. The SDK now retries the AdServices token fetch and backend post with backoff when transient errors occur (e.g. when Apple's attribution endpoint isn't ready yet right after install), and only marks the token as successfully posted after the backend confirms. Previously a single transient failure could permanently lose attribution for that install. +- Filters out the all-zeros IDFA sentinel (returned when App Tracking Transparency is denied) so it no longer pollutes the `idfa` attribute on attribution payloads. + ### Fixes - Changes the Superscript spm package repo source to a new lightweight repo meaning that the download of the package is way faster. +- Fixes a race where two near-simultaneous triggers (config arrival + app foreground) could both start an AdServices token fetch. ## 4.15.1 diff --git a/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift b/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift new file mode 100644 index 0000000000..86bfe49390 --- /dev/null +++ b/Sources/SuperwallKit/Analytics/Attribution/AdServicesAttributionAttempts.swift @@ -0,0 +1,16 @@ +// +// AdServicesAttributionAttempts.swift +// SuperwallKit +// + +import Foundation + +struct AdServicesAttributionAttempts: Codable, Equatable { + /// Total attempts that have completed (either at Apple's SDK call or the + /// backend post). + var count: Int + /// When we first tried for this install. Used to bound how long we keep + /// retrying — Apple's attribution data is only useful within ~24h of install. + var firstAttemptDate: Date + var lastAttemptDate: Date +} diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index d7657c9faa..15a3d381ec 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -43,12 +43,21 @@ final class AttributionFetcher { return nil } + // When ATT hasn't been authorized iOS returns the all-zeros UUID + // sentinel. Don't pass that through as an IDFA — it pollutes attribution + // payloads with junk that downstream MMPs treat as a real id. + if identifierValue == Self.zeroAdvertisingIdentifier { + return nil + } + return identifierValue.uuidString } #endif return nil } + private static let zeroAdvertisingIdentifier = UUID(uuidString: "00000000-0000-0000-0000-000000000000") + // should match OS availability in https://developer.apple.com/documentation/ad_services @available(iOS 14.3, tvOS 14.3, macOS 11.1, watchOS 6.2, macCatalyst 14.3, *) var adServicesToken: String? { diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 9563824ab8..7c8ab990a3 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -7,9 +7,31 @@ import Foundation import Combine +#if canImport(AdServices) +import AdServices +#endif final class AttributionPoster { + /// Hard cap on attempts across launches. Apple's attribution endpoint can be + /// unhealthy for short periods around install, so we want enough retries to + /// survive that, but not so many that we keep hammering it for users who + /// will never have attribution data. + private static let maxAttempts = 8 + + /// Maximum window from the first attempt during which we'll keep retrying. + /// Apple's attribution data is only useful within ~24h of install, so 48h + /// gives generous slack for the very-first launch happening late in the + /// window without continuing indefinitely. + private static let maxRetryWindow: TimeInterval = 48 * 60 * 60 + + /// In-session retry plan for transient errors at the SDK or network layer. + /// Backoff grows so a single bad-network pocket doesn't burn through the + /// per-install attempt budget. + private static let inSessionBackoff: [TimeInterval] = [2, 6, 15] + + private let stateQueue = DispatchQueue(label: "com.superwall.attributionposter.state") private var isCollecting = false + private var currentTask: Task? private unowned let storage: Storage private unowned let network: Network @@ -17,25 +39,6 @@ final class AttributionPoster { private unowned let attributionFetcher: AttributionFetcher private var cancellables: [AnyCancellable] = [] - private var adServicesTokenToPostIfNeeded: String? { - get async throws { - #if os(tvOS) || os(watchOS) - return nil - #else - guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { - return nil - } - - guard storage.get(AdServicesTokenStorage.self) == nil else { - return nil - } - - await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) - return try await attributionFetcher.adServicesToken - #endif - } - } - init( storage: Storage, network: Network, @@ -59,17 +62,17 @@ final class AttributionPoster { ) } - @available(iOS 14.3, *) private func listenToConfig() { + // Don't use `.first` here: if the dashboard flips Apple Search Ads from + // disabled to enabled mid-session we still want to react. configManager.configState .compactMap { $0.getConfig() } - .first { config in - config.attribution?.appleSearchAds?.enabled == true - } + .filter { $0.attribution?.appleSearchAds?.enabled == true } + .removeDuplicates { _, _ in true } // only trigger once per process .sink( receiveCompletion: { _ in }, - receiveValue: { _ in + receiveValue: { [weak self] _ in Task { [weak self] in await self?.getAdServicesTokenIfNeeded() } @@ -85,7 +88,10 @@ final class AttributionPoster { return } - Task(priority: .background) { + // .utility rather than .background — background-priority tasks can be + // deferred indefinitely under load, but the attribution token is only + // useful within ~24h of install. + Task(priority: .utility) { if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { await getAdServicesTokenIfNeeded() } @@ -93,38 +99,201 @@ final class AttributionPoster { #endif } + /// Cancel any in-flight attempt. Used during reset so we don't race a + /// completing post against the new user's state. + func cancelInFlight() { + stateQueue.sync { + currentTask?.cancel() + currentTask = nil + isCollecting = false + } + } + // Should match OS availability in https://developer.apple.com/documentation/ad_services @available(iOS 14.3, tvOS 14.3, watchOS 6.2, macOS 11.1, macCatalyst 14.3, *) @available(tvOS, unavailable) @available(watchOS, unavailable) func getAdServicesTokenIfNeeded() async { - if isCollecting { + // Single-flight: only one collection at a time. Synchronous check on the + // state queue avoids the TOCTOU race in the previous implementation. + let shouldStart: Bool = stateQueue.sync { + if isCollecting { + return false + } + isCollecting = true + return true + } + guard shouldStart else { return } defer { - isCollecting = false + stateQueue.sync { + isCollecting = false + currentTask = nil + } } - do { - isCollecting = true - guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { + + // Already successfully posted for this install. + if storage.get(AdServicesTokenStorage.self) != nil { + return + } + + guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { + return + } + + let attempts = storage.get(AdServicesAttributionAttemptsStorage.self) + if let attempts = attempts { + if attempts.count >= Self.maxAttempts { return } - guard let token = try await adServicesTokenToPostIfNeeded else { + if Date().timeIntervalSince(attempts.firstAttemptDate) > Self.maxRetryWindow { return } + } - storage.save(token, forType: AdServicesTokenStorage.self) + await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) + + let task = Task { [weak self] in + await self?.runAttempt(existingAttempts: attempts) + } + stateQueue.sync { + currentTask = task + } + await task.value + } + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + private func runAttempt(existingAttempts: AdServicesAttributionAttempts?) async { + let token: String + do { + token = try await fetchTokenWithBackoff() + } catch let error as PosterError where error == .permanentlyUnsupported { + // Don't burn the attempt budget on a device that will never have a token. await Superwall.shared.track( - InternalSuperwallEvent.AdServicesTokenRetrieval(state: .complete(token)) + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) - - let data = await network.sendToken(token) - Superwall.shared.setUserAttributes(data) + return } catch { + recordFailedAttempt(existing: existingAttempts) await Superwall.shared.track( InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) + return + } + + if Task.isCancelled { + return + } + + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .complete(token)) + ) + + do { + let response = try await postTokenWithBackoff(token: token) + if Task.isCancelled { + return + } + // The backend can explicitly tell us this user wasn't from Search Ads — + // treat that the same as success so we stop retrying. + let attribution = convertJSONToDictionary(attribution: response.attribution) + storage.save(token, forType: AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + + if !attribution.isEmpty { + Superwall.shared.setUserAttributes(attribution) + } + } catch { + recordFailedAttempt(existing: existingAttempts) } } + + /// Retrieves the token from Apple's AdServices framework, retrying transient + /// errors a few times in-session. `AAAttribution.attributionToken()` can + /// throw `networkError` very early in the app's lifecycle even though the + /// next call milliseconds later would succeed. + @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) + private func fetchTokenWithBackoff() async throws -> String { + var lastError: Error? + for delay in [0.0] + Self.inSessionBackoff { + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + if Task.isCancelled { + throw CancellationError() + } + do { + if let token = try await attributionFetcher.adServicesToken { + return token + } + throw PosterError.tokenUnavailable + } catch { + if Self.isPermanentTokenError(error) { + throw PosterError.permanentlyUnsupported + } + lastError = error + } + } + throw lastError ?? PosterError.tokenUnavailable + } + + /// Sends the token to our backend, retrying transient failures with + /// backoff. `CustomURLSession` already retries the HTTP call internally — + /// these retries cover the case where every internal retry fails but a + /// short pause yields a different result (e.g. Apple's attribution endpoint + /// catching up post-install). + private func postTokenWithBackoff(token: String) async throws -> AdServicesResponse { + var lastError: Error? + for delay in [0.0] + Self.inSessionBackoff { + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + if Task.isCancelled { + throw CancellationError() + } + do { + return try await network.sendToken(token) + } catch { + lastError = error + } + } + throw lastError ?? NetworkError.unknown + } + + private func recordFailedAttempt(existing: AdServicesAttributionAttempts?) { + let now = Date() + let updated: AdServicesAttributionAttempts + if let existing = existing { + updated = AdServicesAttributionAttempts( + count: existing.count + 1, + firstAttemptDate: existing.firstAttemptDate, + lastAttemptDate: now + ) + } else { + updated = AdServicesAttributionAttempts( + count: 1, + firstAttemptDate: now, + lastAttemptDate: now + ) + } + storage.save(updated, forType: AdServicesAttributionAttemptsStorage.self) + } + + private static func isPermanentTokenError(_ error: Error) -> Bool { + // AAAttributionErrorCode is not consistently exposed as a typed enum + // across SDK versions, so match on the NSError domain/code numerically. + // Codes 2 (.platformNotSupported) and 3 (.attributionUnsupported) are + // permanent on this device; the rest are treated as transient. + let nsError = error as NSError + guard nsError.domain == "AAAttributionErrorDomain" else { + return false + } + return nsError.code == 2 || nsError.code == 3 + } + + private enum PosterError: Error, Equatable { + case tokenUnavailable + case permanentlyUnsupported + } } diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index a0c016705e..487bc9e9ab 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -9,4 +9,11 @@ import Foundation struct AdServicesResponse: Decodable { let attribution: [String: JSON] + + // Apple's attribution endpoint can confirm a user is ineligible (e.g. they + // didn't come from a Search Ads campaign). When the backend forwards that + // signal we can stop retrying — distinct from a transient failure where the + // payload simply hasn't arrived yet. + let eligible: Bool? + let error: String? } diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index bd863ef451..370b2a5a20 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -308,13 +308,12 @@ class Network { } } - func sendToken(_ token: String) async -> [String: Any] { + func sendToken(_ token: String) async throws -> AdServicesResponse { do { - let jsonDict = try await urlSession.request( + return try await urlSession.request( .adServices(token: token), data: SuperwallRequestData(factory: factory) - ).attribution - return convertJSONToDictionary(attribution: jsonDict) + ) } catch { Logger.debug( logLevel: .error, @@ -323,7 +322,7 @@ class Network { info: ["payload": token], error: error ) - return [:] + throw error } } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 4265176d30..0304057afe 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -217,6 +217,21 @@ enum AdServicesTokenStorage: Storable { typealias Value = String } +/// Retry bookkeeping for the Apple Search Ads token post. +/// +/// The presence of an entry under ``AdServicesTokenStorage`` is treated as a +/// "successfully posted to backend" sentinel. While we're still trying, this +/// secondary record tracks how many attempts we've made and when, so we can +/// bound retries (Apple's attribution endpoint only yields useful data within +/// ~24h of install). +enum AdServicesAttributionAttemptsStorage: Storable { + static var key: String { + "store.adServicesAttributionAttempts" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = AdServicesAttributionAttempts +} + enum SK2TransactionIds: Storable { static var key: String { "store.syncedSK2TransactionIds" diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index e75668226d..f2abc389c4 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -436,15 +436,11 @@ public final class Superwall: NSObject, ObservableObject { // This task runs on a background thread, even if called from a main thread. // This is because the function isn't marked to run on the main thread, // therefore, we don't need to make this detached. + // + // Note: we don't kick off Apple Search Ads attribution here — it requires + // config to be loaded to know whether it's enabled. `AttributionPoster` + // subscribes to `configState` and fires automatically once config arrives. Task { - Task { - #if os(iOS) || os(macOS) || os(visionOS) - if #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) { - await dependencyContainer.attributionPoster.getAdServicesTokenIfNeeded() - } - #endif - } - dependencyContainer.storage.recordAppInstall(trackPlacement: track) async let fetchConfig: () = await dependencyContainer.configManager.fetchConfiguration() @@ -1028,6 +1024,9 @@ public final class Superwall: NSObject, ObservableObject { /// Asynchronously resets. Presentation of paywalls is suspended until reset completes. func reset(duringIdentify: Bool) { dependencyContainer.identityManager.reset(duringIdentify: duringIdentify) + // Cancel any in-flight attribution post before wiping its storage, so a + // late-completing post can't race the new user's state. + dependencyContainer.attributionPoster.cancelInFlight() dependencyContainer.storage.reset() dependencyContainer.paywallManager.resetCache() diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index ed9c757d65..34ab6bdc9c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -360,6 +360,7 @@ AE9F583082A5CDCE595BDA2D /* AppSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8165DD71323903F7CF426D2C /* AppSession.swift */; }; AEAB0C0C168B0B3D864109EA /* CoreDataManagerFakeDataMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E74C7DE0FAFE0C01F374DDF0 /* CoreDataManagerFakeDataMock.swift */; }; AEB9D461AF5103FB7257AD25 /* SwiftyJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */; }; + AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */; }; AF4AD928FACF9056E00D5920 /* HandleTriggerResultOperatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B27F0D55EF3480E2B65C8DFD /* HandleTriggerResultOperatorTests.swift */; }; B078481CA0ADD4B4F3BEFD15 /* LocalFileSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63B0C49F4A92D8C5C05FA026 /* LocalFileSchemeHandler.swift */; }; B0AD4A89AD5101360F93652D /* SubscriptionTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACDC7427B6340E9D86F9B0F /* SubscriptionTransaction.swift */; }; @@ -411,6 +412,7 @@ C18384B9272067CFE7C7610D /* StoreProductType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A959F1F550446C980DC5E5 /* StoreProductType.swift */; }; C22C4011CB9F4D4FA99F8206 /* FakeAudioSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 673E3BA33A9530AD1168346A /* FakeAudioSession.swift */; }; C23744AAAF31A533693281B6 /* SurveyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5ABEC694245E0DC00E409 /* SurveyManager.swift */; }; + C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */; }; C32766E26E92FD03B1BA60A3 /* ProductPurchaserSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */; }; C34A4AF2C8CD9ACBD2C370F8 /* SubscriptionPeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1443B535E6E1D572A74733F /* SubscriptionPeriod.swift */; }; C366CDBA75B69D05DC28394A /* WaitForSubsStatusAndConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731F01C2EA1AC1F06AC1499D /* WaitForSubsStatusAndConfig.swift */; }; @@ -953,6 +955,7 @@ A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileSchemeHandlerTests.swift; sourceTree = ""; }; A7C3FC26DCBF604D7319FBB2 /* zh_Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh_Hant; path = zh_Hant.lproj/Localizable.strings; sourceTree = ""; }; A7D0B6F781D32B2DCDBE689C /* PermissionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandling.swift; sourceTree = ""; }; + A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionTests.swift; sourceTree = ""; }; A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = ""; }; AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutWebViewController.swift; sourceTree = ""; }; @@ -1154,6 +1157,7 @@ F96ABDCA3686C7F1CAF41B03 /* en_GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en_GB; path = en_GB.lproj/Localizable.strings; sourceTree = ""; }; F98F66F7AA2F9F3E96849D8A /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; F9D2422F9742D74360FB716B /* TaskRetryingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRetryingTests.swift; sourceTree = ""; }; + F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionAttempts.swift; sourceTree = ""; }; FA3A82C80F89023672D56AD7 /* LogLevel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; FB28BCE1EE94BFE935B984AB /* DeviceHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelperTests.swift; sourceTree = ""; }; FBE7D1E1AF61D199E17B5C05 /* SWLocalizationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWLocalizationViewController.swift; sourceTree = ""; }; @@ -2562,6 +2566,7 @@ B24A2445833C9796434E8DA2 /* Attribution */ = { isa = PBXGroup; children = ( + F9D538EA68425ECB218BA3CA /* AdServicesAttributionAttempts.swift */, AF989B3D90DC3D88FACC4D45 /* ASIdManagerProxy.swift */, 64EAB177118BC78B02C3C00A /* AttributionFetcher.swift */, 0B31ACE25727649F21DEEBAF /* AttributionPoster.swift */, @@ -2853,6 +2858,7 @@ DEA1A1177CB76234AA134723 /* Attribution */ = { isa = PBXGroup; children = ( + A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */, 6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */, ); path = Attribution; @@ -3149,7 +3155,7 @@ ); mainGroup = 5CE8CEF97A892FFF3D0D8F06; packageReferences = ( - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */, + 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */, ); projectDirPath = ""; projectRoot = ""; @@ -3180,6 +3186,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C2A9B3F073EA27F9CD6FCA02 /* AdServicesAttributionTests.swift in Sources */, 9B49485A1CFAC2621A89B150 /* AppSessionLogicTests.swift in Sources */, 1E81A71ADE8A5EAD9E609E1D /* AppSessionManagerMock.swift in Sources */, E2E0E2A82200943E73E3A92A /* AppSessionManagerTests.swift in Sources */, @@ -3317,6 +3324,7 @@ E95C8C0485512D77E37703C5 /* ASN1Templates.swift in Sources */, 2C7867867DDAD83E5856ECC9 /* ASN1Types.swift in Sources */, 5E05FDE4F45BD5B0DF6AFB9F /* ActivityIndicatorView.swift in Sources */, + AECD80682E1909735CCDAA78 /* AdServicesAttributionAttempts.swift in Sources */, ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */, 90EE0190126A3BBA4661122E /* AddPaywallProducts.swift in Sources */, 14D4EA6EAA43647D24A9716A /* AlertControllerFactory.swift in Sources */, @@ -4019,7 +4027,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */ = { + 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/superwall/superscript-ios-next"; requirement = { @@ -4032,7 +4040,7 @@ /* Begin XCSwiftPackageProductDependency section */ 721C720FA8360B9851DE843D /* Superscript */ = { isa = XCSwiftPackageProductDependency; - package = 8DF6D231FF02D65F59962D80 /* XCRemoteSwiftPackageReference "superscript-ios-next" */; + package = 89F17188BC665EFC6FE5CEFA /* XCRemoteSwiftPackageReference "superscript-ios-next" */; productName = Superscript; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift new file mode 100644 index 0000000000..75d5cbcc67 --- /dev/null +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -0,0 +1,136 @@ +// +// AdServicesAttributionTests.swift +// SuperwallKit +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite +struct AdServicesAttributionTests { + // MARK: - AdServicesResponse decoding + + @Test + func adServicesResponse_decodesFullPayload() throws { + let json = """ + { + "attribution": { + "campaignId": 12345, + "keywordId": "kw-1" + }, + "eligible": true, + "error": null + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) + + #expect(decoded.eligible == true) + #expect(decoded.error == nil) + #expect(decoded.attribution["campaignId"]?.intValue == 12345) + #expect(decoded.attribution["keywordId"]?.stringValue == "kw-1") + } + + @Test + func adServicesResponse_decodesIneligibleResult() throws { + let json = """ + { + "attribution": {}, + "eligible": false + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) + + #expect(decoded.eligible == false) + #expect(decoded.error == nil) + #expect(decoded.attribution.isEmpty) + } + + @Test + func adServicesResponse_decodesWithoutOptionalFields() throws { + let json = """ + { + "attribution": { "campaignId": 1 } + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) + + #expect(decoded.eligible == nil) + #expect(decoded.error == nil) + #expect(decoded.attribution["campaignId"]?.intValue == 1) + } + + // MARK: - AdServicesAttributionAttempts round-trip + + @Test + func attempts_roundTripsThroughCodable() throws { + let original = AdServicesAttributionAttempts( + count: 3, + firstAttemptDate: Date(timeIntervalSince1970: 1_700_000_000), + lastAttemptDate: Date(timeIntervalSince1970: 1_700_001_000) + ) + + let data = try JSONEncoder().encode(original) + let restored = try JSONDecoder().decode(AdServicesAttributionAttempts.self, from: data) + + #expect(restored == original) + } + + @Test + func attempts_storageRoundTripsViaCache() { + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + + // Make sure we start clean — previous test runs can leave state behind. + storage.delete(AdServicesAttributionAttemptsStorage.self) + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + + let attempts = AdServicesAttributionAttempts( + count: 2, + firstAttemptDate: Date(timeIntervalSince1970: 1_700_000_000), + lastAttemptDate: Date(timeIntervalSince1970: 1_700_000_500) + ) + storage.save(attempts, forType: AdServicesAttributionAttemptsStorage.self) + + let restored = storage.get(AdServicesAttributionAttemptsStorage.self) + #expect(restored == attempts) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + // MARK: - AttributionPoster gating + + @Test + func getAdServicesToken_noOpsWhenAlreadyPosted() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + // Pretend a previous run successfully posted. + storage.save("sentinel-token", forType: AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + + await poster.getAdServicesTokenIfNeeded() + + // Sentinel should still be there and we should not have recorded a fresh + // failed attempt — the poster should have bailed before touching either. + #expect(storage.get(AdServicesTokenStorage.self) == "sentinel-token") + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + + // cleanup + storage.delete(AdServicesTokenStorage.self) + } + + @Test + func cancelInFlight_clearsCollectingFlag() { + let dependencyContainer = DependencyContainer() + // Should be a no-op when nothing is in flight, but must not crash. + dependencyContainer.attributionPoster!.cancelInFlight() + } +} From 3f2c7c32c1c316fce2826037bc21254a19db8d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 15:02:11 +0200 Subject: [PATCH 04/25] Fix outer-call cleanup race in AttributionPoster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelInFlight() only cancels the inner runAttempt task, but the outer getAdServicesTokenIfNeeded() function is blocked on `await task.value` and not tracked. When it unblocks after cancellation, its `defer` unconditionally cleared isCollecting/currentTask — which could clobber state belonging to a newly started call (e.g. the fetch reset() kicks off right after cancelInFlight). Stamp each outer call with a monotonic generation when it claims the slot, and only run the cleanup defer if we still own that generation. cancelInFlight bumps the generation so any in-flight outer call's defer becomes a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 7c8ab990a3..a97e7c10ea 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -32,6 +32,10 @@ final class AttributionPoster { private let stateQueue = DispatchQueue(label: "com.superwall.attributionposter.state") private var isCollecting = false private var currentTask: Task? + /// Monotonic generation stamped onto each outer call that claims the slot. + /// `cancelInFlight` bumps this so a late-completing call's `defer` won't + /// clobber the state belonging to a newly started call. + private var ownerGeneration: UInt64 = 0 private unowned let storage: Storage private unowned let network: Network @@ -106,6 +110,10 @@ final class AttributionPoster { currentTask?.cancel() currentTask = nil isCollecting = false + // Invalidate the in-flight outer call's ownership so its `defer` + // becomes a no-op once it unblocks from `await task.value` — otherwise + // it would clobber state belonging to a newly started call. + ownerGeneration &+= 1 } } @@ -116,18 +124,25 @@ final class AttributionPoster { func getAdServicesTokenIfNeeded() async { // Single-flight: only one collection at a time. Synchronous check on the // state queue avoids the TOCTOU race in the previous implementation. - let shouldStart: Bool = stateQueue.sync { + // The generation stamp lets the cleanup `defer` detect whether we still + // own the slot when we unblock — `cancelInFlight` may have released it + // and a new call may have already claimed it. + let myGeneration: UInt64? = stateQueue.sync { if isCollecting { - return false + return nil } isCollecting = true - return true + ownerGeneration &+= 1 + return ownerGeneration } - guard shouldStart else { + guard let myGeneration = myGeneration else { return } defer { stateQueue.sync { + guard ownerGeneration == myGeneration else { + return + } isCollecting = false currentTask = nil } From ec63850d1e31d55bc7c8987603874af4e51b94c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 15:14:34 +0200 Subject: [PATCH 05/25] Don't bookkeep failed attempts on cancellation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelInFlight (called from reset(duringIdentify:)) cancels the inner runAttempt task, which causes fetchTokenWithBackoff or postTokenWithBackoff to throw CancellationError from their Task.isCancelled checks. That was falling through to the generic catch and calling recordFailedAttempt with the pre-reset `existingAttempts` snapshot — writing a stale attempt count into the freshly cleared storage for the new user. Catch CancellationError explicitly in both call sites and return without bookkeeping. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index a97e7c10ea..327e8efb09 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -183,6 +183,12 @@ final class AttributionPoster { let token: String do { token = try await fetchTokenWithBackoff() + } catch is CancellationError { + // Cancellation means `cancelInFlight` (typically via reset) asked us to + // abandon — not a real failure. Don't bookkeep an attempt, especially + // because storage may have just been wiped and `existingAttempts` is + // stale; writing it would inflate the new user's attempt count. + return } catch let error as PosterError where error == .permanentlyUnsupported { // Don't burn the attempt budget on a device that will never have a token. await Superwall.shared.track( @@ -219,6 +225,8 @@ final class AttributionPoster { if !attribution.isEmpty { Superwall.shared.setUserAttributes(attribution) } + } catch is CancellationError { + return } catch { recordFailedAttempt(existing: existingAttempts) } From d7902f8ae9d208cc9e4655636bbe0bfde6591919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 15:15:26 +0200 Subject: [PATCH 06/25] Treat backend error as retryable; document eligible behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post response's `error` field was decoded but not consulted, so a backend error response was indistinguishable from success: we'd save the sentinel and stop retrying. Now a non-nil `error` is thrown into the retry path so the next launch/foreground tries again. Also surface the failure through the AdServicesTokenRetrieval analytics event (previously only the SDK-side fetch failure was tracked, not the post failure), and add a comment clarifying that `eligible == false` intentionally falls through to the success path — Apple has given a definitive answer that this user wasn't from Search Ads and there's nothing to retry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 327e8efb09..8c60a0e6bc 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -216,8 +216,20 @@ final class AttributionPoster { if Task.isCancelled { return } - // The backend can explicitly tell us this user wasn't from Search Ads — - // treat that the same as success so we stop retrying. + // A non-nil `error` means the backend (or Apple, via the backend) + // couldn't resolve attribution for this token. Treat as a retryable + // failure rather than burying it under the success sentinel. + if let backendError = response.error { + throw NSError( + domain: "com.superwall.attributionposter", + code: -1, + userInfo: [NSLocalizedDescriptionKey: backendError] + ) + } + // `eligible == false` is a definitive answer from Apple ("this user + // wasn't from Search Ads") — fall through to the success path so we + // save the sentinel and stop retrying. Same outcome as a non-empty + // attribution; only the user-attribute write is skipped. let attribution = convertJSONToDictionary(attribution: response.attribution) storage.save(token, forType: AdServicesTokenStorage.self) storage.delete(AdServicesAttributionAttemptsStorage.self) @@ -229,6 +241,9 @@ final class AttributionPoster { return } catch { recordFailedAttempt(existing: existingAttempts) + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) + ) } } From 10f9d741bd4923a88d472daef2b0fdce21dd1b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 15:22:36 +0200 Subject: [PATCH 07/25] =?UTF-8?q?Make=20listenToConfig=20actually=20detect?= =?UTF-8?q?=20off=E2=86=92on=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous removeDuplicates { _, _ in true } sat after .filter, so by the time it saw values they were all `true` and it suppressed every emission after the first — observably identical to the .first { ... } it replaced. The intended "re-trigger when the dashboard flag flips back on mid-session" behaviour never happened. Map config → bool first, dedup on the bool so toggles are visible, then filter to true. First-emission semantics unchanged; off→on now fires again. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 8c60a0e6bc..8f991027ac 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -68,12 +68,15 @@ final class AttributionPoster { @available(iOS 14.3, *) private func listenToConfig() { - // Don't use `.first` here: if the dashboard flips Apple Search Ads from - // disabled to enabled mid-session we still want to react. + // Track the enabled flag across config refreshes. `removeDuplicates` has + // to see the toggles to detect a transition, so we dedup on the bool + // *before* filtering. This fires once when the flag first becomes true, + // and again if it goes true → false → true mid-session. configManager.configState .compactMap { $0.getConfig() } - .filter { $0.attribution?.appleSearchAds?.enabled == true } - .removeDuplicates { _, _ in true } // only trigger once per process + .map { $0.attribution?.appleSearchAds?.enabled == true } + .removeDuplicates() + .filter { $0 } .sink( receiveCompletion: { _ in }, receiveValue: { [weak self] _ in From cae3d67de17c7664c5cf5d0c84a93ad4b73a9543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 16:20:24 +0200 Subject: [PATCH 08/25] Drop redundant outer post-side backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomURLSession.request already wraps every call in Task.retrying, and the AdServices endpoint is configured with retryCount: 3, retryInterval: 5 (Endpoint.swift). The outer postTokenWithBackoff was stacking 4 more attempts on top, meaning a single getAdServicesTokenIfNeeded could fire up to 12 HTTP requests. Call network.sendToken once and let Task.retrying handle transient transport errors. Persistent failures (including a 200 response with a non-nil `error` payload, which Task.retrying doesn't see) fall through to recordFailedAttempt and the cross-launch attempt budget picks them up on the next launch — the right level for those, since the same token retried back-to-back will get the same answer. AAAttribution.attributionToken() is NOT covered by Task.retrying, so fetchTokenWithBackoff stays — rename the constant accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 8f991027ac..6188159b6a 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -24,10 +24,11 @@ final class AttributionPoster { /// window without continuing indefinitely. private static let maxRetryWindow: TimeInterval = 48 * 60 * 60 - /// In-session retry plan for transient errors at the SDK or network layer. - /// Backoff grows so a single bad-network pocket doesn't burn through the - /// per-install attempt budget. - private static let inSessionBackoff: [TimeInterval] = [2, 6, 15] + /// In-session retry plan for transient errors from `AAAttribution.attributionToken()`, + /// which can throw `networkError` if called too soon after launch. The HTTP + /// post to our backend is already covered by `Task.retrying` inside + /// `CustomURLSession`, so we don't add an outer backoff there. + private static let tokenFetchBackoff: [TimeInterval] = [2, 6, 15] private let stateQueue = DispatchQueue(label: "com.superwall.attributionposter.state") private var isCollecting = false @@ -215,7 +216,12 @@ final class AttributionPoster { ) do { - let response = try await postTokenWithBackoff(token: token) + // CustomURLSession already wraps the request in Task.retrying (3 + // attempts × 5s for the AdServices endpoint, see Endpoint.swift), so + // we don't need our own outer backoff here. Persistent failures fall + // through to recordFailedAttempt and the next launch picks up via the + // cross-launch attempt budget. + let response = try await network.sendToken(token) if Task.isCancelled { return } @@ -257,7 +263,7 @@ final class AttributionPoster { @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) private func fetchTokenWithBackoff() async throws -> String { var lastError: Error? - for delay in [0.0] + Self.inSessionBackoff { + for delay in [0.0] + Self.tokenFetchBackoff { if delay > 0 { try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } @@ -279,29 +285,6 @@ final class AttributionPoster { throw lastError ?? PosterError.tokenUnavailable } - /// Sends the token to our backend, retrying transient failures with - /// backoff. `CustomURLSession` already retries the HTTP call internally — - /// these retries cover the case where every internal retry fails but a - /// short pause yields a different result (e.g. Apple's attribution endpoint - /// catching up post-install). - private func postTokenWithBackoff(token: String) async throws -> AdServicesResponse { - var lastError: Error? - for delay in [0.0] + Self.inSessionBackoff { - if delay > 0 { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } - if Task.isCancelled { - throw CancellationError() - } - do { - return try await network.sendToken(token) - } catch { - lastError = error - } - } - throw lastError ?? NetworkError.unknown - } - private func recordFailedAttempt(existing: AdServicesAttributionAttempts?) { let now = Date() let updated: AdServicesAttributionAttempts From 733f38261148a278daf2d48953376c3224444dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 16:54:45 +0200 Subject: [PATCH 09/25] Persist permanent-unsupported sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A permanent AAAttribution error (platformNotSupported, attributionUnsupported) was returning from runAttempt without writing any state — the attempt budget is intentionally not bumped for non-transient errors, but the success sentinel wasn't written either. On every subsequent launch all the guards passed, the SDK call ran again, and the same permanent error was raised again. Indefinitely. Add AdServicesAttributionUnsupportedStorage as a dedicated boolean sentinel for this state. Check it alongside the success sentinel and write it from the permanent-error catch so affected devices stop re-attempting. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 11 ++++++++++- Sources/SuperwallKit/Storage/Cache/CacheKeys.swift | 13 +++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 6188159b6a..54ce580ffc 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -157,6 +157,12 @@ final class AttributionPoster { return } + // This device permanently can't provide an attribution token (missing + // entitlement, unsupported platform). Stop retrying. + if storage.get(AdServicesAttributionUnsupportedStorage.self) == true { + return + } + guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { return } @@ -194,7 +200,10 @@ final class AttributionPoster { // stale; writing it would inflate the new user's attempt count. return } catch let error as PosterError where error == .permanentlyUnsupported { - // Don't burn the attempt budget on a device that will never have a token. + // Don't burn the attempt budget on a device that will never have a + // token, but do persist a sentinel so subsequent launches short-circuit + // instead of repeating the doomed SDK call indefinitely. + storage.save(true, forType: AdServicesAttributionUnsupportedStorage.self) await Superwall.shared.track( InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(error)) ) diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 0304057afe..26cc6552c4 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -232,6 +232,19 @@ enum AdServicesAttributionAttemptsStorage: Storable { typealias Value = AdServicesAttributionAttempts } +/// Set when `AAAttribution.attributionToken()` returns a permanent error +/// (e.g. `platformNotSupported` / `attributionUnsupported`). Without this, +/// such devices would re-attempt the SDK call on every launch indefinitely +/// — the attempt budget doesn't cover them because we intentionally don't +/// bump it for non-transient errors. +enum AdServicesAttributionUnsupportedStorage: Storable { + static var key: String { + "store.adServicesAttributionUnsupported" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = Bool +} + enum SK2TransactionIds: Storable { static var key: String { "store.syncedSK2TransactionIds" From 90554fad415a302c976f7fbd56d831789b684b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 17:16:45 +0200 Subject: [PATCH 10/25] Close cancellation race between track suspension and task storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelInFlight() only cancels currentTask, so it's a no-op during the window between getAdServicesTokenIfNeeded's `await track(.start)` and the subsequent `currentTask = task` write — currentTask is still nil in that window. If reset(duringIdentify:) fires there, cancel misses, storage.reset() runs, and the outer call resumes to create+await a fresh task with the pre-reset existingAttempts snapshot, which can write the old attribution sentinel into the new user's storage. After the track call, re-check ownerGeneration AND create+store the task in a single stateQueue.sync block. The combined atomicity is important: a separate re-check before task creation would still let cancelInFlight slip in between Task() and the store, leaving the new task uncancellable. Also extracted the start-condition guards into canStartAttempt() to keep the function under the cyclomatic-complexity budget. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 54ce580ffc..a0e6e566a8 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -152,40 +152,62 @@ final class AttributionPoster { } } - // Already successfully posted for this install. - if storage.get(AdServicesTokenStorage.self) != nil { + let attempts = storage.get(AdServicesAttributionAttemptsStorage.self) + guard canStartAttempt(currentAttempts: attempts) else { + return + } + + await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) + + // Re-check ownership, then create and store the task atomically inside + // the queue. `cancelInFlight()` may have fired while we were suspended + // on the track call above (before `currentTask` was ever stored), in + // which case our pre-reset `existingAttempts` snapshot is now stale and + // running runAttempt would clobber the new user's freshly reset storage. + // Doing the create+store inside the same sync block also prevents + // cancelInFlight from running between Task creation and storage, which + // would otherwise leave the task uncancellable from the outside. + let task: Task? = stateQueue.sync { + guard ownerGeneration == myGeneration else { + return nil + } + let task = Task { [weak self] in + await self?.runAttempt(existingAttempts: attempts) + } + currentTask = task + return task + } + guard let task = task else { return } + await task.value + } + /// Storage and config guards that decide whether it's worth starting a + /// fresh attempt. Pulled out of `getAdServicesTokenIfNeeded` so the main + /// flow stays under the cyclomatic-complexity budget. + private func canStartAttempt(currentAttempts: AdServicesAttributionAttempts?) -> Bool { + // Already successfully posted for this install. + if storage.get(AdServicesTokenStorage.self) != nil { + return false + } // This device permanently can't provide an attribution token (missing // entitlement, unsupported platform). Stop retrying. if storage.get(AdServicesAttributionUnsupportedStorage.self) == true { - return + return false } - guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { - return + return false } - - let attempts = storage.get(AdServicesAttributionAttemptsStorage.self) - if let attempts = attempts { + if let attempts = currentAttempts { if attempts.count >= Self.maxAttempts { - return + return false } if Date().timeIntervalSince(attempts.firstAttemptDate) > Self.maxRetryWindow { - return + return false } } - - await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) - - let task = Task { [weak self] in - await self?.runAttempt(existingAttempts: attempts) - } - stateQueue.sync { - currentTask = task - } - await task.value + return true } @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) From 395c3d1f45d2d59471ee1ce985c05fd42088e9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 17:55:36 +0200 Subject: [PATCH 11/25] Add tests for canStartAttempt guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the budget / window / unsupported-sentinel bails in the preconditions check. Each test sets up storage state that should make canStartAttempt return false, calls getAdServicesTokenIfNeeded, and asserts that storage state was not mutated (no attempts bumped, no token sentinel written). Expose maxAttempts and maxRetryWindow as internal so tests can reference them without hardcoding magic numbers. Concurrency / cancellation / network paths still need NetworkMock support and an injectable AAAttribution seam — tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 4 +- .../AdServicesAttributionTests.swift | 94 +++++++++++++++++++ 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index a0e6e566a8..d077051a8b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -16,13 +16,13 @@ final class AttributionPoster { /// unhealthy for short periods around install, so we want enough retries to /// survive that, but not so many that we keep hammering it for users who /// will never have attribution data. - private static let maxAttempts = 8 + static let maxAttempts = 8 /// Maximum window from the first attempt during which we'll keep retrying. /// Apple's attribution data is only useful within ~24h of install, so 48h /// gives generous slack for the very-first launch happening late in the /// window without continuing indefinitely. - private static let maxRetryWindow: TimeInterval = 48 * 60 * 60 + static let maxRetryWindow: TimeInterval = 48 * 60 * 60 /// In-session retry plan for transient errors from `AAAttribution.attributionToken()`, /// which can throw `networkError` if called too soon after launch. The HTTP diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift index 75d5cbcc67..5d23e775d5 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -133,4 +133,98 @@ struct AdServicesAttributionTests { // Should be a no-op when nothing is in flight, but must not crash. dependencyContainer.attributionPoster!.cancelInFlight() } + + // MARK: - canStartAttempt guards + // + // We test the guards by setting up storage state where they should bail, + // calling `getAdServicesTokenIfNeeded`, and asserting nothing was written + // or mutated. The alternative — passing the canStartAttempt guard — would + // make a real network call, so every test in this block must arrange for a + // bail. Config is left in `.retrieving` state for most of these because the + // tests don't depend on the config-enabled branch; the budget / sentinel + // checks short-circuit before the config check is reached only when the + // config-enabled guard is also failing — that's still a valid bail, and we + // assert "nothing changed" either way. + + @Test + func getAdServicesToken_bailsWhenMaxAttemptsReached() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionUnsupportedStorage.self) + let saturated = AdServicesAttributionAttempts( + count: AttributionPoster.maxAttempts, + firstAttemptDate: Date(), + lastAttemptDate: Date() + ) + storage.save(saturated, forType: AdServicesAttributionAttemptsStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + // Attempts record must NOT have been bumped — that would mean we tried + // anyway, defeating the cap. + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == saturated) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + @Test + func getAdServicesToken_bailsWhenRetryWindowExpired() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionUnsupportedStorage.self) + let firstAttempt = Date().addingTimeInterval(-AttributionPoster.maxRetryWindow - 60) + let expired = AdServicesAttributionAttempts( + count: 2, + firstAttemptDate: firstAttempt, + lastAttemptDate: firstAttempt.addingTimeInterval(30) + ) + storage.save(expired, forType: AdServicesAttributionAttemptsStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == expired) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + + storage.delete(AdServicesAttributionAttemptsStorage.self) + } + + @Test + func getAdServicesToken_bailsWhenPermanentlyUnsupported() async { + guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { + return + } + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesTokenStorage.self) + storage.delete(AdServicesAttributionAttemptsStorage.self) + storage.save(true, forType: AdServicesAttributionUnsupportedStorage.self) + dependencyContainer.configManager.configState.send(.retrieved(.stub())) + + await poster.getAdServicesTokenIfNeeded() + + // No attempts record written, no success token written — the unsupported + // sentinel is a hard stop. + #expect(storage.get(AdServicesAttributionAttemptsStorage.self) == nil) + #expect(storage.get(AdServicesTokenStorage.self) == nil) + #expect(storage.get(AdServicesAttributionUnsupportedStorage.self) == true) + + storage.delete(AdServicesAttributionUnsupportedStorage.self) + } } From 7e4f96a99e23529799c0777c24eaa6f9b44037af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 18:04:02 +0200 Subject: [PATCH 12/25] Serialize AdServicesAttributionTests suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift Testing runs tests in parallel by default, but several of these tests mutate on-disk storage at fixed keys, so concurrent runs read each other's in-flight writes. The existing AttributionTests suite uses @Suite(.serialized) for the same reason — match it here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AdServicesAttributionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift index 5d23e775d5..a80dd346ff 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -7,7 +7,7 @@ import Foundation import Testing @testable import SuperwallKit -@Suite +@Suite(.serialized) struct AdServicesAttributionTests { // MARK: - AdServicesResponse decoding From ad840c4b6de2552ed54c62728719732502954be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 13 May 2026 20:23:53 +0200 Subject: [PATCH 13/25] Drop fictional eligible/error fields from AdServicesResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against paywall-next:/apple-search-ads/token (apps/web/fapi/ adServices.ts). The real response shape is: - 200 success: { status: "ok", attribution: { ... } } - 4xx error: { status: "error", error: "..." } I had added `eligible` (doesn't exist anywhere in the response) and `error` (only present on non-2xx responses, which Task.retrying in CustomURLSession throws before we ever decode the body). The `if let backendError = response.error` block in runAttempt was dead code — those bodies are never decoded. Strip both fields. Backend errors still drive retries because Task.retrying surfaces non-2xx as a thrown URLError, which the generic catch in runAttempt already handles. Update the decoder test to use the actual backend success shape (with `status` field that we don't model and snake_case apple_search_ads_* attribution keys). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 20 ++----- .../Models/AdServicesResponse.swift | 12 ++--- .../AdServicesAttributionTests.swift | 53 +++++-------------- 3 files changed, 20 insertions(+), 65 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index d077051a8b..1f516c8e4f 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -249,27 +249,13 @@ final class AttributionPoster { do { // CustomURLSession already wraps the request in Task.retrying (3 // attempts × 5s for the AdServices endpoint, see Endpoint.swift), so - // we don't need our own outer backoff here. Persistent failures fall - // through to recordFailedAttempt and the next launch picks up via the - // cross-launch attempt budget. + // we don't need our own outer backoff here. Backend error responses + // come back as non-2xx and are thrown by Task.retrying — they end up + // in the catch below and bump the cross-launch attempt budget. let response = try await network.sendToken(token) if Task.isCancelled { return } - // A non-nil `error` means the backend (or Apple, via the backend) - // couldn't resolve attribution for this token. Treat as a retryable - // failure rather than burying it under the success sentinel. - if let backendError = response.error { - throw NSError( - domain: "com.superwall.attributionposter", - code: -1, - userInfo: [NSLocalizedDescriptionKey: backendError] - ) - } - // `eligible == false` is a definitive answer from Apple ("this user - // wasn't from Search Ads") — fall through to the success path so we - // save the sentinel and stop retrying. Same outcome as a non-empty - // attribution; only the user-attribute write is skipped. let attribution = convertJSONToDictionary(attribution: response.attribution) storage.save(token, forType: AdServicesTokenStorage.self) storage.delete(AdServicesAttributionAttemptsStorage.self) diff --git a/Sources/SuperwallKit/Models/AdServicesResponse.swift b/Sources/SuperwallKit/Models/AdServicesResponse.swift index 487bc9e9ab..948c4c7a20 100644 --- a/Sources/SuperwallKit/Models/AdServicesResponse.swift +++ b/Sources/SuperwallKit/Models/AdServicesResponse.swift @@ -8,12 +8,10 @@ import Foundation struct AdServicesResponse: Decodable { + // Backend (`paywall-next:/apple-search-ads/token`) returns + // `{ status: "ok", attribution: {...} }` on success. Error states come + // back as non-2xx with `{ status: "error", error: "..." }`, but those are + // thrown by `CustomURLSession`'s Task.retrying before we ever decode the + // body, so we only model the success shape here. let attribution: [String: JSON] - - // Apple's attribution endpoint can confirm a user is ineligible (e.g. they - // didn't come from a Search Ads campaign). When the backend forwards that - // signal we can stop retrying — distinct from a transient failure where the - // payload simply hasn't arrived yet. - let eligible: Bool? - let error: String? } diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift index a80dd346ff..fb66475313 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -12,55 +12,26 @@ struct AdServicesAttributionTests { // MARK: - AdServicesResponse decoding @Test - func adServicesResponse_decodesFullPayload() throws { + func adServicesResponse_decodesBackendSuccessShape() throws { + // Matches paywall-next:/apple-search-ads/token success body: + // { "status": "ok", "attribution": { ... } } + // We don't model `status`; we just need attribution to decode. let json = """ { + "status": "ok", "attribution": { - "campaignId": 12345, - "keywordId": "kw-1" - }, - "eligible": true, - "error": null + "apple_search_ads_campaign_id": "12345", + "apple_search_ads_keyword_id": "kw-1", + "acquisition_source": "apple_search_ads" + } } """.data(using: .utf8)! let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) - #expect(decoded.eligible == true) - #expect(decoded.error == nil) - #expect(decoded.attribution["campaignId"]?.intValue == 12345) - #expect(decoded.attribution["keywordId"]?.stringValue == "kw-1") - } - - @Test - func adServicesResponse_decodesIneligibleResult() throws { - let json = """ - { - "attribution": {}, - "eligible": false - } - """.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) - - #expect(decoded.eligible == false) - #expect(decoded.error == nil) - #expect(decoded.attribution.isEmpty) - } - - @Test - func adServicesResponse_decodesWithoutOptionalFields() throws { - let json = """ - { - "attribution": { "campaignId": 1 } - } - """.data(using: .utf8)! - - let decoded = try JSONDecoder().decode(AdServicesResponse.self, from: json) - - #expect(decoded.eligible == nil) - #expect(decoded.error == nil) - #expect(decoded.attribution["campaignId"]?.intValue == 1) + #expect(decoded.attribution["apple_search_ads_campaign_id"]?.stringValue == "12345") + #expect(decoded.attribution["apple_search_ads_keyword_id"]?.stringValue == "kw-1") + #expect(decoded.attribution["acquisition_source"]?.stringValue == "apple_search_ads") } // MARK: - AdServicesAttributionAttempts round-trip From 68f42766e05b60ba774c2fe92273e6fb88755e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 10:11:09 +0200 Subject: [PATCH 14/25] Tighten retry window from 48h to 24h MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 48h was loose hedging. Apple's docs specify the attribution token is valid for 24h after generation and posts should happen in that window. We generate a fresh token on each attempt, so token freshness isn't the binding constraint — it's Apple's install-side attribution data, which becomes unmatchable to a campaign past ~24h regardless of token freshness. Retrying for another 24h beyond that was burning attempts on requests that couldn't succeed. The existing test references the constant via AttributionPoster.maxRetryWindow, so it stays correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 1f516c8e4f..901c4264f9 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -19,10 +19,12 @@ final class AttributionPoster { static let maxAttempts = 8 /// Maximum window from the first attempt during which we'll keep retrying. - /// Apple's attribution data is only useful within ~24h of install, so 48h - /// gives generous slack for the very-first launch happening late in the - /// window without continuing indefinitely. - static let maxRetryWindow: TimeInterval = 48 * 60 * 60 + /// Apple's docs say the attribution token is valid for 24h after it's + /// generated and that posts should happen within that window. We generate + /// a fresh token on every attempt, but the first attempt's clock is what + /// bounds Apple's install-side attribution data — past 24h, even a fresh + /// token won't resolve to a campaign. + static let maxRetryWindow: TimeInterval = 24 * 60 * 60 /// In-session retry plan for transient errors from `AAAttribution.attributionToken()`, /// which can throw `networkError` if called too soon after launch. The HTTP From b97bfe538c422ae7c6826be811d16d1cbc3aa726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 10:16:20 +0200 Subject: [PATCH 15/25] Drop redundant inner [weak self] in listenToConfig sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outer closure already captures self weakly, so inside it `self` is a Self? optional. Re-capturing that with [weak self] on the inner Task doesn't make it weaker — it just creates a second weak reference to the same underlying object. The `self?.` at the call site works either way because the outer self is already optional. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SuperwallKit/Analytics/Attribution/AttributionPoster.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 901c4264f9..362ef6bf35 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -83,7 +83,7 @@ final class AttributionPoster { .sink( receiveCompletion: { _ in }, receiveValue: { [weak self] _ in - Task { [weak self] in + Task { await self?.getAdServicesTokenIfNeeded() } } From f163b3b7ce78ccd9ab573b186c744ff1b4da52fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 11:21:49 +0200 Subject: [PATCH 16/25] Make ASA attribution install-scoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Search Ads attribution is install-scoped: the campaign that drove the install is a fixed device-level fact that doesn't change when a user logs out and another logs in. The previous design wiped the attribution sentinel on reset() and re-fetched per user, which works within Apple's 24h post-install window but silently fails past it — and burns extra Apple traffic even when it succeeds, since the underlying campaign is the same. Changes: - All AdServices storables (token sentinel, attempts, unsupported) move to .appSpecificDocuments so they survive reset(duringIdentify:). - New AdServicesAttributionDataStorage caches the decoded attribution dict, also app-scoped. - runAttempt now writes the dict to that cache on success. - New AttributionPoster.reapplyCachedAttribution() reads the cache and calls setUserAttributes — wired into reset(duringIdentify:) after storage.reset() wipes user files, so the new user inherits the install-scoped campaign keys without re-fetching. - Migration: on AttributionPoster init, if the old user-specific token exists and the new app-specific one doesn't, copy it over. The legacy file is left in place (its on-disk key collides with the new one in memCache; a delete would evict the entry we just wrote). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + .../Attribution/AttributionPoster.swift | 51 ++++++++++++++++--- .../Storage/Cache/CacheKeys.swift | 39 ++++++++++++-- Sources/SuperwallKit/Superwall.swift | 5 ++ .../AdServicesAttributionTests.swift | 35 +++++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e19660d12..d79dc0add0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Improves Apple Search Ads attribution capture rate. The SDK now retries the AdServices token fetch and backend post with backoff when transient errors occur (e.g. when Apple's attribution endpoint isn't ready yet right after install), and only marks the token as successfully posted after the backend confirms. Previously a single transient failure could permanently lose attribution for that install. +- Apple Search Ads attribution is now install-scoped: once the campaign data is fetched for a device, it's cached and re-applied to any new user identity on `reset()`/`identify()`. Previously each new user would trigger a fresh fetch, which silently failed past Apple's 24h post-install window. Existing users are migrated transparently on first launch. - Filters out the all-zeros IDFA sentinel (returned when App Tracking Transparency is denied) so it no longer pollutes the `idfa` attribute on attribution payloads. ### Fixes diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 362ef6bf35..91f1aa3fb8 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -57,6 +57,8 @@ final class AttributionPoster { self.configManager = configManager self.attributionFetcher = attributionFetcher + Self.migrateLegacyTokenSentinelIfNeeded(storage: storage) + if #available(iOS 14.3, *) { listenToConfig() } @@ -109,6 +111,38 @@ final class AttributionPoster { #endif } + /// Moves the AdServices token sentinel from the legacy user-specific + /// location to the new app-specific location on first launch after upgrade. + /// Without this, existing users would look "never attributed" and we'd + /// hammer Apple with retries even though we already finished for them. + private static func migrateLegacyTokenSentinelIfNeeded(storage: Storage) { + if storage.get(AdServicesTokenStorage.self) != nil { + return + } + guard let legacyToken = storage.get(LegacyUserScopedAdServicesTokenStorage.self) else { + return + } + storage.save(legacyToken, forType: AdServicesTokenStorage.self) + // We don't delete the legacy file — its on-disk key collides with the + // new one in the memCache layer, so a delete would evict the entry we + // just wrote. The orphan file is ~50 bytes and harmless. + } + + /// Re-applies the cached ASA attribution dict to the current user's + /// attributes. Called from `reset(duringIdentify:)` after user files are + /// wiped so the new user identity inherits the install-scoped campaign + /// keys without us re-hitting Apple. No-op if nothing was ever fetched. + func reapplyCachedAttribution() { + guard let cached = storage.get(AdServicesAttributionDataStorage.self) else { + return + } + let attribution = convertJSONToDictionary(attribution: cached) + if attribution.isEmpty { + return + } + Superwall.shared.setUserAttributes(attribution) + } + /// Cancel any in-flight attempt. Used during reset so we don't race a /// completing post against the new user's state. func cancelInFlight() { @@ -258,10 +292,11 @@ final class AttributionPoster { if Task.isCancelled { return } - let attribution = convertJSONToDictionary(attribution: response.attribution) storage.save(token, forType: AdServicesTokenStorage.self) + storage.save(response.attribution, forType: AdServicesAttributionDataStorage.self) storage.delete(AdServicesAttributionAttemptsStorage.self) + let attribution = convertJSONToDictionary(attribution: response.attribution) if !attribution.isEmpty { Superwall.shared.setUserAttributes(attribution) } @@ -322,8 +357,15 @@ final class AttributionPoster { } storage.save(updated, forType: AdServicesAttributionAttemptsStorage.self) } +} + +private enum PosterError: Error, Equatable { + case tokenUnavailable + case permanentlyUnsupported +} - private static func isPermanentTokenError(_ error: Error) -> Bool { +extension AttributionPoster { + static func isPermanentTokenError(_ error: Error) -> Bool { // AAAttributionErrorCode is not consistently exposed as a typed enum // across SDK versions, so match on the NSError domain/code numerically. // Codes 2 (.platformNotSupported) and 3 (.attributionUnsupported) are @@ -334,9 +376,4 @@ final class AttributionPoster { } return nsError.code == 2 || nsError.code == 3 } - - private enum PosterError: Error, Equatable { - case tokenUnavailable - case permanentlyUnsupported - } } diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index 26cc6552c4..b9a0e50750 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -209,11 +209,18 @@ enum LatestEnrichment: Storable { typealias Value = Enrichment } +/// Apple Search Ads attribution is install-scoped — the campaign that drove +/// the install doesn't change when a user logs out and another user logs in +/// on the same device. So all the AdServices state lives in +/// `.appSpecificDocuments`, surviving `reset(duringIdentify:)`. The cached +/// attribution dict (``AdServicesAttributionDataStorage``) is re-applied to +/// the new user's attributes after a reset so they pick up the same +/// `apple_search_ads_*` / `acquisition_*` keys without re-fetching. enum AdServicesTokenStorage: Storable { static var key: String { "store.adServicesToken" } - static var directory: SearchPathDirectory = .userSpecificDocuments + static var directory: SearchPathDirectory = .appSpecificDocuments typealias Value = String } @@ -228,7 +235,7 @@ enum AdServicesAttributionAttemptsStorage: Storable { static var key: String { "store.adServicesAttributionAttempts" } - static var directory: SearchPathDirectory = .userSpecificDocuments + static var directory: SearchPathDirectory = .appSpecificDocuments typealias Value = AdServicesAttributionAttempts } @@ -241,10 +248,36 @@ enum AdServicesAttributionUnsupportedStorage: Storable { static var key: String { "store.adServicesAttributionUnsupported" } - static var directory: SearchPathDirectory = .userSpecificDocuments + static var directory: SearchPathDirectory = .appSpecificDocuments typealias Value = Bool } +/// The decoded attribution payload from the backend, cached so we can +/// re-apply it to a new user's attributes after `reset(duringIdentify:)`. +/// Install-scoped: the same campaign keys apply to whichever user is logged +/// in on this install. +enum AdServicesAttributionDataStorage: Storable { + static var key: String { + "store.adServicesAttributionData" + } + static var directory: SearchPathDirectory = .appSpecificDocuments + typealias Value = [String: JSON] +} + +/// Read-side shim for the pre-install-scoped location of the token sentinel. +/// Older SDK versions wrote ``AdServicesTokenStorage`` to user-specific +/// documents. On first launch after upgrade we migrate that value over to the +/// new app-specific location so existing users aren't re-attempted. +enum LegacyUserScopedAdServicesTokenStorage: Storable { + static var key: String { + // Same key string as AdServicesTokenStorage so we hit the same on-disk + // filename, just in the legacy directory. + "store.adServicesToken" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = String +} + enum SK2TransactionIds: Storable { static var key: String { "store.syncedSK2TransactionIds" diff --git a/Sources/SuperwallKit/Superwall.swift b/Sources/SuperwallKit/Superwall.swift index f2abc389c4..6d3741917f 100644 --- a/Sources/SuperwallKit/Superwall.swift +++ b/Sources/SuperwallKit/Superwall.swift @@ -1029,6 +1029,11 @@ public final class Superwall: NSObject, ObservableObject { dependencyContainer.attributionPoster.cancelInFlight() dependencyContainer.storage.reset() + // ASA attribution is install-scoped, so re-apply the cached campaign + // dict to the new user's attributes after the wipe. AdServices state + // itself lives in app-specific storage and is preserved through reset. + dependencyContainer.attributionPoster.reapplyCachedAttribution() + dependencyContainer.paywallManager.resetCache() presentationItems.reset() Task { diff --git a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift index fb66475313..f5c82e735d 100644 --- a/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift +++ b/Tests/SuperwallKitTests/Analytics/Attribution/AdServicesAttributionTests.swift @@ -174,6 +174,41 @@ struct AdServicesAttributionTests { storage.delete(AdServicesAttributionAttemptsStorage.self) } + @Test + func attributionData_roundTripsThroughStorage() { + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + + storage.delete(AdServicesAttributionDataStorage.self) + + let cached: [String: JSON] = [ + "apple_search_ads_campaign_id": "campaign-123", + "acquisition_source": "apple_search_ads" + ] + storage.save(cached, forType: AdServicesAttributionDataStorage.self) + + let restored = storage.get(AdServicesAttributionDataStorage.self) + #expect(restored?["apple_search_ads_campaign_id"]?.stringValue == "campaign-123") + #expect(restored?["acquisition_source"]?.stringValue == "apple_search_ads") + + storage.delete(AdServicesAttributionDataStorage.self) + } + + @Test + func reapplyCachedAttribution_noCrashWhenNothingCached() { + // The user-attribute side effect happens on Superwall.shared (the global + // singleton) and is detached from the test's local DependencyContainer, + // so asserting on the resulting attributes from inside this test isn't + // reliable. Just confirm the no-op path doesn't crash with empty state. + let dependencyContainer = DependencyContainer() + let storage = dependencyContainer.storage! + let poster = dependencyContainer.attributionPoster! + + storage.delete(AdServicesAttributionDataStorage.self) + + poster.reapplyCachedAttribution() + } + @Test func getAdServicesToken_bailsWhenPermanentlyUnsupported() async { guard #available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) else { From 9ad9582b7dfe21d1c9da572d17ffa7c269542d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 13:58:26 +0200 Subject: [PATCH 17/25] Wire AdServices token migration through V4Migrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the inline migration in AttributionPoster.init with a proper V4Migrator that fits the existing FileManagerMigrator pattern: - Bump DataStoreVersion to v5. - New V4Migrator (v4 → v5) reads the legacy user-specific AdServicesTokenStorage, writes it back at the new app-specific location, deletes the legacy file. Runs once via the version-keyed migration chain Storage.migrateData() already calls. - LegacyUserScopedAdServicesTokenStorage moves out of CacheKeys.swift and lives next to the migrator, matching V3Migrator's pattern with LegacyLatestRedeemResponse. The memCache delete-collision concern from the old inline approach doesn't apply here: migration runs once at Storage init, before any other code reads AdServicesTokenStorage, so deleting the legacy entry doesn't evict a "just-written" memCache entry that any caller depends on — the next read just falls through to the new app-specific disk file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 19 ---------- .../Storage/Cache/CacheKeys.swift | 14 -------- .../Migration/FileManagerMigrator.swift | 3 ++ .../Storage/Migration/V4Migrator.swift | 36 +++++++++++++++++++ SuperwallKit.xcodeproj/project.pbxproj | 4 +++ 5 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 Sources/SuperwallKit/Storage/Migration/V4Migrator.swift diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 91f1aa3fb8..33a2f31191 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -57,8 +57,6 @@ final class AttributionPoster { self.configManager = configManager self.attributionFetcher = attributionFetcher - Self.migrateLegacyTokenSentinelIfNeeded(storage: storage) - if #available(iOS 14.3, *) { listenToConfig() } @@ -111,23 +109,6 @@ final class AttributionPoster { #endif } - /// Moves the AdServices token sentinel from the legacy user-specific - /// location to the new app-specific location on first launch after upgrade. - /// Without this, existing users would look "never attributed" and we'd - /// hammer Apple with retries even though we already finished for them. - private static func migrateLegacyTokenSentinelIfNeeded(storage: Storage) { - if storage.get(AdServicesTokenStorage.self) != nil { - return - } - guard let legacyToken = storage.get(LegacyUserScopedAdServicesTokenStorage.self) else { - return - } - storage.save(legacyToken, forType: AdServicesTokenStorage.self) - // We don't delete the legacy file — its on-disk key collides with the - // new one in the memCache layer, so a delete would evict the entry we - // just wrote. The orphan file is ~50 bytes and harmless. - } - /// Re-applies the cached ASA attribution dict to the current user's /// attributes. Called from `reset(duringIdentify:)` after user files are /// wiped so the new user identity inherits the install-scoped campaign diff --git a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift index b9a0e50750..6a42dc57ba 100644 --- a/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift +++ b/Sources/SuperwallKit/Storage/Cache/CacheKeys.swift @@ -264,20 +264,6 @@ enum AdServicesAttributionDataStorage: Storable { typealias Value = [String: JSON] } -/// Read-side shim for the pre-install-scoped location of the token sentinel. -/// Older SDK versions wrote ``AdServicesTokenStorage`` to user-specific -/// documents. On first launch after upgrade we migrate that value over to the -/// new app-specific location so existing users aren't re-attempted. -enum LegacyUserScopedAdServicesTokenStorage: Storable { - static var key: String { - // Same key string as AdServicesTokenStorage so we hit the same on-disk - // filename, just in the legacy directory. - "store.adServicesToken" - } - static var directory: SearchPathDirectory = .userSpecificDocuments - typealias Value = String -} - enum SK2TransactionIds: Storable { static var key: String { "store.syncedSK2TransactionIds" diff --git a/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift b/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift index e32df3f70b..8e137900c5 100644 --- a/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift +++ b/Sources/SuperwallKit/Storage/Migration/FileManagerMigrator.swift @@ -13,6 +13,7 @@ enum DataStoreVersion: Int, CaseIterable, Codable { case v2 case v3 case v4 + case v5 } enum FileManagerMigrator { @@ -34,6 +35,8 @@ enum FileManagerMigrator { case .v3: V3Migrator.migrateToNextVersion(cache: cache) case .v4: + V4Migrator.migrateToNextVersion(cache: cache) + case .v5: break } diff --git a/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift b/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift new file mode 100644 index 0000000000..35618d6e9b --- /dev/null +++ b/Sources/SuperwallKit/Storage/Migration/V4Migrator.swift @@ -0,0 +1,36 @@ +// +// V4Migrator.swift +// SuperwallKit +// + +import Foundation + +/// Pre-v5, the AdServices token sentinel lived in `.userSpecificDocuments` — +/// it was scoped per-user so that `reset(duringIdentify:)` would wipe it and a +/// new user could re-fetch. Apple Search Ads attribution is install-scoped +/// (the campaign that drove the install doesn't change with who's logged in), +/// so as of v5 the sentinel lives in `.appSpecificDocuments` and survives +/// `reset`. This shim reads from the legacy directory during migration. +enum LegacyUserScopedAdServicesTokenStorage: Storable { + static var key: String { + // Same key string as AdServicesTokenStorage — only the directory differs. + "store.adServicesToken" + } + static var directory: SearchPathDirectory = .userSpecificDocuments + typealias Value = String +} + +/// Migrates the AdServices token sentinel from user-specific to app-specific +/// storage so existing attributed users aren't seen as "never attributed" +/// after upgrade and re-attempted. +enum V4Migrator: Migratable { + static func migrateToNextVersion(cache: Cache) { + if cache.read(AdServicesTokenStorage.self) == nil, + let legacyToken = cache.read(LegacyUserScopedAdServicesTokenStorage.self) { + cache.write(legacyToken, forType: AdServicesTokenStorage.self) + cache.delete(LegacyUserScopedAdServicesTokenStorage.self, fromDirectory: .userSpecificDocuments) + } + + cache.write(.v5, forType: Version.self) + } +} diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 34ab6bdc9c..e1bb2f4e0e 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 3EA92DE86764CBAC557F8522 /* Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E41300E7467F017BCD5E5C /* Capabilities.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 */; }; 40314E44991DCB66B4572C25 /* LocationPermissionDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4623C4E5EDA10B38746C384 /* LocationPermissionDelegateTests.swift */; }; 40E6B7996E9BA4B5D65F1432 /* RedemptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = B382F84E55D4D8C37A6021E3 /* RedemptionResult.swift */; }; 412C74624BB8933162DAAC5F /* Experiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5836EFACFA00594CE8F9F377 /* Experiment.swift */; }; @@ -1052,6 +1053,7 @@ C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternallySetSubscriptionStatusTests.swift; sourceTree = ""; }; C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Location.swift"; sourceTree = ""; }; C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenListener.swift; sourceTree = ""; }; + C9D685A5912892EF9C2931B1 /* V4Migrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V4Migrator.swift; sourceTree = ""; }; CA65A320EE640CDB878F43E9 /* UIWindow+Landscape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+Landscape.swift"; sourceTree = ""; }; CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewMessageTests.swift; sourceTree = ""; }; CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasingCoordinator.swift; sourceTree = ""; }; @@ -1214,6 +1216,7 @@ 4BE6E7AC2E38E0EB43C35AF5 /* V1Migrator.swift */, 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */, 891BDDF19DEC970709DDF4BB /* V3Migrator.swift */, + C9D685A5912892EF9C2931B1 /* V4Migrator.swift */, ); path = Migration; sourceTree = ""; @@ -3722,6 +3725,7 @@ 519196E0035C17C73C525526 /* V2Migrator.swift in Sources */, 12A2722FD932939699923E60 /* V2ProductsResponse.swift in Sources */, 9F517D76DDEFF5278DDBACC8 /* V3Migrator.swift in Sources */, + 3F6DD6FB62BDF53536FC4EF7 /* V4Migrator.swift in Sources */, 7DCBF8A78D0B6A7A2649553D /* Validation.swift in Sources */, 5C504112376B6E0798CA20CE /* Variables.swift in Sources */, DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */, From da17853202f93a6236cdff51356a6f5a84737bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 14:37:34 +0200 Subject: [PATCH 18/25] Fix and extend migrator tests for V4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrateFromV1ToV4 now runs through V4 too, so the final version is v5 — rename and update the expectation. The migrateRedeemResponseFromV3 test calls V3Migrator directly, so its .v4 expectation is unchanged. Add three focused V4Migrator tests: - Happy path: legacy user-specific token moves to app-specific, legacy file is deleted, version bumps to v5. - No legacy data: version still bumps to v5, nothing else touched. - Both legacy and new present: don't overwrite the new value. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Migration/FileManagerMigratorTests.swift | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift b/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift index 2a62e55f1a..2c08062606 100644 --- a/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift +++ b/Tests/SuperwallKitTests/Storage/Migration/FileManagerMigratorTests.swift @@ -12,7 +12,7 @@ import Foundation struct FileManagerMigratorTests { @Test - func migrateFromV1ToV4() { + func migrateFromV1ToV5() { let cache = CacheMock() // Write all possible values to the cache. @@ -57,9 +57,9 @@ struct FileManagerMigratorTests { #expect(newAssignments?.first?.experimentId == experimentId) #expect(newAssignments?.first?.variant == variant) - // Check the new version is v4 + // Check the new version is v5 let version = cache.read(Version.self) - #expect(version == .v4) + #expect(version == .v5) } @Test @@ -118,4 +118,51 @@ struct FileManagerMigratorTests { let version = cache.read(Version.self) #expect(version == .v4) } + + @Test + func migrateAdServicesTokenFromV4ToV5() { + let cache = CacheMock() + + // Pre-v5 state: token sentinel in user-specific docs. + cache.write("legacy-token", forType: LegacyUserScopedAdServicesTokenStorage.self) + cache.write(.v4, forType: Version.self) + + #expect(cache.read(LegacyUserScopedAdServicesTokenStorage.self) == "legacy-token") + #expect(cache.read(AdServicesTokenStorage.self) == nil) + + V4Migrator.migrateToNextVersion(cache: cache) + + // Token moved to app-specific, legacy file gone. + #expect(cache.read(AdServicesTokenStorage.self) == "legacy-token") + #expect(cache.read(LegacyUserScopedAdServicesTokenStorage.self) == nil) + #expect(cache.read(Version.self) == .v5) + } + + @Test + func migrateAdServicesTokenFromV4ToV5_noLegacyData() { + let cache = CacheMock() + cache.write(.v4, forType: Version.self) + + V4Migrator.migrateToNextVersion(cache: cache) + + // Nothing to migrate — version still bumps to v5. + #expect(cache.read(AdServicesTokenStorage.self) == nil) + #expect(cache.read(Version.self) == .v5) + } + + @Test + func migrateAdServicesTokenFromV4ToV5_doesntOverwriteExisting() { + let cache = CacheMock() + + // Both legacy and new exist (shouldn't happen in practice, but defend + // against it — the new value wins). + cache.write("legacy", forType: LegacyUserScopedAdServicesTokenStorage.self) + cache.write("current", forType: AdServicesTokenStorage.self) + cache.write(.v4, forType: Version.self) + + V4Migrator.migrateToNextVersion(cache: cache) + + #expect(cache.read(AdServicesTokenStorage.self) == "current") + #expect(cache.read(Version.self) == .v5) + } } From a44acb5b698a878138c2a937aa4fc9b1495d565c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 14:45:50 +0200 Subject: [PATCH 19/25] Only blacklist platformNotSupported (drop incorrect internalError check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous check matched code 2 OR 3 against the AAAttributionErrorDomain. From Apple's AAAttribution.h: AAAttributionErrorCodeNetworkError = 1 (transient) AAAttributionErrorCodeInternalError = 2 (transient) AAAttributionErrorCodePlatformNotSupported = 3 (permanent) So matching code 2 was wrong — internalError is documented as "unable to provide a token because of an internal error", i.e. a server-side issue worth retrying, not a permanent device state. The fictional `attributionUnsupported` from the old comment doesn't exist in the API. Match only code 3 (`platformNotSupported`) and name the constant with a citation to the Apple header. We don't reference the Swift `AAAttributionErrorCode` enum directly because NS_ERROR_ENUM types don't import reliably across SDK versions ("cannot find 'AAAttributionErrorCode' in scope" at compile time). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionPoster.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 33a2f31191..12dca8120b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -346,15 +346,25 @@ private enum PosterError: Error, Equatable { } extension AttributionPoster { + /// AdServices.framework error code for `platformNotSupported`. From + /// Apple's `AAAttribution.h`: + /// + /// AAAttributionErrorCodeNetworkError = 1, // transient + /// AAAttributionErrorCodeInternalError = 2, // transient + /// AAAttributionErrorCodePlatformNotSupported = 3, // permanent + /// + /// `NS_ERROR_ENUM` doesn't reliably import as a Swift symbol across SDK + /// versions, so we match on the raw code with the constant clearly named. + private static let platformNotSupportedCode = 3 + static func isPermanentTokenError(_ error: Error) -> Bool { - // AAAttributionErrorCode is not consistently exposed as a typed enum - // across SDK versions, so match on the NSError domain/code numerically. - // Codes 2 (.platformNotSupported) and 3 (.attributionUnsupported) are - // permanent on this device; the rest are treated as transient. + // Only `platformNotSupported` is permanent — this OS/app config will + // never produce a token. `networkError` and `internalError` are both + // transient and should keep retrying within the per-install budget. let nsError = error as NSError guard nsError.domain == "AAAttributionErrorDomain" else { return false } - return nsError.code == 2 || nsError.code == 3 + return nsError.code == platformNotSupportedCode } } From 085545ff9559ee250a910bcbec9cd14404b27363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 15:18:59 +0200 Subject: [PATCH 20/25] Propagate cancellation from Task.sleep in fetchTokenWithBackoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backoff loop used `try?` on Task.sleep, which silently discards the CancellationError that Task.sleep throws when its task is cancelled. The next Task.isCancelled check only fires after the sleep completes, so cancelInFlight() during a 15s backoff delay would leave the inner task running until the full sleep elapsed — defeating the point of cancellation during reset(duringIdentify:). Use `try` so the CancellationError unwinds immediately through fetchTokenWithBackoff and into runAttempt's `catch is CancellationError` handler, which already returns without bookkeeping. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 12dca8120b..b4c144720e 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -300,7 +300,10 @@ final class AttributionPoster { var lastError: Error? for delay in [0.0] + Self.tokenFetchBackoff { if delay > 0 { - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + // Propagate cancellation from the sleep itself — using `try?` would + // swallow CancellationError and force callers waiting up to 15s for + // the next post-sleep `Task.isCancelled` check to notice. + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } if Task.isCancelled { throw CancellationError() From ede372878fd3f75fdcab4ddde9ec7d96b1f7400c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 16:33:19 +0200 Subject: [PATCH 21/25] Tighten listenToConfig task priority and isPermanentTokenError scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Task in the config sink now uses .utility, matching the foreground call site. Default-priority tasks can be deferred under load, and attribution is bounded by Apple's 24h install window. - isPermanentTokenError is private — it's only used inside this file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionPoster.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index b4c144720e..9cca0584f1 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -83,7 +83,10 @@ final class AttributionPoster { .sink( receiveCompletion: { _ in }, receiveValue: { [weak self] _ in - Task { + // Match applicationWillEnterForeground's priority: default-priority + // tasks can be deferred under load, and attribution is bounded by + // Apple's 24h install window. + Task(priority: .utility) { await self?.getAdServicesTokenIfNeeded() } } @@ -360,7 +363,7 @@ extension AttributionPoster { /// versions, so we match on the raw code with the constant clearly named. private static let platformNotSupportedCode = 3 - static func isPermanentTokenError(_ error: Error) -> Bool { + private static func isPermanentTokenError(_ error: Error) -> Bool { // Only `platformNotSupported` is permanent — this OS/app config will // never produce a token. `networkError` and `internalError` are both // transient and should keep retrying within the per-install budget. From 8dbb96c910ecab237121c4d4fe1453db46c8c35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 16:34:26 +0200 Subject: [PATCH 22/25] Update changelog --- CHANGELOG.md | 4 +--- .../project.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d79dc0add0..844598f64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Improves Apple Search Ads attribution capture rate. The SDK now retries the AdServices token fetch and backend post with backoff when transient errors occur (e.g. when Apple's attribution endpoint isn't ready yet right after install), and only marks the token as successfully posted after the backend confirms. Previously a single transient failure could permanently lose attribution for that install. -- Apple Search Ads attribution is now install-scoped: once the campaign data is fetched for a device, it's cached and re-applied to any new user identity on `reset()`/`identify()`. Previously each new user would trigger a fresh fetch, which silently failed past Apple's 24h post-install window. Existing users are migrated transparently on first launch. +- Improves Apple Search Ads attribution capture rate. - Filters out the all-zeros IDFA sentinel (returned when App Tracking Transparency is denied) so it no longer pollutes the `idfa` attribute on attribution payloads. ### Fixes - Changes the Superscript spm package repo source to a new lightweight repo meaning that the download of the package is way faster. -- Fixes a race where two near-simultaneous triggers (config arrival + app foreground) could both start an AdServices token fetch. ## 4.15.1 diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b898197654..140cce312f 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "a06accc1543fcedc3094d4b5b9a40b84e2213e8e", - "version": "5.69.0" + "revision": "6b95744e70f1edc43f89f2b522b0832ddfdd41a1", + "version": "5.73.0" } }, { From 90722d8ccdc9d07bc3bdcfb14e324ca2cfbae00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 16:53:27 +0200 Subject: [PATCH 23/25] Pair every analytics .start with a terminal; skip dev simulator path early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1: track(.start) was emitted from the outer getAdServicesTokenIfNeeded before the ownership re-check, so a cancelInFlight() that fired while we were suspended on the track call left an orphan .start with no .complete or .fail. Move .start into runAttempt at the top (guarded by a Task.isCancelled check) so it only fires when the inner task actually runs. Both `catch is CancellationError` branches now emit .fail(CancellationError()) so every .start has a paired terminal. Issue 2: simulator-without-mock and !canImport(AdServices) builds were hitting fetchTokenWithBackoff, throwing PosterError.tokenUnavailable (classified as transient), and burning ~23s of sleep plus one of the 8 cross-launch attempts on every dev launch. Add AttributionFetcher.canProduceAdServicesToken and check it in canStartAttempt so we skip cleanly before claiming the slot — no backoff burn, no attempts bookkeeping. Recovers automatically when the developer adds SUPERWALL_MOCK_AD_SERVICES_TOKEN and relaunches. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Attribution/AttributionFetcher.swift | 17 +++++++ .../Attribution/AttributionPoster.swift | 44 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index 15a3d381ec..8a83487db5 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -58,6 +58,23 @@ final class AttributionFetcher { private static let zeroAdvertisingIdentifier = UUID(uuidString: "00000000-0000-0000-0000-000000000000") + /// Whether this build/environment can ever produce an AdServices token. + /// `false` on builds that didn't link AdServices.framework and on debug + /// simulator runs without `SUPERWALL_MOCK_AD_SERVICES_TOKEN`. Lets the + /// poster short-circuit before entering its 23s backoff schedule in + /// development. + var canProduceAdServicesToken: Bool { + #if !canImport(AdServices) + return false + #else + #if targetEnvironment(simulator) && DEBUG + return ProcessInfo.processInfo.environment["SUPERWALL_MOCK_AD_SERVICES_TOKEN"] != nil + #else + return true + #endif + #endif + } + // should match OS availability in https://developer.apple.com/documentation/ad_services @available(iOS 14.3, tvOS 14.3, macOS 11.1, watchOS 6.2, macCatalyst 14.3, *) var adServicesToken: String? { diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift index 9cca0584f1..a6ba3ab84d 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift @@ -177,16 +177,12 @@ final class AttributionPoster { return } - await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) - - // Re-check ownership, then create and store the task atomically inside - // the queue. `cancelInFlight()` may have fired while we were suspended - // on the track call above (before `currentTask` was ever stored), in - // which case our pre-reset `existingAttempts` snapshot is now stale and - // running runAttempt would clobber the new user's freshly reset storage. - // Doing the create+store inside the same sync block also prevents - // cancelInFlight from running between Task creation and storage, which - // would otherwise leave the task uncancellable from the outside. + // Atomic ownership re-check + task creation + store. We don't track + // `.start` here — it's deferred into `runAttempt` so we don't leak an + // unpaired event if cancelInFlight raced us. Creating and storing the + // task inside the same sync block also prevents cancelInFlight from + // running between Task() and the store, which would leave the task + // uncancellable from the outside. let task: Task? = stateQueue.sync { guard ownerGeneration == myGeneration else { return nil @@ -216,6 +212,13 @@ final class AttributionPoster { if storage.get(AdServicesAttributionUnsupportedStorage.self) == true { return false } + // No-token environment (simulator without SUPERWALL_MOCK_AD_SERVICES_TOKEN, + // build without AdServices.framework). Skip without burning an attempt — + // the developer might add the env var and relaunch, or this might just + // be the simulator path of a build that works on real devices. + if !attributionFetcher.canProduceAdServicesToken { + return false + } guard configManager.config?.attribution?.appleSearchAds?.enabled == true else { return false } @@ -232,6 +235,14 @@ final class AttributionPoster { @available(iOS 14.3, macOS 11.1, macCatalyst 14.3, *) private func runAttempt(existingAttempts: AdServicesAttributionAttempts?) async { + // Fire `.start` from inside the task body so the event isn't orphaned if + // `cancelInFlight` raced the outer call's ownership re-check. If we're + // already cancelled by the time this runs, skip — no `.start`, no orphan. + if Task.isCancelled { + return + } + await Superwall.shared.track(InternalSuperwallEvent.AdServicesTokenRetrieval(state: .start)) + let token: String do { token = try await fetchTokenWithBackoff() @@ -239,7 +250,12 @@ final class AttributionPoster { // Cancellation means `cancelInFlight` (typically via reset) asked us to // abandon — not a real failure. Don't bookkeep an attempt, especially // because storage may have just been wiped and `existingAttempts` is - // stale; writing it would inflate the new user's attempt count. + // stale; writing it would inflate the new user's attempt count. We + // still emit a terminal so the prior `.start` isn't orphaned in + // analytics. + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(CancellationError())) + ) return } catch let error as PosterError where error == .permanentlyUnsupported { // Don't burn the attempt budget on a device that will never have a @@ -285,6 +301,12 @@ final class AttributionPoster { Superwall.shared.setUserAttributes(attribution) } } catch is CancellationError { + // `.complete(token)` was already emitted above, but the post never + // finished — emit a terminal `.fail` so the session has an unambiguous + // outcome rather than trailing off after `.complete`. + await Superwall.shared.track( + InternalSuperwallEvent.AdServicesTokenRetrieval(state: .fail(CancellationError())) + ) return } catch { recordFailedAttempt(existing: existingAttempts) From 1c81ac5527f68c7239eda0ae6cc9038dc8e20217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 17:41:00 +0200 Subject: [PATCH 24/25] Use non-optional UUID init for zero-IDFA sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UUID(uuidString:) returns Optional, which would silently make the equality check at the call site false if the literal ever failed to parse — the zero-IDFA sentinel would then slip through unfiltered. Switch to UUID(uuid:) which takes the 16-byte tuple directly and returns a non-optional, removing the optionality from the comparison without resorting to force-unwrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionFetcher.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index 8a83487db5..cb7cba3edf 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -56,7 +56,10 @@ final class AttributionFetcher { return nil } - private static let zeroAdvertisingIdentifier = UUID(uuidString: "00000000-0000-0000-0000-000000000000") + // Non-optional construction via `init(uuid:)` — `init(uuidString:)` returns + // an Optional which would make the equality check silently false-positive + // if the literal ever failed to parse. + private static let zeroAdvertisingIdentifier = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) /// Whether this build/environment can ever produce an AdServices token. /// `false` on builds that didn't link AdServices.framework and on debug From 2e2900cbb5830cd158f57dc5fbcc3aecbb392cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 14 May 2026 17:46:01 +0200 Subject: [PATCH 25/25] =?UTF-8?q?Correct=20false-positive=20=E2=86=92=20fa?= =?UTF-8?q?lse-negative=20in=20zero-IDFA=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failure mode is the opposite of what the comment said: an Optional `nil` would make the equality check return `false`, so the `if` wouldn't fire and the zero IDFA would pass through unfiltered — a false negative (we failed to filter when we should have), not a false positive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Analytics/Attribution/AttributionFetcher.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift index cb7cba3edf..f4f15fbe7b 100644 --- a/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift +++ b/Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift @@ -57,8 +57,8 @@ final class AttributionFetcher { } // Non-optional construction via `init(uuid:)` — `init(uuidString:)` returns - // an Optional which would make the equality check silently false-positive - // if the literal ever failed to parse. + // an Optional which would make the equality check silently false-negative + // (zero-IDFA passes through unfiltered) if the literal ever failed to parse. private static let zeroAdvertisingIdentifier = UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) /// Whether this build/environment can ever produce an AdServices token.