Skip to content

4.16.2 - #498

Merged
yusuftor merged 75 commits into
masterfrom
develop
Aug 14, 2026
Merged

4.16.2#498
yusuftor merged 75 commits into
masterfrom
develop

Conversation

@yusuftor

@yusuftor yusuftor commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Changes in this pull request

Promotes developmaster for the 4.16.2 patch release.

Enhancements

  • Adds the user's system-wide text size (Dynamic Type) as three device attributes for use in paywalls and audience filters: fontScale, fontSize, and preferredContentSizeCategory.
  • Links subscriptions to the user server-side after a successful purchase or restore.

Fixes

  • Fixes network requests that can never succeed, such as those with an invalid API key, taking up to a minute to fail instead of failing straight away. Timeouts and server errors still retry as before.
  • Fixes failed network requests being reported as a decoding error rather than the HTTP error that actually occurred.
  • Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls.

Release contents

Version 4.16.2 (Constants.swift, SuperwallKit.podspec, CHANGELOG.md all match)
Commits 34 since master
PRs included #488, #489, #490, #494

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

Greptile Summary

This patch prepares the 4.16.2 SDK release.

  • Adds Dynamic Type attributes to device and paywall data.
  • Links subscriptions server-side following successful purchases and restores.
  • Revises network retry and HTTP-error handling.
  • Adds scalable paywall-debugger listing and identifier resolution.
  • Refactors optional permission integrations to avoid unused privacy API signatures.
  • Updates Superscript, release metadata, tests, and CI validation.

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

Filename Overview
Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Caches UI trait snapshots and performs the underlying UIKit reads on the main thread for background device-attribute callers.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Adds asynchronous server-side subscription redemption after successful purchase and restore handling.
Sources/SuperwallKit/Network/Custom URL Session/TaskRetryLogic.swift Classifies terminal HTTP responses so unrecoverable client failures bypass retry backoff.
Sources/SuperwallKit/Debug/DebugViewController.swift Reworks debugger paywall selection around the V2 preview-list and identifier-resolution endpoints.
Sources/SuperwallKit/Permissions/PermissionHandler.swift Refactors permission handling to keep unused privacy-sensitive API signatures out of shipped binaries.

Reviews (2): Last reviewed commit: "Update CHANGELOG.md" | Re-trigger Greptile

claude and others added 30 commits July 2, 2026 20:34
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>

@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 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 @objc Fake* shims deletedFakeContactStore, FakeLocationManager, FakeAudioSession and FakeTrackingManager mirrored Apple's selectors verbatim, so their names landed in __TEXT,__objc_methname. Each *Proxy now takes the resolved class through an init(xClass: AnyClass? = NSClassFromString(mangled.rot13())) seam and guards on nil instead.
  • @objc dropped from the *SelectorName properties on all four proxies, with a comment on each explaining why they must stay Swift-only.
  • Continuation wrappers renamedrequestRecordPermission/requestAccess/requestTrackingAuthorization became requestPermission/requestPermission/requestAuthorization, because withCheckedContinuation's function: String = #function default expands the enclosing name into a __cstring literal.
  • Two plaintext literals mangledPlistKey.tracking is now ROT13-decoded at read time, and LocationPermissionDelegate derives its KVC key from LocationManagerProxy.mangledAuthorizationStatusSelector rather than "authorizationStatus".
  • New CI gatescripts/scan-privacy-signatures.sh greps __objc_methname, __objc_classname and __cstring for a 14-name denylist, run in tests.yml against the framework the test step just built at -derivedDataPath .build.
  • Tests reshaped around the missing-class path — each deleted fake's suite becomes a *MissingClassTests suite driving the new init seam with nil, 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 developmaster 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 the xcodebuild invocation. 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 #function mechanism this PR actually fixed is only described down at the denylist (:39-42), and the implementation reads __objc_methname, __objc_classname and __cstring.

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

Comment thread Tests/SuperwallKitTests/Permissions/MicrophonePermissionTests.swift Outdated
yusuftor and others added 7 commits August 12, 2026 15:29
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

@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 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 readisUIKitReadSafe (UIApplication.sharedApplication != nil || Bundle.main.bundleURL.pathExtension != "app") is consulted first inside both makeUITraits() and makeScreenMetrics(), which now return nil rather than touching UIScreen / UIFontMetrics / trait collections before UIApplicationMain has run.
  • Screen metrics moved from init-time lets to a self-healing cachescreenWidth / screenHeight / devicePixelRatio became computed properties over a @DispatchQueueBacked screenMetrics?, serving ScreenMetrics.placeholder while the gate is shut and filling on the first allowed read.
  • Two healing routes for a too-early init — an empty uiTraits cache takes a blocking fill off-main instead of scheduling a refresh, and the notification handler now also fills screenMetrics when it's still nil.
  • The gate is injected per instance — an isUIKitReadSafe: () -> Bool init parameter, because the pre-UIApplicationMain state can't be recreated in-process; UITraits, ScreenMetrics and both make* readers dropped private for 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) — _ = dependencyContainer is the lifetime anchor for a container the helper only holds unowned, but it sits before the assertions, so ARC is free to release it there. Nothing dangles today because no assertion reaches factory; withExtendedLifetime(dependencyContainer) { … } would make the anchor actually hold before someone adds a getTemplateDevice() assertion to one of these.
  • Tests/SuperwallKitTests/Network/DeviceHelperTests.swift:426-430instance_fillsCachesOnNotification's trait assertions only hold on iOS 17+: below 17 currentUITraits bypasses the cache entirely, so re-shutting the gate before reading yields UITraits.unavailable rather than the notification's fill. CI runs OS=latest so 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 to didBecomeActiveNotification, but both notifications share one handler, and instance_fillsCachesOnNotification deliberately relies on UIContentSizeCategory.didChangeNotification doing it.

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

Comment thread Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Outdated
…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>
yusuftor and others added 3 commits August 13, 2026 15:13
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

@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 #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-sourcemakeUITraits() scales through UIFontMetrics.default.scaledValue(for:compatibleWith:) against a UITraitCollection(preferredContentSizeCategory:) built from the category it already resolved, so fontSize, fontScale and preferredContentSizeCategory in one snapshot can no longer disagree, and nothing depends on the thread-local UITraitCollection.current.
  • One trait read path on every OS version — the #available(iOS 17.0, *) fork in currentUITraits is gone; iOS 13-16 now serves the cache with refresh-on-read instead of reading live, removing the blocking DispatchQueue.main.sync that makeHeaders' X-Device-Interface-Style and getTemplateDevice() each paid per call below iOS 17.
  • The launch gate's deciding clause became testableisUIKitReadSafe delegates to a pure isUIKitReadSafe(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 deterministicallysharedInstance_returnsNonNil became sharedInstance_matchesWhetherTheClassResolves, tying both sides to whether AVAudioSession resolves 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.

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

Comment thread CHANGELOG.md Outdated
Comment thread Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift Outdated
yusuftor and others added 5 commits August 13, 2026 16:32
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

@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 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 preloadDevicePreloadScript.source(deviceLocale:) JSON-encodes ["deviceLocale": locale] and wraps it as window.__SW_DEVICE_PRELOAD__ = {…};, returning nil if the payload can't be encoded.
  • Wired it into the paywall webviewSWWebView.init's factory widened to FeatureFlagsFactory & DeviceHelperFactory and registers the script as a WKUserScript (.atDocumentStart, forMainFrameOnly: true) alongside the existing paywallMessageHandler registration, so paywall.js can localize on first paint instead of waiting for template_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 from makeDeviceInfo()). webView.configuration.userContentController.userScripts is directly assertable, and WebEntitlementRedeemerTests already constructs an SWWebView with dependencyContainer, so the setup cost is small.

Pullfrog  | Fix it ➔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.

ℹ️ 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.15Package.swift (.exact), Package.resolved, project.yml, the xcodeproj Package.resolved and Examples/Advanced's lockfile all move from 1.0.14 (abb2c8c) to 1.0.15 (c3d1ee8).
  • CocoaPods caught up two versions — the podspec's Superscript dependency goes 1.0.131.0.15, closing the SPM/CocoaPods drift that existed on master.
  • Xcode project regeneratedproject.pbxproj now carries DevicePreloadScript.swift and DevicePreloadScriptTests.swift, which closes the stale-project item from the 4dfa44e review.
  • 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 .failurenoMatch 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.resolved still pins Superscript 1.0.14 / abb2c8c, while Examples/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.

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

Comment thread CHANGELOG.md Outdated
yusuftor and others added 4 commits August 14, 2026 17:22
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>

@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 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 abortevaluateExpression_unresolvableFunctionArgument_noMatch drives the real evaluator with daysSince(app_install) >= 1 and asserts noMatch(source: .expression, …), closing the coverage item from the 1a052ce review.
  • Examples/Basic's lockfile caught up — Superscript 1.0.141.0.15 (c3d1ee8), so both example lockfiles now agree with the root .exact("1.0.15") pin.
  • RevenueCat bumped in Examples/Advanced5.81.25.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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

@yusuftor
yusuftor merged commit aed98de into master Aug 14, 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.

4 participants