Conversation
Xcode 26.0.1 fails to type-check the mixed CGFloat/Double expression. Convert to Double once so the operators resolve. Same result. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/fable
Two guards, one principle: nothing that is not an authoritative answer may downgrade a subscriber whose entitlement has not expired. 1. AutomaticPurchaseController.syncSubscriptionStatus: an empty device read no longer sets .inactive while the current .active status holds an unexpired entitlement. StoreKit returns nothing at cold launch before it hydrates, and web/Stripe subscribers never have App Store purchases. Entitlements with no expiry date do not hold the status, so a revoked lifetime purchase still deactivates. 2. WebEntitlementRedeemer.pollWebEntitlements: a response with zero entitlements no longer replaces cached web entitlements that are still within their expiry date. The poll is keyed on appUserId and deviceId alone, so one anomalous response could poison the cache and make every later cold launch read the subscriber as inactive. Production data showed a paying Stripe subscriber flip to INACTIVE ten times at cold launch, 0.8s after start, before the network poll could recover them. The new tests reproduce that flip and fail without the guards. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/fable
Refines the guard per review: an empty entitlement set is only a non-answer when the purchases set is completely empty. Refunded and expired transactions stay in the set as inactive (SK2 reads Transaction.all; the SK1 receipt keeps cancelled purchases), so a non-empty set with no active purchases is an authoritative answer and downgrades immediately. This closes the window where a refunded App Store subscription kept access until its pre-refund expiry date. A device read also has no authority over entitlements from other stores. An unexpired Stripe/web entitlement now holds the status even when unrelated inactive App Store purchases exist. This mirrors RevenueCat's model: a local StoreKit read never overwrites cached state it has no authority over (shouldComputeOfflineCustomerInfo requires a nil cache), and their offline path reads currentEntitlements, which already excludes revoked transactions. Also adds the isActive check to the guard predicate so both guards use the same definition of an unexpired entitlement. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/fable
Per review: Entitlement.store decodes with no default, so a web or manual grant whose payload omits store is nil — and the guard treated nil as App-Store-refutable, demoting the exact population this PR protects. Flip the predicate so nil holds the status. Flipping alone would break SK1 refund enforcement: SK1ReceiptManager built its receipt-derived entitlements without a store, so they were nil too. Stamp .appStore on them, matching what EntitlementProcessor already does on the SK2 path. After that, every device-derived active entitlement is explicitly .appStore and the only nil-store actives are grants from outside the App Store, which a device read cannot refute. The transition is fail-open: caches written by older versions hold nil-store SK1 entitlements, which the flipped guard protects until the first sync rewrites them with .appStore. One side effect: equality includes store, so SK1 users get a single active-to-active status change event on first launch after upgrading. 🌸 Shipped with Kanna — https://kanna.sh Co-Authored-By: Kanna <noreply@kanna.sh> Kanna-Agent: claude/fable
…tatus-anti-downgrade # Conflicts: # CHANGELOG.md # Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
An active purchase whose product no longer maps to any entitlement (dropped from config or served by a stale cache) produced an empty entitlement set alongside a non-empty purchases set, which the guard read as an authoritative demotion of a paying subscriber. Hold the status only when a still-active purchase unlocks the cached entitlement, so refunds with unrelated active purchases still deactivate immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unconditional stamp put .appStore on config entitlements no receipt transaction unlocks, contradicting the documented Entitlement.store contract (nil without transactions) and the StoreKit 2 path, and shifting public equality for every SK1 install on upgrade. Derive the store from the receipt's purchased product ids instead; active entitlements always have a purchase, so the anti-downgrade guard's invariant is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lead with the developer-visible effect and drop the blanket "while within its expiry date" claim: refunded and expired App Store subscriptions deactivate immediately under the scoped guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the mapping-failure hold, SK2's subscription-level correction of Purchase.isActive is disabled along with the mapping, so the branch reads the raw transaction-level value and can miss a revocation with no revocationDate; state that in the comment along with the expiry bound. Add the web bullet's revocation tradeoff to the changelog to match the App Store bullet's disclosure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verified against subscriptions-api (code and a live grant/revoke cycle): the server reports a revocation by returning the entitlement as inactive and enumerates every config-mapped entitlement even for users with no purchases, so real revocations arrive non-empty and apply immediately. A fully empty entitlements array is a backend or config artifact, which is the only shape the guard ignores. Update the changelog and the guard comment to stop claiming revocations wait for the expiry date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hand-edited parallelizable="NO" only applied to local Xcode runs anyway — CI and scripts/test.sh regenerate the scheme via xcodegen, which drops the attribute — so remove it and keep the committed file matching what regeneration produces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rename the Unreleased section to 4.16.4 and bump Constants.swift and the podspec with it, since develop and master are both on 4.16.3 and this is a patch fix. Document the rule in CLAUDE.md: unreleased changes always live under the next concrete version; when develop's version is already above master's, append to that section instead of bumping again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard only preserves a cached active status whose entitlement is unexpired, so say "subscribers with an unexpired subscription" rather than implying every subscriber is covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…owngrade Keep subscribers active through empty StoreKit reads and empty web polls
… reports Converts the bug report template to a GitHub issue form so key triage fields are enforced at submission time: a dashboard link to an affected user, SDK/iOS/Xcode versions, installation method, and a description of how the SDK is integrated. Adds optional fields for the last working SDK version, occurrence time, and debug logs, and drops the stale superwall-me/paywall-ios links. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ted-user link The dashboard link is auth-gated, but the URL itself carries the app user ID, which some apps set to an email or internal ID. Steer those reporters to the random $SuperwallAlias URL or the dashboard support chat instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto-apply the bug label, point unidentified reporters at Superwall.shared.userId for their alias (it never appears in plain-text device logs), and prompt redaction of personal data in debug logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chore(github): require an affected-user link and setup details in bug reports
DependencyContainer.init passed itself as the factory to WebEntitlementRedeemer, whose init immediately spawned a task calling makeIsContainerReady() on a background thread — reading configManager while init was still assigning stored properties. Thread Sanitizer flags this as a data race on every plain configure() call, and the racy guard also made the cold-launch Stripe recovery poll fire only when it happened to lose the race. The redeemer no longer starts any work in its init. The cold-launch poll is now kicked off explicitly by DependencyContainer as the last statement of its init, once every dependency is assigned — which also gives the background task a proper happens-before edge on all of the container's stored properties and makes the poll deterministic. Fixes #504 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Moves pollPendingStripeCheckoutOnColdLaunch() from the last statement of DependencyContainer.init into Superwall's configure-path convenience init, after self.init(dependencyContainer:) returns. Container completeness is now guaranteed by language rule instead of a "must stay last" comment, and bare DependencyContainer() constructions in tests no longer fire the poll. Also reverts unintended xcodegen scheme drift and trims the TSan repro loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…race Fix data race in DependencyContainer.init on plain configure()
…rk-support # Conflicts: # SuperwallKit.xcodeproj/project.pbxproj
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev mode is a new feature, so the staged release gets a minor bump instead of a patch. Bumps the version in all three places. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses pullfrog's review of 6519c3c: - handleDeepLink no longer claims a superwall_dev link when dev mode is off, so production apps keep routing such URLs down their handler chain. The pre-configuration storeDeepLink path only claims dev links once options are checkable. - A deep-link-supplied dev-server base must now be a host superwall dev could have printed (loopback, .local, private-network ranges) or match the developer-supplied devServerURL, so an arbitrary internet host can no longer be handed the paywall JS bridge. - Dev-mode paywalls skip the request-hash memoisation and fold the mount URL into cacheKey, so a transient server miss no longer pins the published paywall for the process and a moved server reloads the web view. - The debugger's withTimeout now genuinely resumes at the deadline instead of waiting out the slow product call and discarding it. - The cached-base move-to-front uses removeAll/insert instead of an irreflexive sort predicate. - Removes trailing whitespace flagged by SwiftLint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A manifest fetched from a trusted base could still name an absolute URL on any origin, since URL(string:relativeTo:) ignores the base for absolute strings. mountURL now rejects any resolved URL whose scheme, host, or port differs from the base, covering both the request-pipeline and debugger callers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A representation mismatch (localhost vs 127.0.0.1, or a portless devServerURL against an explicit-port surface url) would otherwise disable the override with no trace, which is the one failure mode this subsystem otherwise always logs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
Dev mode's "inert in production" safety net doesn't cover TestFlight, which is the one pre-production channel with real users on it — a beta build that still sets devServer simulates every purchase, forces subscriptionStatus to .inactive, and logs no warning about it.
Reviewed changes — the delta since the aecdd16 review, i.e. PR #511 (christo/sw-framework-support) merged into develop: the whole superwall dev / dev-mode feature. 33 files, +2702/-49.
- Added
SuperwallOptions.devServer—.default(localhost 6100–6104) or.url(_:), withenableDevServer()/enableDevServer(url:)for Objective-C. It is deliberately absent fromSuperwallOptions.CodingKeys, so nothing about it reaches the backend. - Gated the whole feature behind
DevMode.isActive—devServer != nilplus a newDeviceHelper.isSandboxEnvironment, with a one-shot warn when a production build still has it set, and an overridable static so tests can exercise the production path a simulator can't produce. - Added
DevServerLocator— an actor that probes{base}/device/manifest.jsonacross the candidate bases, caches hits for 2s and misses for 5s, pins a deep-link-supplied base ahead of the rest, and distinguishes "nothing there" from an ATS block and from a manifest this SDK can't read. - Added the manifest model —
DevServerManifestrequiressurfaces(so a stray JSON responder can't end the port walk) and decodes per-element throughThrowable;mountURL(for:base:)pins every surface URL to the base's scheme, host and port so an absoluteurlcan't name another origin. - Synthesised a
Paywallfrom a surface —Paywall.devServer(surface:url:inheriting:)takes bytes, products and whateverconfig.tsdeclares from the surface, identity and every unexpressed setting from the published paywall, forcesonDeviceCacheoff andlocalNotificationsempty, and folds a settings fingerprint intocacheKeyso an editedconfig.tscan't live-reload inside a stale native frame. - Wired the override into the register path —
RawPaywallResponse.applyDevServerOverrideIfNeeded, plusdevServerPaywall(forId:)for syntheticdev:ids, andPaywallRequestManagerstops memoisingpaywallsByHashin dev mode so a transient miss can't pin the published paywall for the process. - Reworked the debugger — a
superwall_devdeep link opens a new searchable, sectionedDebugPaywallPickerViewControllerover local surfaces and published paywalls (backed by pureDebugPickerLogic), replacing the old alert sheet; the published list now falls back to the downloaded config when no preview token is available. - Made dev mode imply test mode —
TestModeManager.evaluateTestModeforce-enables it, andConfigManager.applyDefaultTestModeStateseeds the state the intro sheet would collect instead of presenting it. Preloading is disabled. - Narrowed both example apps' ATS —
NSAllowsArbitraryLoadsreplaced byNSAllowsArbitraryLoadsInWebContent+NSAllowsLocalNetworking. - Added 7 test files covering
DevModegating, deep-link parsing and base trust, manifest and settings decoding,Paywall.devServer's inherit-vs-own split, and the picker's section/search/index logic.
I probed the trust boundary specifically, since this path hands a plain-http origin the paywall JS bridge, and found no way in: with devServer unset every entry point short-circuits before touching the network, the //evil.example.com and 10.0.0.1.evil.example.com shapes are both explicitly defended and tested, UInt8(String) rejects hex/octal IPv4 spellings, userinfo tricks don't diverge URL.host from the host actually connected to, and mountURL still holds against a redirected manifest fetch because base is the pre-redirect candidate. Separately I confirmed the synthesised cacheKey being surface-scoped is harmless — the view-controller cache keys on paywall.identifier, which Paywall.devServer correctly takes from the published paywall, so one surface serving several paywalls can't collapse them — and that a config.ts naming products the store doesn't know still presents, since AddPaywallProducts swallows the fetch failure into paywall_products_load_fail.
⚠️ Dev mode activates in TestFlight, and that is exactly where the production warning cannot fire
DeviceHelper.isSandboxEnvironment is true for TestFlight, so a beta build that still sets devServer puts every tester into test mode: purchases are simulated rather than real, and applyDefaultTestModeState forces subscriptionStatus to .inactive and empties LatestCustomerInfo on each cold launch — including for testers who are genuine subscribers. DevMode.warnAboutProduction() only fires on the else branch of that same sandbox guard, so it is unreachable there; the only signal is DevServerLocator's "no dev server was found" warn, which most apps don't surface. options.testModeBehavior doesn't help either, since dev mode is checked ahead of it, so .never is not an escape hatch.
Technical details
# Dev mode activates for TestFlight testers with no warning
## Affected sites
- `Sources/SuperwallKit/DevServer/DevMode.swift:22-31` — `isActive` returns `true` whenever `isSandboxEnvironment()` is true, which includes TestFlight.
- `Sources/SuperwallKit/DevServer/DevMode.swift:33-45` — `warnAboutProduction()` is only reachable on the `else` branch of that guard, so it is unreachable in TestFlight.
- `Sources/SuperwallKit/TestMode/TestModeManager.swift:112-116` — dev mode force-enables test mode ahead of `options.testModeBehavior`, so `.never` does not opt out.
- `Sources/SuperwallKit/Config/ConfigManager.swift:806-822` — `applyDefaultTestModeState` assigns `.inactive` and an empty `CustomerInfo`; both writes reach disk (`SubscriptionStatusKey` via `publishSubscriptionStatus`, `LatestCustomerInfo` via the `$customerInfo` sink), so a tester's real status is wrong until the next non-dev launch's StoreKit read lands.
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:403-435` — "for development builds only" reads as "builds I run from Xcode", not "anything Apple signs as sandbox".
## Required outcome
A developer who leaves `devServer` set in a build that reaches testers must learn it from the SDK, not from a tester reporting they can't buy anything.
## Suggested approach (optional)
Either narrow the gate (simulator plus a locally-signed development build, excluding TestFlight), or keep the gate and move the warning so it fires whenever dev mode activates outside the simulator — it is a loud condition either way, since it means purchases are simulated.
## Open questions for the human
- Is TestFlight a supported dev-mode target? If yes, the option's doc comment and the changelog entry should say so explicitly.
- Should `testModeBehavior == .never` win over dev mode's force-enable, so a build that must keep real purchases has an opt-out?ℹ️ Nothing covers the two seams where dev mode meets the paywall pipeline
All seven new test files exercise pure logic: manifest and settings decoding, Paywall.devServer's inherit-vs-own split, DebugPickerLogic's sections, DevMode's gate, deep-link parsing. applyDevServerOverrideIfNeeded and devServerPaywall(forId:) — the two functions that decide whether a presentation actually gets local bytes — have no coverage at all, and the inline finding below sits in exactly that gap. Both are PaywallRequestManager methods taking their inputs from factory.makeSuperwallOptions() and the DevServerLocator singleton, so pinning them needs a seam on the locator (the same shape as DevMode.isSandboxEnvironment) rather than any new abstraction.
Technical details
# The dev-server override is untested end to end
## Affected sites
- `Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift:33-65` — `applyDevServerOverrideIfNeeded`, no test.
- `Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift:70-94` — `devServerPaywall(forId:)`, no test.
- `Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift:126-140` — the dev-mode memoisation bypass, no test.
## Required outcome
A test that answers "given a manifest whose surface is bound to paywall P, does presenting P load the surface's bytes?" for both the register path and the debugger path, so the identifier-shape coupling described in the inline comment can't regress silently.
## Suggested approach (optional)
Give `DevServerLocator` an injectable override in the same style as `DevMode.isSandboxEnvironment` (a `static var` yielding a canned `DevServerLocation?`), then drive `applyDevServerOverrideIfNeeded` directly with a stub `Paywall` and assert on the returned `url`, `cacheKey` and `productIds`.ℹ️ Nitpicks
Sources/SuperwallKit/DevServer/DevServerManifest.swift:7— the header still points at`SuperwallOptions/devMode`, whichb61b66ereplaced withdevServer. It's a DocC link, so it now resolves to nothing.Sources/SuperwallKit/Debug/DebugManager.swift:17andSources/SuperwallKit/Debug/DebugViewController.swift:124— both hold(base: URL, surfaces: [DevServerSurface])?, which isDevServerLocationwith the manifest unwrapped, soRawPaywallResponse.devServerPaywallhas to rebuild aDevServerManifest(surfaces:)from the tuple just to callmountURL. Storing theDevServerLocationthe locator already returns would drop that reconstruction.Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift:132—isLocalis a new public property andis_locala new param on every paywall event, but neither is mentioned in the changelog alongside thedevServerentries. Worth a clause, since integrators readingPaywallInfowill see it.
Claude Opus | 𝕏
The locator's cached hit, cached miss and deep-link pin were global, so pointing SuperwallOptions.devServer at another machine could answer with the old one for a couple of seconds, skip the lookup for five after an unrelated miss, or keep probing a previously scanned server first. It now drops all of that when the requested address changes, and forget() clears a pin on demand. Also narrows the ATS exception in the example apps to NSAllowsLocalNetworking. App Transport Security doesn't apply to plain IP addresses, so NSAllowsArbitraryLoadsInWebContent was only widening what the web view could load from anywhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
On the outside-diff finding Paid Offers Become Trials (
|
|
@greptile review |
There was a problem hiding this comment.
ℹ️ No regressions in this delta — two rough edges inline. The five open threads from earlier reviews are all still untouched by these commits, which is the other reason this isn't an approval.
Reviewed changes — the delta since the 8ffa7e2 review: one commit, 2941322 ("Scope the dev server lookup to the address it was asked for"). Six files, dev-server only; no source or changelog change outside it.
- Scoped the locator's state to the address it was asked for —
locatenow calls a newforget()(clearing the hit cache, the miss cache and any deep-link pin) whendevServerURLdiffers from the previous call, so repointingSuperwallOptions.devServermid-session can no longer serve, suppress or prefer the old server. AhasBeenAskedcarve-out keeps the first call from throwing away a pin a deep link set before it. - Made the manifest probe injectable — a
Loadtypealias andinit(load:)with the old inlinewithCheckedThrowingContinuationmoved into aloadWithURLSessiondefault, so the probing and caching are testable without a server. - Added
DevServerLocatorTests— six tests over an injectedProbeactor: hit reuse, hit/miss/pin dropped on an address change, pin priority, andforget(). - Narrowed the examples' ATS exception to
NSAllowsLocalNetworking—NSAllowsArbitraryLoadsInWebContentis gone from both Info.plists, and thedevServerdoc comment and the locator's ATS warning were reworded to match.
I checked the plists parse and that neither example app loads plain http anywhere else, so the narrowing costs them nothing. I confirmed the new init(load:) default referencing a private static member is legal in this package's language mode rather than assuming it (Swift only applies the default-argument access check at the public/@inlinable boundary). On the tests: three of the six (locate_discardsHitWhenAddressChanges, locate_discardsMissWhenAddressChanges, locate_discardsPinWhenAddressChanges) fail if this commit's logic is reverted, and the hit-reuse test's requestedURLs.count == 1 is exact because DevServerCandidates.bases returns a single base for an explicit URL. I also walked every locate/pin/forget call site — RawPaywallResponse.swift:39, DebugViewController.swift:230/385, DevServerPreview.swift:119-123 — against the pin-before-first-locate ordering, which holds; what doesn't hold is the interleaved case inline below.
Claude Opus | 𝕏
- A lookup still in flight for the previous address can no longer write its answer into the locator's cache: probing suspends the actor, so another lookup can point devServer somewhere else in the meantime. - The debugger now presents a selected dev surface under its synthetic `dev:` id rather than paywall.identifier. A surface the CLI has pushed carries the dashboard identifier, and presenting under that would have fetched the published version instead of the local bytes. - Dev server settings read presentation styles with the push API's own enum, so `NONE` inherits the dashboard's style instead of being reported as a value this SDK can't read. - Fixes a stale DocC link to `SuperwallOptions/devMode`. - Covers the SDK presenting a paywall the app is holding from `getPaywall`, which had no test in its `isPresentedBySDK` state. - The inheritance test pinned "INELIGIBLE", which isn't a raw value the SDK knows, so it decoded to the same default the assertion expected either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@greptile review |
Comparing the requested address wasn't enough: a superwall_dev link pinning a base mid-walk leaves the address unchanged, so a walk that suspended before the pin could still nil the pinned hit and arm the five second miss cache, blanking the preview. A generation counter bumped by pin() and forget() is captured before the walk and checked before anything is written. Also corrects what the devServer docs say about App Transport Security. ATS stopped allowing connections to plain IP addresses by default in iOS 17, and NSAllowsLocalNetworking is what re-enables them, so that key is required for the Device URL rather than beside the point for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@greptile review |
Making the load's Task doesn't run its first line, so config can be published before the load's body is scheduled and the assertion that it was kicked off raced with it. It now polls for up to a second, which still fails if the load is never started and leaves the two second load delay intact for the "didn't finish" assertion below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No regressions in this delta — one unverified wire contract inline, plus two nitpicks. Both threads from the last review are addressed by
e7f2760and are now resolved.
Reviewed changes — the delta since the 2941322 review: c1ae976, plus e7f2760, which landed while this run was in flight and is included here rather than deferred. Nine files, dev-server and debugger, plus one shared-paywall test file.
- Routed the debugger's Present button through the synthetic
dev:id —DevServerSurface.previewIdentifier("dev:\(id)") is now the single source both sides use:DebugViewController.loadDevServerPreviewstores it instead ofpaywall.identifier, anddevServerPaywall(forId:)matches on the same property. A manifest carrying a dashboardidentifierno longer makes Present fetch the published paywall under a local preview thumbnail. - Read the CLI's presentation styles with the push API's own enum —
WireStylebecame atypealiasforPaywallPresentationStyle.InternalPresentationStyle. Raw values are identical for all six shared cases, and the newly reachableNONEreturnsnil(inherit the dashboard's) instead of warning "unreadable", which is the same resulting style with one fewer spurious log. - Held the locator to the server it set out to find — a
generationcounter bumped bypin()andforget()is captured after the repoint branch and checked after every probe, so a walk that suspended before a repoint, a pin, a forget or a return-to-the-previous-address returnsnilrather than committing. This supersedesc1ae976'sdevServerURL != requestedURLcheck, which only caught the two-party repoint. - Corrected what the
devServerdoc comment says about App Transport Security —NSAllowsLocalNetworkingis now described as what re-enables plain IP addresses, which ATS stopped permitting by default in iOS 17. - Pinned
isPresentedBySDKin itstruestate —sdkPresentationReportsItsOwnPlacementOverAHandedOutClaimarms a handed-out.getPaywallclaim and then drives the realpresent(on:request:paywall:…)through aCompletingPresenterstub, so the!isPresentedBySDKguard is finally exercised in the state that matters. - Fixed a fixture that let an inheritance assertion pass either way —
DevServerPaywallTests' published-paywall JSON now saysALWAYS_INELIGIBLE, whichIntroOfferEligibilityactually decodes, rather thanINELIGIBLE, which fell back to.automatic— alsoPaywall.devServer's own default.
I traced the locator's state machine across every interleaving rather than taking the commit message at face value: two concurrent locates on the same address still both commit, but both answers describe that address so nothing is corrupted; repoint, pin-mid-walk, external forget() and the three-party A→B→A sequence all now bounce off the generation check, because the repoint branch reaches it through forget(). Both GatedProbe tests are deterministic — load sets hasArrived, resumes arrival and appends to gate in one uninterrupted actor turn, so release() can never find gate empty and the tests can't hang — and both fail if the generation check is reverted. On the debugger side I checked the previewIdentifier swap against the deep-link entry with and without superwall_dev_surface, a dashboard preview link resolving a server through ensureDevServer(), and the picker switching local↔published in both directions; debugManager.devServer and DebugViewController.devServer are written together on every path that can reach the resolver. I also confirmed the InternalPresentationStyle raw values match the deleted copy exactly and that NONE reaches the same presentationInfo fallback the old unreadable path did.
ℹ️ Nitpicks
Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift:387— with the fixture corrected,test_inheritsComputedPropertiesAndIntroOfferEligibilitynow pins the inheritance branch for real, but the assertion is stillXCTAssertEqual(paywall.introOfferEligibility, fields.introductoryOfferEligibility)— i.e. compared against whatever the fixture happened to decode to. A literal.ineligiblewould make the exact class of typo this commit just fixed fail loudly instead of silently reverting to a tautology.Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift:85— whendevServerPaywall(forId:)can't resolve adev:-prefixed id, the request falls throughmakeStaticPaywall(nil for debugger requests) tonetwork.getPaywall(withId: "dev:…"), which can never resolve, so the developer gets an opaque presentation error rather than "that local surface is no longer on the dev server". Narrow — it needs a secondsuperwall_devscan while the debugger is open, or a surface removed fromconfig.tsmid-session — and the sharedpreviewIdentifierremoves the drift that used to cause it, so this is worth knowing rather than fixing.
Claude Opus | 𝕏
The anti-downgrade guard kept the whole status once any entitlement in it held. A refunded App Store entitlement next to a live web one therefore kept granting access. Each entitlement is now judged on its own, and the status is reassigned with just the ones that hold. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…al surface The debugger resolved dev: identifiers from the manifest captured when it opened, so edits to a surface's config.ts kept the old products and presentation settings. The manifest is now fetched again on every preview load and before each presentation, and the selected surface is resolved from it. The old snapshot only stands in when the server can't be reached. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No regressions in the refund fix itself — two rough edges inline. Not approving because the
DevServerSettings.swift:55-59thread from thec1ae976review is still open and untouched by these commits.
Reviewed changes — the delta since the c1ae976/e7f2760 review: two commits, 6e75fa7 and 4f2139a. Seven files, one entitlement fix and one dev-server fix, each with tests.
- Judged each entitlement on its own in the anti-downgrade guard —
AutomaticPurchaseController'sholdsStatusbecame afilter, and when only some entitlements hold the status is reassigned to just those, so a refunded App Store entitlement no longer rides along on a live web one. This is what makes the existing changelog line "Refunded and expired App Store subscriptions still deactivate immediately" true for mixed sets. - Refetched the dev server manifest before previewing a local surface —
DebugViewController.ensureDevServer()lost itsdevServer == nilguard, so everyloadPreview()re-locates, writes both snapshot stores and re-maps the selected surface against the fresh manifest. - Refetched it before presenting one too —
devServerPaywall(forId:)now locates, refreshesdebugManager.devServer, and resolves through the newDevServerPreview.resolveSurface(previewIdentifier:fresh:snapshot:), which prefers the server's current manifest and only falls back to the debugger's snapshot when the server can't be reached. - Added
DevServerManifest.surface(forPreviewIdentifier:)so both sides of the Present path look adev:id up the same way. - Added five tests — four on
resolveSurface(fresh wins, snapshot fallback, a removed surface resolves to nil, nothing to resolve) and one pinning that a refunded App Store entitlement is dropped beside a live web one.
I checked the refund test against a revert rather than taking it at face value: under the old contains/early-return, the web entitlement satisfies the predicate, the whole set is left alone and the !entitlements.contains { $0.id == refundedEntitlement.id } assertion fails — so it is a real regression test. I also walked the reassignment across the two production entry points (ReceiptManager.loadPurchasedProducts and the early-publish ReceiptManager+Restore path) against the web, nil-store, expired, mapping-failure and inactive-purchase shapes, and confirmed internallySetSubscriptionStatus re-unions entitlementsInfo.web afterwards, so a dropped web entitlement that is still active in LatestRedeemResponse comes straight back. The one shape that has no such rescue is inline below. On the dev-server side I confirmed the refresh can't disturb the paywall view controller cache: debugger presentations pass isDebuggerLaunched: true, which bypasses the cache entirely, and on the register path the synthesized paywall still takes its identity from the published one while only Paywall.cacheKey moves with config.ts.
ℹ️ Nitpicks
Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift:97-102— the refresh writesdebugManager.devServerbut notDebugViewController.devServer, which is what backs the picker's local-surface list,canOpenPickerandloadDevServerPreview's base.DebugManageronly pushes its copy into the view controller insidepresentDebugger, so after a Present the two disagree until the nextloadPreview()re-syncs them. The comment on those lines says this copy is read by "the debugger's picker and any later presentation", which is true of the manager's copy but not the one the picker actually reads.Sources/SuperwallKit/Debug/DebugViewController.swift:220—ensureDevServer()is now awaited on everyloadPreview(), including a locale change or a picker selection that lands on a published paywall with no local surface involved. While a server is up this is a 2s-cached no-op, but once a located server goes away each interaction pays a fresh candidate walk (bounded by the 5s miss cache) before the preview renders; the olddevServer == nilguard skipped it entirely after the first hit.
Claude Opus | 𝕏
Both callers sent configState right after processConfig returned, unless the early publish branch had already sent it and said so through a return value. The send now lives at the end of processConfig itself, after the test mode modal, so the callers and the return value go. Also drops a redundant nil check on the test mode reason that was unwrapped again a line later. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in this delta — the refactor is behaviour-preserving. Not approving only because three pullfrog threads from earlier reviews are still open and untouched by this commit (
DevServerSettings.swift:55-59,DebugViewController.swift:246-248,AutomaticPurchaseController.swift:92-101), and the### Breaking Changessection94fb59dremoved still hasn't come back to the 4.17.0 block.
Reviewed changes — the delta since the 4f2139a review: one commit, dcc2e9e, one file, +13/-16. No tests, no changelog.
- Moved the
configStatesend intoprocessConfig— both callers used to send.retrievedright after theawaitreturned, unless the early-publish branch had already sent and reported it back through@discardableResult -> Bool. The send is now the last statement ofprocessConfigguarded byhasPublishedConfig, and the return value, the@discardableResultand both caller-side sends are gone. - Named the ordering constraint in a comment — the send sits after the test-mode modal on purpose, so anything waiting on config sees the entitlements the tester picked.
- Collapsed the test-mode alert gate —
shouldShowTestModeAlertinlined, and thetestModeReason != nilcheck plus the secondelse if let reason = …unwrap folded into one binding with a plainelse.
I checked the relocation rather than taking the commit message for it. processConfig is private with exactly two call sites (ConfigManager.swift:145 refresh, :216 first fetch), both of which sent .retrieved on the statement immediately following the await, and the new send is the function's final statement — so the send's position in program order is identical on every path: test mode, the dev-mode applyDefaultTestModeState branch, test-mode-just-deactivated, the early publish (which still sends at :507 before await purchasesLoad.value and suppresses the tail send), and the refresh. The refresh never passes publishingEarlyFrom, so it could not double-send before and cannot now, and nothing reachable from inside processConfig waits on configState, so publishing later than the modal introduces no new self-wait. The gate collapse is equivalent too — there is no suspension point between the two old reads of testModeReason, and the outer clause already required it to be non-nil before the else if was evaluated.
Claude Opus | 𝕏
… they hold The per-entitlement filter used one predicate for two questions. A lifetime App Store unlock has no expiry, so it can't hold the status up, and the filter also dropped it from the status on an empty read that said nothing about it. Holding and surviving are now separate: an entitlement survives unless the read had authority over it and did not confirm it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The preview kept rendering a surface that had been removed from the project, while presenting it failed. The selection is now cleared with a warning, matching the presentation path, and an empty selection stops the spinner instead of leaving it running. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The CLI serializer writes introductory_offer_eligibility on every surface in lowercase, defaulting to automatic. The fixture now carries the key so a casing change on either side fails here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in the debugger or fixture commits — one open question on the entitlement rewrite inline, plus two nitpicks. All three threads this delta set out to close were replied to and resolved by the author. Not approving only because the
### Breaking Changessection94fb59dremoved still hasn't come back to the 4.17.0 block, so the release notes don't disclose the$subscriptionStatussource break the PR body itself describes.
Reviewed changes — the delta since the dcc2e9e review: three commits, one source file each plus two test files. Every commit answers an open pullfrog thread.
- Separated "holds the status up" from "stays in the status" —
AutomaticPurchaseController's singleheldEntitlementsfilter split intoisRefuted(no expiry gate; only an App Store entitlement the read had authority over and did not confirm) and an expiry-gatedholdsStatus, so a lifetime App Store unlock beside a held subscription is no longer stripped out and persisted on an empty read. - Dropped a selection the refreshed manifest no longer lists —
ensureDevServer()clearsdevSurfacewith a warning and clears thepaywallIdentifier/paywallDatabaseIdpair it had installed, andfinishLoadingPreview()stops the spinner on an empty selection instead of leaving it turning. - Pinned
introductory_offer_eligibilityin the verbatim manifest fixture — the key now appears on all three surfaces oftest_readsTheManifestTheCliServeswith an assertion on the bound one, and the doc comment names the CLI serializer (paywallSettingsOf) that writes it and why the casing is mixed per key.
I checked the new entitlement predicate against the shapes the suite doesn't cover rather than just the two tests it touches. With purchases non-empty the refuted-drop is unchanged, and testRefundedAppStoreEntitlement_besideWebEntitlement_isDropped passes under both the old and the new predicate — so it isn't the test that moved. testEmptyDeviceRead_lifetimeBesideHeldEntitlement_keepsBoth does flip: under the previous filter the lifetime entitlement fails the expiry guard, the reassign fires and the contains assertion fails, so it's a real regression test for the thing it names. The one shape that changed and has no test is inline below. On the debugger side the cleared state is coherent — canOpenPicker returns true again once paywallDatabaseId is nil, the Present button's paywallIdentifier guard short-circuits, and the paywallIdentifier == devSurface.previewIdentifier test can only match a selection loadDevServerPreview installed itself, so a published-paywall selection is never collaterally cleared. The new fixture assertion isn't vacuous either: the published paywall it inherits from is .automatic, so .ineligible can only have come from the manifest.
ℹ️ Nitpicks
Sources/SuperwallKit/Debug/DebugViewController.swift:261— when the selection is dropped,previewPickerButtonkeeps the"\(surface.id) (local)"titleloadDevServerPreviewset, whileloadPreview()has already removedpreviewViewContent. The developer is left looking at a blank preview under a title naming the surface that no longer exists, and the only signal is a.warnlog they'd have to open the console to see. Resetting the title alongsidedevSurface = nilwould make the empty state legible.Sources/SuperwallKit/Debug/DebugViewController.swift:251— the preview path re-implements the fresh-manifest lookup inline (located.surfaces.first { $0.id == devSurface.id }) while the Present path goes throughDevServerManifest.surface(forPreviewIdentifier:)viaDevServerPreview.resolveSurface. The two now encode the same rule in two places, and the inline copy is the one that can't be unit-tested because it's wired toDevServerLocator.sharedandSuperwall.shared— routing it through the shared helper would collapse both problems into one.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ The code in this delta is a clean no-op removal — one suggestion inline on the changelog edit that rode with it. Two things from earlier reviews are still outstanding and untouched: the
### Breaking Changessection94fb59dremoved still hasn't returned to the 4.17.0 block, and theAutomaticPurchaseController.swift:93-99thread is still open.
Reviewed changes — the delta since the 62e294a review: one commit, dbaebd5 ("Update changelog, remove redundant code"), two files, +3/−6.
- Dropped
mergeAndApplyCustomerInfo's unused return value — the method went-> CustomerInfotoasync-only and lost its trailingreturn mergedCustomerInfo, with the_ =gone from both call sites. - Removed the
CaseIterablebullet from 4.17.0's Enhancements — the conformance itself stays in the source.
I checked the removal against the whole file rather than the two hunks: mergeAndApplyCustomerInfo has exactly two call sites — :451 on the redeem-completion path and :979 in pollWebEntitlements — and neither ever bound the result, so nothing downstream loses the merged copy. Both paths still get the merged CustomerInfo through the MainActor.run { superwall.customerInfo = … } the method ends on, and the status they publish afterwards is derived independently (updateSubscriptionStatus(with:) from the device+web merge, and the poll path deliberately excludes grants from the assigned status). The doc comment already described the method by its side effect, so it needed no update.
Claude Opus | 𝕏
…d nothing Surviving a held status only asked whether the read had authority over the entitlement, so an App Store subscription whose cached expiry had already passed stayed in the status across empty reads. Time passing needs no read to confirm it: a lapsed expiry now drops the entitlement too, while a nil expiry still keeps a lifetime unlock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues — the prune does exactly what the open thread asked for, and the new test is a real regression test. One inline note on how far the new rule actually reaches. Not approving only because the
### Breaking Changessection94fb59dremoved still hasn't come back to the 4.17.0 block, so the release notes don't disclose the$subscriptionStatussource break the PR body describes.
Reviewed changes — the delta since the dbaebd5 review: one commit, 0014fae ("Drop an entitlement past its own expiry even when the device read said nothing"), two files, +60/−8.
- Pruned entitlements whose own cached expiry has passed —
survivorsinAutomaticPurchaseController's anti-downgrade guard gained&& !isLapsed($0), so a lapsed record no longer rides along on a held one when the device read is a non-answer. This closes theAutomaticPurchaseController.swift:93-99thread from the62e294areview. - Restated
holdsStatusthrough the same helper —(expiresAt ?? .distantPast) > Date()becameexpiresAt != nil && !isLapsed(…), and the block comment's two-questions paragraph was rewritten to name both conditions for membership. - Added
testEmptyDeviceRead_lapsedAppStoreBesideHeldEntitlement_dropsLapsed— a live Stripe entitlement beside an App Store one whose cachedexpiresAtwas yesterday, on an empty read.
I checked the holdsStatus rewrite is a genuine no-op rather than assuming it: isLapsed returns false for a nil expiry, so expiresAt != nil && !isLapsed(e) is exactly the old (expiresAt ?? .distantPast) > Date() on every input. The new test is non-vacuous — under dbaebd5, isRefuted short-circuits to false for the whole set when purchases.isEmpty, so survivors == currentEntitlements, the reassign never fires and the negative assertion fails. It also isn't carried by the web re-merge, since the suite deletes LatestRedeemResponse and the Stripe entitlement survives on survivors alone. survivors can't go empty while holdsStatus, because the entitlement satisfying holdsStatus passes both filters, so no .active([]) is reachable; and once the pruned set is assigned, the next empty read finds survivors == currentEntitlements and stops.
I also chased the one shape that worried me — an App Store subscriber whose cached expiresAt legitimately lags reality, since Apple's billing grace period keeps a subscription in Transaction.currentEntitlements while expirationDate is already past — and it isn't a new risk here. EntitlementProcessor.swift:203-209 computes isActive as expirationDate > now with no grace-period carve-out, so the authoritative read demotes that subscriber too, and holdsStatus has bounded the guard on the same cached expiry since before this commit, so a lapsed sole entitlement already went .inactive. This commit only makes the mixed-set case agree with the single-entitlement one.
Claude Opus | 𝕏
The preview path looked the refreshed surface up inline while Present went through DevServerPreview.resolveSurface, so the same rule lived in two places. The preview now uses the shared resolver. When the surface is gone the picker title is reset too, instead of naming a surface that no longer exists over an empty preview. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Web entitlements are merged back from the redeem cache on every assign, so the clock only decides App Store and nil-store records here. The comment read store-agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No new issues in this delta. Not approving only because the
### Breaking Changessection94fb59dremoved still hasn't come back to the 4.17.0 block, so the release notes don't disclose the$subscriptionStatussource break the PR body itself describes.
Reviewed changes — the delta since the 0014fae review: two commits, two files, +19/−5.
- Routed the debugger's surface carry-over through the shared resolver —
ensureDevServer()replaced its inlinelocated.surfaces.first { $0.id == devSurface.id }withDevServerPreview.resolveSurface(previewIdentifier:fresh:snapshot:), the same callRawPaywallResponse.devServerPaywall(forId:)makes, so preview and Present can't disagree about whether a selected surface still exists. - Reset the picker title when the selection is dropped — the button kept naming a surface the refreshed manifest no longer serves, over an empty preview.
- Named which stores the expiry prune actually settles — the
AutomaticPurchaseControllerblock comment now says the clock decides App Store and nil-store records only, and that a lapsed web record comes straight back from the redeem cache. Comment only; this closes the one open thread from the0014faereview, which the author replied to and resolved.
I checked the resolver swap rather than taking the commit message for it: previewIdentifier is "dev:\(id)" (DevServerSurface.swift:41-43) and surface(forPreviewIdentifier:) matches on exactly that (DevServerManifest.swift:69-71), so the lookup itself is the old id comparison; snapshot: nil is correct because ensureDevServer already returned at its guard let location, making fresh non-nil and the snapshot arm unreachable on this path. The one genuine widening is that resolveSurface also requires mountURL(for:base:) to resolve, so a surface whose url lands off base's origin now drops the selection instead of carrying it — which is the outcome Present already produces, and the two agreeing is the point of the commit. After a drop, finishLoadingPreview falls to its nothing-selected branch and loadAndShowPaywall guards on the now-nil paywallIdentifier, so the Preview button is inert rather than presenting a stale surface.
I also verified the new comment's claim instead of trusting it: EntitlementsInfo.web filters on isActive alone with no expiry gate (EntitlementsInfo.swift:46-52) and internallySetSubscriptionStatus unions it back into whatever it is handed (SubscriptionStatusPublishing.swift:90-99), so a lapsed web record still flagged active in LatestRedeemResponse does return in the same call, while nil-store records — which the redeem cache doesn't carry — stay pruned. The comment is accurate as written.
ℹ️ Nitpicks
Sources/SuperwallKit/Debug/DebugViewController.swift:261-266— the warning now also fires when the refreshed manifest does list the surface but itsurlresolves offbase's origin, where "The dev server no longer lists the surface …" isn't quite what happened.mountURLlogs its own explanation immediately before, so nothing is left unexplained; worth knowing rather than fixing.
Claude Opus | 𝕏

Changes in this pull request
Release 4.17.0. Bumps
Constants.swift,SuperwallKit.podspecandCHANGELOG.mdto 4.17.0.Enhancements
grantedEntitlementsso you can grant entitlements from your own backend, which the SDK merges with device and web entitlements.$subscriptionStatusfrom a@Publishedpublisher to anAnyPublisher. Subscribing to it works as before, but it can no longer be the target ofassign(to:).Fixes
subscriptionStatusDidChangenow fires only when the logical status changes — the status case, the set of entitlements, or an entitlement'sisActiveflag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; usecustomerInfoDidChangefor those.inactiveon cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately.registeris called from more than one thread at a time.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
The PR appears safe to merge because the latest concurrency fix prevents stale dev-server probes from returning or caching obsolete locations, and no blocking findings remain.
Summary
This release adds granted entitlements and local dev-server support while revising subscription publication, purchase restoration, attribution identifiers, paywall reuse, and concurrency handling.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Locate dev server] --> B[Capture generation] B --> C[Probe candidate] C --> D{Generation unchanged?} D -- No --> E[Discard stale result] D -- Yes --> F{Manifest found?} F -- Yes --> G[Cache and return location] F -- No --> H[Probe next candidate] I[Pin server or change configured URL] --> J[Increment generation] J --> EReviews (11) · Last reviewed commit: "Hold the dev server lookup to the server..."