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
84 changes: 61 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` (id/name/slug only — not the full paywalls
/// the pre-#3456 fetch-all returned). Empty when the request fails or the app
/// has a single paywall, in which case the picker declines to open.
var previewPaywalls: [PaywallPreviewListItem] = []
var previewViewContent: UIView?
private var cancellable: AnyCancellable?
private var initialLocaleIdentifier: String?
Expand Down Expand Up @@ -156,6 +160,12 @@ final class DebugViewController: UIViewController {
initialLocaleIdentifier = Superwall.shared.options.localeIdentifier
addSubviews()
Task { await loadPreview() }
// Independent of the preview load on purpose. The picker is most useful
// precisely when the requested paywall fails to render — that is when you
// want to switch to another one — so it must not sit behind the preview's
// success path. Runs concurrently, and only here, so switching paywalls via
// the picker doesn't refetch a list that cannot have changed.
Task { await loadPreviewPaywalls() }
}

private func addSubviews() {
Expand Down Expand Up @@ -204,32 +214,31 @@ 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, 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 Down Expand Up @@ -310,20 +319,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
55 changes: 53 additions & 2 deletions Sources/SuperwallKit/Models/Paywall/Paywall.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,59 @@

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
}

/// One row of the debugger's paywall picker, from
/// `GET /v2/paywalls/preview-list`.
///
/// Structurally identical to ``PaywallIdentifierResolution`` — the picker needs
/// exactly what the resolver returns, for every paywall in the application
/// rather than one. Kept as its own type so the two endpoints can diverge.
struct PaywallPreviewListItem: 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
}

/// The response from `GET /v2/paywalls/preview-list`.
///
/// Lists the non-archived paywalls of the application in the debugger's `sat_`
/// preview token, so the picker can offer alternatives without fetching every
/// paywall in full. Deliberately carries no presentable paywall JSON.
///
/// Decoded with `JSONDecoder.fromSnakeCase`. The endpoint also returns `object`,
/// `has_more` and `application_id`; none are decoded here. `has_more` in
/// particular is deliberately omitted rather than declared and ignored — the
/// picker does not paginate, and a non-optional field nothing reads would turn
/// any future change in the response shape into a `keyNotFound` that empties the
/// whole picker.
struct PaywallPreviewList: Decodable {
/// The paywalls available to preview, capped server-side.
let data: [PaywallPreviewListItem]
}

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
48 changes: 43 additions & 5 deletions Sources/SuperwallKit/Network/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -207,15 +207,53 @@ 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
)
}
}

// 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 (`GET /v2/paywalls/preview-list`).
///
/// Backs the debugger's "Your Paywalls" picker. Returns id/identifier/name
/// only — never the presentable paywall JSON — so switching previews costs one
/// small request rather than the fetch-all this replaced.
///
/// Same auth as `resolvePaywall`: the `sat_` token from the deeplink, sent as
/// `Authorization: Bearer <debugKey>` (see `Network.listPreviewPaywalls`).
static func listPreviewPaywalls(retryCount: Int) -> Self {
return Endpoint(
retryCount: retryCount,
components: Components(
host: .paywallsV2,
path: "paywalls/preview-list"
),
method: .get
)
Expand Down
Loading
Loading