Conversation
The debug/preview flow fetched ALL paywalls for the app just to translate
the deep-link numeric `paywall_id` into the paywall `identifier`, which is
slow and breaks for apps with many paywalls.
Add a single-lookup resolver: `GET /v2/paywalls/resolve?id=<id>` on the V2
API returns `{ id, identifier, name }` for a paywall id, scoped to the
app's public key. The preview flow now resolves the identifier with one
request, then fetches that one paywall exactly as before.
- Add `.paywallsV2` endpoint host (base host, `/v2/` prefix)
- Add `Endpoint.resolvePaywall(byDatabaseId:)` + `Network.resolvePaywallIdentifier`
(authenticated with the public key via `isForDebugging: false`)
- Rewrite `DebugViewController` preview to use the resolver; drop the fetch-all
- Remove the now-unused `Paywalls` list model and `getPaywalls()`
- The "Your Paywalls" multi-paywall picker is removed (it depended on the
fetch-all); previewing one paywall by id is unaffected
Requires the backend resolver endpoint (superwall/paywall-next#3456).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the UIContentSizeCategory-to-dashboard-token switch into a pure static DeviceHelper.contentSizeCategoryToken(for:) and lock every token via unit tests, since these tokens are a backend audience-filter contract.
Add system font size as a device attribute
…ew-resolve-endpoint # Conflicts: # Sources/SuperwallKit/Network/API.swift
With the fetch-all gone, the "Your Paywalls" multi-paywall picker can no longer be populated, so `pressedPreview()` was unreachable and the picker chip still advertised a dropdown that did nothing. - Remove `pressedPreview()` and the always-empty `paywalls` property - Drop the picker tap target from the name chip and the preview container - Remove the down-arrow affordance; the chip is now a display-only name label (renamed `previewPickerButton` -> `previewNameButton`) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The V2 API is served from the `superwall.com` domain (api.superwall.com in production, api.superwall.dev in developer), which is a DIFFERENT apex domain than `baseHost` (the legacy v1 API on api.superwall.me). Using baseHost would have sent the resolver to api.superwall.me/v2/... in production and 404'd. Add a dedicated `NetworkEnvironment.apiV2Host` (mirroring `enrichmentHost`, another superwall.com-domain service) and point the `PaywallsV2` host config at it. Local uses localhost:3001 (the apps/api wrangler dev port). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A successful native (App Store) purchase only refreshed entitlements locally via `loadPurchasedProducts` and never called `/redeem`, so a new subscription for an already-identified user wasn't linked to them server-side until another trigger fired. Call `webEntitlementRedeemer.redeem(.existingCodes)` after `loadPurchasedProducts` in `TransactionManager.loadPurchasedProductsIfNeeded` so the freshly-loaded receipts + appTransactionId are pushed to the backend on every native purchase. This is the single funnel for both SK1 and SK2 purchases and, being gated by `shouldSkipReceiptLoading`, skips custom/web products and test mode. Restores go through `didRestore` and are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrsXRkhSGciN62RK7mVpBV
Extend SW-5516 (already redeeming after a native purchase) to also fire webEntitlementRedeemer.redeem(.existingCodes) on the successful restore path. Added in TransactionManager.didRestore, which is only reached after a restore succeeds and after receipts/entitlements have been reloaded, so a freshly-restored subscription is linked to the identified user server-side. Skipped in test mode, mirroring the purchase path's shouldSkipReceiptLoading gate. Updated the purchase-path comment and CHANGELOG accordingly.
Trigger /redeem after a successful native purchase
`Task.retrying` threw `URLError(.badServerResponse)` for any non-2xx response, which its own `catch` then swallowed and retried. A permanent failure such as 401 or 404 therefore burned the full backoff schedule — roughly 65 seconds and 7 round-trips at the default `retryCount` of 6 — before `CustomURLSession.getRequestId` surfaced it to the caller. Return the response immediately for terminal client errors instead. This matches what already happens once retries are exhausted, since the loop's final attempt returns the response unchecked, so callers see the same error, just without the wasted requests and delay. Client errors that may succeed on a retry (408, 425, 429, 499) and all server errors keep their existing retry behaviour. Fixes #492 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getRequestId` only threw for 401 and 404. Every other non-2xx response returned normally, so the error body was then decoded as if it were a success payload. That failed, and the caller received `NetworkError.decoding` — describing the wrong failure — while the SDK tracked a `network_decoding_fail` event for what was really an HTTP error. Add `NetworkError.http(statusCode:)` and classify every non-2xx response through `NetworkError.make(fromStatusCode:)`. 401 and 404 keep their existing dedicated cases and log messages, so nothing matching on those changes behaviour. The three near-identical logging blocks collapse into one that also records the status code. `NetworkError` gains an explicit `Equatable` conformance because adding an associated value drops the conformance simple enums get implicitly, which `PaywallLogic.handlePaywallError` relies on for `error == .notFound`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`NetworkError.make(fromStatusCode:)` treats any non-2xx response as an error, but `TaskRetryLogic.isTerminal` only considered 4xx terminal. A 3xx therefore threw `URLError(.badServerResponse)` and burned the full retry schedule before surfacing as `.http(statusCode:)`. The test suite asserted both opinions, so it documented the contradiction rather than catching it. A redirect only reaches the caller when `URLSession` couldn't follow it — a 3xx with no `Location` header, for instance — and it comes back the same way however many times it's sent, so there's nothing to gain from retrying. Reframe `isTerminal` as "not 2xx, and retrying can't help": 5xx and the retryable client errors are still sent again, everything else outside 2xx is terminal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rrors Don't retry terminal HTTP errors, and surface them properly
…endpoint Brings back the multi-paywall picker this branch removed in c6cde4b, without reinstating the fetch-all that made it removable. The picker let you switch previews from inside the debugger. It was backed by the SDK fetching every paywall in full; when that went away the array could never be populated, `pressedPreview()` was unreachable behind a `guard !paywalls.isEmpty`, and the chip advertised a dropdown that did nothing — so it was deleted. Without it, previewing a different paywall means going back to the dashboard for a fresh QR code. paywall-next#3657 adds `GET /v2/paywalls/preview-list`, which returns id/identifier/name for the non-archived paywalls of the application in the debugger's `sat_` preview token — no presentable paywall JSON. That is enough to render the picker at a fraction of the old payload, fetched on demand rather than on every debugger launch. - `PaywallPreviewList` / `PaywallPreviewListItem` decodables - `Endpoint.listPreviewPaywalls` on the `.paywallsV2` host - `Network.listPreviewPaywalls`, same `isForDebugging: true` auth as the resolver, with a lower retry count since it only feeds an optional picker - Restores `previewPickerButton` (name, down arrow, tap target) and `pressedPreview`, now keyed off `previewPaywalls` The list loads after the previewed paywall is on screen, so the picker never delays what the user asked for, and a failure degrades to an empty picker rather than an error. `pressedPreview` needs more than one entry before opening — an action sheet offering only the paywall already on screen is noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two review findings on the picker restoration. `hasMore` was a non-optional `Bool` that nothing read. The decoder only applies `convertFromSnakeCase`, so if `preview-list` ever stopped sending `has_more` the whole list decode would throw `keyNotFound` and the picker would silently empty behind a `.warn`. The SDK does not paginate, so the field is dropped rather than made optional — no reason to carry a decode dependency on something unused. `loadPreviewPaywalls()` sat at the end of the render success path, and both the resolve `catch` and the fetch `catch` return before reaching it. The picker therefore only populated when the paywall loaded, leaving the down-arrow inert exactly when switching away is most useful — the case the picker exists for. It now runs from `viewDidLoad` alongside the preview load, concurrently and independently, so a failed render still offers alternatives. Calling it only from `viewDidLoad` also means switching paywalls via the picker no longer refetches a list that cannot have changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills the gap left by the picker restoration: `listPreviewPaywalls` had no test, while `resolvePaywall` did. - `listPreviewPaywalls_endpointBuildsRequest` mirrors the resolver's test — GET, correct path, and the `sat_` debug key as the bearer. Also asserts the URL carries no query string: the application is taken from the token's scope server-side, so a client-supplied `application_id` would be a way around that scoping. - Three decoding tests around `PaywallPreviewList`, which declares only `data` while the endpoint also returns `object`, `has_more` and `application_id`. They pin both directions — undeclared fields present and absent — plus the empty-list case, so the response shape can change without a `keyNotFound` silently emptying the picker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…point tests Two more review findings. `pressedPreview` still bailed on `guard let id = paywallDatabaseId`, which was the last thing keeping the picker inert in the case b708cda set out to fix: a debug deep link without a `paywall_id` leaves it nil, so nothing renders, but the now-independent list load populates `previewPaywalls` and the picker refused to open anyway. The id was only used to mark the current row with a checkmark, so it never needed to be non-nil. Replaces both that guard and the `count > 1` check with a single condition — open when the list contains something other than what is already on screen. That still declines on an empty list and on a single entry matching the current paywall, while opening when nothing rendered. The endpoint tests matched only the path, so they passed regardless of which host resolved — exactly how the V2 resolver shipped pointing at the v1 `baseHost` (b073dda). Both now assert host and path together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…points `/resolve` and `/preview-list` return the same three fields (id, identifier, name), so the two identically-shaped structs collapse into `PaywallSummary`. `PaywallPreviewList` stays as the list envelope, and its endpoint keeps its own `Response == PaywallPreviewList` constraint. Also lifts these types out of Paywall.swift into their own files under Models/Paywall, and trims their doc comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ndpoint feat: resolve preview paywall by id instead of fetching all paywalls
| let category = UIApplication.sharedApplication?.preferredContentSizeCategory | ||
| ?? UIScreen.main.traitCollection.preferredContentSizeCategory | ||
| return Self.contentSizeCategoryToken(for: category) |
There was a problem hiding this comment.
When background tasks build device attributes, preferredContentSizeCategory reads UIApplication and UIScreen state without main-actor isolation, causing Main Thread Checker violations and unsafe UIKit access. The related fontSize and fontScale reads have the same threading problem.
Knowledge Base Used: Networking Layer
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
Line: 231-233
Comment:
**UIKit state read off-main**
When background tasks build device attributes, `preferredContentSizeCategory` reads `UIApplication` and `UIScreen` state without main-actor isolation, causing Main Thread Checker violations and unsafe UIKit access. The related `fontSize` and `fontScale` reads have the same threading problem.
**Knowledge Base Used:** [Networking Layer](https://app.greptile.com/superwall/-/custom-context/knowledge-base/superwall/superwall-ios/-/docs/networking-layer.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Important
The two new font device attributes resolve against an ambient trait collection that Apple documents as undefined in the context they're read from, so they can disagree with the preferredContentSizeCategory shipped alongside them. Worth settling before this goes out, since these feed audience filters.
Reviewed changes — full review of the 4.16.2 release promotion (develop → master), covering all 34 commits across the four aggregated PRs (#488, #489, #490, #494).
- Dynamic Type device attributes —
DeviceHelpergainsfontSize,fontScaleandpreferredContentSizeCategory, wired intoDeviceTemplateandgetTemplateDevice(), with acontentSizeCategoryToken(for:)map that is a backend audience-filter contract. - Terminal HTTP errors no longer retried —
TaskRetryLogic.isTerminal(statusCode:)short-circuits the backoff for 3xx and non-{408,425,429,499}4xx, so an invalid API key fails immediately instead of after the full retry schedule. - Non-2xx surfaced as the real error —
NetworkErrorbecomesEquatable, gains.http(statusCode:),make(fromStatusCode:)andlogMessage;CustomURLSessionthrows before decoding so failures stop masquerading as.decoding/network_decoding_fail. - Redeem after native purchase and restore —
TransactionManagerfireswebEntitlementRedeemer.redeem(.existingCodes)fromloadPurchasedProductsIfNeededanddidRestore, both excluded in test mode. - Debugger preview moved to the V2 API — new
paywallsV2endpoint host pluspaywalls/resolveandpaywalls/preview-list, replacing the fetch-every-paywall approach;Paywalls/getPaywalls()deleted and the "Your Paywalls" picker restored on aPaywallSummarymodel. SWBounceButtontitle fix —showLoading = falseon an already-idle button no longer blanks the title.- Release hygiene — 4.16.2 in
Constants.swift,SuperwallKit.podspecandCHANGELOG.md; project regenerated; new tests for retry classification,NetworkError, the V2 endpoints and the content-size token map.
I traced the retry/error-classification change through every endpoint and every NetworkError consumer and found no regression: only PaywallLogic.swift:63 matches a specific case (.notFound, unchanged), and redemption error states are body-level discriminators inside 2xx responses, so no endpoint loses a body it still needs to decode. The two redeem triggers also check out — both are reachable only on success, and the purchase and restore paths are disjoint, so a single user action can't fire both.
ℹ️ The new redeem triggers have no test coverage and are wired through the singleton rather than the injected factory
Both new redeem(.existingCodes) calls reach through Superwall.shared.dependencyContainer.webEntitlementRedeemer — the only two places in TransactionManager that bypass its own injected factory, where ConfigManager and IdentityManager hold the redeemer as a constructor-injected dependency. That's also why neither call site can be exercised in a test, and there is currently no test anywhere that covers linking a subscription server-side after a purchase or restore. CLAUDE.md asks for a unit test with new functionality, and this one ships as a CHANGELOG-advertised enhancement.
Technical details
# Redeem-on-purchase/restore: DI shortcut and missing coverage
## Affected sites
- `Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:490` — `didRestore` reaches
`Superwall.shared.dependencyContainer.webEntitlementRedeemer` directly.
- `Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift:861` —
`loadPurchasedProductsIfNeeded` does the same.
- `Tests/SuperwallKitTests/` — no `TransactionManagerTests.swift` exists; neither trigger is
covered by any test.
## Required outcome
- The redeemer is reachable from `TransactionManager` through the same dependency-injection route
its other collaborators use, so the trigger can be observed in a test.
- At least one test asserts that a successful purchase and a successful restore each fire
`redeem(.existingCodes)` exactly once, and that test mode fires neither.
## Suggested approach
- Add a `makeWebEntitlementRedeemer()` (or equivalent) requirement to the factory protocol
composition `TransactionManager` already depends on (`FactoryProtocols.swift:88`,
`TransactionManagerFactory`), matching how `ConfigManager.swift:44` and `IdentityManager.swift:120`
hold `private unowned let webEntitlementRedeemer: WebEntitlementRedeemer`.
- In `didRestore`, the test-mode guard currently reads as a bare `return` at the end of the
function, which silently swallows anything appended after it later. Inverting it to
`if !factory.makeTestModeManager().isTestMode { Task { ... } }` keeps the same behaviour without
that trap.ℹ️ Nitpicks
Sources/SuperwallKit/Debug/DebugViewController.swift:121-125— the doc comment sayspreviewPaywallsis "Empty when the request fails or the app has a single paywall", but a single-paywall app returns a one-entry list, not an empty one. ThepressedPreviewcomment on line 339 describes that case correctly; this one contradicts it.Sources/SuperwallKit/Debug/DebugViewController.swift:227-235— whenresolvePaywallIdentifierfails (deleted or wrongpaywall_idin a deep link)finishLoadingPreviewreturns withoutactivityIndicator.stopAnimating(), leaving the debugger spinning with no user-facing error. The removedgetPaywalls()failure path had the same gap, so this isn't a regression, but the new resolve endpoint makes it easier to hit.Sources/SuperwallKit/Debug/DebugViewController.swift:344— tapping the picker in the window betweenviewDidLoadandloadPreviewPaywalls()completing is a silent no-op, sincepreviewPaywallsis still empty. Indistinguishable from "the request failed" for the user.Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift:14—idis typedStringand the only evidence for that shape is the PR's own fixtures. Worth confirming against the liveapps/apiworker: a numericiddegrades gracefully forpreview-list(empty picker) but breaks everypaywall_iddeep link throughresolvePaywallIdentifier.
Claude Opus | 𝕏
| var fontSize: Int { | ||
| #if os(visionOS) | ||
| return 16 | ||
| #else | ||
| return Int(UIFontMetrics.default.scaledValue(for: 16.0).rounded()) | ||
| #endif | ||
| } | ||
|
|
||
| var fontScale: Double { | ||
| #if os(visionOS) | ||
| return 1.0 | ||
| #else | ||
| let scale = UIFontMetrics.default.scaledValue(for: 16.0) / 16.0 | ||
| return (scale * 100).rounded() / 100 | ||
| #endif | ||
| } |
There was a problem hiding this comment.
scaledValue(for:) is the implicit-trait overload — it resolves against UITraitCollection.current, which Apple documents as undefined outside UIView/UIViewController/UIPresentationController trait callbacks and stores as a thread-local. getTemplateDevice() and the public Superwall.getDeviceAttributes() are plain async with no main-actor isolation, so these two can report an unscaled 16 / 1.0 while preferredContentSizeCategory right below — read from UIApplication, which is not trait-context dependent — correctly reports e.g. xxxLarge. Deriving all three from one explicit trait collection makes them agree and removes the thread dependency.
Technical details
# Font attributes resolve against an undefined ambient trait collection
## Affected sites
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:214` — `fontSize` via
`UIFontMetrics.default.scaledValue(for: 16.0)`.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:222` — `fontScale` via the same call.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:231` — `preferredContentSizeCategory`
reads a different, trait-independent source (`UIApplication.sharedApplication?.preferredContentSizeCategory`),
which is why the three can disagree.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:656-658` — all three feed
`DeviceTemplate` in `getTemplateDevice()`, which is `async` and not main-actor isolated.
## Required outcome
- `fontSize`, `fontScale` and `preferredContentSizeCategory` all derive from a single content-size
category, so they can never contradict each other in the payload sent to audience filters.
- The reported values reflect the user's actual Dynamic Type setting regardless of which thread or
executor `getTemplateDevice()` happens to run on.
- The values stay live: the user can change text size mid-session, so caching once at `init` (the
way `makeScreenMetrics()` caches screen bounds) would be wrong here.
## Suggested approach
- Resolve the category once (the existing `UIApplication.sharedApplication?.preferredContentSizeCategory`
`??` `UIScreen.main.traitCollection.preferredContentSizeCategory` expression), then pass
`UITraitCollection(preferredContentSizeCategory:)` into
`UIFontMetrics.default.scaledValue(for:compatibleWith:)` for both numbers.
- If the `UIApplication` read itself should be main-thread-safe, the file already has the house
pattern for it in `makeScreenMetrics()` (line ~141):
`Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)`.
## Open questions for the human
- Are `fontSize` and `fontScale` both needed on the backend? `fontSize` is `fontScale * 16` rounded,
so one is derivable from the other and two fields are two chances to disagree.
## Sources
- `UITraitCollection.current`: "Outside these methods, you're responsible for ensuring the `current`
property has a valid trait collection. Otherwise, the contents of the collection are undefined."
and "UIKit stores the value of the `current` property as a thread-local variable." —
https://developer.apple.com/documentation/uikit/uitraitcollection/current
- `scaledValue(for:)` — "Scales an arbitrary layout value based on the current Dynamic Type settings"
vs. `scaledValue(for:compatibleWith:)` — "...and the specified traits." —
https://developer.apple.com/documentation/uikit/uifontmetrics/scaledvalue(for:compatiblewith:)
Changes in this pull request
Promotes
develop→masterfor the 4.16.2 patch release.Enhancements
fontScale,fontSize, andpreferredContentSizeCategory.Fixes
Release contents
Constants.swift,SuperwallKit.podspec,CHANGELOG.mdall match)masterChecklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.🤖 Generated with Claude Code
Greptile Summary
This patch prepares the 4.16.2 release.
Confidence Score: 4/5
The PR should not merge until Dynamic Type collection is made main-thread-safe and purchase/restore redemption is bound to the identity associated with the completed transaction.
Ordinary background device-attribute collection now reaches UIKit trait APIs without main-actor isolation, while deferred redemption can observe a different global identity after a logout or account switch.
Files Needing Attention: Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift; Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift
Important Files Changed
Sequence Diagram
Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Merge pull request #489 from superwall/f..." | Re-trigger Greptile