Conversation
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.
Switch SwiftPM Superscript dep to superscript-ios-next
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…early 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) <noreply@anthropic.com>
…bution Improve Apple Search Ads attribution capture
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
4.15.2
Enhancements
idfaattribute on attribution payloads.Fixes
Greptile Summary
This PR releases version 4.15.2 with two attribution improvements: filtering out the all-zeros IDFA sentinel (returned when ATT is denied), and a substantial overhaul of the Apple Search Ads attribution retry system to improve capture rate.
Superscript-iOSreplaced by the lightersuperscript-ios-next(v1.0.14) across allPackage.resolved,Package.swift,project.yml, and Xcode project files.AttributionFetchernow discards the00000000-…UUID returned when ATT is not authorized, preventing it from appearing as a real advertiser ID in attribution payloads.AttributionPostergains a cross-launch attempt budget (8 tries, 24h window), an in-session backoff for transient Apple SDK errors (2/6/15s), a generation-stamp single-flight guard, cooperative cancellation for reset races, and a migration (v4→v5) that moves the token sentinel from user-specific to app-scoped storage so it survives user logout without re-fetching.Confidence Score: 4/5
Safe to merge; the attribution logic is well-guarded and the storage migration has a no-op path for users without legacy data.
The attribution poster rewrite is large but thoroughly commented and backed by new tests covering the key guard conditions. The one notable style issue is
zeroAdvertisingIdentifierbeing typed asUUID?rather thanUUID, meaning the sentinel filter relies on optional equality — harmless in practice since the literal is always valid, but worth tightening. No data-loss, auth, or crash risks were identified.Sources/SuperwallKit/Analytics/Attribution/AttributionFetcher.swift — the
zeroAdvertisingIdentifieroptional type; Sources/SuperwallKit/Analytics/Attribution/AttributionPoster.swift — the redesigned retry/cancellation logic is the most complex part of this PR and deserves a careful read.Important Files Changed
canProduceAdServicesTokenguard;zeroAdvertisingIdentifieris typedUUID?rather thanUUID, creating a potentially silent no-op comparison if the optional were nil.AdServicesTokenStoragefrom.userSpecificDocumentsto.appSpecificDocumentsand adds three new app-scoped storage types for retry bookkeeping, unsupported-device sentinel, and cached attribution data; all appropriately scoped.v5toDataStoreVersionand wiresV4Migratorinto the recursive migration chain; existing tests were updated to match the new terminal version.configure()(replaced by config-subscription-driven triggering inAttributionPoster) and addscancelInFlight+reapplyCachedAttributionaroundstorage.reset()in the reset path.sendTokenfrom returning[String: Any](swallowing errors) to throwingAdServicesResponse, letting the caller handle failures and bump the retry budget.Superscript-iOSrepo (bloated xcframework) to the lightersuperscript-ios-nextat version 1.0.14.Sequence Diagram
sequenceDiagram participant SW as Superwall participant AP as AttributionPoster participant CM as ConfigManager participant AF as AttributionFetcher participant NW as Network participant ST as Storage SW->>AP: init() → listenToConfig() CM-->>AP: "configState emits (enabled=true)" AP->>AP: getAdServicesTokenIfNeeded() AP->>ST: get(AdServicesAttributionAttemptsStorage) AP->>AP: canStartAttempt() guards Note over AP: Already posted? Unsupported? No token env? Config off? Budget/window exceeded? AP->>AP: "Task { runAttempt() }" AP->>AF: adServicesToken (with 0s/2s/6s/15s backoff) AF-->>AP: token or error alt Permanent error AP->>ST: save(true, AdServicesAttributionUnsupportedStorage) else Transient error AP->>ST: recordFailedAttempt() else Token obtained AP->>NW: sendToken(token) throws AdServicesResponse NW-->>AP: AdServicesResponse AP->>ST: save(token, AdServicesTokenStorage) AP->>ST: save(attribution, AdServicesAttributionDataStorage) AP->>SW: setUserAttributes(attribution) end SW->>AP: reset() → cancelInFlight() AP->>AP: task.cancel() + ownerGeneration++ SW->>ST: storage.reset() SW->>AP: reapplyCachedAttribution() AP->>ST: get(AdServicesAttributionDataStorage) AP->>SW: setUserAttributes(cachedAttribution)Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Merge pull request #470 from superwall/i..." | Re-trigger Greptile