diff --git a/CHANGELOG.md b/CHANGELOG.md index d06cfe70fa..41643aad40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 6c1bf36d07..c39e7ab207 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -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 { diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 37a7e31a62..6391fa404b 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -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) @@ -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 }() @@ -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? @@ -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 @@ -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 } @@ -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 { @@ -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) diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 4b943d5221..a4a128e087 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -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 { diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index d84a8cfb99..d8a45fd318 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -13,6 +13,7 @@ enum EndpointHost { case enrichment case adServices case subscriptionsApi + case paywallsV2 case mmp } @@ -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) { @@ -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) } @@ -58,6 +61,8 @@ struct Api { return adServices case .subscriptionsApi: return subscriptionsApi + case .paywallsV2: + return paywallsV2 case .mmp: return mmp } @@ -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 } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index bf4c600c93..994f53664a 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -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=`). + /// + /// 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 `), 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 ) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index cc11ddd8f4..0846fe6ba5 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -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 ` — 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 diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index ac9f1fd86f..0eee86c92f 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -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.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") + } }