diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8ed9f98a4..b0a769b554 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,11 @@
The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub.
-## 4.16.4
+## 4.17.0
+
+### Enhancements
+
+- Adds `SuperwallOptions.devServer` for development builds: with a `superwall dev` server running, paywalls render from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Use `.default` on a simulator, which finds the dev server on localhost automatically; on a physical device use `.url(...)` with the Device URL `superwall dev` prints. The dev server also activates test mode, disables preloading, and skips the test mode intro sheet.
### Fixes
diff --git a/Examples/Advanced/Advanced/Info.plist b/Examples/Advanced/Advanced/Info.plist
index b7789af4d7..9050b0e879 100644
--- a/Examples/Advanced/Advanced/Info.plist
+++ b/Examples/Advanced/Advanced/Info.plist
@@ -17,7 +17,9 @@
NSAppTransportSecurity
- NSAllowsArbitraryLoads
+ NSAllowsArbitraryLoadsInWebContent
+
+ NSAllowsLocalNetworking
UIAppFonts
diff --git a/Examples/Basic/Basic/Info.plist b/Examples/Basic/Basic/Info.plist
index 05ff7f9a98..99411a3793 100644
--- a/Examples/Basic/Basic/Info.plist
+++ b/Examples/Basic/Basic/Info.plist
@@ -13,6 +13,13 @@
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoadsInWebContent
+
+ NSAllowsLocalNetworking
+
+
UIAppFonts
Rubik-Regular.ttf
diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift
index 54aaf9a35d..a56fa96c03 100644
--- a/Sources/SuperwallKit/Config/ConfigManager.swift
+++ b/Sources/SuperwallKit/Config/ConfigManager.swift
@@ -440,8 +440,12 @@ class ConfigManager {
let shouldShowTestModeAlert = isFirstTime || testModeJustActivated
if shouldShowTestModeAlert,
testModeManager.isTestMode,
- let reason = testModeManager.testModeReason {
- await presentTestModeModal(reason: reason, config: config)
+ testModeManager.testModeReason != nil {
+ if DevMode.isActive(options) {
+ await applyDefaultTestModeState(testModeManager: testModeManager)
+ } else if let reason = testModeManager.testModeReason {
+ await presentTestModeModal(reason: reason, config: config)
+ }
}
}
@@ -558,7 +562,10 @@ class ConfigManager {
///
/// A developer can disable preloading of paywalls by setting ``SuperwallOptions/shouldPreloadPaywalls``.
private func preloadPaywalls() async {
- guard Superwall.shared.options.paywalls.shouldPreload else {
+ guard
+ Superwall.shared.options.paywalls.shouldPreload,
+ !DevMode.isActive(Superwall.shared.options)
+ else {
return
}
await preloadAllPaywalls()
@@ -724,6 +731,24 @@ class ConfigManager {
}
}
+ /// Seeds the state the test mode modal would otherwise collect, without
+ /// presenting it. Used when a dev server drives the SDK: every entitlement
+ /// starts inactive so paywalls present, and purchases flip them for real.
+ @MainActor
+ private func applyDefaultTestModeState(testModeManager: TestModeManager) async {
+ testModeManager.setEntitlements([])
+ let testModeCustomerInfo = CustomerInfo(
+ subscriptions: [],
+ nonSubscriptions: [],
+ entitlements: []
+ )
+ testModeManager.overriddenCustomerInfo = testModeCustomerInfo
+ Superwall.shared.customerInfo = testModeCustomerInfo
+ testModeManager.overriddenSubscriptionStatus = .inactive
+ Superwall.shared.subscriptionStatus = .inactive
+ storage.save(false, forType: IsTestModeActiveSubscription.self)
+ }
+
@MainActor
private func presentTestModeModal(reason: TestModeReason, config: Config) async {
guard
diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
index c39e7ab207..b3366f8b70 100644
--- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
+++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
@@ -388,6 +388,60 @@ public final class SuperwallOptions: NSObject, Encodable {
/// - `.always`: Test mode is always activated, regardless of configuration.
public var testModeBehavior: TestModeBehavior = .automatic
+ /// A running `superwall dev` server for ``SuperwallOptions/devServer`` to connect to.
+ public enum DevServer: Equatable {
+ /// Finds the dev server on `localhost` ports 6100–6104, which reaches a server
+ /// running on the same machine from a simulator.
+ case `default`
+
+ /// The dev server at an exact address — the `Device` URL's origin that
+ /// `superwall dev` prints, e.g. `http://192.168.1.10:6100`. Use this on a
+ /// physical device, which can't reach your machine via `localhost`.
+ case url(URL)
+ }
+
+ /// Connects this SDK instance to a running `superwall dev` server, for development builds only.
+ ///
+ /// Paywalls with a local counterpart on the dev server then render from your live, local
+ /// paywall code instead of their published versions, while configuration, placements,
+ /// audience evaluation and assignment all stay real. Paywalls without a local counterpart
+ /// still load their published versions.
+ ///
+ /// Use ``DevServer/default`` on a simulator; on a physical device use ``DevServer/url(_:)``
+ /// with the `Device` URL that `superwall dev` prints. Defaults to `nil`: no dev server.
+ ///
+ /// The dev server also activates test mode (simulated purchases, product data from the
+ /// dashboard), disables paywall preloading, and skips the test mode intro sheet.
+ ///
+ /// The host app must allow local networking in its `Info.plist`
+ /// (`NSAppTransportSecurity` → `NSAllowsLocalNetworking` and
+ /// `NSAllowsArbitraryLoadsInWebContent`).
+ @nonobjc public var devServer: DevServer?
+
+ /// Objective-C only: connects to a `superwall dev` server found on `localhost`.
+ @available(swift, obsoleted: 1.0)
+ public func enableDevServer() {
+ devServer = .default
+ }
+
+ /// Objective-C only: connects to the `superwall dev` server at this address.
+ @available(swift, obsoleted: 1.0)
+ public func enableDevServer(url: URL) {
+ devServer = .url(url)
+ }
+
+ var isDevServerEnabled: Bool {
+ return devServer != nil
+ }
+
+ /// Where ``devServer``'s ``DevServer/url(_:)`` case points, if that's what is set.
+ var devServerURL: URL? {
+ if case .url(let url) = devServer {
+ return url
+ }
+ return nil
+ }
+
/// Determines the number of times the SDK will attempt to get the Superwall configuration after a network
/// failure before it times out. Defaults to 6.
///
diff --git a/Sources/SuperwallKit/Debug/DebugManager.swift b/Sources/SuperwallKit/Debug/DebugManager.swift
index bb2ebe140f..9b11092855 100644
--- a/Sources/SuperwallKit/Debug/DebugManager.swift
+++ b/Sources/SuperwallKit/Debug/DebugManager.swift
@@ -12,6 +12,10 @@ final class DebugManager {
@MainActor var viewController: DebugViewController?
var isDebuggerLaunched = false
+ /// The surfaces a running `superwall dev` server exposes, and where it lives.
+ /// Set when the debugger is opened from a `superwall_dev` deep link.
+ @MainActor var devServer: (base: URL, surfaces: [DevServerSurface])?
+
private unowned let storage: Storage
private unowned let factory: ViewControllerFactory
struct DeepLinkOutcome {
@@ -68,31 +72,39 @@ final class DebugManager {
///
/// Remember to add your URL scheme in settings for QR code scanning to work.
@MainActor
- func launchDebugger(withPaywallId paywallDatabaseId: String? = nil) async {
+ func launchDebugger(
+ withPaywallId paywallDatabaseId: String? = nil,
+ devSurfaceId: String? = nil
+ ) async {
if Superwall.shared.isPaywallPresented {
await Superwall.shared.dismiss()
- await launchDebugger(withPaywallId: paywallDatabaseId)
+ await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId)
} else {
if viewController == nil {
let milliseconds = 200
let nanoseconds = UInt64(milliseconds * 1_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
- await presentDebugger(withPaywallId: paywallDatabaseId)
+ await presentDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId)
} else {
await closeDebugger(animated: true)
- await launchDebugger(withPaywallId: paywallDatabaseId)
+ await launchDebugger(withPaywallId: paywallDatabaseId, devSurfaceId: devSurfaceId)
}
}
}
@MainActor
- func presentDebugger(withPaywallId paywallDatabaseId: String? = nil) async {
+ func presentDebugger(
+ withPaywallId paywallDatabaseId: String? = nil,
+ devSurfaceId: String? = nil
+ ) async {
isDebuggerLaunched = true
if let viewController = viewController {
if viewController.isBeingPresented {
return
}
viewController.paywallDatabaseId = paywallDatabaseId
+ viewController.devServer = devServer
+ viewController.selectDevSurface(id: devSurfaceId)
await viewController.loadPreview()
await UIViewController.topMostViewController?.present(
viewController,
@@ -100,6 +112,8 @@ final class DebugManager {
)
} else {
let viewController = factory.makeDebugViewController(withDatabaseId: paywallDatabaseId)
+ viewController.devServer = devServer
+ viewController.selectDevSurface(id: devSurfaceId)
UIViewController.topMostViewController?.present(
viewController,
animated: true,
diff --git a/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift
new file mode 100644
index 0000000000..29285c7a55
--- /dev/null
+++ b/Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift
@@ -0,0 +1,172 @@
+//
+// DebugPaywallPickerViewController.swift
+// SuperwallKit
+//
+// The debugger's paywall list: a searchable, sectioned table of the local
+// surfaces a `superwall dev` server serves and the app's published paywalls.
+//
+
+import UIKit
+
+@MainActor
+final class DebugPaywallPickerViewController: UIViewController {
+ private let localSurfaceIds: [String]
+ private let publishedNames: [String]
+ private let selectedLocalId: String?
+ private let selectedPublishedIndex: Int?
+ private let onSelect: (DebugPickerLogic.Kind) -> Void
+
+ private var sections: [DebugPickerLogic.Section] = []
+
+ private lazy var tableView: UITableView = {
+ let table = UITableView(frame: .zero, style: .insetGrouped)
+ table.backgroundColor = darkBackgroundColor
+ table.separatorColor = UIColor.white.withAlphaComponent(0.1)
+ table.dataSource = self
+ table.delegate = self
+ table.keyboardDismissMode = .onDrag
+ table.translatesAutoresizingMaskIntoConstraints = false
+ return table
+ }()
+
+ private lazy var searchController: UISearchController = {
+ let controller = UISearchController(searchResultsController: nil)
+ controller.searchResultsUpdater = self
+ controller.obscuresBackgroundDuringPresentation = false
+ controller.searchBar.placeholder = "Search paywalls"
+ controller.searchBar.tintColor = primaryColor
+ controller.searchBar.searchTextField.textColor = .white
+ return controller
+ }()
+
+ init(
+ localSurfaceIds: [String],
+ publishedNames: [String],
+ selectedLocalId: String?,
+ selectedPublishedIndex: Int?,
+ onSelect: @escaping (DebugPickerLogic.Kind) -> Void
+ ) {
+ self.localSurfaceIds = localSurfaceIds
+ self.publishedNames = publishedNames
+ self.selectedLocalId = selectedLocalId
+ self.selectedPublishedIndex = selectedPublishedIndex
+ self.onSelect = onSelect
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ view.backgroundColor = darkBackgroundColor
+ title = "Paywalls"
+
+ navigationItem.searchController = searchController
+ navigationItem.hidesSearchBarWhenScrolling = false
+ navigationItem.rightBarButtonItem = UIBarButtonItem(
+ barButtonSystemItem: .close,
+ target: self,
+ action: #selector(pressedClose)
+ )
+ navigationItem.rightBarButtonItem?.tintColor = primaryColor
+
+ view.addSubview(tableView)
+ NSLayoutConstraint.activate([
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+
+ reload(query: "")
+ }
+
+ private func reload(query: String) {
+ sections = DebugPickerLogic.sections(
+ localSurfaceIds: localSurfaceIds,
+ publishedNames: publishedNames,
+ selectedLocalId: selectedLocalId,
+ selectedPublishedIndex: selectedPublishedIndex,
+ query: query
+ )
+ tableView.reloadData()
+ }
+
+ @objc private func pressedClose() {
+ dismiss(animated: true)
+ }
+}
+
+// MARK: - Table
+
+extension DebugPaywallPickerViewController: UITableViewDataSource, UITableViewDelegate {
+ func numberOfSections(in tableView: UITableView) -> Int {
+ return sections.count
+ }
+
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ return sections[section].rows.count
+ }
+
+ func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
+ return sections[section].title
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let row = sections[indexPath.section].rows[indexPath.row]
+ let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
+ cell.backgroundColor = lightBackgroundColor
+ cell.textLabel?.text = row.title
+ cell.textLabel?.textColor = .white
+ cell.textLabel?.font = .systemFont(ofSize: 16, weight: row.isSelected ? .semibold : .regular)
+ cell.accessoryType = row.isSelected ? .checkmark : .none
+ cell.tintColor = primaryColor
+ let selected = UIView()
+ selected.backgroundColor = UIColor.white.withAlphaComponent(0.08)
+ cell.selectedBackgroundView = selected
+ return cell
+ }
+
+ func tableView(
+ _ tableView: UITableView,
+ willDisplayHeaderView view: UIView,
+ forSection section: Int
+ ) {
+ guard let header = view as? UITableViewHeaderFooterView else {
+ return
+ }
+ // A grouped header renders through its content configuration on iOS 14+,
+ // which ignores `textLabel` — the default grey is unreadable on the
+ // debugger's near-black sheet.
+ if #available(iOS 14.0, *) {
+ var configuration = header.defaultContentConfiguration()
+ configuration.text = sections[section].title
+ configuration.textProperties.color = UIColor.white.withAlphaComponent(0.5)
+ configuration.textProperties.font = .systemFont(ofSize: 13, weight: .semibold)
+ header.contentConfiguration = configuration
+ } else {
+ header.textLabel?.textColor = UIColor.white.withAlphaComponent(0.5)
+ header.textLabel?.font = .systemFont(ofSize: 13, weight: .semibold)
+ }
+ }
+
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ tableView.deselectRow(at: indexPath, animated: true)
+ let row = sections[indexPath.section].rows[indexPath.row]
+ let onSelect = self.onSelect
+ dismiss(animated: true) {
+ onSelect(row.kind)
+ }
+ }
+}
+
+// MARK: - Search
+
+extension DebugPaywallPickerViewController: UISearchResultsUpdating {
+ func updateSearchResults(for searchController: UISearchController) {
+ reload(query: searchController.searchBar.text ?? "")
+ }
+}
diff --git a/Sources/SuperwallKit/Debug/DebugPickerLogic.swift b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift
new file mode 100644
index 0000000000..6dd064651a
--- /dev/null
+++ b/Sources/SuperwallKit/Debug/DebugPickerLogic.swift
@@ -0,0 +1,63 @@
+//
+// DebugPickerLogic.swift
+// SuperwallKit
+//
+// Builds the debugger's paywall list: the surfaces a running
+// `superwall dev` server serves, then the paywalls the app has published.
+//
+
+import Foundation
+
+enum DebugPickerLogic {
+ enum Kind: Equatable {
+ case local(index: Int)
+ case published(index: Int)
+ }
+
+ struct Row: Equatable {
+ let title: String
+ let kind: Kind
+ let isSelected: Bool
+ }
+
+ struct Section: Equatable {
+ let title: String
+ let rows: [Row]
+ }
+
+ static let localTitle = "Local · superwall dev"
+ static let publishedTitle = "Published"
+
+ static func sections(
+ localSurfaceIds: [String],
+ publishedNames: [String],
+ selectedLocalId: String?,
+ selectedPublishedIndex: Int?,
+ query: String = ""
+ ) -> [Section] {
+ let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ func matches(_ title: String) -> Bool {
+ return needle.isEmpty || title.lowercased().contains(needle)
+ }
+
+ let local = localSurfaceIds.enumerated()
+ .filter { matches($0.element) }
+ .map { index, id in
+ Row(title: id, kind: .local(index: index), isSelected: id == selectedLocalId)
+ }
+ let published = publishedNames.enumerated()
+ .filter { matches($0.element) }
+ .map { index, name in
+ Row(
+ title: name,
+ kind: .published(index: index),
+ isSelected: index == selectedPublishedIndex && selectedLocalId == nil
+ )
+ }
+
+ return [
+ Section(title: localTitle, rows: local),
+ Section(title: publishedTitle, rows: published)
+ ].filter { !$0.rows.isEmpty }
+ }
+}
diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift
index e02936f7a0..cd2435ff3f 100644
--- a/Sources/SuperwallKit/Debug/DebugViewController.swift
+++ b/Sources/SuperwallKit/Debug/DebugViewController.swift
@@ -118,6 +118,14 @@ final class DebugViewController: UIViewController {
var paywallDatabaseId: String?
var paywallIdentifier: String?
var paywall: Paywall?
+
+ /// Set when the debugger is opened from a `superwall dev` link: the surfaces
+ /// that server exposes, and where to load them from.
+ var devServer: (base: URL, surfaces: [DevServerSurface])?
+
+ /// The dev-server surface to render instead of fetching a published paywall.
+ private var devSurface: DevServerSurface?
+
/// Backs the "Your Paywalls" picker.
///
/// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app
@@ -209,10 +217,31 @@ final class DebugViewController: UIViewController {
func loadPreview() async {
activityIndicator.startAnimating()
previewViewContent?.removeFromSuperview()
+ await ensureDevServer()
await finishLoadingPreview()
}
+ /// Dev mode's local surfaces belong in the debugger however it was opened —
+ /// a dashboard preview link should list them too, not just a dev link.
+ private func ensureDevServer() async {
+ guard
+ devServer == nil,
+ DevMode.isActive(Superwall.shared.options),
+ let location = await DevServerLocator.shared.locate(
+ devServerURL: Superwall.shared.options.devServerURL
+ )
+ else {
+ return
+ }
+ devServer = (base: location.base, surfaces: location.manifest.surfaces)
+ }
+
func finishLoadingPreview() async {
+ if let devSurface = devSurface {
+ await loadDevServerPreview(surface: devSurface)
+ return
+ }
+
var paywallId: String?
if let paywallIdentifier = paywallIdentifier {
@@ -247,8 +276,7 @@ final class DebugViewController: UIViewController {
)
var paywall = try await paywallRequestManager.getPaywall(from: request)
- let productVariables = await storeKitManager.getProductVariables(for: paywall)
- paywall.productVariables = productVariables
+ paywall.productVariables = await storeKitManager.getProductVariables(for: paywall)
self.paywall = paywall
self.previewPickerButton.setTitle("\(paywall.name)", for: .normal)
@@ -335,41 +363,110 @@ final class DebugViewController: UIViewController {
}
}
+ /// Picks the dev-server surface the debugger opens with, if any.
+ func selectDevSurface(id: String?) {
+ guard let id = id else {
+ return
+ }
+ devSurface = devServer?.surfaces.first { $0.id == id }
+ }
+
+ /// Renders a surface straight from the dev server, synthesised from the
+ /// manifest (local URL + the products its `config.ts` declares). Nothing is
+ /// fetched from the dashboard, so a paywall that has never been pushed —
+ /// or whose app lives in another environment — still previews.
+ private func loadDevServerPreview(surface: DevServerSurface) async {
+ guard
+ let devServer = devServer,
+ let location = await DevServerLocator.shared.locate(
+ devServerURL: Superwall.shared.options.devServerURL
+ ),
+ let url = location.manifest.mountURL(for: surface, base: devServer.base)
+ else {
+ activityIndicator.stopAnimating()
+ return
+ }
+
+ var paywall = Paywall.devServer(surface: surface, url: url)
+ // Product variables are best-effort here: a surface can name products the
+ // store has no record of yet, and the preview must still render.
+ paywall.productVariables = await storeKitManager.getProductVariables(for: paywall)
+ self.paywall = paywall
+ paywallIdentifier = paywall.identifier
+ paywallDatabaseId = paywall.databaseId
+ previewPickerButton.setTitle("\(surface.id) (local)", for: .normal)
+ activityIndicator.stopAnimating()
+ addPaywallPreview()
+ }
+
+ /// The published paywalls to offer. The debugger's preview list needs the
+ /// token a dashboard preview link carries; the downloaded config carries the
+ /// same paywalls for free, which is what a `superwall dev` link relies on.
+ private var publishedPaywalls: [(id: String, identifier: String, name: String)] {
+ if !previewPaywalls.isEmpty {
+ return previewPaywalls.map { (id: $0.id, identifier: $0.identifier, name: $0.name) }
+ }
+ let config = Superwall.shared.dependencyContainer.configManager?.config
+ return (config?.paywalls ?? []).map {
+ (id: $0.databaseId, identifier: $0.identifier, name: $0.name)
+ }
+ }
+
+ /// Whether the picker has anything to offer: local surfaces, a choice of
+ /// published paywalls, or no paywall selected yet.
+ private var canOpenPicker: Bool {
+ if devServer?.surfaces.isEmpty == false {
+ return true
+ }
+ if publishedPaywalls.count > 1 {
+ return true
+ }
+ if paywallDatabaseId == nil {
+ return true
+ }
+ return false
+ }
+
@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
-
- // 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) ✓"
+ guard canOpenPicker else {
+ return
+ }
+ let devSurfaces = devServer?.surfaces ?? []
+ let published = publishedPaywalls
+
+ let picker = DebugPaywallPickerViewController(
+ localSurfaceIds: devSurfaces.map { $0.id },
+ publishedNames: published.map { $0.name },
+ selectedLocalId: devSurface?.id,
+ selectedPublishedIndex: published.firstIndex { $0.id == paywallDatabaseId }
+ ) { [weak self] kind in
+ guard let self = self else {
+ return
}
-
- let alert = AlertOption(
- title: name,
- action: { [weak self] in
- self?.paywallDatabaseId = paywall.id
- self?.paywallIdentifier = paywall.identifier
- Task { await self?.loadPreview() }
- },
- style: .default
- )
- return alert
+ switch kind {
+ case .local(let index):
+ self.devSurface = devSurfaces[index]
+ case .published(let index):
+ self.devSurface = nil
+ self.paywallDatabaseId = published[index].id
+ self.paywallIdentifier = published[index].identifier
+ }
+ Task { await self.loadPreview() }
}
- presentAlert(
- title: nil,
- message: "Your Paywalls",
- options: options,
- on: previewPickerButton
- )
+ let navigationController = UINavigationController(rootViewController: picker)
+ navigationController.navigationBar.barStyle = .black
+ navigationController.navigationBar.titleTextAttributes = [.foregroundColor: UIColor.white]
+ navigationController.modalPresentationStyle = .pageSheet
+ #if !os(visionOS)
+ if #available(iOS 15.0, *) {
+ if let sheet = navigationController.sheetPresentationController {
+ sheet.detents = [.medium(), .large()]
+ sheet.prefersGrabberVisible = true
+ }
+ }
+ #endif
+ present(navigationController, animated: true)
}
@objc func pressedExitButton() {
diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift
index 39191cf33c..8564bc1cce 100644
--- a/Sources/SuperwallKit/DeepLinkRouter.swift
+++ b/Sources/SuperwallKit/DeepLinkRouter.swift
@@ -63,6 +63,10 @@ final class DeepLinkRouter {
return true
}
+ if DevServerPreview.handle(url: deepLinkUrl) {
+ return true
+ }
+
// Return true for Superwall deep links (we handled it above)
if isSuperwallDeepLink {
return true
@@ -135,6 +139,15 @@ final class DeepLinkRouter {
return true
}
+ // Dev links count as Superwall's only while dev mode is verifiably on.
+ // Before initialization there are no options to check (and touching
+ // `Superwall.shared` would assert), so don't claim the link — it is
+ // stored above and routed again once config arrives.
+ if Superwall.isInitialized,
+ DevServerPreview.canHandle(url: url, options: Superwall.shared.options) {
+ return true
+ }
+
// Check cached config for deepLink_open trigger
let cache = Cache()
if let config = cache.read(LatestConfig.self) {
diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift
new file mode 100644
index 0000000000..c8f87a9dcb
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevMode.swift
@@ -0,0 +1,46 @@
+//
+// DevMode.swift
+// SuperwallKit
+//
+// Dev mode is a development-only facility: it serves paywalls from a local
+// `superwall dev` server and simulates purchases. Shipping it to the App
+// Store would mean nobody could buy anything, so it is inert in production
+// no matter how the SDK was configured.
+//
+
+import Foundation
+
+enum DevMode {
+ private static var hasWarnedAboutProduction = false
+
+ /// Whether this build is running somewhere dev mode is allowed. Overridable
+ /// so tests can exercise the production path, which no simulator can produce.
+ static var isSandboxEnvironment: () -> Bool = { DeviceHelper.isSandboxEnvironment }
+
+ /// Whether dev mode should actually do anything right now: asked for, and
+ /// running somewhere it is safe to (simulator, TestFlight, development).
+ static func isActive(_ options: SuperwallOptions) -> Bool {
+ guard options.isDevServerEnabled else {
+ return false
+ }
+ guard isSandboxEnvironment() else {
+ warnAboutProduction()
+ return false
+ }
+ return true
+ }
+
+ private static func warnAboutProduction() {
+ if hasWarnedAboutProduction {
+ return
+ }
+ hasWarnedAboutProduction = true
+ Logger.debug(
+ logLevel: .warn,
+ scope: .superwallCore,
+ message: "SuperwallOptions.devServer is set in a production build, so it is being ignored: "
+ + "paywalls load their published versions and purchases are real. "
+ + "Remove devServer before shipping."
+ )
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerCandidates.swift b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift
new file mode 100644
index 0000000000..02197b1e26
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerCandidates.swift
@@ -0,0 +1,22 @@
+//
+// DevServerCandidates.swift
+// SuperwallKit
+//
+// Where dev mode looks for a running `superwall dev` server.
+//
+
+import Foundation
+
+enum DevServerCandidates {
+ static let defaultPorts = 6100...6104
+
+ /// The bases dev mode tries, in order: an explicit URL wins, otherwise
+ /// localhost across the default port range `superwall dev` walks when
+ /// its preferred port is taken.
+ static func bases(devServerURL: URL?) -> [URL] {
+ if let devServerURL = devServerURL {
+ return [devServerURL]
+ }
+ return defaultPorts.compactMap { URL(string: "http://localhost:\($0)") }
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerLocation.swift b/Sources/SuperwallKit/DevServer/DevServerLocation.swift
new file mode 100644
index 0000000000..699529de19
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerLocation.swift
@@ -0,0 +1,14 @@
+//
+// DevServerLocation.swift
+// SuperwallKit
+//
+// A found `superwall dev` server: the base it answered on and the
+// manifest it served from there.
+//
+
+import Foundation
+
+struct DevServerLocation: Equatable {
+ let base: URL
+ let manifest: DevServerManifest
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift
new file mode 100644
index 0000000000..c3e990d034
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift
@@ -0,0 +1,115 @@
+//
+// DevServerLocator.swift
+// SuperwallKit
+//
+// Finds the running `superwall dev` server by probing the candidate
+// bases for /device/manifest.json, with short-lived caching of both
+// hits and misses so paywall requests don't hammer the network.
+//
+
+import Foundation
+
+actor DevServerLocator {
+ static let shared = DevServerLocator()
+
+ private var cached: (location: DevServerLocation, fetchedAt: Date)?
+ private var lastMissAt: Date?
+ private var pinnedBase: URL?
+ private var hasWarnedAboutTransportSecurity = false
+
+ func pin(base: URL) {
+ pinnedBase = base
+ cached = nil
+ lastMissAt = nil
+ }
+
+ func locate(devServerURL: URL?) async -> DevServerLocation? {
+ if let cached = cached,
+ Date().timeIntervalSince(cached.fetchedAt) < 2 {
+ return cached.location
+ }
+ if let lastMissAt = lastMissAt,
+ Date().timeIntervalSince(lastMissAt) < 5 {
+ return nil
+ }
+
+ var bases = DevServerCandidates.bases(devServerURL: devServerURL)
+ if let pinnedBase = pinnedBase {
+ bases.removeAll { $0 == pinnedBase }
+ bases.insert(pinnedBase, at: 0)
+ }
+ if let cached = cached {
+ bases.removeAll { $0 == cached.location.base }
+ bases.insert(cached.location.base, at: 0)
+ }
+
+ for base in bases {
+ if let manifest = await fetchManifest(from: base) {
+ let location = DevServerLocation(base: base, manifest: manifest)
+ cached = (location, Date())
+ lastMissAt = nil
+ return location
+ }
+ }
+
+ cached = nil
+ lastMissAt = Date()
+ Logger.debug(
+ logLevel: .warn,
+ scope: .superwallCore,
+ message: "Dev mode is on but no superwall dev server was found at "
+ + "\(bases.map { $0.absoluteString }.joined(separator: ", ")). "
+ + "Paywalls will load their published versions. On a physical device, "
+ + "set SuperwallOptions.devServer to the Device URL superwall dev prints."
+ )
+ return nil
+ }
+
+ /// App Transport Security blocks plain-http requests unless the app opts in,
+ /// and the failure is otherwise indistinguishable from "no server there".
+ private func warnIfBlockedByAppTransportSecurity(_ error: Error, base: URL) {
+ let code = (error as NSError).code
+ guard code == NSURLErrorAppTransportSecurityRequiresSecureConnection else {
+ return
+ }
+ if hasWarnedAboutTransportSecurity {
+ return
+ }
+ hasWarnedAboutTransportSecurity = true
+ Logger.debug(
+ logLevel: .error,
+ scope: .superwallCore,
+ message: "App Transport Security blocked \(base.absoluteString). Add this to the app's "
+ + "Info.plist to preview local paywalls:\n"
+ + "NSAppTransportSecurity\n\n"
+ + " NSAllowsArbitraryLoadsInWebContent\n"
+ + " NSAllowsLocalNetworking\n"
+ )
+ }
+
+ private func fetchManifest(from base: URL) async -> DevServerManifest? {
+ guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else {
+ return nil
+ }
+ var request = URLRequest(url: manifestURL)
+ request.timeoutInterval = 5
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+
+ do {
+ let data: Data = try await withCheckedThrowingContinuation { continuation in
+ let task = URLSession.shared.dataTask(with: request) { data, _, error in
+ if let data = data {
+ continuation.resume(returning: data)
+ } else {
+ continuation.resume(throwing: error ?? URLError(.badServerResponse))
+ }
+ }
+ task.resume()
+ }
+ return try JSONDecoder().decode(DevServerManifest.self, from: data)
+ } catch {
+ warnIfBlockedByAppTransportSecurity(error, base: base)
+ return nil
+ }
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift
new file mode 100644
index 0000000000..2ade494c12
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift
@@ -0,0 +1,50 @@
+//
+// DevServerManifest.swift
+// SuperwallKit
+//
+// The surface list a running `superwall dev` server exposes at
+// /device/manifest.json, used to map dashboard paywalls to locally
+// served paywall code when `SuperwallOptions/devMode` is on.
+//
+
+import Foundation
+
+struct DevServerManifest: Decodable, Equatable {
+ let surfaces: [DevServerSurface]
+
+ /// Picks the local surface for a dashboard paywall: an explicit
+ /// `superwall.lock` binding wins, otherwise a project with exactly one
+ /// paywall serves it for everything.
+ func surface(forPaywallDatabaseId databaseId: String) -> DevServerSurface? {
+ if let bound = surfaces.first(where: { $0.paywallId == databaseId }) {
+ return bound
+ }
+ let paywalls = surfaces.filter { $0.kind == "paywall" }
+ if paywalls.count == 1 {
+ return paywalls.first
+ }
+ return nil
+ }
+
+ func mountURL(for surface: DevServerSurface, base: URL) -> URL? {
+ guard let resolved = URL(string: surface.url, relativeTo: base)?.absoluteURL else {
+ return nil
+ }
+ // An absolute `url` resolves off `base` entirely, so a server reached at a
+ // trusted address could otherwise name any origin it likes.
+ guard
+ resolved.scheme == base.scheme,
+ resolved.host == base.host,
+ resolved.port == base.port
+ else {
+ Logger.debug(
+ logLevel: .warn,
+ scope: .superwallCore,
+ message: "Ignoring dev server surface \(surface.id): its url \(surface.url) resolves to "
+ + "\(resolved.absoluteString), which is off \(base.absoluteString)'s origin."
+ )
+ return nil
+ }
+ return resolved
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift
new file mode 100644
index 0000000000..20a0697d05
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift
@@ -0,0 +1,55 @@
+//
+// DevServerPaywall.swift
+// SuperwallKit
+//
+// Builds a `Paywall` for a surface that a running `superwall dev` server
+// serves, so the debugger can preview local paywall code that has never
+// been pushed to the dashboard.
+//
+
+import Foundation
+import UIKit
+
+extension Paywall {
+ static func devServer(surface: DevServerSurface, url: URL) -> Paywall {
+ let products = (surface.products ?? [:])
+ .sorted { $0.key < $1.key }
+ .map { reference, identifier in
+ Product(
+ name: reference,
+ type: .appStore(.init(id: identifier)),
+ id: identifier,
+ entitlements: []
+ )
+ }
+
+ return Paywall(
+ databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)",
+ identifier: surface.identifier ?? "dev:\(surface.id)",
+ name: surface.id,
+ cacheKey: "dev:\(surface.id):\(url.absoluteString)",
+ buildId: "dev",
+ url: url,
+ urlConfig: WebViewURLConfig(
+ endpoints: [WebViewEndpoint(url: url, timeout: 15, percentage: 100)],
+ maxAttempts: 1
+ ),
+ htmlSubstitutions: "",
+ presentation: PaywallPresentationInfo(style: .modal, delay: 0),
+ backgroundColorHex: "#FFFFFF",
+ backgroundColor: .white,
+ darkBackgroundColorHex: nil,
+ darkBackgroundColor: nil,
+ productItems: products,
+ productIds: products.map { $0.id },
+ appStoreProductIds: products.map { $0.id },
+ responseLoadingInfo: .init(),
+ webviewLoadingInfo: .init(),
+ productsLoadingInfo: .init(),
+ shimmerLoadingInfo: .init(),
+ paywalljsVersion: "",
+ isScrollEnabled: true,
+ introOfferEligibility: .automatic
+ )
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift
new file mode 100644
index 0000000000..b72d18f1dd
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift
@@ -0,0 +1,139 @@
+//
+// DevServerPreview.swift
+// SuperwallKit
+//
+// Handles superwall_dev deep links: scanning the QR that `superwall dev`
+// prints opens this in-app picker of the dev server's local surfaces, and
+// selecting one presents it through the real paywall pipeline (which the
+// dev mode override then points at the local bytes).
+//
+
+import Combine
+import Foundation
+import UIKit
+
+enum DevServerPreview {
+ struct DeepLinkOutcome: Equatable {
+ let base: URL
+ let surfaceId: String?
+ }
+
+ /// Parses a `superwall_dev` deep link: the dev server base carried in the
+ /// `superwall_dev` query item, which must be a web URL, plus the optional
+ /// `superwall_dev_surface` to open with.
+ static func outcomeForDeepLink(url: URL) -> DeepLinkOutcome? {
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ return nil
+ }
+ guard let items = components.queryItems else {
+ return nil
+ }
+ guard let raw = items.first(where: { $0.name == "superwall_dev" })?.value else {
+ return nil
+ }
+ guard let base = URL(string: raw) else {
+ return nil
+ }
+ guard base.scheme == "http" || base.scheme == "https" else {
+ return nil
+ }
+ let surfaceId = items.first { $0.name == "superwall_dev_surface" }?.value
+ return DeepLinkOutcome(base: base, surfaceId: surfaceId)
+ }
+
+ /// Whether `handle(url:)` would consume this URL: it parses, dev mode is on,
+ /// and the base is a host `superwall dev` could actually have printed.
+ static func canHandle(url: URL, options: SuperwallOptions) -> Bool {
+ guard let outcome = outcomeForDeepLink(url: url) else {
+ return false
+ }
+ return DevMode.isActive(options)
+ && isTrustedBase(outcome.base, devServerURL: options.devServerURL)
+ }
+
+ /// A deep-link-supplied base may only name a host `superwall dev` ever
+ /// prints — loopback, `.local`, or a private-network address — or the
+ /// developer-supplied `devServer` URL, which is trusted input. Anything else
+ /// is an arbitrary internet host that must not be handed the paywall
+ /// pipeline's JS bridge.
+ static func isTrustedBase(_ base: URL, devServerURL: URL?) -> Bool {
+ if let devServerURL = devServerURL,
+ base.scheme == devServerURL.scheme,
+ base.host == devServerURL.host,
+ base.port == devServerURL.port {
+ return true
+ }
+ guard let host = base.host?.lowercased() else {
+ return false
+ }
+ if host == "localhost" || host == "::1" || host.hasSuffix(".local") {
+ return true
+ }
+ // Every component must be a numeric octet: compactMap alone would let a
+ // DNS name like 10.0.0.1.evil.example.com pass as a private address.
+ let components = host.split(separator: ".")
+ let octets = components.compactMap { UInt8($0) }
+ if components.count != 4 || octets.count != 4 {
+ return false
+ }
+ switch (octets[0], octets[1]) {
+ case (127, _), (10, _), (192, 168), (169, 254), (172, 16...31):
+ return true
+ default:
+ return false
+ }
+ }
+
+ static func handle(url: URL) -> Bool {
+ guard let outcome = outcomeForDeepLink(url: url) else {
+ return false
+ }
+ let options = Superwall.shared.options
+ guard DevMode.isActive(options) else {
+ Logger.debug(
+ logLevel: .warn,
+ scope: .superwallCore,
+ message: "Scanned a superwall dev link, but SuperwallOptions.devServer is not set "
+ + "in this build. Set devServer to preview local paywalls in the app."
+ )
+ return false
+ }
+ guard isTrustedBase(outcome.base, devServerURL: options.devServerURL) else {
+ Logger.debug(
+ logLevel: .warn,
+ scope: .superwallCore,
+ message: "Ignoring a superwall dev link pointing at \(outcome.base.absoluteString): "
+ + "dev servers only run on localhost, .local hosts, or private-network addresses. "
+ + "To use another host, set it as SuperwallOptions.devServer's url."
+ )
+ return false
+ }
+ Task { @MainActor in
+ await open(outcome: outcome)
+ }
+ return true
+ }
+
+ @MainActor
+ private static func open(outcome: DeepLinkOutcome) async {
+ await DevServerLocator.shared.pin(base: outcome.base)
+ guard
+ let location = await DevServerLocator.shared.locate(
+ devServerURL: Superwall.shared.options.devServerURL
+ )
+ else {
+ return
+ }
+
+ guard let debugManager: DebugManager = Superwall.shared.dependencyContainer.debugManager else {
+ return
+ }
+ debugManager.devServer = (base: location.base, surfaces: location.manifest.surfaces)
+ await debugManager.launchDebugger(
+ withPaywallId: nil,
+ devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first {
+ $0.kind == "paywall"
+ }?.id
+ )
+ }
+}
diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift
new file mode 100644
index 0000000000..974f35db1e
--- /dev/null
+++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift
@@ -0,0 +1,19 @@
+//
+// DevServerSurface.swift
+// SuperwallKit
+//
+// One entry in the surface list a running `superwall dev` server exposes:
+// a locally served paywall or funnel, and the dashboard paywall it is
+// bound to via `superwall.lock`, if any.
+//
+
+import Foundation
+
+struct DevServerSurface: Decodable, Equatable {
+ let kind: String
+ let id: String
+ let url: String
+ let paywallId: String?
+ let identifier: String?
+ let products: [String: String]?
+}
diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift
index 968fea372d..7ca78bdad4 100644
--- a/Sources/SuperwallKit/Misc/Constants.swift
+++ b/Sources/SuperwallKit/Misc/Constants.swift
@@ -18,5 +18,5 @@ let sdkVersion = """
*/
let sdkVersion = """
-4.16.4
+4.17.0
"""
diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift
index 2082680480..1033735cac 100644
--- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift
+++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift
@@ -28,7 +28,7 @@ struct Paywall: Codable {
var url: URL
/// An array of potential URLs to load the paywall from.
- let urlConfig: WebViewURLConfig
+ var urlConfig: WebViewURLConfig
/// Contains the website modifications that are made on the paywall editor to be accepted
/// by the webview.
@@ -151,7 +151,7 @@ struct Paywall: Codable {
/// A listing of all the files referenced in a paywall to be able to preload the whole
/// paywall into a web archive.
- let manifest: ArchiveManifest?
+ var manifest: ArchiveManifest?
/// The state of the paywall, updated on paywall did dismiss.
var state: [String: Any] = [:]
@@ -366,8 +366,8 @@ struct Paywall: Codable {
try container.encodeIfPresent(introOfferEligibility, forKey: .introductoryOfferEligibility)
}
- // Only used in stub
- private init(
+ // Used by the stub and by `Paywall.devServer(surface:url:)`.
+ init(
databaseId: String,
identifier: String,
name: String,
diff --git a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
index c59f84159b..319fd289ad 100644
--- a/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
+++ b/Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift
@@ -592,6 +592,13 @@ class DeviceHelper {
return Self.detectSandbox()
}
+ /// Whether the app is running outside App Store production: simulator,
+ /// TestFlight, or a development build. Unlike ``isSandbox`` this ignores
+ /// test mode, so it can be used to decide whether test mode may activate.
+ static var isSandboxEnvironment: Bool {
+ return detectSandbox() == "true"
+ }
+
private static func detectSandbox() -> String {
#if targetEnvironment(simulator)
return "true"
diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift
index e4fce9a239..9fad08a314 100644
--- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift
+++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift
@@ -16,6 +16,9 @@ extension PaywallRequestManager {
placement: request.placementData
)
var paywall = try await getPaywallResponse(from: request)
+ if !request.isDebuggerLaunched {
+ paywall = await applyDevServerOverrideIfNeeded(to: paywall)
+ }
paywall.presentationId = UUID().uuidString
let paywallInfo = paywall.getInfo(fromPlacement: request.placementData)
@@ -27,6 +30,67 @@ extension PaywallRequestManager {
return paywall
}
+ func applyDevServerOverrideIfNeeded(to paywall: Paywall) async -> Paywall {
+ let options = factory.makeSuperwallOptions()
+ guard DevMode.isActive(options) else {
+ return paywall
+ }
+ guard
+ let location = await DevServerLocator.shared.locate(devServerURL: options.devServerURL),
+ let surface = location.manifest.surface(forPaywallDatabaseId: paywall.databaseId),
+ let mountURL = location.manifest.mountURL(for: surface, base: location.base)
+ else {
+ return paywall
+ }
+
+ // The local surface replaces the published paywall wholesale, so what
+ // presents is exactly what its config.ts declares — products included.
+ // Only the assignment's experiment and the fetch timings carry over,
+ // keeping holdouts and analytics coherent. The synthesized cacheKey
+ // embeds the mount URL, so a moved dev server or a published fallback
+ // reloads the web view instead of presenting the stale page.
+ var devPaywall = Paywall.devServer(surface: surface, url: mountURL)
+ devPaywall.experiment = paywall.experiment
+ devPaywall.responseLoadingInfo = paywall.responseLoadingInfo
+
+ Logger.debug(
+ logLevel: .info,
+ scope: .superwallCore,
+ message: "Dev server override: paywall \(paywall.identifier) is served as local surface "
+ + "\(surface.id) from \(mountURL.absoluteString)."
+ )
+ return devPaywall
+ }
+
+ /// Resolves a synthetic `dev:` identifier — a dev-server surface the
+ /// debugger selected that has never been pushed to the dashboard — from the
+ /// debugger's manifest, since the backend has nothing to fetch for it.
+ private func devServerPaywall(forId paywallId: String?) async -> Paywall? {
+ guard let paywallId = paywallId else {
+ return nil
+ }
+ guard paywallId.hasPrefix("dev:") else {
+ return nil
+ }
+ guard DevMode.isActive(factory.makeSuperwallOptions()) else {
+ return nil
+ }
+ guard let devServer = await MainActor.run(body: {
+ Superwall.shared.dependencyContainer.debugManager.devServer
+ }) else {
+ return nil
+ }
+ guard let surface = devServer.surfaces.first(where: { "dev:\($0.id)" == paywallId }) else {
+ return nil
+ }
+ guard let mountURL = DevServerManifest(surfaces: devServer.surfaces)
+ .mountURL(for: surface, base: devServer.base)
+ else {
+ return nil
+ }
+ return Paywall.devServer(surface: surface, url: mountURL)
+ }
+
private func getPaywallResponse(
from request: PaywallRequest
) async throws -> Paywall {
@@ -36,7 +100,9 @@ extension PaywallRequestManager {
var paywall: Paywall
do {
- if let staticPaywall = factory.makeStaticPaywall(
+ if let devPaywall = await devServerPaywall(forId: paywallId) {
+ paywall = devPaywall
+ } else if let staticPaywall = factory.makeStaticPaywall(
withId: paywallId,
isDebuggerLaunched: request.isDebuggerLaunched
) {
diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift
index 4a2ed40267..c177f12e67 100644
--- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift
+++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift
@@ -19,6 +19,7 @@ actor PaywallRequestManager {
typealias Factory = DeviceHelperFactory
& ConfigManagerFactory
& ReceiptFactory
+ & OptionsFactory
init(
storeKitManager: StoreKitManager,
@@ -125,8 +126,16 @@ actor PaywallRequestManager {
isDebuggerLaunched: Bool
) {
activeTasks[requestHash] = nil
- if !isDebuggerLaunched {
- paywallsByHash[requestHash] = paywall
+ if isDebuggerLaunched {
+ return
}
+ // The request hash carries no dev-server component, so memoising in dev
+ // mode would freeze whatever the dev server's state was at first fetch —
+ // a transient miss would pin the published paywall for the whole process.
+ // Preloading is off in dev mode, so this caching buys nothing there.
+ if DevMode.isActive(factory.makeSuperwallOptions()) {
+ return
+ }
+ paywallsByHash[requestHash] = paywall
}
}
diff --git a/Sources/SuperwallKit/TestMode/TestModeManager.swift b/Sources/SuperwallKit/TestMode/TestModeManager.swift
index 9f5db30447..ab59ab08cd 100644
--- a/Sources/SuperwallKit/TestMode/TestModeManager.swift
+++ b/Sources/SuperwallKit/TestMode/TestModeManager.swift
@@ -109,6 +109,11 @@ final class TestModeManager {
/// Evaluates whether the current user should be in test mode based on the config
/// and the `testModeBehavior` option. Called on every config refresh.
func evaluateTestMode(config: Config, options: SuperwallOptions) {
+ if DevMode.isActive(options) {
+ isTestMode = true
+ testModeReason = .testModeOption
+ return
+ }
switch options.testModeBehavior {
case .never:
isTestMode = false
diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec
index b031453c92..39380da9e4 100644
--- a/SuperwallKit.podspec
+++ b/SuperwallKit.podspec
@@ -1,7 +1,7 @@
Pod::Spec.new do |s|
s.name = "SuperwallKit"
- s.version = "4.16.4"
+ s.version = "4.17.0"
s.summary = "Superwall: In-App Paywalls Made Easy"
s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com"
diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj
index f7df29d0aa..60dfa65223 100644
--- a/SuperwallKit.xcodeproj/project.pbxproj
+++ b/SuperwallKit.xcodeproj/project.pbxproj
@@ -20,10 +20,12 @@
03EBC531CDC26957534DE46A /* PurchaseResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96237542E710511C51A39070 /* PurchaseResult.swift */; };
061A6342D61F14BD286F202C /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCAE23C483138D33A1CF8889 /* ProductsFetcherSK1.swift */; };
0661E89598C9FF5C1B8B9B13 /* String+SHA256.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FDB1F66C8DB4C53466266D8 /* String+SHA256.swift */; };
+ 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */; };
070DFAAB357CE1D547E946E1 /* PurchaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BE8AD685B39ACAB331109C /* PurchaseManager.swift */; };
074EBBBF7E3B2B207B00A275 /* SWWebViewLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4634E3B868871DD24C2555F9 /* SWWebViewLogic.swift */; };
07862D18809FA5DEA95AE440 /* NSManagedObjectContext+mergeChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B35EF62D8C986B504B052C /* NSManagedObjectContext+mergeChanges.swift */; };
07CAD1B0A849A7593B1EF6D4 /* FakeContactsAuthorizationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8708F74D80CB9A440F34A556 /* FakeContactsAuthorizationStatus.swift */; };
+ 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */; };
0911B1213899E3C4E1620119 /* Dictionary+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E9E1A8F57B5DCA4CD16296F /* Dictionary+Keys.swift */; };
097719E21BBD153BA6FD6785 /* SubscriptionPeriodPriceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF1F5EAC9C4E384EBBE5EA9 /* SubscriptionPeriodPriceTests.swift */; };
09AA071B4A67A6256D00BDD6 /* PaywallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187934D3C89220C605299A17 /* PaywallManager.swift */; };
@@ -123,6 +125,7 @@
339F1D07DB57DBEC46940DB6 /* CheckoutWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */; };
342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; };
3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; };
+ 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E37562E243AE6632134D94A /* DevServerSurface.swift */; };
35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; };
3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; };
369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; };
@@ -180,6 +183,7 @@
4EFA142D3D37B0564BDB52CB /* NetworkMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10D5ABDB23D56393EFDCF73A /* NetworkMock.swift */; };
4FE19D26711ECB7B2EE8806D /* TriggerRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481D47E5121C521DDA268609 /* TriggerRule.swift */; };
507E017DBEC2663F1B4727E0 /* Dictionary+Merging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335888285131F832E1C91A8F /* Dictionary+Merging.swift */; };
+ 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C76F083AB62E59C5DF7FEE /* DevMode.swift */; };
519196E0035C17C73C525526 /* V2Migrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6728F289EC434B1C856BD2 /* V2Migrator.swift */; };
53113582D4E7F54236FD493C /* CacheKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 865262BC55BAEA135E51698D /* CacheKeys.swift */; };
53227994995EA43A8CAFB0DA /* LocalizationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF091A5565631C9F426F304B /* LocalizationManager.swift */; };
@@ -220,6 +224,7 @@
654A73B0F1E27315DB1AE2D4 /* Redeemable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E232690489360042465DB2 /* Redeemable.swift */; };
65F02A298EC782E84EE2D1D0 /* EntitlementsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6BB83F17D20143827C28042 /* EntitlementsInfo.swift */; };
666FBAEC100FD378E9EC816D /* EntitlementsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EBE35B7BB7FEBE02C8992D8 /* EntitlementsResponse.swift */; };
+ 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */; };
67C020751429B5677D9A0727 /* IdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 236900A8A8F95CE92E612458 /* IdentityManager.swift */; };
67DE6918459F0E911D4D2D26 /* LogErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E4F7C1AA96162D7C97493E /* LogErrors.swift */; };
6838BDF35DFEB69351777883 /* MMPMatchResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BC23D4C0614CF0E9E83290 /* MMPMatchResponseTests.swift */; };
@@ -235,6 +240,7 @@
6C752C1F0F303B77FB243D10 /* LocalFileSchemeHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7A8FDBB0F8D450288C3FEA0 /* LocalFileSchemeHandlerTests.swift */; };
6C98C5DAAC3F493511A57AC3 /* WaitForEntitlementsAndConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DFD245D3CB9044C225E1503 /* WaitForEntitlementsAndConfigTests.swift */; };
6C9B0FB29EA135B4D9A20705 /* WebViewURLConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */; };
+ 6CAB392FECDE6E5F0CA2B476 /* DebugPickerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */; };
6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A50766D6FBAFA61D1121B51 /* DevicePreloadScript.swift */; };
6D60D1CC06D717C764BDE181 /* ProductPurchaserSK2.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */; };
6DA0EC8307544D09C2478EBB /* ExperimentTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC52CA0CE82A5605AFF7A075 /* ExperimentTemplate.swift */; };
@@ -263,6 +269,7 @@
77EDD2927FF8DCF95579BE3E /* IdentityLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AE19B5A9B237A2552D5F36 /* IdentityLogicTests.swift */; };
77FF632568317C7745451D67 /* ConfirmPaywallAssignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F7B4D9230BF922AE19A830 /* ConfirmPaywallAssignment.swift */; };
78113F737BA99A2848850904 /* TrackableSuperwallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */; };
+ 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */; };
7909E7B477A2E2EBF84598F2 /* InternallySetSubscriptionStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8EE9572F945C698DE4A2EAA /* InternallySetSubscriptionStatusTests.swift */; };
795E7752217DF07AD7EB8660 /* Trigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2628BCB13E80DC539F35C7B5 /* Trigger.swift */; };
79E35504745555BC5CA14360 /* SK1ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4966E857D1F9596B96910E /* SK1ReceiptManager.swift */; };
@@ -279,6 +286,7 @@
7F630637D79A1F24CC17A8A5 /* GetPaywallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFB9AEAF72391341B4BDF6CD /* GetPaywallVC.swift */; };
7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */; };
803BFA630F96B638E3BDE715 /* GameControllerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */; };
+ 8088933E13346C69FEE58919 /* DebugPickerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */; };
80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */; };
81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; };
822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; };
@@ -300,6 +308,8 @@
8BA210D88B69EA78419354E1 /* InternalPresentationLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84489E65AE8F692F620866F /* InternalPresentationLogic.swift */; };
8BBC7DE9391A8974DD5B6A32 /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7106327DAD1C9044E4A57DD5 /* ProductStore.swift */; };
8C3A81E3D75F027539933310 /* BottomPaddingAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */; };
+ 8D0B281D5CB739D6AD5EBC0D /* DebugPaywallPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */; };
+ 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7781E6093CC05F1467B431D /* DevServerLocator.swift */; };
8E5661E20F318661BB005E2F /* CustomerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2031E7FE7D2ECC7AFF8519AE /* CustomerInfo.swift */; };
8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB5B56470F69FBE1C34EAA8 /* PaywallRequest.swift */; };
8F18BFB254E432BFBEAB1324 /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3A82C80F89023672D56AD7 /* LogLevel.swift */; };
@@ -384,6 +394,7 @@
B15607185B9E4229C6C4F240 /* SK2StoreTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B96E2A1A289D96267EC0BC /* SK2StoreTransaction.swift */; };
B162BE92B3568078BC0ADD1B /* StoreProductBillingPlanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F98E1C9554F6AFAECF9B3430 /* StoreProductBillingPlanTests.swift */; };
B294572426111EC04F225289 /* MockExternalPurchaseControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */; };
+ B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */; };
B2AB1E9283FDE2D544C8BCA8 /* MockReceiptData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B81D88316F06C0C2757F10 /* MockReceiptData.swift */; };
B2AC4436371BC96FAA4FB5B3 /* CustomCallbackRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFC75AD1252D05D2033D7B0 /* CustomCallbackRegistryTests.swift */; };
B2B5684F46FB49AB9E3C1BE0 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03E47DA89C9F4FBD7FA038F5 /* Cache.swift */; };
@@ -489,6 +500,7 @@
DB6FF170AE90FF8623A31E14 /* DispatchQueueBacked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC4A0048583B47040C498B /* DispatchQueueBacked.swift */; };
DB7858A959C145FA32F6C9EC /* PaywallPresentationInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 831F679BDAC779043091DB7E /* PaywallPresentationInfoTests.swift */; };
DBF70D987418DD9EB504FBDE /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42956918D4FFA5FBA79F3AA5 /* Constants.swift */; };
+ DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */; };
DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; };
DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */; };
DE62F8E261EC7C60FBAAAE1D /* BundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34468E3988E779132CE101A /* BundleHelper.swift */; };
@@ -527,6 +539,7 @@
E9F892ABB9BDA85F4794E3CF /* SubscriptionStatusResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C15CF29C17FE1EE3BFDEDC /* SubscriptionStatusResolutionTests.swift */; };
EA50607230AA07B509E90E10 /* TestStoreUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7162E1E791297A3BF80B65A4 /* TestStoreUser.swift */; };
EA66951B1DF341C4F0448C9F /* PlacementsQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682AB10207309C439F64BC69 /* PlacementsQueueTests.swift */; };
+ EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */; };
EB1964816A8297CE133F96BF /* PurchaseError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E522F5BCABB3A95B97549E /* PurchaseError.swift */; };
EB6540A8E1ECC3548C5E6368 /* PaywallMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */; };
ECA7E9C9898CAB24B56E7054 /* SK2PriceFormatRoundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC580BF1CC720ECBC4E68A28 /* SK2PriceFormatRoundingTests.swift */; };
@@ -535,6 +548,7 @@
ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */; };
ED66539CDB2C991A812A6CC7 /* PaywallSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12EB4944354482783293010 /* PaywallSummary.swift */; };
EDAEC46845C1DB11CB4C99AE /* SWConsoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */; };
+ EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */; };
EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */; };
F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */; };
F14330769F5384B9F4FD726E /* RestorationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF71CC25A340374B0A19295 /* RestorationResult.swift */; };
@@ -569,6 +583,7 @@
FCEF5A897E34F9B7AB691C56 /* PaywallOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A64CCBCB23CC1715DF79AC /* PaywallOptions.swift */; };
FCF3B638D0D802202113DCBD /* ArchiveManifestUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884DF3D8A1CA382BFEE9F8F4 /* ArchiveManifestUsage.swift */; };
FCF498808FF38EEC5E9895DB /* IdentityOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320AA5349848F696950F441A /* IdentityOptions.swift */; };
+ FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577D25646DA26238881BF6AB /* DevModeTests.swift */; };
FFC1A413FF8B96275C4C1649 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C16CBCBF2093DD9C5F3E105 /* Storage.swift */; };
/* End PBXBuildFile section */
@@ -646,6 +661,7 @@
187934D3C89220C605299A17 /* PaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManager.swift; sourceTree = ""; };
18DB52B223C181E0A8FA1D6D /* BottomPaddingAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomPaddingAnimation.swift; sourceTree = ""; };
18E059F7745769ABCA0F2A99 /* AppStoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProduct.swift; sourceTree = ""; };
+ 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPaywallPickerViewController.swift; sourceTree = ""; };
194B8214C0A66407CEDCC0F4 /* VariantOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariantOption.swift; sourceTree = ""; };
19F010DC597017F5BEAEDE86 /* SwiftyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftyJSON.swift; sourceTree = ""; };
1A2E8A96B1F4C3C78CFA696C /* GetPresentationResultLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPresentationResultLogic.swift; sourceTree = ""; };
@@ -735,6 +751,7 @@
3CC2A1B3F139D6D01D2E8A0F /* ContactStoreProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxyTests.swift; sourceTree = ""; };
3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWConsoleViewController.swift; sourceTree = ""; };
3D8AD1A7B62E8CBBDFB65BE5 /* TrackableSuperwallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackableSuperwallEvent.swift; sourceTree = ""; };
+ 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywall.swift; sourceTree = ""; };
3E3E1BAFC4A22DC46C49F00C /* String+RemoveChars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+RemoveChars.swift"; sourceTree = ""; };
3E828EBAB18CCC0B236EF71D /* CoreDataStackMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackMock.swift; sourceTree = ""; };
405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Notification.swift"; sourceTree = ""; };
@@ -761,6 +778,7 @@
4D7749FB975F9B2B5B156328 /* es_419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es_419; path = es_419.lproj/Localizable.strings; sourceTree = ""; };
4D7EED1CCDE71C3CB5F87F84 /* PaywallPresentationStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationStyleTests.swift; sourceTree = ""; };
4E0895B5C0A26AA7FD3C0178 /* ArchivalManifestDownloaded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchivalManifestDownloaded.swift; sourceTree = ""; };
+ 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreview.swift; sourceTree = ""; };
4EB1F47410CBC84D9ABD2F14 /* AutomaticPurchaseController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticPurchaseController.swift; sourceTree = ""; };
4EC3DA8E774FBFE31F811FAF /* ConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManager.swift; sourceTree = ""; };
50458143450675EF205CE2C3 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; };
@@ -780,6 +798,7 @@
571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Cache.swift"; sourceTree = ""; };
57478172574516BD5EDD254A /* LoadingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingInfo.swift; sourceTree = ""; };
577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerManager.swift; sourceTree = ""; };
+ 577D25646DA26238881BF6AB /* DevModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevModeTests.swift; sourceTree = ""; };
57AD390BC73341A49301B4AA /* ProductsFetcherSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK2.swift; sourceTree = ""; };
57C7673988B39FB0BDEA8BE4 /* Date+IsoStringTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsoStringTests.swift"; sourceTree = ""; };
582CE0C5BA6EA57C7FE3EE43 /* CheckNoPaywallAlreadyPresented.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckNoPaywallAlreadyPresented.swift; sourceTree = ""; };
@@ -795,6 +814,7 @@
5D44CEC91693B4B900472C1C /* Survey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Survey.swift; sourceTree = ""; };
5D8D539E4636D23E549B4520 /* TestModePurchaseDrawer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModePurchaseDrawer.swift; sourceTree = ""; };
5DD4E7007670C369DD8FF5D9 /* Date+IsWithinAnHourBeforeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+IsWithinAnHourBeforeTests.swift"; sourceTree = ""; };
+ 5E37562E243AE6632134D94A /* DevServerSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerSurface.swift; sourceTree = ""; };
5EB5A772C1F2ECE6D0E0BD69 /* PaywallState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallState.swift; sourceTree = ""; };
60B80BEE0364C0EF86E2084E /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; };
61062B4B7A0AB23514A2F439 /* SwiftVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersion.swift; sourceTree = ""; };
@@ -826,6 +846,7 @@
6944763A0D07AFA102B023C5 /* PaywallManagerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallManagerLogicTests.swift; sourceTree = ""; };
69A4D77D819DDB696834E1B7 /* UIViewController+AsyncPresent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncPresent.swift"; sourceTree = ""; };
6A56D712042043783D7CA142 /* ProductPurchaserSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK1.swift; sourceTree = ""; };
+ 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerCandidates.swift; sourceTree = ""; };
6B103FA8F9AE387E7DB4B471 /* LocationPermissionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionDelegate.swift; sourceTree = ""; };
6B7CFAF4B3E32AE628A249C8 /* AttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTests.swift; sourceTree = ""; };
6B9E9E16EBDA97E736968496 /* PaywallPresentationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPresentationHandler.swift; sourceTree = ""; };
@@ -837,6 +858,7 @@
6D1887F247BF6F770122F257 /* StorageMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageMock.swift; sourceTree = ""; };
6DB09C4AF80761DF4205C4C2 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; };
6DE89E115B095A63FAC09719 /* StripeProductType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StripeProductType.swift; sourceTree = ""; };
+ 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreviewTests.swift; sourceTree = ""; };
6EDC14C0D6958144679F149D /* DecodingError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DecodingError+Extensions.swift"; sourceTree = ""; };
6F35F68AF572F7CDF174320C /* ContactStoreProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactStoreProxy.swift; sourceTree = ""; };
70D2B0D671B1A1E665B7CCD8 /* UIViewController+AsyncDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+AsyncDismiss.swift"; sourceTree = ""; };
@@ -967,12 +989,14 @@
A21ED2D8CB4DDA70E228E8BC /* TaskExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExecutor.swift; sourceTree = ""; };
A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; };
A2D40088A465E104CF5C67CC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; };
+ A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifestTests.swift; sourceTree = ""; };
A3781CF21200CD2333F6779A /* GetPaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallManager.swift; sourceTree = ""; };
A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationIdTests.swift; sourceTree = ""; };
A3F4F74393061C17CEB18F90 /* ManagedEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedEventData.swift; sourceTree = ""; };
A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Guarded.swift"; sourceTree = ""; };
A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicIdentity.swift; sourceTree = ""; };
A453816DBA43C08410D12DE6 /* PaywallViewControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewControllerMock.swift; sourceTree = ""; };
+ A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywallTests.swift; sourceTree = ""; };
A5110E43405C69969E9DA67B /* PublicGetPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicGetPaywall.swift; sourceTree = ""; };
A524F7AAE90E48C3B8D7E99A /* PurchaseResult+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurchaseResult+Internal.swift"; sourceTree = ""; };
A5C4AD6349F2D432132F36D5 /* MockSubscriptionPeriod.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSubscriptionPeriod.swift; sourceTree = ""; };
@@ -984,6 +1008,7 @@
A7D0B6F781D32B2DCDBE689C /* PermissionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandling.swift; sourceTree = ""; };
A82783401B92298C47BF14F7 /* AdServicesAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesAttributionTests.swift; sourceTree = ""; };
A94120D51C7B36AC9EA32B8B /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; };
+ A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogicTests.swift; sourceTree = ""; };
A99ACA54EC311F399BB025CD /* ProductPurchaserSK2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPurchaserSK2.swift; sourceTree = ""; };
AA0B401CD38DBD6D90E4EB3E /* CheckoutWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckoutWebViewController.swift; sourceTree = ""; };
AAEBD34CFE62DCFE0AFD80D6 /* RestoreType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreType.swift; sourceTree = ""; };
@@ -993,6 +1018,7 @@
ABD045A5C4A47B1CA9365285 /* MicrophonePermissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrophonePermissionTests.swift; sourceTree = ""; };
AC11F0D5B6B8A0F5EC5D200B /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; };
AC74F16DC5A17489E97061EA /* PushTransitionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransitionLogic.swift; sourceTree = ""; };
+ ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifest.swift; sourceTree = ""; };
AD23BBF9EA198ED8798A8F62 /* SystemInfo+NotificationName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemInfo+NotificationName.swift"; sourceTree = ""; };
AE32816F1CA1637897AC87A2 /* UNUserNotificationCenter+SuperwallNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNUserNotificationCenter+SuperwallNotifications.swift"; sourceTree = ""; };
AE406AAED11F2B63E4A5A1FD /* UserInitiatedEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserInitiatedEvents.swift; sourceTree = ""; };
@@ -1053,6 +1079,7 @@
BFE42FD8C7F01915D74D0008 /* Trackable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Trackable.swift; sourceTree = ""; };
C054891709C534F59C93C815 /* URLSessionRetryLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSessionRetryLogicTests.swift; sourceTree = ""; };
C0A53CC571B7BC3F5AAD71BF /* ReceiptLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptLogic.swift; sourceTree = ""; };
+ C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogic.swift; sourceTree = ""; };
C10310294FD27EB6A0621341 /* StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProduct.swift; sourceTree = ""; };
C1B0B4A4E57D6B7F63510D07 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; };
C22CA9431D5F791BE7A9BE27 /* Documentation.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = Documentation.docc; sourceTree = ""; };
@@ -1091,6 +1118,7 @@
CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallMessage.swift; sourceTree = ""; };
CC89718EBDD71E09AB5F41DA /* AppSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSessionManager.swift; sourceTree = ""; };
CCAE23C483138D33A1CF8889 /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK1.swift; sourceTree = ""; };
+ CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocation.swift; sourceTree = ""; };
CCFFBE357699F5CAAB803DA7 /* ManagedTriggerRuleOccurrence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedTriggerRuleOccurrence.swift; sourceTree = ""; };
CD8C0C8DA633BE856F5B9EEF /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; };
CD9298A79020030E9A1357A6 /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; };
@@ -1117,6 +1145,7 @@
D6340ACDA40937ACAC66FA3D /* EntitlementPriorityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitlementPriorityTests.swift; sourceTree = ""; };
D69BCC259F5FBE15AB02D662 /* PermissionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHandler.swift; sourceTree = ""; };
D7434029CB9E4680C85D3FB6 /* PermissionHandler+Microphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionHandler+Microphone.swift"; sourceTree = ""; };
+ D7781E6093CC05F1467B431D /* DevServerLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerLocator.swift; sourceTree = ""; };
D7B0C7BDA06D25D9D5A865A3 /* TestModeManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestModeManagerTests.swift; sourceTree = ""; };
D7E232690489360042465DB2 /* Redeemable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Redeemable.swift; sourceTree = ""; };
D81D656CEA8B5B86458038D4 /* ms */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ms; path = ms.lproj/Localizable.strings; sourceTree = ""; };
@@ -1124,6 +1153,7 @@
D86D76FB5809C3B8122778A9 /* EventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventData.swift; sourceTree = ""; };
D8749262F8F31B90DA975B26 /* AttributionTypeFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributionTypeFactory.swift; sourceTree = ""; };
D9777790D2B73EFF94E7C648 /* CheckDebuggerPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckDebuggerPresentation.swift; sourceTree = ""; };
+ D9C76F083AB62E59C5DF7FEE /* DevMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevMode.swift; sourceTree = ""; };
DA1E17A7907C42F27817C958 /* NonSubscriptionTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonSubscriptionTransaction.swift; sourceTree = ""; };
DA1F5AFA28F20FAE4D0AA3B5 /* LocalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNotification.swift; sourceTree = ""; };
DAAE50F011668C1D62B86751 /* ConfigManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigManagerMock.swift; sourceTree = ""; };
@@ -1726,6 +1756,7 @@
38C02C19ED9C9958A7A61FB1 /* Debug */ = {
isa = PBXGroup;
children = (
+ A97EAA0E1AE80E91ACDB9AEB /* DebugPickerLogicTests.swift */,
C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */,
);
path = Debug;
@@ -2280,6 +2311,7 @@
97F6AA52B81B82F72AB80D7C /* Debug */,
9C21AAF80FD220C960FE568F /* Delegate */,
A34416D82C5BDBAA82A119C1 /* Dependencies */,
+ A9BD6565A1CB1609A86DF616 /* DevServer */,
5943C0902B0D5EC30C0C3B8C /* Game Controller */,
C36C5C30F60C1DFCDCF25E16 /* Graveyard */,
66F9C998E9BBCFFCF80386FE /* Identity */,
@@ -2446,6 +2478,8 @@
isa = PBXGroup;
children = (
5383FA48A6E9EF8F30683C9B /* DebugManager.swift */,
+ 19162D473A3E154574733AA2 /* DebugPaywallPickerViewController.swift */,
+ C0ED0B91277B33BB56F9DFA6 /* DebugPickerLogic.swift */,
F9098101E599AEB01521FE89 /* DebugViewController.swift */,
299F91895EE88281B5ED8320 /* SWBounceButton.swift */,
3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */,
@@ -2575,6 +2609,21 @@
path = Logic;
sourceTree = "";
};
+ A9BD6565A1CB1609A86DF616 /* DevServer */ = {
+ isa = PBXGroup;
+ children = (
+ D9C76F083AB62E59C5DF7FEE /* DevMode.swift */,
+ 6A970BB7B4C3063A22F0B252 /* DevServerCandidates.swift */,
+ CCD506DA245EEFB3B0DB8D4E /* DevServerLocation.swift */,
+ D7781E6093CC05F1467B431D /* DevServerLocator.swift */,
+ ACAF662B855E4A70DDEE66F6 /* DevServerManifest.swift */,
+ 3DE9BCE12E3F4300AD2379B4 /* DevServerPaywall.swift */,
+ 4E9B7111D8087FC1DF3E6B80 /* DevServerPreview.swift */,
+ 5E37562E243AE6632134D94A /* DevServerSurface.swift */,
+ );
+ path = DevServer;
+ sourceTree = "";
+ };
AC10C98F9F6D03AD79376ADE /* Capabilities */ = {
isa = PBXGroup;
children = (
@@ -2646,6 +2695,17 @@
path = Attribution;
sourceTree = "";
};
+ B2625661B3ACD184CFAF2390 /* DevServer */ = {
+ isa = PBXGroup;
+ children = (
+ 577D25646DA26238881BF6AB /* DevModeTests.swift */,
+ A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */,
+ A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */,
+ 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */,
+ );
+ path = DevServer;
+ sourceTree = "";
+ };
B26ABA960333F1BE7CAF7FAF /* Config */ = {
isa = PBXGroup;
children = (
@@ -2719,6 +2779,7 @@
38C02C19ED9C9958A7A61FB1 /* Debug */,
3B16D25FCB6991D55E0F63B3 /* DeepLink */,
E9FF04AB866B9CCA7DFBE592 /* Dependencies */,
+ B2625661B3ACD184CFAF2390 /* DevServer */,
373AFF230833A951B6E5DF36 /* Identity */,
8DD7B7C5E111EAB0878886B6 /* Logger */,
4D7656D6A565958F58A644AF /* Misc */,
@@ -3301,8 +3362,13 @@
37FDB46DD55E649FA10D753C /* CustomerInfoDecodingTests.swift in Sources */,
654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */,
D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */,
+ 8088933E13346C69FEE58919 /* DebugPickerLogicTests.swift in Sources */,
01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */,
4ABF9FB54105917343865145 /* DependencyContainerInitTests.swift in Sources */,
+ FEA3AED0B70D730993A16B2C /* DevModeTests.swift in Sources */,
+ 069391992F6191874022F2BA /* DevServerManifestTests.swift in Sources */,
+ 669B86B82B4CCD7BC7D02B55 /* DevServerPaywallTests.swift in Sources */,
+ EE1A7003F266DF3C1481EEBA /* DevServerPreviewTests.swift in Sources */,
0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */,
9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */,
2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */,
@@ -3485,10 +3551,20 @@
B4C9FECB4894151E42A34F85 /* Date+TimeIntervalMilliseconds.swift in Sources */,
F326AADBFE0083F8F18E81CE /* Date+WithinAnHourBefore.swift in Sources */,
432FB2AE172725B4ED208416 /* DebugManager.swift in Sources */,
+ 8D0B281D5CB739D6AD5EBC0D /* DebugPaywallPickerViewController.swift in Sources */,
+ 6CAB392FECDE6E5F0CA2B476 /* DebugPickerLogic.swift in Sources */,
11FF1373BAAE0B019CE6AE21 /* DebugViewController.swift in Sources */,
B5AE7BCDCB6D49FC8493D55B /* DecodingError+Extensions.swift in Sources */,
CB2F2B4DA3709F171E54CBB8 /* DeepLinkRouter.swift in Sources */,
3F4BE7ECC80EEA757454F9B6 /* DependencyContainer.swift in Sources */,
+ 50E0E5F4B476F2F5B8DF299E /* DevMode.swift in Sources */,
+ B29A93B51FE9421DD5E271C2 /* DevServerCandidates.swift in Sources */,
+ DC1E01DEAD4D0E2F59CBCEF0 /* DevServerLocation.swift in Sources */,
+ 8D22B4A1500BF56E91DC731F /* DevServerLocator.swift in Sources */,
+ 789B734B60F87DBC23FC7930 /* DevServerManifest.swift in Sources */,
+ EA6F422EB1C1F0E6882E4AEF /* DevServerPaywall.swift in Sources */,
+ 08C89125100BC25CE015A7B6 /* DevServerPreview.swift in Sources */,
+ 346A77D3F31E471EB7CC4D5C /* DevServerSurface.swift in Sources */,
7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */,
191AA8FBBF617251EF6F8628 /* DeviceInfo.swift in Sources */,
6CF900F9770237D75585A681 /* DevicePreloadScript.swift in Sources */,
diff --git a/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift
new file mode 100644
index 0000000000..f64f3f99d6
--- /dev/null
+++ b/Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift
@@ -0,0 +1,81 @@
+//
+// DebugPickerLogicTests.swift
+// SuperwallKitTests
+//
+
+import XCTest
+@testable import SuperwallKit
+
+final class DebugPickerLogicTests: XCTestCase {
+ func test_splitsLocalSurfacesAndPublishedPaywallsIntoSections() {
+ let sections = DebugPickerLogic.sections(
+ localSurfaceIds: ["chatgpt-plus", "pro"],
+ publishedNames: ["Winback", "Onboarding"],
+ selectedLocalId: "pro",
+ selectedPublishedIndex: nil
+ )
+ XCTAssertEqual(sections.map { $0.title }, [
+ DebugPickerLogic.localTitle,
+ DebugPickerLogic.publishedTitle
+ ])
+ XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus", "pro"])
+ XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 0), .local(index: 1)])
+ XCTAssertEqual(sections[1].rows.map { $0.kind }, [.published(index: 0), .published(index: 1)])
+ }
+
+ func test_marksTheShowingPaywall() {
+ let local = DebugPickerLogic.sections(
+ localSurfaceIds: ["pro"],
+ publishedNames: ["Winback"],
+ selectedLocalId: "pro",
+ selectedPublishedIndex: 0
+ )
+ XCTAssertTrue(local[0].rows[0].isSelected)
+ // a local surface is on screen, so no published row is marked
+ XCTAssertFalse(local[1].rows[0].isSelected)
+
+ let published = DebugPickerLogic.sections(
+ localSurfaceIds: [],
+ publishedNames: ["Winback", "Onboarding"],
+ selectedLocalId: nil,
+ selectedPublishedIndex: 1
+ )
+ XCTAssertEqual(published[0].rows.filter { $0.isSelected }.map { $0.title }, ["Onboarding"])
+ }
+
+ func test_searchFiltersBothSectionsAndDropsEmptyOnes() {
+ let sections = DebugPickerLogic.sections(
+ localSurfaceIds: ["chatgpt-plus", "pro"],
+ publishedNames: ["Winback"],
+ selectedLocalId: nil,
+ selectedPublishedIndex: nil,
+ query: " CHAT "
+ )
+ XCTAssertEqual(sections.count, 1)
+ XCTAssertEqual(sections[0].title, DebugPickerLogic.localTitle)
+ XCTAssertEqual(sections[0].rows.map { $0.title }, ["chatgpt-plus"])
+ // the row still points at its original index, not the filtered one
+ XCTAssertEqual(sections[0].rows[0].kind, .local(index: 0))
+ }
+
+ func test_keepsIndicesStableWhenSearchHidesEarlierRows() {
+ let sections = DebugPickerLogic.sections(
+ localSurfaceIds: ["alpha", "beta", "gamma"],
+ publishedNames: [],
+ selectedLocalId: nil,
+ selectedPublishedIndex: nil,
+ query: "gamma"
+ )
+ XCTAssertEqual(sections[0].rows.map { $0.kind }, [.local(index: 2)])
+ }
+
+ func test_omitsASectionWithNothingInIt() {
+ let sections = DebugPickerLogic.sections(
+ localSurfaceIds: [],
+ publishedNames: ["Winback"],
+ selectedLocalId: nil,
+ selectedPublishedIndex: 0
+ )
+ XCTAssertEqual(sections.map { $0.title }, [DebugPickerLogic.publishedTitle])
+ }
+}
diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift
index 3d2be91593..1c4401500a 100644
--- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift
+++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift
@@ -95,6 +95,15 @@ struct DeepLinkRouterTests {
#expect(result == false)
}
+ // MARK: - Dev Server Preview URLs
+
+ @Test("Returns false for a superwall_dev link when no dev server is set")
+ func storeDeepLink_devServerLink_devServerOff() {
+ let url = URL(string: "myapp://?superwall_dev=http://localhost:6100")!
+ let result = DeepLinkRouter.storeDeepLink(url)
+ #expect(result == false)
+ }
+
// MARK: - Non-Superwall URLs
@Test("Returns false for generic app URL")
diff --git a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift
new file mode 100644
index 0000000000..0fdaa1acb8
--- /dev/null
+++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift
@@ -0,0 +1,47 @@
+//
+// DevModeTests.swift
+// SuperwallKitTests
+//
+
+import XCTest
+@testable import SuperwallKit
+
+final class DevModeTests: XCTestCase {
+ override func tearDown() {
+ DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment }
+ super.tearDown()
+ }
+
+ private func options(devServer: SuperwallOptions.DevServer? = nil) -> SuperwallOptions {
+ let options = SuperwallOptions()
+ options.devServer = devServer
+ return options
+ }
+
+ func test_isInactiveWhenNobodyAskedForIt() {
+ DevMode.isSandboxEnvironment = { true }
+ XCTAssertFalse(DevMode.isActive(options()))
+ }
+
+ func test_isActiveInSandboxWhenTheToggleIsOn() {
+ DevMode.isSandboxEnvironment = { true }
+ XCTAssertTrue(DevMode.isActive(options(devServer: .default)))
+ }
+
+ /// The one that matters: an App Store build must behave as if dev mode was
+ /// never set, so purchases stay real and paywalls stay published.
+ func test_isInertInProductionEvenWhenTheToggleIsOn() {
+ DevMode.isSandboxEnvironment = { false }
+ XCTAssertFalse(DevMode.isActive(options(devServer: .default)))
+ }
+
+ func test_anExplicitDevServerUrlIsAlsoGated() throws {
+ let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100"))
+
+ DevMode.isSandboxEnvironment = { true }
+ XCTAssertTrue(DevMode.isActive(options(devServer: .url(url))))
+
+ DevMode.isSandboxEnvironment = { false }
+ XCTAssertFalse(DevMode.isActive(options(devServer: .url(url))))
+ }
+}
diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift
new file mode 100644
index 0000000000..88ce07bb20
--- /dev/null
+++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift
@@ -0,0 +1,133 @@
+//
+// DevServerManifestTests.swift
+// SuperwallKitTests
+//
+
+import XCTest
+@testable import SuperwallKit
+
+final class DevServerManifestTests: XCTestCase {
+ private func manifest(_ json: String) throws -> DevServerManifest {
+ return try JSONDecoder().decode(DevServerManifest.self, from: Data(json.utf8))
+ }
+
+ func test_decodesManifestJson() throws {
+ let decoded = try manifest("""
+ {
+ "surfaces": [
+ { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" },
+ { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" }
+ ]
+ }
+ """)
+ XCTAssertEqual(decoded.surfaces.count, 2)
+ XCTAssertEqual(decoded.surfaces[0].paywallId, "12345")
+ XCTAssertNil(decoded.surfaces[1].paywallId)
+ }
+
+ func test_boundPaywallWinsOverSingleFallback() throws {
+ let decoded = try manifest("""
+ {
+ "surfaces": [
+ { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "12345" },
+ { "kind": "paywall", "id": "max", "url": "/preview/paywall/max", "paywallId": "678" }
+ ]
+ }
+ """)
+ XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "678")?.id, "max")
+ }
+
+ func test_singlePaywallServesEveryDatabaseId() throws {
+ let decoded = try manifest("""
+ {
+ "surfaces": [
+ { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" },
+ { "kind": "funnel", "id": "onboarding", "url": "/preview/funnel/onboarding" }
+ ]
+ }
+ """)
+ XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "anything")?.id, "pro")
+ }
+
+ func test_severalUnboundPaywallsMatchNothing() throws {
+ let decoded = try manifest("""
+ {
+ "surfaces": [
+ { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" },
+ { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" }
+ ]
+ }
+ """)
+ XCTAssertNil(decoded.surface(forPaywallDatabaseId: "anything"))
+ }
+
+ func test_candidatesDefaultToLocalhostAcrossTheDevPortRange() {
+ let bases = DevServerCandidates.bases(devServerURL: nil)
+ XCTAssertEqual(
+ bases.map { $0.absoluteString },
+ (6100...6104).map { "http://localhost:\($0)" }
+ )
+ }
+
+ func test_anExplicitDevServerUrlIsTheOnlyCandidate() throws {
+ let url = try XCTUnwrap(URL(string: "http://192.168.1.10:7000"))
+ XCTAssertEqual(DevServerCandidates.bases(devServerURL: url), [url])
+ }
+
+ func test_decodesTheIdentifierWhenTheManifestCarriesIt() throws {
+ let decoded = try manifest("""
+ { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro", "paywallId": "1", "identifier": "pro-slug" }] }
+ """)
+ XCTAssertEqual(decoded.surfaces.first?.identifier, "pro-slug")
+ }
+
+ func test_devLinkOutcomeParsesBaseAndOptionalSurface() throws {
+ let base = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=http://192.168.1.10:6100"))
+ let outcome = try XCTUnwrap(DevServerPreview.outcomeForDeepLink(url: base))
+ XCTAssertEqual(outcome.base.absoluteString, "http://192.168.1.10:6100")
+ XCTAssertNil(outcome.surfaceId)
+
+ let direct = try XCTUnwrap(URL(
+ string: "exampleapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=chatgpt-plus"
+ ))
+ XCTAssertEqual(
+ DevServerPreview.outcomeForDeepLink(url: direct)?.surfaceId,
+ "chatgpt-plus"
+ )
+ }
+
+ func test_devLinkOutcomeRejectsNonHttpBasesAndOtherLinks() throws {
+ let js = try XCTUnwrap(URL(string: "exampleapp://?superwall_dev=javascript:alert(1)"))
+ XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: js))
+ let debug = try XCTUnwrap(URL(string: "exampleapp://?superwall_debug=true&token=abc"))
+ XCTAssertNil(DevServerPreview.outcomeForDeepLink(url: debug))
+ }
+
+ func test_mountUrlResolvesAgainstTheDevServerOrigin() throws {
+ let decoded = try manifest("""
+ { "surfaces": [{ "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }] }
+ """)
+ let surface = try XCTUnwrap(decoded.surfaces.first)
+ let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100"))
+ XCTAssertEqual(
+ decoded.mountURL(for: surface, base: base)?.absoluteString,
+ "http://192.168.1.10:6100/preview/paywall/pro"
+ )
+ }
+
+ func test_mountUrlRejectsSurfacesPointingOffTheDevServerOrigin() throws {
+ let decoded = try manifest("""
+ {
+ "surfaces": [
+ { "kind": "paywall", "id": "absolute", "url": "https://evil.example.com/x" },
+ { "kind": "paywall", "id": "protocol-relative", "url": "//evil.example.com/x" },
+ { "kind": "paywall", "id": "other-port", "url": "http://192.168.1.10:9999/x" }
+ ]
+ }
+ """)
+ let base = try XCTUnwrap(URL(string: "http://192.168.1.10:6100"))
+ for surface in decoded.surfaces {
+ XCTAssertNil(decoded.mountURL(for: surface, base: base), surface.id)
+ }
+ }
+}
diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift
new file mode 100644
index 0000000000..656a90f9c2
--- /dev/null
+++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift
@@ -0,0 +1,75 @@
+//
+// DevServerPaywallTests.swift
+// SuperwallKitTests
+//
+
+import XCTest
+@testable import SuperwallKit
+
+final class DevServerPaywallTests: XCTestCase {
+ private func surface(
+ id: String = "pro",
+ paywallId: String? = nil,
+ identifier: String? = nil,
+ products: [String: String]? = nil
+ ) -> DevServerSurface {
+ let json = """
+ {
+ "kind": "paywall",
+ "id": "\(id)",
+ "url": "/preview/paywall/\(id)",
+ \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "")
+ \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "")
+ "products": \(products.map { dict in
+ "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}"
+ } ?? "null")
+ }
+ """
+ // swiftlint:disable:next force_try
+ return try! JSONDecoder().decode(DevServerSurface.self, from: Data(json.utf8))
+ }
+
+ private let url = URL(string: "http://localhost:6100/preview/paywall/pro")!
+
+ func test_pointsEveryUrlAtTheDevServerAndDisablesTheArchive() {
+ let paywall = Paywall.devServer(surface: surface(), url: url)
+
+ XCTAssertEqual(paywall.url, url)
+ XCTAssertEqual(paywall.urlConfig.endpoints.map { $0.url }, [url])
+ XCTAssertEqual(paywall.urlConfig.maxAttempts, 1)
+ // a local build is never the archived, published bytes
+ XCTAssertNil(paywall.manifest)
+ XCTAssertFalse(paywall.isUsingManifest)
+ }
+
+ func test_carriesTheProductsTheSurfaceDeclares() {
+ let paywall = Paywall.devServer(
+ surface: surface(products: ["plus": "chatgpt_plus_1999_month", "go": "chatgpt_go_999_month"]),
+ url: url
+ )
+
+ // sorted by reference name, so the order is stable across runs
+ XCTAssertEqual(paywall.products.map { $0.name }, ["go", "plus"])
+ XCTAssertEqual(paywall.productIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"])
+ XCTAssertEqual(paywall.appStoreProductIds, ["chatgpt_go_999_month", "chatgpt_plus_1999_month"])
+ }
+
+ func test_worksForASurfaceThatHasNeverBeenPushed() {
+ let paywall = Paywall.devServer(surface: surface(id: "draft"), url: url)
+
+ XCTAssertEqual(paywall.name, "draft")
+ XCTAssertTrue(paywall.databaseId.contains("draft"))
+ XCTAssertTrue(paywall.identifier.contains("draft"))
+ XCTAssertTrue(paywall.products.isEmpty)
+ }
+
+ func test_keepsTheDashboardIdentityOfAPushedSurface() {
+ let paywall = Paywall.devServer(
+ surface: surface(paywallId: "253583", identifier: "chatgpt-plus"),
+ url: url
+ )
+
+ XCTAssertEqual(paywall.databaseId, "253583")
+ XCTAssertEqual(paywall.identifier, "chatgpt-plus")
+ }
+}
diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift
new file mode 100644
index 0000000000..bfa03db849
--- /dev/null
+++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift
@@ -0,0 +1,100 @@
+//
+// DevServerPreviewTests.swift
+// SuperwallKitTests
+//
+
+import Foundation
+import Testing
+@testable import SuperwallKit
+
+@Suite(.serialized)
+struct DevServerPreviewTests {
+ private func options(
+ devServer: SuperwallOptions.DevServer? = .default
+ ) -> SuperwallOptions {
+ let options = SuperwallOptions()
+ options.devServer = devServer
+ return options
+ }
+
+ // MARK: - Deep link parsing
+
+ @Test("Parses the base and surface from a dev link")
+ func outcome_parsesBaseAndSurface() throws {
+ let url = try #require(
+ URL(string: "myapp://?superwall_dev=http://localhost:6100&superwall_dev_surface=pro")
+ )
+ let outcome = try #require(DevServerPreview.outcomeForDeepLink(url: url))
+ #expect(outcome.base.absoluteString == "http://localhost:6100")
+ #expect(outcome.surfaceId == "pro")
+ }
+
+ // MARK: - Trusted bases
+
+ @Test(
+ "Hosts superwall dev can print are trusted",
+ arguments: [
+ "http://localhost:6100",
+ "http://127.0.0.1:6100",
+ "http://[::1]:6100",
+ "http://yusufs-macbook.local:6100",
+ "http://10.0.1.5:6100",
+ "http://172.20.10.2:6100",
+ "http://192.168.1.10:6100",
+ "http://169.254.5.5:6100"
+ ]
+ )
+ func trustedBase_privateHosts(base: String) throws {
+ let url = try #require(URL(string: base))
+ #expect(DevServerPreview.isTrustedBase(url, devServerURL: nil))
+ }
+
+ @Test(
+ "Arbitrary internet hosts are not trusted",
+ arguments: [
+ "https://evil.example.com",
+ "http://8.8.8.8:6100",
+ "http://172.32.0.1:6100",
+ "http://10.0.0.1.evil.example.com:6100"
+ ]
+ )
+ func trustedBase_publicHosts(base: String) throws {
+ let url = try #require(URL(string: base))
+ #expect(!DevServerPreview.isTrustedBase(url, devServerURL: nil))
+ }
+
+ @Test("The developer-supplied devServerURL is trusted wherever it points")
+ func trustedBase_matchingDevServerURL() throws {
+ let devServerURL = try #require(URL(string: "https://tunnel.example.com:8443"))
+ let matching = try #require(URL(string: "https://tunnel.example.com:8443"))
+ let otherPort = try #require(URL(string: "https://tunnel.example.com:9999"))
+ #expect(DevServerPreview.isTrustedBase(matching, devServerURL: devServerURL))
+ #expect(!DevServerPreview.isTrustedBase(otherPort, devServerURL: devServerURL))
+ }
+
+ // MARK: - canHandle
+
+ @Test("A dev link is not Superwall's when no dev server is set")
+ func canHandle_devServerOff() throws {
+ let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100"))
+ #expect(!DevServerPreview.canHandle(url: url, options: options(devServer: nil)))
+ }
+
+ @Test("A dev link pointing at a local host is Superwall's when a dev server is set")
+ func canHandle_devServerOnLocalHost() throws {
+ DevMode.isSandboxEnvironment = { true }
+ defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } }
+
+ let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100"))
+ #expect(DevServerPreview.canHandle(url: url, options: options()))
+ }
+
+ @Test("A dev link pointing at an internet host is refused even with a dev server set")
+ func canHandle_devServerOnPublicHost() throws {
+ DevMode.isSandboxEnvironment = { true }
+ defer { DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } }
+
+ let url = try #require(URL(string: "myapp://?superwall_dev=https://evil.example.com"))
+ #expect(!DevServerPreview.canHandle(url: url, options: options()))
+ }
+}