Repository navigation
Conversation
The SDK presents a paywall in a window of its own whose root was a plain UIViewController, so the paywall allowed every orientation regardless of the app. Apps that lock orientation in a view controller rather than Info.plist could rotate while a paywall was up, and stayed rotated after it closed. Unity games hit this: Unity's app controller asks each window's root which orientations it allows, so Screen.autorotateTo* was never consulted for the paywall's window. The window's root now takes its orientations from the top view controller of the app's window it covers, and PaywallViewController from its presenter. Tearing the window down asks the app to check its orientations again. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/opus
The Swift counterpart of the Android SDK's SequentialActor. It holds a piece of state that can be read synchronously, applies pure reducers atomically, and runs async actions one at a time in dispatch order, even across suspension points, which a plain Swift actor doesn't guarantee. Calls made from inside an action run inline, flush() lets tests wait for the queue without sleeping, and interceptors wrap updates and actions for persistence, pending-work tracking and debug logging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Covers launch (fresh and restored ids, attribute cleaning), configure with and without assignments, identify (trimming, blank ids, repeat calls, seed derivation, waiting for assignments when restoring), reset, attribute merging and ordering, and switching users. They describe today's behavior so the store-based rewrite can be checked against them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The identity now lives in an IdentityState on a SequentialStore instead of underscored vars behind a DispatchQueue, a DispatchGroup and a manually toggled subject. Reads are synchronous and never wait. identify, reset and attribute merges are IdentityActions that run one at a time in call order, and readiness is derived from the work still pending in the state. Waiting on the config (for the seed) or on assignments happens outside the queue, so it no longer holds up later changes. This also fixes: - the seed derived from the user id was written off the queue, racing reads, and could land on a user who had since been switched or reset - after reset() the user attributes were left empty instead of carrying the new aliasId and seed - a public reset() could have its new alias and seed wiped from disk by the storage reset that follows it - a reset or switch could fire several user_attributes events, one per id that changed The reducers and actions are covered by tests that run without the rest of the SDK, using a fake IdentityContext. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The config state, triggers, retry count and initial purchases load now live in a ConfigManagerState on a store and change through ConfigUpdate reducers, replacing a hand-written queue and a property wrapper. configState stays the publisher everything subscribes to, but it now mirrors the store, so tests set the config through store.update(). Fetching and refreshing config are ConfigActions, so they run one at a time in call order instead of being able to overlap and apply their configs out of order. The refreshes a fetch schedules are queued rather than started in a Task, which would have counted as part of the fetch. A refresh called before there's any config stays a no-op, as before. The identity tests now start from a published config: a newly identified user isn't ready until the config says whether to derive the seed from the user id, which held for the old IdentityManager too. The user switch test waits for the outcome instead of for reads that used to block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
IdentityManager held storage, the config manager and the web entitlement redeemer unowned. Its side effects and queued actions can outlive them, and then reading one crashed the process. They're weak now, so that work does nothing instead. The new identity and config tests let their harness, or the dependency container behind it, go away while the manager was still in use, which crashed the test process on CI. They keep it alive to the end now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The old IdentityManager marked the identity ready whenever no identity work was running and a configure, identify or reset had finished, through its DispatchGroup and didSetIdentity(). The rewrite left configuration pending until configure() ran, so a reset or identify on an identity that hadn't been configured never became ready, and a test that resets in setUp waited on hasIdentity forever. The state now records whether the identity has been established once, and configuration is pending only while configure() runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The old IdentityManager queued writes and made reads wait for the queue, so identify(userId:) followed by reading userId on the same thread returned the new id, and the same for setUserAttributes and userAttributes. Running identity actions on the store's async queue lost that, and CI caught it in the attribution, test mode and CEL tests. None of the identity actions wait on anything, so they're now SyncStoreActions run with SequentialStore.dispatch(_:in:): on the calling thread, one at a time in dispatch order, with an action dispatched from inside another running once that one finishes. A dispatch made while another thread is running actions is queued for that thread and returns straight away. Blocking there could deadlock: an identify that switches user resets the attribution fetcher with queue.sync, while that fetcher's queue merges attributes into the identity. No lock is held while an action runs. Identify and reset now apply before they return, so the pending markers for them are gone. The identity is established by an explicit update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Keeps SequentialStore.swift within SwiftLint's file and type length limits. SerialDispatcher holds the non-blocking drain behind dispatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Left to inference, the closure parameter in `attributes["key\($0)"] as? Int == $0` became `Int?`, so every key was looked up as "keyOptional(0)" and the test failed although every merge was applied. Types the index explicitly, and adds the same check against the identity actions directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The Slack announcement now goes out once the release is on SPM and CocoaPods and the wrappers have been told. Docs and Emerge failures are still reported by report-failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Announce releases without waiting for docs and Emerge
The failure message said the release failed even when only a job after tagging did, such as CocoaPods during its freeze. It now names the failed jobs, and says the version is tagged and on SPM if it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release pushes the tag before the release branch and the GitHub Release, so it can fail with the version already out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sage Say which release job failed, and whether the version is out
IdentityLogic.mergeAttributes renamed a `$key` to `key` and then looked the value up under the new name. That found nothing, so it deleted any attribute already stored as `key`. setUserAttributes documents `$` keys as reserved and dropped, and filters them itself, so only internal merges such as attributes set from a paywall reached this. They're now skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The IdentityManager rewrite returned nil when the user id isn't a UUID, dropping the old fallback to the UUID in the alias. Anonymous users and users with non-UUID ids then purchased without an appAccountToken (SK2) or applicationUsername (SK1), so their transactions couldn't be linked back to them. The behavior test had pinned the regression; it now checks the alias UUID. Also included in this commit, from the same review: identify's reset for a new user runs in store.callingOut so waiting dispatchers are released, flush() waits for dispatched identity changes, and the test harness no longer resets the shared instance on a user switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
- dispatch now waits for the caller's own action when another thread is running dispatched actions, so a read straight after identify, setUserAttributes or reset sees the change. The running action can release waiting callers with callingOut(_:), which identify uses around its reset for a new user: that reset takes other objects' queues, which a waiting caller may hold. - The store recognizes its own action by the task running it rather than a task-local value. A Task started inside an action, or by a delegate callback it awaits, inherited the task-local and ran its own action inline, alongside the one still running, so two config refreshes could overlap again. - Pending assignments are keyed by user id, like the seed, so restoring an earlier user's assignments can't make the next user ready early. - An identify that switches user marks the new user's seed pending before the reset, so the anonymous user the reset leaves never counts as ready. - The identity observer holds its store weakly; it formed a cycle. - ConfigManager applies and publishes a config change under one lock, so configState sends changes in the order they were applied. - CHANGELOG wording for user_attributes events and config refreshes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The fast path schedules a background config refresh. It's queued behind the fetch now, so it can start after the 300ms the tests slept before their container went away, and then read the container's freed objects through ConfigManager's unowned references, crashing the test process. The tests now wait for the store's queue to drain, bounded at 5s, before the sleep for work the refresh starts in the background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
…l's action - Pending seed and assignments work was keyed by user id. Identifying a user, resetting, and identifying the same user again before the first identify's work finished put both under one key, so the first to finish cleared the second's: with restorePaywallAssignments, paywalls could proceed before the current restore returned. Each identify now has its own IdentifyOperation, and resolves only its own work. - immediateUntil didn't queue its action when the state already matched the predicate. It's now queued however the wait registers. Both from Maple's review of #593. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
identifyAnonymousUser checked that the seed was still pending right after identify, but the fake answered the seed lookup straight away, so on a busy runner the side effect could resolve it first. The test now holds the lookup until after the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Keeps develop's shorter 4.19.0 changelog wording and adds this branch's fixes in the same style. Tests develop added that sent to ConfigManager.configState now update its store, which configState mirrors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
ConfigManagerState kept the error in a separate `failure` field beside `configState`, so a state could hold an error together with a retrieved config. The error is now the payload of `.failed`. Setting a failed state directly completes configState with that error, as a failure from a fetch does. The triggers stay a separate field: they take effect when a config arrives, before it's published, so events tracked while purchases load can still trigger paywalls, and during a refresh they're the new config's while the old one is still published. The state's doc comment says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
- The identity observer read the state before and after an update and saved the difference with nothing held across those steps. Updates made outside dispatched actions, such as configure or a restore finishing in a side effect, run on other threads, so one could save a snapshot after a later update had saved its own: a configure finishing around an identify and a reset could write the logged-in user back over the anonymous one. One lock now covers the whole sequence. - reset() became synchronous in this PR, so the identity never went unready while Superwall.reset() wiped storage and the paywall cache, and a paywall registered on another thread could be presented in the middle of that. The old queued reset kept it unready for that window. Superwall.reset() now marks a pending reset until its cleanup is done. Both from Maple's review of #593 (F3, F4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Keeps IdentityManager.swift within SwiftLint's file length limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
The package declared watchOS but never built there, and tvOS wasn't declared at all. Both lack WebKit, so everything that needs a paywall on screen is now behind `#if canImport(WebKit)`: the paywall view controller and presentation, debugger, Test Mode, Customer Center, permissions and the SDK's alerts. Purchases, observer mode, restores, entitlements, customer info, user attributes, events and web checkout redemption work as on iOS. - `register` runs the audience filters, then skips (holdout, no match, placement not found) or sends a code 108 error when a paywall would show. Implicit triggers and paywall preloading do nothing. - `getPresentationResult` returns the paywall the user would see. - Test mode never turns on, since its pickers can't be shown. - Superscript has no tvOS slice, so `Package.swift` drops it on tvOS and audience filters with an expression never match there. - watchOS gets WatchKit-backed device info, lifecycle notifications and `PlatformApplication`. MMP install matching is off on both. - Shared `@available` checks name matching watchOS/tvOS versions. - CI builds watchOS and tvOS. CocoaPods stays iOS/macOS only. Bumps to 4.20.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard only stopped the paywall downloads, so the preload entry points still evaluated audiences, read occurrences from Core Data and could leave a queued task sleeping for five seconds. Guard the entry points instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
register used to send its own error whenever a paywall would show, so non-gated features never ran there. It now ends the way iOS does without internet: it fetches the paywall, and declines it with webViewFailedToLoad. Non-gated features run, gated ones get the existing web view error, and a failed fetch sends its error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reusing the web view failure path called onDismiss for a paywall that was never presented. register now passes its feature block down: a non-gated paywall runs it with no present or dismiss callbacks, and a gated one sends error 106. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in register register already refused placements named after a standard Superwall event, but did so silently: no error handler call, no feature block. It now sends the error to the handler. getPaywall (and PaywallView, which uses it) had no check at all, so a placement like "paywall_open" was tracked as a real event and could match campaigns meant for the standard one. It now throws the same error before tracking. Bumps to 4.19.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
refreshWaitsForFetch ran a full launch fetch against a plain DependencyContainer, whose real StoreKit purchase load never finished on CI, so the test run hung. It now uses the early-publish tests' harness, which mocks the device helper, receipts and products, and every wait in it is bounded so a regression fails instead of hanging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stop getCustomerInfo hanging when config fails; fix Mac preview links
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in develop (getCustomerInfo no longer hangs when config fails; Mac preview link fix) and the identity doc updates. Keeps both sets of changelog entries, and switches the two new getCustomerInfo and refreshCustomerInfo tests to change config through the store, like the rest of the file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reset's cleanup wipes the user's storage once the identity is unlocked. With user changes on two threads, a switch's cleanup could run after another switch, or a login after reset(), had already restored its user's assignments, and delete them. An identify or reset now holds a user-change lock until its deferred cleanup has finished. The lock is taken before the identity's and never with it held. The cleanup only hops to private queues, so it can't wait on the thread that's waiting for the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CR7gAkpYE4U7gP8oNpsp8m
configState can briefly lag the store while another thread is still publishing, so the IP collection gates and the paywall gate's quick config check read ConfigManager.config instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebuild the identity on a locked state store with an Android-style reset
Move ConfigManager state onto StateStore and serialize fetch and refresh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep each paywall slot's billing plan when slots share a product
The enrichment request posts the user and device attributes, so it now follows eventTrackingBehavior = .none like the install-attribution match does. It's checked again after the attributes are built, in case the app opts out in between, and enrichment is fetched as soon as the app opts back in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request waiting for the foreground or a retry now checks tracking before each attempt and stops without sending. A reply that lands after opting out is dropped, and enrichment from before the opt-out, or restored from disk, is left out of the device attributes while tracking is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ing-off Skip enrichment while event tracking is off
…-orientation # Conflicts: # CHANGELOG.md # Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift # SuperwallKit.xcodeproj/project.pbxproj
UIKit only asks the root, or a view controller presented over the whole window, which orientations a window allows. Following every presented view controller meant a paywall opened from a sheet copied the sheet's default mask instead of the locked screen under it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he store rules dispatch now returns what the action returned, so an action can update the state and report back (whether it claimed something, the state it saw) in one locked step instead of the caller reading the state in a second call. read(_:) reads part of the state with the store locked, so a copy of the whole state can't outlive a removal. The new State stores section in CLAUDE.md is the shape every store follows: enum-shaped state, named reducers, closed actions, side effects on the Context protocol, and a SerialTaskCoordinator for async work that must not overlap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an iPhone in landscape a page sheet covers the whole window, so its presentation controller says it presents full screen. UIKit still doesn't ask a sheet about orientation, so a paywall opened from a sheet in a landscape-only app rotated to portrait. Only full-screen and over-full-screen presentations, and custom ones that fill the window, now count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
read held the lock without counting itself as an update or dispatch, so an update or dispatch inside it ran its deferred work with the lock still held. If that work waited on another thread that needed the store, they deadlocked. read now goes through the same locked path as update and dispatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Let StateStore actions return a value, and write down the store rules
Keep paywalls to the orientations the app allows
Maple review🟢 Confidence 10/10 · safe to merge. Regression tests cover the changed state transitions and SDK behavior; all reported platform builds, unit tests and lint checks pass. Releases 4.19.1 with watchOS/tvOS headless support, serialized identity and configuration state, and fixes for billing-plan selection, tracking opt-out and presentation behavior. No confirmed defects require changes before merging.
|
Collaborator
Author
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.
Changes in this pull request
Releases 4.19.1 from
developtomaster.Enhancements
registerruns non-gated features and returns an error for gated ones. (Build on watchOS and tvOS without paywalls #594)Fixes
getPaywallandPaywallViewtoo, andregisterreports this to its error handler instead of silently doing nothing. (Reject standard Superwall event names in getPaywall, and report them in register #595)$-prefixed attribute from a paywall deleting the existing attribute with the same name. (Rebuild the identity on a locked state store with an Android-style reset #593)reset()leaving the new user's attributes withoutaliasIdandseed, and occasionally deleting the new user's alias and seed from disk. (Rebuild the identity on a locked state store with an Android-style reset #593)reset(), andidentify(userId:)switching user, sending severaluser_attributesevents for one change. (Rebuild the identity on a locked state store with an Android-style reset #593)configurationStatusstaying.pendingwhen the config couldn't be fetched. (Move ConfigManager state onto StateStore and serialize fetch and refresh #599)getCustomerInfo()never returning when the SDK can't load its configuration, andrefreshCustomerInfo()returning empty customer info when the fetch fails. (Stop getCustomerInfo hanging when config fails; fix Mac preview links #596)eventTrackingBehavioris.none. (Skip enrichment while event tracking is off #600)Also in this release
Superscriptpod 1.0.17, since 1.0.18 only adds slices CocoaPods doesn't install. (Build on watchOS and tvOS without paywalls #594)The version is 4.19.1 in
Constants.swift,SuperwallKit.podspecandCHANGELOG.md. Merging starts the Release workflow: the unit tests and, because of theuitests-requiredlabel, the UI tests run on the merge commit. Only if they pass does it tag 4.19.1, publish the GitHub Release and push to CocoaPods. SPM users get it once the tag exists.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code


Confidence Score: 5/5
The reviewed changes appear safe to merge.Summary
Releases
4.19.1with watchOS and tvOS support and fixes across identity, configuration, purchases, and paywall presentation.No actionable issues were identified. Apple-platform builds and tests were not run in this Linux environment.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[App registers placement] --> B[Wait for config and identity] B --> C[Evaluate audience] C -->|Skipped| D[Run feature] C -->|Paywall matches| E{WebKit available?} E -->|Yes| F[Load and present paywall] E -->|No| G[Fetch paywall access rule] G -->|Non-gated| D G -->|Gated or fetch fails| H[Report error]Reviews (1) · Last reviewed commit: "Merge pull request #568 from superwall/b..." · Reviewed by Greptile