Skip to content
Merged
Show file tree
Hide file tree
Changes from 63 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
40a1f4a
Add fontScale and preferredContentSizeCategory device attributes
claude Jul 2, 2026
dbbc43e
feat: resolve preview paywall by id instead of fetching all paywalls
Makisuo Jul 3, 2026
a5bfffd
Use normalized fontSize (px) device attribute
claude Jul 3, 2026
0fedd7a
Add fontScale and preferredContentSizeCategory device attributes
claude Jul 3, 2026
3a08143
Update version and changelog
yusuftor Jul 3, 2026
08a1d5a
Add test for preferredContentSizeCategory token mapping
claude Jul 3, 2026
9480901
Fix DeviceHelperTests: use UIContentSizeCategory(rawValue:) initializer
claude Jul 3, 2026
6d98fb5
Merge pull request #488 from superwall/add-font-scale-device-attribute
yusuftor Jul 3, 2026
03c8d9e
Merge remote-tracking branch 'origin/develop' into feat/paywall-previ…
Makisuo Jul 3, 2026
c6cde4b
refactor(debug): remove dead preview-picker path
Makisuo Jul 3, 2026
b073dda
fix(network): point V2 resolver at api.superwall.com, not baseHost
Makisuo Jul 3, 2026
22d5def
Trigger /redeem after a successful native purchase
claude Jul 18, 2026
92ad515
Trigger /redeem after a successful restore
claude Jul 18, 2026
fb00fc4
fix: resolve paywall preview via debugger signed token instead of app…
claude Jul 21, 2026
0844b18
Update TransactionManager.swift
yusuftor Jul 22, 2026
1691490
Makes redeem async
yusuftor Jul 22, 2026
6119661
Merge pull request #490 from superwall/redeem-on-native-purchase
yusuftor Jul 22, 2026
cd31438
Don't retry HTTP errors that can never succeed
yusuftor Jul 27, 2026
d9fbbf7
Surface HTTP errors instead of reporting them as decoding failures
yusuftor Jul 27, 2026
671d6a2
Align redirect handling between the retry loop and error mapping
yusuftor Jul 27, 2026
22e8edf
Simplify changelog entries
yusuftor Jul 27, 2026
355891c
Merge pull request #494 from superwall/fix/dont-retry-terminal-http-e…
yusuftor Jul 27, 2026
f441e98
Merge branch 'develop' into feat/paywall-preview-resolve-endpoint
yusuftor Jul 28, 2026
62a9bf4
Update Package.resolved
yusuftor Jul 28, 2026
d57bc0d
Merge branch 'develop' into feat/paywall-preview-resolve-endpoint
yusuftor Jul 28, 2026
d2aa389
feat(debug): restore the "Your Paywalls" picker via the preview-list …
yusuftor Jul 29, 2026
b708cda
fix(debug): load the paywall picker independently of the preview render
yusuftor Jul 29, 2026
9260802
test(network): cover the preview-list endpoint and its decoding
yusuftor Jul 29, 2026
b8ea624
fix(debug): open the picker when no paywall rendered; pin host in end…
yusuftor Jul 29, 2026
9c9ba10
refactor(debug): share one PaywallSummary type across the preview end…
yusuftor Jul 30, 2026
da3811d
Update CHANGELOG.md
yusuftor Jul 30, 2026
03c4949
Update SWBounceButton.swift
yusuftor Jul 30, 2026
c463ab8
Update CHANGELOG.md
yusuftor Jul 30, 2026
8009f06
Merge pull request #489 from superwall/feat/paywall-preview-resolve-e…
yusuftor Jul 30, 2026
afd9c05
fix(device): read UIKit traits on the main thread and cache them
yusuftor Jul 30, 2026
815dc10
fix(device): refresh cached traits on read so appearance changes can'…
yusuftor Jul 30, 2026
2c3ee93
fix(device): hook trait changes so appearance flips land at the momen…
yusuftor Jul 30, 2026
dad046b
fix(device): observe the scene's traits and re-arm when it goes away
yusuftor Jul 30, 2026
ae03842
docs(device): record why iOS 13-16 has no trait hook
yusuftor Jul 30, 2026
b805a17
test(device): drain the refresh deterministically instead of sleeping
yusuftor Jul 30, 2026
b6350d9
fix(device): read traits live before iOS 17 rather than serving a sta…
yusuftor Jul 30, 2026
2b8ddd5
docs(device): correct the pre-iOS 17 note on registerForTraitChanges
yusuftor Jul 31, 2026
10aee46
perf(device): take one trait snapshot per device template
yusuftor Jul 31, 2026
477aa1c
docs(device): pair the two interface-style override resolutions
yusuftor Jul 31, 2026
ba1f45e
test(device): name the template trait test after what it pins
yusuftor Jul 31, 2026
f405d64
Inject device locale into paywall webview at document start
claude Aug 10, 2026
76b0e29
fix(device): re-arm trait observation when another scene takes over
yusuftor Aug 11, 2026
84e7b13
Merge pull request #497 from superwall/fix/device-helper-main-thread-…
yusuftor Aug 11, 2026
4ed0dfc
fix: avoid dormant ATT signatures
thisislvca Jul 29, 2026
cbbb253
fix(permissions): stop the sibling handlers leaking Apple's selectors…
yusuftor Aug 11, 2026
110667d
fix(permissions): close the #function leak the scanner was blind to
yusuftor Aug 11, 2026
64261cc
chore(permissions): guard the AdSupport names and sharpen the changelog
yusuftor Aug 12, 2026
b29cf44
Merge pull request #496 from thisislvca/codex/fix-att-static-signatures
yusuftor Aug 12, 2026
b2d2ad3
fix(device): defer UIKit reads until the application exists
yusuftor Aug 12, 2026
875d8d6
docs(changelog): resolve leftover stash-conflict markers
yusuftor Aug 12, 2026
e086280
docs(device): scope the UIKit-read claim and rename a mislabeled test
yusuftor Aug 12, 2026
f417e0c
test(device): cover the instance-level self-healing caches
yusuftor Aug 12, 2026
b0a9b3e
test(device): wait for the notification fill instead of assuming inli…
yusuftor Aug 12, 2026
bba3660
test(device): hold the gate open until the observer's fill has landed
yusuftor Aug 12, 2026
a8dd696
Merge pull request #501 from superwall/fix/accent-color-pre-launch-ui…
yusuftor Aug 12, 2026
0c89b73
fix(device): scale fonts against the resolved category, not ambient t…
yusuftor Aug 13, 2026
2970f61
perf(device): serve cached traits below iOS 17 instead of reading live
yusuftor Aug 13, 2026
8e52f21
test(permissions): pin the microphone proxy's mangled names determini…
yusuftor Aug 13, 2026
c8edc89
Merge pull request #502 from superwall/fix/pullfrog-device-trait-foll…
yusuftor Aug 13, 2026
0c121e2
docs: drop the changelog line for a fix whose bug never shipped
yusuftor Aug 13, 2026
26ed839
Merge branch 'develop' into claude/paywall-translation-perf-o43go3
yusuftor Aug 14, 2026
4b1bfa0
docs: fold the locale-injection changelog entry into 4.16.2
claude Aug 14, 2026
1c82a08
test(webview): exercise the non-ASCII preload case with real non-ASCI…
claude Aug 14, 2026
4dfa44e
Merge pull request #503 from superwall/claude/paywall-translation-per…
yusuftor Aug 14, 2026
1a052ce
Update Superscript
yusuftor Aug 14, 2026
a5cd5b6
docs: describe the Superscript crash fix accurately in the changelog
yusuftor Aug 14, 2026
e814d25
Update Package.resolved
yusuftor Aug 14, 2026
88a6207
test: assert an unresolvable audience filter argument degrades to noM…
yusuftor Aug 14, 2026
61fed83
Update Basic example's Package.resolved to Superscript 1.0.15
yusuftor Aug 14, 2026
4ce7578
Update CHANGELOG.md
yusuftor Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
paths:
- '.github/workflows/tests.yml'
- 'project.yml'
- 'scripts/**'
- '**/*.swift'
- '!Examples/**'
workflow_dispatch:
Expand Down Expand Up @@ -33,4 +34,10 @@ jobs:
uses: xavierLowmiller/xcodegen-action@1.2.3
- name: Run Tests
run: |
xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' test
xcodebuild -project SuperwallKit.xcodeproj -scheme SuperwallKit -sdk iphonesimulator -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17 Pro' -derivedDataPath .build test
# Whether a permission API name reaches the shipped binary is invisible to the
# test suite — it depends on what the compiler emits, not on what the code
# returns. Scan the binary the tests just built.
- name: Scan for privacy API signatures
run: |
./scripts/scan-privacy-signatures.sh .build/Build/Products/Debug-iphonesimulator/SuperwallKit.framework/SuperwallKit
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.

