Skip to content

4.17.0 - #525

Merged
yusuftor merged 185 commits into
masterfrom
develop
Sep 22, 2026
Merged

4.17.0#525
yusuftor merged 185 commits into
masterfrom
develop

Conversation

@yusuftor

@yusuftor yusuftor commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Release 4.17.0. Bumps Constants.swift, SuperwallKit.podspec and CHANGELOG.md to 4.17.0.

Enhancements

  • Adds grantedEntitlements so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements.
  • Changes $subscriptionStatus from a @Published publisher to an AnyPublisher. Subscribing to it works as before, but it can no longer be the target of assign(to:).

Fixes

  • Fixes duplicate device attribute and subscription status change events being tracked when the subscription status is repeatedly set to the same logical state. As part of this, subscriptionStatusDidChange now fires only when the logical status changes — the status case, the set of entitlements, or an entitlement's isActive flag. Updates to transaction metadata such as expiry dates or renewal state no longer trigger it; use customerInfoDidChange for those.
  • Fixes subscribers with an unexpired subscription being reported as inactive on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately.
  • Fixes slow cold launches for subscribers on a weak network by no longer fetching their purchased products from StoreKit before the SDK is ready. Applies to StoreKit 2.
  • Fixes a data race during SDK configuration that Thread Sanitizer flagged on every launch.
  • Fixes a crash when register is called from more than one thread at a time.
  • Fixes issue where paying web users could end up having a temporary inactive subscription status if the server temporarily returns no entitlement data for them.
  • Fixes audiences matching users they shouldn't when you use a Purchase Controller.

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

RetriggerConfidence Score: 5/5

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.

  • The latest changes invalidate suspended dev-server lookups when a pin or configured origin changes.
  • Tests cover a deep-link pin arriving while a previous server probe is suspended.
  • Dev-server documentation now accurately describes the local-network ATS requirement.
  • No new actionable issues were identified since the previous review.

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 --> E
Loading

Reviews (11) · Last reviewed commit: "Hold the dev server lookup to the server..."

