Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## Unreleased

### Fixes

- Fixes the debugger paywall preview so its resolve request authenticates with the debugger's signed preview token instead of the app's public API key.

## 4.16.2

### Enhancements
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
88 changes: 26 additions & 62 deletions Sources/SuperwallKit/Debug/DebugViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ final class DebugViewController: UIViewController {
return button
}()

lazy var previewPickerButton: SWBounceButton = {
lazy var previewNameButton: SWBounceButton = {
let button = SWBounceButton()
button.setTitle("", for: .normal)
button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 14)
Expand All @@ -86,14 +86,11 @@ final class DebugViewController: UIViewController {
button.setTitleColor(primaryColor, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
button.translatesAutoresizingMaskIntoConstraints = false
button.imageView?.tintColor = primaryColor
button.layer.cornerRadius = 10

let image = UIImage(named: "SuperwallKit_down_arrow", in: Bundle.module, compatibleWith: nil)!
button.semanticContentAttribute = .forceRightToLeft
button.setImage(image, for: .normal)
button.imageView?.tintColor = primaryColor
button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered)
// Display-only chip showing the previewed paywall's name. The multi-paywall
// picker (a dropdown opened from this chip) was removed when the preview
// flow stopped fetching all paywalls, so it no longer responds to taps.
button.isUserInteractionEnabled = false
return button
}()

Expand All @@ -111,14 +108,12 @@ final class DebugViewController: UIViewController {
let button = SWBounceButton()
button.shouldAnimateLightly = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(pressedPreview), for: .primaryActionTriggered)
return button
}()

var paywallDatabaseId: String?
var paywallIdentifier: String?
var paywall: Paywall?
var paywalls: [Paywall] = []
var previewViewContent: UIView?
private var cancellable: AnyCancellable?
private var initialLocaleIdentifier: String?
Expand Down Expand Up @@ -165,7 +160,7 @@ final class DebugViewController: UIViewController {
view.addSubview(consoleButton)
view.addSubview(exitButton)
view.addSubview(bottomButton)
previewContainerView.addSubview(previewPickerButton)
previewContainerView.addSubview(previewNameButton)
view.backgroundColor = lightBackgroundColor

previewContainerView.clipsToBounds = false
Expand Down Expand Up @@ -195,41 +190,40 @@ final class DebugViewController: UIViewController {
bottomButton.widthAnchor.constraint(equalTo: view.layoutMarginsGuide.widthAnchor, constant: -40),
bottomButton.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: -10),

previewPickerButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0),
previewPickerButton.heightAnchor.constraint(equalToConstant: 26),
previewPickerButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor)
previewNameButton.centerXAnchor.constraint(equalTo: previewContainerView.centerXAnchor, constant: 0),
previewNameButton.heightAnchor.constraint(equalToConstant: 26),
previewNameButton.centerYAnchor.constraint(equalTo: previewContainerView.bottomAnchor)
])
}

func loadPreview() async {
activityIndicator.startAnimating()
previewViewContent?.removeFromSuperview()
await finishLoadingPreview()
}