## 4.16.2

### 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 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.
- Fixes network requests on iOS 16 and earlier briefly waiting on the main thread.
Comment thread
yusuftor marked this conversation as resolved.
Outdated
- Fixes Main Thread Checker warnings caused by reading the device's interface style and text size from a background thread.
- Prevents unused App Tracking Transparency support from triggering App Store Connect tracking warnings.
- Stops Apple's microphone, location, and contacts class and selector names appearing in your app's binary when you don't use those permissions.
- Fixes the app's accent color resetting to the system blue when configuring the SDK from a SwiftUI `App` initializer.

## 4.16.1

### Enhancements
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ public final class SuperwallOptions: NSObject, Encodable {
}
}

/// Host for the Superwall V2 API (the `apps/api` Cloudflare Worker), whose
/// routes live under a `/v2/` path.
///
/// This is a DIFFERENT host from ``baseHost`` (the legacy v1 API on
/// `api.superwall.me`): the V2 API is served from the `superwall.com`
/// domain — `api.superwall.com` in production and `api.superwall.dev` in the
/// developer/staging environment.
var apiV2Host: String {
switch self {
case .developer:
return "api.superwall.dev"
case .local:
return "localhost:3001"
default:
return "api.superwall.com"
}
}

/// The base URL for the Superwall dashboard.
var dashboardBaseUrl: String {
switch self {
Expand Down
78 changes: 55 additions & 23 deletions Sources/SuperwallKit/Debug/DebugViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ final class DebugViewController: UIViewController {
var paywallDatabaseId: String?
var paywallIdentifier: String?
var paywall: Paywall?
var paywalls: [Paywall] = []
/// Backs the "Your Paywalls" picker.
///
/// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app
/// has a single paywall, in which case the picker declines to open.
var previewPaywalls: [PaywallSummary] = []
var previewViewContent: UIView?
private var cancellable: AnyCancellable?
private var initialLocaleIdentifier: String?
Expand Down Expand Up @@ -156,6 +160,7 @@ final class DebugViewController: UIViewController {
initialLocaleIdentifier = Superwall.shared.options.localeIdentifier
addSubviews()
Task { await loadPreview() }
Task { await loadPreviewPaywalls() }
}

private func addSubviews() {
Expand Down Expand Up @@ -204,32 +209,30 @@ final class DebugViewController: UIViewController {
func loadPreview() async {
activityIndicator.startAnimating()
previewViewContent?.removeFromSuperview()
await finishLoadingPreview()
}

func finishLoadingPreview() async {
var paywallId: String?

if paywalls.isEmpty {
if let paywallIdentifier = paywallIdentifier {
paywallId = paywallIdentifier
} else if let paywallDatabaseId = paywallDatabaseId {
// Resolve the numeric database id from the deep link to the paywall's
// identifier (slug) with a single lookup.
do {
paywalls = try await network.getPaywalls()
await finishLoadingPreview()
let resolution = try await network.resolvePaywallIdentifier(forDatabaseId: paywallDatabaseId)
paywallId = resolution.identifier
paywallIdentifier = resolution.identifier
} catch {
Logger.debug(
logLevel: .error,
scope: .debugViewController,
message: "Failed to Fetch Paywalls",
message: "Failed to Resolve Paywall",
error: error
)
return
}
} else {
await finishLoadingPreview()
}
}

func finishLoadingPreview() async {
var paywallId: String?

if let paywallIdentifier = paywallIdentifier {
paywallId = paywallIdentifier
} else if let paywallDatabaseId = paywallDatabaseId {
paywallId = paywalls.first { $0.databaseId == paywallDatabaseId }?.identifier
paywallIdentifier = paywallId
} else {
return
}
Expand Down Expand Up @@ -310,20 +313,49 @@ final class DebugViewController: UIViewController {
}
}

@objc func pressedPreview() {
guard let id = paywallDatabaseId else { return }
/// Populates the "Your Paywalls" picker from the application in the debugger's
/// preview token.
///
/// Kicked off from `viewDidLoad` alongside — not after — the preview load, so
/// the picker is available even when the requested paywall fails to render.
/// Best-effort: a failure leaves `previewPaywalls` empty and `pressedPreview`
/// declines to open, which is the behaviour before this was restored.
private func loadPreviewPaywalls() async {
do {
let list = try await network.listPreviewPaywalls()
previewPaywalls = list.data
} catch {
Logger.debug(
logLevel: .warn,
scope: .debugViewController,
message: "Failed to Load Paywall Picker",
info: nil,
error: error
)
}
}

let options: [AlertOption] = paywalls.map { paywall in
@objc func pressedPreview() {
// Open whenever there is something to switch *to*. That covers an empty list
// (the request failed) and a single-entry list whose one paywall is already
// on screen, without gating on `paywallDatabaseId` — which is nil when the
// deep link carried no `paywall_id` and nothing rendered. That is precisely
// when the picker is most useful, so it must not be inert then.
guard previewPaywalls.contains(where: { $0.id != paywallDatabaseId }) else { return }

let options: [AlertOption] = previewPaywalls.map { paywall in
var name = paywall.name

if id == paywall.databaseId {
// Optional comparison: with no paywall on screen nothing is marked, which
// is correct rather than a case to guard against.
if paywall.id == paywallDatabaseId {
name = "\(name) ✓"
}

let alert = AlertOption(
title: name,
action: { [weak self] in
self?.paywallDatabaseId = paywall.databaseId
self?.paywallDatabaseId = paywall.id
self?.paywallIdentifier = paywall.identifier
Task { await self?.loadPreview() }
},
Expand Down
13 changes: 11 additions & 2 deletions Sources/SuperwallKit/Debug/SWBounceButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ final class SWBounceButton: UIButton {
self.activityIndicator.startAnimating()
self.isEnabled = false
} else {
self.setTitle(oldTitle, for: .normal)
self.oldTitle = ""
// `didSet` fires on every assignment, not just on a change, so
// `showLoading = false` can arrive when loading was never started (or
// was already ended). `oldTitle` is empty in that case, and restoring
// it unconditionally would blank a perfectly good title — e.g. the
// debugger's "Preview" button vanishes when a second terminal state
// (skipped, then presentationError) lands on an already-idle button.
// Only restore when there is a saved title to restore.
if !oldTitle.isEmpty {
self.setTitle(oldTitle, for: .normal)
self.oldTitle = ""
}
self.activityIndicator.stopAnimating()
self.isEnabled = true
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SuperwallKit/Misc/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ let sdkVersion = """
*/

let sdkVersion = """
4.16.1
4.16.2
"""
6 changes: 6 additions & 0 deletions Sources/SuperwallKit/Misc/Extensions/Task+Retrying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ extension Task where Failure == Error {
if let (_, response) = result as? (Data, URLResponse),
let httpResponse = response as? HTTPURLResponse,
!(200...299).contains(httpResponse.statusCode) {
// A client error won't succeed on retry, so hand the response back
// for the caller to map rather than burning the whole backoff
// schedule on a request that can only fail the same way.
if TaskRetryLogic.isTerminal(statusCode: httpResponse.statusCode) {
return result
}
throw URLError(.badServerResponse)
}
return result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,25 @@ extension UIApplication {
let windows = sharedApplication.connectedScenes.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
return windows.first { $0.isKeyWindow } ?? windows.first
}

/// The scene behind ``activeWindow``, using the same activation-state priority.
///
/// Preferred over the window for trait observation: a window's
/// `overrideUserInterfaceStyle` doesn't propagate up to its scene, so the scene's
/// `userInterfaceStyle` follows the system the way `UIScreen.main` does. It also
/// outlives the individual windows it hosts.
var activeWindowScene: UIWindowScene? {
guard let sharedApplication = UIApplication.sharedApplication else {
return nil
}
if let windowScene = sharedApplication.connectedScenes
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene
}
if let windowScene = sharedApplication.connectedScenes
.first(where: { $0.activationState == .foregroundInactive }) as? UIWindowScene {
return windowScene
}
return sharedApplication.connectedScenes.compactMap { $0 as? UIWindowScene }.first
}
}
4 changes: 0 additions & 4 deletions Sources/SuperwallKit/Models/Paywall/Paywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@

import UIKit

struct Paywalls: Decodable {
var paywalls: [Paywall]
}

struct Paywall: Codable {
/// The id of the paywall in the database.
var databaseId: String
Expand Down
14 changes: 14 additions & 0 deletions Sources/SuperwallKit/Models/Paywall/PaywallPreviewList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// PaywallPreviewList.swift
// SuperwallKit
//
// Created by Yusuf Tör on 30/07/2026.
//

import Foundation

/// The response from `GET /v2/paywalls/preview-list`.
struct PaywallPreviewList: Decodable {
/// The paywalls available to preview, capped server-side.
let data: [PaywallSummary]
}
21 changes: 21 additions & 0 deletions Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// PaywallSummary.swift
// SuperwallKit
//
// Created by Yusuf Tör on 30/07/2026.
//

import Foundation

/// The minimal paywall metadata the debug/preview flow needs: enough to fetch
/// and label a paywall.
struct PaywallSummary: Decodable {
/// The id of the paywall in the database.
let id: String

/// The identifier (slug) of the paywall, used to fetch the full paywall.
let identifier: String

/// The display name of the paywall.
let name: String
}
18 changes: 18 additions & 0 deletions Sources/SuperwallKit/Network/API.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ enum EndpointHost {
case enrichment
case adServices
case subscriptionsApi
case paywallsV2
case mmp
}

Expand All @@ -35,6 +36,7 @@ struct Api {
let enrichment: Enrichment
let adServices: AdServices
let subscriptionsApi: SubscriptionsAPI
let paywallsV2: PaywallsV2
let mmp: MMP

init(networkEnvironment: SuperwallOptions.NetworkEnvironment) {
Expand All @@ -43,6 +45,7 @@ struct Api {
enrichment = Enrichment(networkEnvironment: networkEnvironment)
adServices = AdServices(networkEnvironment: networkEnvironment)
subscriptionsApi = SubscriptionsAPI(networkEnvironment: networkEnvironment)
paywallsV2 = PaywallsV2(networkEnvironment: networkEnvironment)
mmp = MMP(networkEnvironment: networkEnvironment)
}

Expand All @@ -58,6 +61,8 @@ struct Api {
return adServices
case .subscriptionsApi:
return subscriptionsApi
case .paywallsV2:
return paywallsV2
case .mmp:
return mmp
}
Expand Down Expand Up @@ -115,6 +120,19 @@ struct Api {
}
}

/// The Superwall V2 API, served under a `/v2/` path on `api.superwall.com`
/// (production) / `api.superwall.dev` (developer). See
/// `NetworkEnvironment.apiV2Host`.
struct PaywallsV2: ApiHostConfig {
let networkEnvironment: SuperwallOptions.NetworkEnvironment
var host: String { return networkEnvironment.apiV2Host }
var path: String { return "/v2/" }

init(networkEnvironment: SuperwallOptions.NetworkEnvironment) {
self.networkEnvironment = networkEnvironment
}
}

struct MMP: ApiHostConfig {
let networkEnvironment: SuperwallOptions.NetworkEnvironment
var host: String { return networkEnvironment.mmpHost }
Expand Down
Loading
Loading