jakemor and others added 30 commits August 16, 2026 00:04
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>
Comment thread Sources/SuperwallKit/DevServer/DevServerPreview.swift
Comment thread Sources/SuperwallKit/DevServer/DevServerLocator.swift
Comment thread Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Comment thread Examples/Basic/Basic/Info.plist Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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(_:), with enableDevServer()/enableDevServer(url:) for Objective-C. It is deliberately absent from SuperwallOptions.CodingKeys, so nothing about it reaches the backend.
  • Gated the whole feature behind DevMode.isActive — devServer != nil plus a new DeviceHelper.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.json across 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 — DevServerManifest requires surfaces (so a stray JSON responder can't end the port walk) and decodes per-element through Throwable; mountURL(for:base:) pins every surface URL to the base's scheme, host and port so an absolute url can't name another origin.
  • Synthesised a Paywall from a surface — Paywall.devServer(surface:url:inheriting:) takes bytes, products and whatever config.ts declares from the surface, identity and every unexpressed setting from the published paywall, forces onDeviceCache off and localNotifications empty, and folds a settings fingerprint into cacheKey so an edited config.ts can't live-reload inside a stale native frame.
  • Wired the override into the register path — RawPaywallResponse.applyDevServerOverrideIfNeeded, plus devServerPaywall(forId:) for synthetic dev: ids, and PaywallRequestManager stops memoising paywallsByHash in dev mode so a transient miss can't pin the published paywall for the process.
  • Reworked the debugger — a superwall_dev deep link opens a new searchable, sectioned DebugPaywallPickerViewController over local surfaces and published paywalls (backed by pure DebugPickerLogic), 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.evaluateTestMode force-enables it, and ConfigManager.applyDefaultTestModeState seeds the state the intro sheet would collect instead of presenting it. Preloading is disabled.
  • Narrowed both example apps' ATS — NSAllowsArbitraryLoads replaced by NSAllowsArbitraryLoadsInWebContent + NSAllowsLocalNetworking.
  • Added 7 test files covering DevMode gating, 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`, which b61b66e replaced with devServer. It's a DocC link, so it now resolves to nothing.
  • Sources/SuperwallKit/Debug/DebugManager.swift:17 and Sources/SuperwallKit/Debug/DebugViewController.swift:124 — both hold (base: URL, surfaces: [DevServerSurface])?, which is DevServerLocation with the manifest unwrapped, so RawPaywallResponse.devServerPaywall has to rebuild a DevServerManifest(surfaces:) from the tuple just to call mountURL. Storing the DevServerLocation the locator already returns would drop that reconstruction.
  • Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift:132 — isLocal is a new public property and is_local a new param on every paywall event, but neither is mentioned in the changelog alongside the devServer entries. Worth a clause, since integrators reading PaywallInfo will see it.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerSettings.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerManifest.swift Outdated
Comment thread Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift Outdated
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>
@yusuftor

Copy link
Copy Markdown
Collaborator Author

On the outside-diff finding Paid Offers Become Trials (TransactionManager.swift:1109) — leaving this as is.

freeTrial_start has never meant strictly a free trial in this SDK. The pre-iOS-17.2 branch immediately below uses coordinator.isFreeTrialAvailable, which comes from ReceiptManager.isFreeTrialAvailable and is just isEligibleForIntroOffer plus a subscription-group check — so a paid intro offer (pay-up-front or pay-as-you-go) has always counted, and that is how freeTrial_start is decided everywhere else it is sent. sk2Transaction.offer != nil matches that. Changing only the StoreKit 2 branch would make the two paths disagree about the same purchase, which is worse than the name being broad.

@yusuftor

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment thread Sources/SuperwallKit/DevServer/DevServerLocator.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ 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 — locate now calls a new forget() (clearing the hit cache, the miss cache and any deep-link pin) when devServerURL differs from the previous call, so repointing SuperwallOptions.devServer mid-session can no longer serve, suppress or prefer the old server. A hasBeenAsked carve-out keeps the first call from throwing away a pin a deep link set before it.
  • Made the manifest probe injectable — a Load typealias and init(load:) with the old inline withCheckedThrowingContinuation moved into a loadWithURLSession default, so the probing and caching are testable without a server.
  • Added DevServerLocatorTests — six tests over an injected Probe actor: hit reuse, hit/miss/pin dropped on an address change, pin priority, and forget().
  • Narrowed the examples' ATS exception to NSAllowsLocalNetworking — NSAllowsArbitraryLoadsInWebContent is gone from both Info.plists, and the devServer doc 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerLocator.swift
Comment thread Sources/SuperwallKit/Config/Options/SuperwallOptions.swift Outdated
- 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>
@yusuftor

Copy link
Copy Markdown
Collaborator Author

@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>
@yusuftor

Copy link
Copy Markdown
Collaborator Author

@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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No regressions in this delta — one unverified wire contract inline, plus two nitpicks. Both threads from the last review are addressed by e7f2760 and 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.loadDevServerPreview stores it instead of paywall.identifier, and devServerPaywall(forId:) matches on the same property. A manifest carrying a dashboard identifier no 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 — WireStyle became a typealias for PaywallPresentationStyle.InternalPresentationStyle. Raw values are identical for all six shared cases, and the newly reachable NONE returns nil (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 generation counter bumped by pin() and forget() 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 returns nil rather than committing. This supersedes c1ae976's devServerURL != requestedURL check, which only caught the two-party repoint.
  • Corrected what the devServer doc comment says about App Transport Security — NSAllowsLocalNetworking is now described as what re-enables plain IP addresses, which ATS stopped permitting by default in iOS 17.
  • Pinned isPresentedBySDK in its true state — sdkPresentationReportsItsOwnPlacementOverAHandedOutClaim arms a handed-out .getPaywall claim and then drives the real present(on:request:paywall:…) through a CompletingPresenter stub, so the !isPresentedBySDK guard is finally exercised in the state that matters.
  • Fixed a fixture that let an inheritance assertion pass either way — DevServerPaywallTests' published-paywall JSON now says ALWAYS_INELIGIBLE, which IntroOfferEligibility actually decodes, rather than INELIGIBLE, which fell back to .automatic — also Paywall.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_inheritsComputedPropertiesAndIntroOfferEligibility now pins the inheritance branch for real, but the assertion is still XCTAssertEqual(paywall.introOfferEligibility, fields.introductoryOfferEligibility) — i.e. compared against whatever the fixture happened to decode to. A literal .ineligible would 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 — when devServerPaywall(forId:) can't resolve a dev:-prefixed id, the request falls through makeStaticPaywall (nil for debugger requests) to network.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 second superwall_dev scan while the debugger is open, or a surface removed from config.ts mid-session — and the shared previewIdentifier removes the drift that used to cause it, so this is worth knowing rather than fixing.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerSettings.swift
yusuftor and others added 2 commits September 21, 2026 10:53
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No regressions in the refund fix itself — two rough edges inline. Not approving because the DevServerSettings.swift:55-59 thread from the c1ae976 review 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's holdsStatus became a filter, 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 its devServer == nil guard, so every loadPreview() 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, refreshes debugManager.devServer, and resolves through the new DevServerPreview.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 a dev: 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 writes debugManager.devServer but not DebugViewController.devServer, which is what backs the picker's local-surface list, canOpenPicker and loadDevServerPreview's base. DebugManager only pushes its copy into the view controller inside presentDebugger, so after a Present the two disagree until the next loadPreview() 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 every loadPreview(), 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 old devServer == nil guard skipped it entirely after the first hit.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ 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 Changes section 94fb59d removed 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 configState send into processConfig — both callers used to send .retrieved right after the await returned, unless the early-publish branch had already sent and reported it back through @discardableResult -> Bool. The send is now the last statement of processConfig guarded by hasPublishedConfig, and the return value, the @discardableResult and 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 — shouldShowTestModeAlert inlined, and the testModeReason != nil check plus the second else if let reason = … unwrap folded into one binding with a plain else.

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.

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

yusuftor and others added 3 commits September 21, 2026 15:20
… 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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ 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 Changes section 94fb59d removed still hasn't come back to the 4.17.0 block, so the release notes don't disclose the $subscriptionStatus source 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 single heldEntitlements filter split into isRefuted (no expiry gate; only an App Store entitlement the read had authority over and did not confirm) and an expiry-gated holdsStatus, 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() clears devSurface with a warning and clears the paywallIdentifier/paywallDatabaseId pair it had installed, and finishLoadingPreview() stops the spinner on an empty selection instead of leaving it turning.
  • Pinned introductory_offer_eligibility in the verbatim manifest fixture — the key now appears on all three surfaces of test_readsTheManifestTheCliServes with 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, previewPickerButton keeps the "\(surface.id) (local)" title loadDevServerPreview set, while loadPreview() has already removed previewViewContent. 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 .warn log they'd have to open the console to see. Resetting the title alongside devSurface = nil would 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 through DevServerManifest.surface(forPreviewIdentifier:) via DevServerPreview.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 to DevServerLocator.shared and Superwall.shared — routing it through the shared helper would collapse both problems into one.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ 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 Changes section 94fb59d removed still hasn't returned to the 4.17.0 block, and the AutomaticPurchaseController.swift:93-99 thread 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 -> CustomerInfo to async-only and lost its trailing return mergedCustomerInfo, with the _ = gone from both call sites.
  • Removed the CaseIterable bullet 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread CHANGELOG.md
…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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ 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 Changes section 94fb59d removed still hasn't come back to the 4.17.0 block, so the release notes don't disclose the $subscriptionStatus source 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 — survivors in AutomaticPurchaseController'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 the AutomaticPurchaseController.swift:93-99 thread from the 62e294a review.
  • Restated holdsStatus through the same helper — (expiresAt ?? .distantPast) > Date() became expiresAt != 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 cached expiresAt was 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

yusuftor and others added 2 commits September 21, 2026 18:31
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>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No new issues in this delta. Not approving only because the ### Breaking Changes section 94fb59d removed still hasn't come back to the 4.17.0 block, so the release notes don't disclose the $subscriptionStatus source 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 inline located.surfaces.first { $0.id == devSurface.id } with DevServerPreview.resolveSurface(previewIdentifier:fresh:snapshot:), the same call RawPaywallResponse.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 AutomaticPurchaseController block 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 the 0014fae review, 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 its url resolves off base's origin, where "The dev server no longer lists the surface …" isn't quite what happened. mountURL logs its own explanation immediately before, so nothing is left unexplained; worth knowing rather than fixing.

Pullfrog  | Fix it ➔ | View workflow run | Using Claude Opus | 𝕏

@yusuftor
yusuftor merged commit abaab53 into master Sep 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants