Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup

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

## 4.16.1

Expand Down
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
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
40 changes: 35 additions & 5 deletions Sources/SuperwallKit/Network/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,45 @@ extension Endpoint where
}
}

// MARK: - PaywallsResponse
// MARK: - PaywallSummary
extension Endpoint where
Kind == EndpointKinds.Superwall,
Response == Paywalls {
static func paywalls() -> Self {
Response == PaywallSummary {
/// Resolves a numeric paywall database id to its identifier (slug).
///
/// Used by the debug/preview flow so it no longer has to fetch every paywall
/// for the app just to translate a deep-link `paywall_id` into an identifier.
static func resolvePaywall(
byDatabaseId databaseId: String,
retryCount: Int
) -> Self {
return Endpoint(
retryCount: retryCount,
components: Components(
host: .base,
path: "paywalls"
host: .paywallsV2,
path: "paywalls/resolve",
queryItems: [URLQueryItem(name: "id", value: databaseId)]
),
method: .get
)
}
}

// MARK: - PaywallPreviewList
extension Endpoint where
Kind == EndpointKinds.Superwall,
Response == PaywallPreviewList {
/// Lists the paywalls available to preview for the application in the
/// debugger's signed preview token.
///
/// Backs the debugger's "Your Paywalls" picker. Returns id/identifier/name
/// only.
static func listPreviewPaywalls(retryCount: Int) -> Self {
return Endpoint(
retryCount: retryCount,
components: Components(
host: .paywallsV2,
path: "paywalls/preview-list"
),
method: .get
)
Expand Down
43 changes: 38 additions & 5 deletions Sources/SuperwallKit/Network/Network.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,54 @@ class Network {
}
}

func getPaywalls() async throws -> [Paywall] {
/// Resolves a numeric paywall database id to its identifier (slug) so the
/// debug/preview flow can fetch a single paywall instead of all of them.
func resolvePaywallIdentifier(
forDatabaseId databaseId: String,
retryCount: Int = 6
) async throws -> PaywallSummary {
do {
let response = try await urlSession.request(
.paywalls(),
return try await urlSession.request(
.resolvePaywall(
byDatabaseId: databaseId,
retryCount: retryCount
),
data: SuperwallRequestData(
factory: factory,
isForDebugging: true
)
)
} catch {
Logger.debug(
logLevel: .error,
scope: .network,
message: "Request Failed: /v2/paywalls/resolve",
error: error
)
throw error
}
}

/// Lists the paywalls available to preview for the application in the
/// debugger's signed preview token, backing the debugger's paywall picker.
///
/// Retries less than the resolver: this only populates an optional picker, so
/// a failure should degrade to "no alternatives to offer" quickly rather than
/// hold the debugger up.
func listPreviewPaywalls(retryCount: Int = 2) async throws -> PaywallPreviewList {
do {
return try await urlSession.request(
.listPreviewPaywalls(retryCount: retryCount),
data: SuperwallRequestData(
factory: factory,
isForDebugging: true
)
)
return response.paywalls
} catch {
Logger.debug(
logLevel: .error,
scope: .network,
message: "Request Failed: /paywalls",
message: "Request Failed: /v2/paywalls/preview-list",
error: error
)
throw error
Expand Down
Loading