if paywalls.isEmpty {
func finishLoadingPreview() async {
var paywallId: String?

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, rather than fetching every
// paywall for the app and filtering in memory.
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 All @@ -248,7 +242,7 @@ final class DebugViewController: UIViewController {
paywall.productVariables = productVariables

self.paywall = paywall
self.previewPickerButton.setTitle("\(paywall.name)", for: .normal)
self.previewNameButton.setTitle("\(paywall.name)", for: .normal)
self.activityIndicator.stopAnimating()
self.addPaywallPreview()
} catch {
Expand Down Expand Up @@ -310,36 +304,6 @@ final class DebugViewController: UIViewController {
}
}

@objc func pressedPreview() {
guard let id = paywallDatabaseId else { return }

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

if id == paywall.databaseId {
name = "\(name) ✓"
}

let alert = AlertOption(
title: name,
action: { [weak self] in
self?.paywallDatabaseId = paywall.databaseId
self?.paywallIdentifier = paywall.identifier
Task { await self?.loadPreview() }
},
style: .default
)
return alert
}

presentAlert(
title: nil,
message: "Your Paywalls",
options: options,
on: previewPickerButton
)
}

@objc func pressedExitButton() {
Task {
await debugManager.closeDebugger(animated: false)
Expand Down
21 changes: 19 additions & 2 deletions Sources/SuperwallKit/Models/Paywall/Paywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,25 @@

import UIKit

struct Paywalls: Decodable {
var paywalls: [Paywall]
/// The minimal paywall metadata returned by the V2 resolver endpoint
/// (`GET /v2/paywalls/resolve`).
///
/// The debug/preview deep link carries a numeric paywall database `id`, but the
/// full-paywall fetch is keyed by `identifier` (slug). This lets the preview
/// flow translate `id` → `identifier` with a single lookup instead of fetching
/// every paywall for the app.
///
/// Decoded with `JSONDecoder.fromSnakeCase`; the endpoint also returns
/// `application_id`, which is not needed here and is ignored.
struct PaywallIdentifierResolution: 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
}

struct Paywall: Codable {
Expand Down
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
23 changes: 18 additions & 5 deletions Sources/SuperwallKit/Network/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,28 @@ extension Endpoint where
}
}

// MARK: - PaywallsResponse
// MARK: - PaywallIdentifierResolution
extension Endpoint where
Kind == EndpointKinds.Superwall,
Response == Paywalls {
static func paywalls() -> Self {
Response == PaywallIdentifierResolution {
/// Resolves a numeric paywall database id to its identifier (slug) via the V2
/// resolver endpoint (`GET /v2/paywalls/resolve?id=<id>`).
///
/// 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.
/// Authenticated with the debugger's signed preview token (the `sat_` token
/// from the deeplink, sent as `Authorization: Bearer <debugKey>`), not the
/// app's public key (see `Network.resolvePaywallIdentifier`).
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
)
Expand Down
22 changes: 17 additions & 5 deletions Sources/SuperwallKit/Network/Network.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,33 @@ 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.
///
/// Authenticated with the debugger's signed preview token (the `sat_` token
/// from the debugger deeplink, stored in `storage.debugKey`) via
/// `isForDebugging: true`, which `makeHeaders` sends as
/// `Authorization: Bearer <debugKey>` — not the app's public key.
func resolvePaywallIdentifier(
forDatabaseId databaseId: String,
retryCount: Int = 6
) async throws -> PaywallIdentifierResolution {
do {
let response = try await urlSession.request(
.paywalls(),
return try await urlSession.request(
.resolvePaywall(
byDatabaseId: databaseId,
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/resolve",
error: error
)
throw error
Expand Down
25 changes: 25 additions & 0 deletions Tests/SuperwallKitTests/Network/NetworkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,29 @@ struct NetworkTests {
#expect(bodyJson["deviceId"] as? String == "device_123")
#expect(bodyJson["appUserId"] as? String == "user_123")
}

@Test func resolvePaywall_endpointBuildsRequest() async throws {
let dependencyContainer = DependencyContainer()
dependencyContainer.storage.debugKey = "sat_test"
let endpoint = Endpoint<EndpointKinds.Superwall, PaywallIdentifierResolution>.resolvePaywall(
byDatabaseId: "123",
retryCount: 6
)

let urlRequest = await endpoint.makeRequest(
with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true),
factory: dependencyContainer
)

#expect(urlRequest?.httpMethod == "GET")

let urlString = try #require(urlRequest?.url?.absoluteString)
#expect(urlString.contains("/v2/paywalls/resolve"))
#expect(urlString.contains("id=123"))

// The resolver authenticates with the debugger's signed preview token
// (isForDebugging: true), so the Authorization header carries the
// `sat_` debug key as a bearer token, not the app's public key.
#expect(urlRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer sat_test")
}
}
Loading