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>
There was a problem hiding this comment.
ℹ️ No new issues in the privacy-signature hardening — the refactor is behavior-preserving and the one behavior change is an improvement. Two housekeeping items below.
Reviewed changes — the delta since the previous pullfrog review at 84e7b13, i.e. the 5 commits of #496 that stop Apple's permission API names reaching the compiled framework.
- Four
@objcFake*shims deleted —FakeContactStore,FakeLocationManager,FakeAudioSessionandFakeTrackingManagermirrored Apple's selectors verbatim, so their names landed in__TEXT,__objc_methname. Each*Proxynow takes the resolved class through aninit(xClass: AnyClass? = NSClassFromString(mangled.rot13()))seam andguards onnilinstead. @objcdropped from the*SelectorNameproperties on all four proxies, with a comment on each explaining why they must stay Swift-only.- Continuation wrappers renamed —
requestRecordPermission/requestAccess/requestTrackingAuthorizationbecamerequestPermission/requestPermission/requestAuthorization, becausewithCheckedContinuation'sfunction: String = #functiondefault expands the enclosing name into a__cstringliteral. - Two plaintext literals mangled —
PlistKey.trackingis now ROT13-decoded at read time, andLocationPermissionDelegatederives its KVC key fromLocationManagerProxy.mangledAuthorizationStatusSelectorrather than"authorizationStatus". - New CI gate —
scripts/scan-privacy-signatures.shgreps__objc_methname,__objc_classnameand__cstringfor a 14-name denylist, run intests.ymlagainst the framework the test step just built at-derivedDataPath .build. - Tests reshaped around the missing-class path — each deleted fake's suite becomes a
*MissingClassTestssuite driving the new init seam withnil, plus a test pinning the tracking plist key's decode.
I checked the mangling claim end-to-end rather than taking it on trust: every ROT13 constant round-trips to the right Apple name, no denylisted name survives as a plaintext literal anywhere in Sources/ (only inside // ROT13("…") comments), the only @objc left in Permissions/ is the pair of CoreLocation delegate callbacks the script deliberately exempts plus three @objc enum status mirrors, no continuation site's #function still expands to an Apple name, and nothing in source, tests or the regenerated project.pbxproj still references a deleted fake. The scan exits 2 when the binary or the sections are missing, so it can't silently pass.
The refactor is sentinel-for-sentinel equivalent to the fake fallbacks (-1 for contacts and microphone, notDetermined for tracking and location) with one deliberate improvement: requestWhenInUseAuthorization() / requestAlwaysAuthorization() now return false when CoreLocation is absent, so PermissionsHandler+Location resumes .unsupported where the fake reported success and left the continuation waiting for a callback that never came.
ℹ️ The release PR body no longer describes the release it's promoting
This description still says "34 commits since master" and lists only #488, #489, #490 and #494, but the branch now carries 52 commits and the 4.16.2 CHANGELOG section includes entries from #497 (Main Thread Checker fix) and #496 (privacy signatures). On a develop → master promotion the body is the release summary, so it's worth reconciling before the merge.
Technical details
# Release PR body is stale relative to the branch
## Affected sites
- PR #498 description, "Release contents" table — `Commits | 34 since master` (actual: 52) and
`PRs included | #488, #489, #490, #494` (missing #496, #497).
- PR #498 description, "Fixes" list — has no line for #496's two CHANGELOG entries, and its
Main Thread Checker line matches `CHANGELOG.md` but isn't attributed to #497.
## Required outcome
- The body's commit count, included-PR list and fix list match `CHANGELOG.md`'s 4.16.2 section
and the actual branch contents at merge time.ℹ️ Nitpicks
scripts/test.sh— CI now scans.build/Build/Products/Debug-iphonesimulator/SuperwallKit.framework/SuperwallKit, but the repo's own test command doesn't pass-derivedDataPath .build, so a developer can't reproduce the new gate without hand-writing thexcodebuildinvocation. Adding-derivedDataPath .build(already.gitignored) plus a trailing call to the scanner would close that gap.scripts/scan-privacy-signatures.sh:12-16— the header says the names "come back in two ways" and that it "scans those two sections", but there are three of each: the#functionmechanism this PR actually fixed is only described down at the denylist (:39-42), and the implementation reads__objc_methname,__objc_classnameand__cstring.
Claude Opus | 𝕏
Touching UIScreen or UIFontMetrics before UIApplicationMain has created the application object — e.g. configure called from a SwiftUI App.init — makes UIKit build its trait system before the app's asset-catalog accent color is registered, permanently resetting the global tint to system blue. DeviceHelper did both synchronously under configure. Screen metrics and UI traits now return nil in that window instead of touching UIKit, and the caches self-heal on first post-launch read or on app activation. Off-main readers block on the main-queue hop, which cannot drain until launch completes, so they always see real values. Processes that never create an application (unit-test runners, app extensions) read UIScreen.main legitimately, so the gate also checks the bundle type: only app bundles run UIApplicationMain. Fixes #493 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 4.16.2 entry was committed mid stash-pop, carrying conflict markers that presented the ATT/permissions fixes and the accent-color fix as competing alternatives. All three bullets belong. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The gate's doc claimed every UIKit read in the file sits behind it, but the UIDevice reads at init (model, vendorId, interfaceType) are unguarded and safe — they don't touch the trait system. Narrow the claim and say why they're exempt. The test ran in a host-less runner where no UIApplication exists, so whenTheApplicationExists described the wrong clause; reads are allowed there via the bundle check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The isUIKitReadSafe seam lived only on the static makers, so no test could hold a real DeviceHelper in the pre-launch state. The gate is now injected at init and consulted by every instance-level cache read and refresh, with the statics keeping their parameter. Three tests pin the behaviors the fix is built on: placeholders served without touching UIKit while reads are disallowed, both caches healing with live values on the first allowed read (traits off-main, through the blocking empty-cache branch), and the notification observer filling the caches — gated shut again before reading so heal-on-read can't mask a broken observer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne delivery The observer registers with queue: .main, so delivery is scheduled onto OperationQueue.main rather than run inline on the posting thread — the test only passed because an await happened to yield the main thread first. Poll the observable condition with a bounded wait; polling with the gate closed can't fill the caches, so the proof is intact. Also flips a misattached 'which' in the uiTraits doc that read as the opposite of the gate's behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bounded poll ran after the gate closed, but the fill consults the gate when its block runs — under deferred delivery nothing could ever fill and the loop was dead code under inline delivery. Await a barrier operation on OperationQueue.main instead, enqueued after the post so it can't run before the observer's block, then close the gate. Nothing reads the helper while the gate is open, so the fill remains the only possible writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kit-reads fix: accent color resetting to system blue when configuring from a SwiftUI App init
There was a problem hiding this comment.
ℹ️ No new issues in the pre-launch read gate — the design holds up under tracing. One testability gap and three housekeeping notes.
Reviewed changes — the delta since the previous pullfrog review at b29cf44, i.e. the 7 commits of #501 that stop the SDK touching UIKit before the application object exists. Three files: DeviceHelper.swift, DeviceHelperTests.swift, one CHANGELOG line.
- One launch gate in front of every appearance-adjacent read —
isUIKitReadSafe(UIApplication.sharedApplication != nil || Bundle.main.bundleURL.pathExtension != "app") is consulted first inside bothmakeUITraits()andmakeScreenMetrics(), which now returnnilrather than touchingUIScreen/UIFontMetrics/ trait collections beforeUIApplicationMainhas run. - Screen metrics moved from init-time
lets to a self-healing cache —screenWidth/screenHeight/devicePixelRatiobecame computed properties over a@DispatchQueueBacked screenMetrics?, servingScreenMetrics.placeholderwhile the gate is shut and filling on the first allowed read. - Two healing routes for a too-early init — an empty
uiTraitscache takes a blocking fill off-main instead of scheduling a refresh, and the notification handler now also fillsscreenMetricswhen it's stillnil. - The gate is injected per instance — an
isUIKitReadSafe: () -> Boolinit parameter, because the pre-UIApplicationMainstate can't be recreated in-process;UITraits,ScreenMetricsand bothmake*readers droppedprivatefor the same reason. - Tests for the gate and the healing — both readers returning nothing behind a shut gate, exact-value screen metrics read off the main thread, and a
Gate-driven instance suite walking placeholders → first-allowed-read → notification-driven fill. - CHANGELOG — one line for the accent-color fix.
I checked the two claims this design rests on rather than taking them on trust. Coverage: DeviceHelper really is the only appearance-adjacent UIKit touch on the synchronous main-thread configure() path — everything else that reads UIScreen / UIColor / UIFontMetrics or constructs a view (PaywallViewController, SWWebView, ShimmerView, SurveyManager, DebugViewController, TestMode/*, ButtonFactory) is reachable only through @MainActor factories, which cannot execute before the run loop starts, and Network.init's UIApplication read sits inside a Task { @MainActor }. Cost of deferring: the placeholders don't actually reach the wire, because every off-main reader (Network.matchMMPInstall:486-488, makeHeaders' X-Device-Interface-Style, getTemplateDevice()) goes through DispatchQueue.main.sync, which can't complete until the main queue is being drained — by which point the application object exists and the read returns real values. So placeholders only surface for a main-thread read inside the pre-launch window, and there's no synchronous public API exposing these attributes. The cooperative-pool threads that hop parks are bounded by core count, and no cyclic wait exists inside the SDK, so it resolves as soon as launch progresses.
ℹ️ Nitpicks
Tests/SuperwallKitTests/Network/DeviceHelperTests.swift:351(also:369,:403) —_ = dependencyContaineris the lifetime anchor for a container the helper only holdsunowned, but it sits before the assertions, so ARC is free to release it there. Nothing dangles today because no assertion reachesfactory;withExtendedLifetime(dependencyContainer) { … }would make the anchor actually hold before someone adds agetTemplateDevice()assertion to one of these.Tests/SuperwallKitTests/Network/DeviceHelperTests.swift:426-430—instance_fillsCachesOnNotification's trait assertions only hold on iOS 17+: below 17currentUITraitsbypasses the cache entirely, so re-shutting the gate before reading yieldsUITraits.unavailablerather than the notification's fill. CI runsOS=latestso it passes — it's one more cost of the version fork in the open thread above.Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:482-484— "Activation also … fills the screen-metrics cache" attributes the fill todidBecomeActiveNotification, but both notifications share one handler, andinstance_fillsCachesOnNotificationdeliberately relies onUIContentSizeCategory.didChangeNotificationdoing it.
Claude Opus | 𝕏
…raits Three follow-ups from review on the trait-caching work. `makeUITraits()` scaled with the implicit `scaledValue(for:)`, which resolves against `UITraitCollection.current` — documented as undefined outside a view or view-controller trait callback, and stored per thread. The category on the line above comes from `UIApplication` and has no such dependency, so the two font numbers could contradict it in the same snapshot. Scale against that category explicitly so all three values in a snapshot have one source. The tests computed their expectations with the implicit overload too, so both sides inherited the fault and couldn't fail on it; they now scale the same explicit way. `isUIKitReadSafe` read both its facts from process-wide globals, so the arm that actually prevents #493 — an app bundle whose application object doesn't exist yet — was unreachable from a test, and loosening `"app"` would have left the suite green. Split the decision into a parameterised overload and pin both arms. `sharedInstance_returnsNonNil` asserted that AVFAudio happened to be loaded in the runner, which is a property of the test host rather than of the proxy. Tie both sides to the same fact instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pre-iOS-17 branch of `currentUITraits` read live on every access, and `makeUITraits()` blocks off-main callers on the main queue — so `makeHeaders`' `X-Device-Interface-Style` read paid that hop on every network request, and `getTemplateDevice()` paid a second, both from cooperative-pool threads whose count is small and fixed. Serve the cache on every version and drop the fork. The cost is bounded: below 17 the only flip the notification observers miss is an in-place automatic light/dark change, now stale for exactly one read before the scheduled refresh lands. Text-size changes and backgrounded flips still land immediately, and from iOS 17 the trait hook keeps the cache current at the moment of the flip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stically The resolves-check ties both sides to `mangledClassName`, so a typo in the constant sends them nil together and leaves the suite green while every microphone permission read degrades to the unavailable sentinel. Pin the decoded class name the way the Contacts, Location, and Tracking suites do, and close the pre-existing gap on the three selectors. The literals land in the test binary, not the shipped SDK, so they don't undo the mangling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…owups fix(device): review follow-ups on the trait caching work
There was a problem hiding this comment.
ℹ️ No new issues in the #502 follow-ups — both trait changes are correct and the tests behind them now have real failure power. Two documentation nits inline.
Reviewed changes — the delta since the previous pullfrog review at a8dd696, i.e. the 4 commits of #502 that close out the three open threads from earlier reviews. Four files: DeviceHelper.swift, DeviceHelperTests.swift, MicrophonePermissionTests.swift, one CHANGELOG line.
- Font attributes are now single-source —
makeUITraits()scales throughUIFontMetrics.default.scaledValue(for:compatibleWith:)against aUITraitCollection(preferredContentSizeCategory:)built from thecategoryit already resolved, sofontSize,fontScaleandpreferredContentSizeCategoryin one snapshot can no longer disagree, and nothing depends on the thread-localUITraitCollection.current. - One trait read path on every OS version — the
#available(iOS 17.0, *)fork incurrentUITraitsis gone; iOS 13-16 now serves the cache with refresh-on-read instead of reading live, removing the blockingDispatchQueue.main.syncthatmakeHeaders'X-Device-Interface-StyleandgetTemplateDevice()each paid per call below iOS 17. - The launch gate's deciding clause became testable —
isUIKitReadSafedelegates to a pureisUIKitReadSafe(hasApplication:bundleURL:), with three tests pinning all three arms including the app-bundle-before-launch one that actually prevents #493. - Test expectations no longer inherit the fault they're meant to catch — a shared
expectedScaledValue(for:)helper computes expectations with the same explicit-trait overload production uses. - Microphone proxy tests pinned deterministically —
sharedInstance_returnsNonNilbecamesharedInstance_matchesWhetherTheClassResolves, tying both sides to whetherAVAudioSessionresolves rather than to the runner image, plus new tests decoding all four ROT13 constants the way the Contacts, Location and Tracking suites do.
I checked the scaledValue(for:compatibleWith:) substitution against Apple's documentation rather than assuming it was a pure refactor: UITraitCollection.init(preferredContentSizeCategory:) is iOS 10+ / Mac Catalyst 13.1+ and creates a collection containing only that trait, scaledValue(for:compatibleWith:) is iOS 11+ / Mac Catalyst 13.1+, and no unspecified trait in that collection changes the scaling — so the numeric values reaching audience filters are unchanged for every real content size category, with the undefined-current dependency removed. I also traced the cache lifecycle with the version fork gone: uiTraits is only ever assigned a non-nil reading, it stays nil solely while the gate is shut, and an empty cache read off-main still takes the blocking fill — so pre-launch placeholder behaviour on iOS 13-16 is identical to before, and the only new cost is the documented one-read staleness after an in-place light/dark flip. The new plaintext Apple names in MicrophonePermissionTests land in the test bundle, and scan-privacy-signatures.sh scans the framework binary passed as $1, so the CI gate is unaffected.
Claude Opus | 𝕏
The iOS-16 main-thread-wait entry described a wait that #497 introduced and #502 removed, both inside unreleased 4.16.2 — no released version had it, and the other entries all describe fixes relative to a release. Also update the registerForTraitChanges note that still described the pre-#502 live-read behaviour below iOS 17. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4.16.2 hasn't shipped yet, so the entry rides the pending release section instead of a separate Unreleased header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JErpaZs7R3kJ4HHHA1P4nm
…I input zh_Hans_CN is pure ASCII, so the case never tested what its name claimed, and its contains assertion was already implied by the exact-equality test above. Feed a genuinely non-ASCII locale and pin the full output, documenting that JSONSerialization emits it as raw UTF-8 rather than \u-escaped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JErpaZs7R3kJ4HHHA1P4nm
…f-o43go3 Inject device locale into paywall webview at document start
There was a problem hiding this comment.
ℹ️ No critical issues in the locale preload — the WebKit injection path checks out. Two housekeeping items, one of which stops the checked-in Xcode project building.
Reviewed changes — the delta since the previous pullfrog review at c8edc89, i.e. #503 plus its changelog line. Four files: DevicePreloadScript.swift (new), SWWebView.swift, DevicePreloadScriptTests.swift (new), one CHANGELOG.md line.
- Added a document-start locale preload —
DevicePreloadScript.source(deviceLocale:)JSON-encodes["deviceLocale": locale]and wraps it aswindow.__SW_DEVICE_PRELOAD__ = {…};, returningnilif the payload can't be encoded. - Wired it into the paywall webview —
SWWebView.init'sfactorywidened toFeatureFlagsFactory & DeviceHelperFactoryand registers the script as aWKUserScript(.atDocumentStart,forMainFrameOnly: true) alongside the existingpaywallMessageHandlerregistration, so paywall.js can localize on first paint instead of waiting fortemplate_variables. - Pinned the encoding — three tests covering the exact emitted string, JSON escaping of an
en"};alert(1);//breakout attempt, and raw UTF-8 passthrough for a non-ASCII identifier. - CHANGELOG — one enhancement line for the locale injection, folded into the 4.16.2 section.
I checked the load-bearing WebKit contract rather than assuming it: addUserScript here runs after super.init(frame:configuration:), which Apple documents as the point where the configuration is copied. WebKit's WKWebViewConfiguration.copyWithZone: delegates to APIPageConfiguration::copyDataFrom, a member-wise copy of a ref-counted userContentController, so the copy shares the same WKUserContentController instance and post-init registration does reach the live webview — which is also exactly what the adjacent, demonstrably working paywallMessageHandler registration depends on. The script therefore applies to the later load(request) and to the loadFileURL archive path, and the paywall renders in the main frame so forMainFrameOnly: true is right. The injected value is also consistent with what arrives later: makeDeviceInfo().locale is deviceHelper.localeIdentifier, the same source getTemplateDevice() uses for DeviceTemplate.deviceLocale, so the preload honors SuperwallOptions.localeIdentifier and can't disagree with template_variables.
ℹ️ The Xcode project wasn't regenerated for the two new files
SuperwallKit.xcodeproj/project.pbxproj is committed in this PR (regenerated for #489's model files), but it contains no reference to DevicePreloadScript.swift or DevicePreloadScriptTests.swift. CI is unaffected because tests.yml runs the xcodegen action before xcodebuild, so this won't show up as a red check — but opening the checked-in project fails to compile SWWebView.swift, since DevicePreloadScript isn't a member of the SuperwallKit target, and the new test isn't in the test target either.
Technical details
# `project.pbxproj` is stale relative to #503's new files
## Affected sites
- `SuperwallKit.xcodeproj/project.pbxproj` — no `PBXFileReference` / `PBXBuildFile` /
`PBXGroup` entry for `Sources/SuperwallKit/Paywall/View Controller/Web View/DevicePreloadScript.swift`
(compare `PaywallSummary.swift`, which #489 did regenerate in).
- `SuperwallKit.xcodeproj/project.pbxproj` — same for
`Tests/SuperwallKitTests/Paywall/View Controller/Web View/DevicePreloadScriptTests.swift`
(compare `NetworkErrorTests.swift`).
## Required outcome
- The committed project builds `SWWebView.swift` and runs `DevicePreloadScriptTests` without a
developer having to regenerate first, i.e. `xcodegen` output is committed alongside the source
files it enumerates, as it was for #489.ℹ️ Nothing in the repo verifies the preload contract the CHANGELOG advertises
The global name (window.__SW_DEVICE_PRELOAD__) and payload key (deviceLocale) are a contract with paywall.js, which lives outside this repo, and the failure mode is silent: if the web bundle doesn't read the global yet, or resolves translations from preferredLocale / preferredLanguageCode rather than deviceLocale, the change is inert while the CHANGELOG tells developers first-paint localization now works. Worth confirming the web side is deployed before this release goes out.
Technical details
# Preload contract is unverifiable from the SDK repo
## Affected sites
- `Sources/SuperwallKit/Paywall/View Controller/Web View/DevicePreloadScript.swift:27` — emits
`window.__SW_DEVICE_PRELOAD__` with a single `deviceLocale` key.
- `CHANGELOG.md:11` — advertises the resulting first-paint behavior as shipped in 4.16.2.
## Open questions for the human
- Is paywall.js's support for `window.__SW_DEVICE_PRELOAD__` already live for every paywall version
this SDK release can render, or does the changelog line need to wait?
- Do translations resolve from `deviceLocale`, or from `preferredLocale` /
`preferredLanguageCode`? `DeviceTemplate` carries all three, and only `deviceLocale` is preloaded.ℹ️ Nitpicks
Sources/SuperwallKit/Paywall/View Controller/Web View/SWWebView.swift:119-133— the tests cover the string builder, but nothing covers the wiring that can actually regress (registered at all,.atDocumentStart, locale taken frommakeDeviceInfo()).webView.configuration.userContentController.userScriptsis directly assertable, andWebEntitlementRedeemerTestsalready constructs anSWWebViewwithdependencyContainer, so the setup cost is small.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues in the Superscript bump — the pin, the tag, the checksum and the published pod all line up. One changelog wording suggestion inline, two verification items below.
Reviewed changes — the delta since the previous pullfrog review at 4dfa44e, i.e. the single commit 1a052ce. Eight files, all release metadata; no SDK source changed.
- Superscript pinned to 1.0.15 —
Package.swift(.exact),Package.resolved,project.yml, the xcodeprojPackage.resolvedandExamples/Advanced's lockfile all move from1.0.14(abb2c8c) to1.0.15(c3d1ee8). - CocoaPods caught up two versions — the podspec's
Superscriptdependency goes1.0.13→1.0.15, closing the SPM/CocoaPods drift that existed onmaster. - Xcode project regenerated —
project.pbxprojnow carriesDevicePreloadScript.swiftandDevicePreloadScriptTests.swift, which closes the stale-project item from the4dfa44ereview. - CHANGELOG — one new fix line for the audience-filter crash.
I checked the bump end to end rather than taking the version strings on trust. Tag 1.0.15 in superscript-ios-next points at exactly the pinned revision c3d1ee8, and its only change over 1.0.14 is the libcel release URL plus checksum — the Swift binding target is untouched, so there is no API surface for the SDK to adopt. Superscript 1.0.15 is published on CocoaPods trunk (15:02 UTC, ten minutes before this commit), and 1.0.14 never was, which is why the podspec sat on 1.0.13; upstream deliberately skipped Rust 1.0.14, so the two-version pod jump carries no undocumented evaluator changes. CELEvaluatorTests drives the real FFI, so a UniFFI checksum mismatch against the new binary would fail this repo's own test run rather than ship. And every .swift file under Sources/ and Tests/ now has both a file reference and a build-phase entry in the regenerated project.
The upstream fix itself is the right shape for #500: execute_with no longer unwrap()s a failed argument resolution, parse_to_ast returns an error instead of panicking, and all four FFI entry points are catch_unwind-guarded under panic = "unwind" — so CELEvaluator's existing .failure → noMatch path finally gets reached instead of the process aborting underneath it.
ℹ️ Nothing in this repo pins the crash fix this release is named for
#500 was 531 crashes across 72 users in 27 minutes from one dashboard typo, with no client release involved — and after this merge the only thing preventing a recurrence is a version string in Package.swift. CELEvaluatorTests already drives the real evaluator, so a case asserting that daysSince(app_install) >= 1 degrades to noMatch would abort the test run on 1.0.14 and pass on 1.0.15, which is exactly the failure power needed to catch a future downgrade or an upstream regression in the panic guard.
Technical details
# No SDK-side regression test for the #500 evaluator abort
## Affected sites
- `Tests/SuperwallKitTests/Paywall/Presentation/Audience Logic/ExpressionEvaluator/CELEvaluatorTests.swift`
— 11 tests, all with well-formed expressions. The closest case,
`evaluateExpression_expressionFalse` (`a == "b"`), is an undeclared reference at the top level,
not an unresolvable argument inside a computed-property call, so it never hit the panic.
- `Package.swift:22` — `.exact("1.0.15")` is currently the only artifact encoding the fix.
## Required outcome
- A test in `CELEvaluatorTests` fails (by aborting) against Superscript 1.0.14 and passes against
1.0.15, so the guarantee is enforced by this repo's CI rather than by a pinned version alone.
## Suggested approach
- Add a case mirroring #500's filter verbatim and assert the non-matching outcome:
`.setting(\.expression, to: "(size(device.activeEntitlements) == 0) && (daysSince(app_install) >= 1)")`
→ `.noMatch(source: .expression, experimentId: rule.experiment.id)`.
- `device.daysSince(app_install) >= 1` is the second reported spelling (it is what the dashboard
displays for a stored `device.daysSince_app_install`), so it is worth a second case.ℹ️ The bump flips the Rust panic strategy on every Apple slice, including the ones this PR's checklist hasn't ticked
Superscript 1.0.15 changes libcel from panic = "abort" to panic = "unwind" and rebuilds the visionOS and watchOS slices with -Zbuild-std=std,core,alloc,panic_unwind. Those -Zbuild-std targets are where a panic-strategy change is most likely to surface as a link or launch problem, and they are also the unchecked boxes in this PR's checklist — so a Catalyst and visionOS run is worth doing before the promotion rather than after.
Technical details
# Verify the non-iOS slices against the relinked `libcel`
## Affected sites
- `Package.swift:22` / `SuperwallKit.podspec:40` — both now resolve a `libcel.xcframework` built
with `panic = "unwind"`; the previous binary was `panic = "abort"`.
- `Package.swift:9-13` — the package declares `.iOS(.v13)`, `.macOS(.v10_12)` and `watchOS "6.2"`,
so the changed slices are all in the supported set.
## Required outcome
- The demo project links and launches on Mac Catalyst and visionOS (and watchOS, if a slice ships
there) against 1.0.15, with the corresponding checklist boxes ticked from an actual run.
## Open questions for the human
- Does anything in the release process size-check the framework? Unwind tables make `libcel`
larger than the `abort` build; worth knowing the delta before customers ask.ℹ️ Nitpicks
Examples/Basic/Basic.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolvedstill pins Superscript1.0.14/abb2c8c, whileExamples/Advanced's lockfile was bumped in this same commit. Cosmetic — Xcode re-resolves it against the root package's.exact("1.0.15")on first open — but the two example lockfiles now disagree in the tree.
Claude Opus | 𝕏
The crash isn't launch-only — filters evaluate on every placement — and the trigger is an argument failing to resolve inside a computed-property call, not a parse-level malformed expression. Name the Superscript version and link its changelog like the earlier entries, so the catch_unwind hardening that ships with 1.0.15 is visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…atch Aborts the test run on Superscript 1.0.14 (libcel panic) and passes on 1.0.15, guarding against a dependency downgrade or an upstream regression in the panic guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — the delta since the previous pullfrog review at 1a052ce, i.e. three commits. Three files, no SDK source changed.
- Regression test for the #500 evaluator abort —
evaluateExpression_unresolvableFunctionArgument_noMatchdrives the real evaluator withdaysSince(app_install) >= 1and assertsnoMatch(source: .expression, …), closing the coverage item from the1a052cereview. Examples/Basic's lockfile caught up — Superscript1.0.14→1.0.15(c3d1ee8), so both example lockfiles now agree with the root.exact("1.0.15")pin.- RevenueCat bumped in
Examples/Advanced—5.81.2→5.83.2, example app only.
I checked that the new test is a real guard rather than a passing formality, since that was the whole point of the ask. The expression is #500's shape: daysSince resolves (the computed/device maps are built from ComputedPropertyRequestType.allCases), while app_install is a placement name that never lands in the attributes map, so the bareword really is an undeclared reference inside a declared property call — and the standalone form is stronger than the && composition I suggested, which could short-circuit past the call entirely. Against the pinned 1.0.15 the Rust side returns {"Ok":{"type":"Null"}} — the upstream fix commit superwall/superscript@7739ad1 adds a test pinning that reply for this verbatim expression — so EvaluationResult decodes .success(.null), falls to CELEvaluator's default: branch and produces exactly the asserted outcome, deterministically and with no host-callback round trip. Against 1.0.14 the same input hits the unconditional .unwrap() under panic = "abort", so a downgrade or an upstream regression aborts the test-runner process; worth knowing that the signal is a crashed run rather than a red assertion.
One behavioural detail that falls out of that: because the reply is Ok(Null) and not Err, the .failure branch's Logger.debug(logLevel: .warn) never fires for this class of dashboard typo — the filter silently never matches, with nothing in the logs. Not worth changing (warning on the whole default: branch would also fire for every legitimate false), but it's the diagnostic a developer will look for next time.
On the RevenueCat bump: every release between 5.81.2 and 5.83.2 is scoped to RevenueCatUI paywalls, remote config and the new checkpoints feature, none of which RCPurchaseController.swift, Delegate.swift or SuperwallAdvancedApp.swift touch; 5.83.2 satisfies the example's own upToNextMajorVersion from 5.16.0; and nothing in the SDK, the podspec or any workflow depends on RevenueCat, so the change is contained to that lockfile.
The two pre-merge items from earlier reviews are unchanged and not repeated here: the description still describes a 34-commit, four-PR release, and the Mac Catalyst / visionOS boxes are still unticked against the relinked libcel.
Claude Opus | 𝕏

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 SDK release.
Confidence Score: 4/5
The PR is not yet safe to merge because transaction redemption can still associate a completed purchase or restore with an identity selected after the transaction.
Purchase and restore completion launch unstructured redemption tasks, while redemption request construction reads the live IdentityManager values; an immediate identify or reset can therefore change which user receives the server-side subscription association.
Files Needing Attention: Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift; Sources/SuperwallKit/Web/WebEntitlementRedeemer.swift
Important Files Changed
Reviews (2): Last reviewed commit: "Update CHANGELOG.md" | Re-trigger Greptile