From 6519c3cf78a505292a2877dc4bf2afb24ef5e22f Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Mon, 24 Aug 2026 15:41:35 +0200 Subject: [PATCH 01/21] feat: add dev mode for previewing local paywalls --- CHANGELOG.md | 6 + Examples/Basic/Basic/Info.plist | 7 + .../SuperwallKit/Config/ConfigManager.swift | 30 ++- .../Config/Options/SuperwallOptions.swift | 27 +++ Sources/SuperwallKit/Debug/DebugManager.swift | 24 ++- .../DebugPaywallPickerViewController.swift | 172 ++++++++++++++++++ .../SuperwallKit/Debug/DebugPickerLogic.swift | 63 +++++++ .../Debug/DebugViewController.swift | 167 +++++++++++++---- Sources/SuperwallKit/DeepLinkRouter.swift | 8 + Sources/SuperwallKit/DevServer/DevMode.swift | 46 +++++ .../DevServer/DevServerManifest.swift | 163 +++++++++++++++++ .../DevServer/DevServerPaywall.swift | 55 ++++++ .../DevServer/DevServerPreview.swift | 76 ++++++++ .../SuperwallKit/Models/Paywall/Paywall.swift | 8 +- .../Network/Device Helper/DeviceHelper.swift | 7 + .../Operators/RawPaywallResponse.swift | 38 ++++ .../Request/PaywallRequestManager.swift | 1 + .../TestMode/TestModeManager.swift | 5 + SuperwallKit.xcodeproj/project.pbxproj | 56 ++++++ .../Debug/DebugPickerLogicTests.swift | 81 +++++++++ .../DevServer/DevModeTests.swift | 48 +++++ .../DevServer/DevServerManifestTests.swift | 117 ++++++++++++ .../DevServer/DevServerPaywallTests.swift | 75 ++++++++ 23 files changed, 1235 insertions(+), 45 deletions(-) create mode 100644 Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift create mode 100644 Sources/SuperwallKit/Debug/DebugPickerLogic.swift create mode 100644 Sources/SuperwallKit/DevServer/DevMode.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerManifest.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerPaywall.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerPreview.swift create mode 100644 Tests/SuperwallKitTests/Debug/DebugPickerLogicTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevModeTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3179377bd1..044a0636e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## Unreleased + +### Enhancements + +- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. + ## 4.16.4 ### Fixes 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..8689eb1f6c 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,9 @@ 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 +730,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..525988b233 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -388,6 +388,33 @@ public final class SuperwallOptions: NSObject, Encodable { /// - `.always`: Test mode is always activated, regardless of configuration. public var testModeBehavior: TestModeBehavior = .automatic + /// Connects this SDK instance to a running `superwall dev` server, for development builds only. + /// + /// Every paywall the SDK would present then renders from the dev server's live, local + /// paywall code instead of its published version, while configuration, placements, + /// audience evaluation and assignment all stay real. On a simulator this finds the dev + /// server on `localhost` automatically; on a physical device set ``devServerURL`` to + /// the `Device` URL that `superwall dev` prints. + /// + /// Dev mode 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`). + public var devMode = false + + /// Where ``devMode`` looks for the `superwall dev` server. Setting this implies ``devMode``. + /// + /// Defaults to `localhost` ports 6100–6104, which reaches a dev server running on the + /// same machine from a simulator. On a physical device set this to the `Device` URL's + /// origin that `superwall dev` prints, e.g. `http://192.168.1.10:6100`. + @nonobjc public var devServerURL: URL? + + var isDevModeEnabled: Bool { + return devMode || devServerURL != 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..dcd0b5891d 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,30 @@ 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 +275,9 @@ 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 withTimeout(seconds: 3) { + await self.storeKitManager.getProductVariables(for: paywall) + } ?? [] self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) @@ -335,41 +364,113 @@ final class DebugViewController: UIViewController { } } - @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) ✓" + private func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async -> T + ) async -> T? { + return await withTaskGroup(of: T?.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil } + let result = await group.next() ?? nil + group.cancelAll() + return result + } + } - 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 + /// 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 } + } - presentAlert( - title: nil, - message: "Your Paywalls", - options: options, - on: previewPickerButton - ) + /// 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 withTimeout(seconds: 3) { + await self.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) + } + } + + @objc func pressedPreview() { + let devSurfaces = devServer?.surfaces ?? [] + let published = publishedPaywalls + guard !devSurfaces.isEmpty || published.count > 1 || paywallDatabaseId == nil else { + return + } + + 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 + } + 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() } + } + + 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..db950a3f05 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,10 @@ final class DeepLinkRouter { return true } + if DevServerPreview.outcomeForDeepLink(url: url) != nil { + 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..21134330cf --- /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.isDevModeEnabled else { + return false + } + guard isSandboxEnvironment() else { + warnAboutProduction() + return false + } + return true + } + + private static func warnAboutProduction() { + guard !hasWarnedAboutProduction else { + return + } + hasWarnedAboutProduction = true + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "SuperwallOptions.devMode is on in a production build, so it is being ignored: " + + "paywalls load their published versions and purchases are real. " + + "Remove devMode before shipping." + ) + } +} diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift new file mode 100644 index 0000000000..20640d3690 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -0,0 +1,163 @@ +// +// 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 DevServerSurface: Decodable, Equatable { + let kind: String + let id: String + let url: String + let paywallId: String? + let identifier: String? + let products: [String: String]? +} + +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? { + return URL(string: surface.url, relativeTo: base)?.absoluteURL + } +} + +struct DevServerLocation: Equatable { + let base: URL + let manifest: DevServerManifest +} + +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)") } + } +} + +actor DevServerLocator { + static let shared = DevServerLocator() + + private var cached: (location: DevServerLocation, fetchedAt: Date)? + private var lastMissAt: Date? + private var pinnedBase: URL? + + 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.sort { first, _ in first == cached.location.base } + } + + 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.devServerURL to the Device URL superwall dev prints." + ) + return nil + } + + private var hasWarnedAboutTransportSecurity = false + + /// 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, + !hasWarnedAboutTransportSecurity + else { + 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/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..0c46780a70 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -0,0 +1,76 @@ +// +// 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? + } + + static func outcomeForDeepLink(url: URL) -> DeepLinkOutcome? { + guard + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let items = components.queryItems, + let raw = items.first(where: { $0.name == "superwall_dev" })?.value, + let base = URL(string: raw), + base.scheme == "http" || base.scheme == "https" + else { + return nil + } + let surfaceId = items.first(where: { $0.name == "superwall_dev_surface" })?.value + return DeepLinkOutcome(base: base, surfaceId: surfaceId) + } + + static func handle(url: URL) -> Bool { + guard let outcome = outcomeForDeepLink(url: url) else { + return false + } + Task { @MainActor in + await open(outcome: outcome) + } + return true + } + + @MainActor + private static func open(outcome: DeepLinkOutcome) async { + guard DevMode.isActive(Superwall.shared.options) else { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Scanned a superwall dev link, but SuperwallOptions.devMode is off " + + "in this build. Enable devMode to preview local paywalls in the app." + ) + return + } + 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(where: { + $0.kind == "paywall" + })?.id + ) + } +} 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..6e7e161e8e 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,41 @@ 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 + } + + var paywall = paywall + paywall.url = mountURL + paywall.urlConfig = WebViewURLConfig( + endpoints: [ + WebViewEndpoint( + url: mountURL, + timeout: 15, + percentage: 100 + ) + ], + maxAttempts: 1 + ) + paywall.manifest = nil + + Logger.debug( + logLevel: .info, + scope: .superwallCore, + message: "Dev server override: paywall \(paywall.identifier) renders from \(mountURL.absoluteString)." + ) + return paywall + } + private func getPaywallResponse( from request: PaywallRequest ) async throws -> Paywall { diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 4a2ed40267..6ff30e7764 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, 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.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 817f1bf3cc..1e4c1f4e82 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -249,6 +249,16 @@ 744F0D34C800E17CF8462820 /* URLSessionRetryLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C054891709C534F59C93C815 /* URLSessionRetryLogicTests.swift */; }; 746FCA3A2499F7BAF653F205 /* PermissionHandler+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405C59153A88E6B9D664585A /* PermissionHandler+Notification.swift */; }; 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */; }; + DE05E27E00000000000001A2 /* DevServerManifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A1 /* DevServerManifest.swift */; }; + DE05E27E00000000000001A5 /* DevServerPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A4 /* DevServerPreview.swift */; }; + DE05E27E00000000000001A7 /* DevServerPaywall.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A6 /* DevServerPaywall.swift */; }; + DE05E27E00000000000001AD /* DevMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001AC /* DevMode.swift */; }; + DE05E27E00000000000001A9 /* DebugPickerLogic.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001A8 /* DebugPickerLogic.swift */; }; + DE05E27E00000000000001AB /* DebugPaywallPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */; }; + DE05E27E00000000000001B5 /* DebugPickerLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */; }; + DE05E27E00000000000001B2 /* DevServerManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B1 /* DevServerManifestTests.swift */; }; + DE05E27E00000000000001B7 /* DevModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B6 /* DevModeTests.swift */; }; + DE05E27E00000000000001B9 /* DevServerPaywallTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */; }; 749117DF0A2364453CCED102 /* LocalizationOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7FFF0EDFF6DA910E4B5CCB7 /* LocalizationOption.swift */; }; 7494124F44F712EC7138C7DF /* UserAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52A0EFBBFE9D2F949EA4C28 /* UserAttributes.swift */; }; 75083E470EB6E25E01F4F28B /* AsyncSequence+Extract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B1DC32C4ABB60B8323E5D28 /* AsyncSequence+Extract.swift */; }; @@ -1016,6 +1026,16 @@ B48AAFA27917F0BE3ADC6FFB /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; B52A0EFBBFE9D2F949EA4C28 /* UserAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAttributes.swift; sourceTree = ""; }; B5637C2D7DDA38C11E48DD1C /* RawPaywallResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawPaywallResponse.swift; sourceTree = ""; }; + DE05E27E00000000000001A1 /* DevServerManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifest.swift; sourceTree = ""; }; + DE05E27E00000000000001A4 /* DevServerPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPreview.swift; sourceTree = ""; }; + DE05E27E00000000000001A6 /* DevServerPaywall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywall.swift; sourceTree = ""; }; + DE05E27E00000000000001AC /* DevMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevMode.swift; sourceTree = ""; }; + DE05E27E00000000000001A8 /* DebugPickerLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogic.swift; sourceTree = ""; }; + DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPaywallPickerViewController.swift; sourceTree = ""; }; + DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugPickerLogicTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B1 /* DevServerManifestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerManifestTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B6 /* DevModeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevModeTests.swift; sourceTree = ""; }; + DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevServerPaywallTests.swift; sourceTree = ""; }; B634347011742D475E3F1A27 /* ConfigLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigLogic.swift; sourceTree = ""; }; B6EB705DC16CB1AC24B75BA7 /* pt_PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt_PT; path = pt_PT.lproj/Localizable.strings; sourceTree = ""; }; B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallRequestBody.swift; sourceTree = ""; }; @@ -1725,6 +1745,7 @@ isa = PBXGroup; children = ( C733A9BE56EA9E10D75B073B /* SWDebugManagerLogicTests.swift */, + DE05E27E00000000000001B4 /* DebugPickerLogicTests.swift */, ); path = Debug; sourceTree = ""; @@ -2278,6 +2299,7 @@ 97F6AA52B81B82F72AB80D7C /* Debug */, 9C21AAF80FD220C960FE568F /* Delegate */, A34416D82C5BDBAA82A119C1 /* Dependencies */, + DE05E27E00000000000001A3 /* DevServer */, 5943C0902B0D5EC30C0C3B8C /* Game Controller */, C36C5C30F60C1DFCDCF25E16 /* Graveyard */, 66F9C998E9BBCFFCF80386FE /* Identity */, @@ -2296,6 +2318,27 @@ path = SuperwallKit; sourceTree = ""; }; + DE05E27E00000000000001A3 /* DevServer */ = { + isa = PBXGroup; + children = ( + DE05E27E00000000000001A1 /* DevServerManifest.swift */, + DE05E27E00000000000001A4 /* DevServerPreview.swift */, + DE05E27E00000000000001A6 /* DevServerPaywall.swift */, + DE05E27E00000000000001AC /* DevMode.swift */, + ); + path = DevServer; + sourceTree = ""; + }; + DE05E27E00000000000001B3 /* DevServer */ = { + isa = PBXGroup; + children = ( + DE05E27E00000000000001B1 /* DevServerManifestTests.swift */, + DE05E27E00000000000001B6 /* DevModeTests.swift */, + DE05E27E00000000000001B8 /* DevServerPaywallTests.swift */, + ); + path = DevServer; + sourceTree = ""; + }; 86DD4496D1218324B5CBBB89 /* Paywall */ = { isa = PBXGroup; children = ( @@ -2444,6 +2487,8 @@ isa = PBXGroup; children = ( 5383FA48A6E9EF8F30683C9B /* DebugManager.swift */, + DE05E27E00000000000001A8 /* DebugPickerLogic.swift */, + DE05E27E00000000000001AA /* DebugPaywallPickerViewController.swift */, F9098101E599AEB01521FE89 /* DebugViewController.swift */, 299F91895EE88281B5ED8320 /* SWBounceButton.swift */, 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */, @@ -2716,6 +2761,7 @@ D554340BB6652F5FA1F21FF8 /* Config */, 38C02C19ED9C9958A7A61FB1 /* Debug */, 3B16D25FCB6991D55E0F63B3 /* DeepLink */, + DE05E27E00000000000001B3 /* DevServer */, 373AFF230833A951B6E5DF36 /* Identity */, 8DD7B7C5E111EAB0878886B6 /* Logger */, 4D7656D6A565958F58A644AF /* Misc */, @@ -3289,6 +3335,10 @@ 654803E77F7CDBF6282D0110 /* Date+IsWithinAnHourBeforeTests.swift in Sources */, D91750797BB4947F6975B2B9 /* Date+IsoStringTests.swift in Sources */, 01BE837B492223B76A95CB5D /* DeepLinkRouterTests.swift in Sources */, + DE05E27E00000000000001B2 /* DevServerManifestTests.swift in Sources */, + DE05E27E00000000000001B7 /* DevModeTests.swift in Sources */, + DE05E27E00000000000001B9 /* DevServerPaywallTests.swift in Sources */, + DE05E27E00000000000001B5 /* DebugPickerLogicTests.swift in Sources */, 0CA13E721ADB243882536D4A /* DeviceHelperMock.swift in Sources */, 9DBDDD10A1EFC7CD3575D9E5 /* DeviceHelperTests.swift in Sources */, 2743143ED664F942D5D758B1 /* DevicePreloadScriptTests.swift in Sources */, @@ -3678,6 +3728,12 @@ 16622133C8DD73C2B153A32A /* Queue.swift in Sources */, FC051A3A8D640AF49D798B25 /* RawExperiment.swift in Sources */, 7477EFEA4C42BB441B92D096 /* RawPaywallResponse.swift in Sources */, + DE05E27E00000000000001A2 /* DevServerManifest.swift in Sources */, + DE05E27E00000000000001A5 /* DevServerPreview.swift in Sources */, + DE05E27E00000000000001A7 /* DevServerPaywall.swift in Sources */, + DE05E27E00000000000001AD /* DevMode.swift in Sources */, + DE05E27E00000000000001A9 /* DebugPickerLogic.swift in Sources */, + DE05E27E00000000000001AB /* DebugPaywallPickerViewController.swift in Sources */, B3E6E82C0240EE6048360C9B /* RawWebMessageHandler.swift in Sources */, 4E7761949715C8BF8DEEF35C /* ReceiptLogic.swift in Sources */, 5634C4E0E082754F7939BB60 /* ReceiptManager.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/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift new file mode 100644 index 0000000000..c35482f7c4 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift @@ -0,0 +1,48 @@ +// +// DevModeTests.swift +// SuperwallKitTests +// + +import XCTest +@testable import SuperwallKit + +final class DevModeTests: XCTestCase { + override func tearDown() { + DevMode.isSandboxEnvironment = { DeviceHelper.isSandboxEnvironment } + super.tearDown() + } + + private func options(devMode: Bool = false, devServerURL: URL? = nil) -> SuperwallOptions { + let options = SuperwallOptions() + options.devMode = devMode + options.devServerURL = devServerURL + return options + } + + func test_isInactiveWhenNobodyAskedForIt() { + DevMode.isSandboxEnvironment = { true } + XCTAssertFalse(DevMode.isActive(options())) + } + + func test_isActiveInSandboxWhenTheToggleIsOn() { + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devMode: true))) + } + + /// 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(devMode: true))) + } + + func test_anExplicitDevServerUrlAlsoImpliesDevModeAndIsAlsoGated() throws { + let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) + + DevMode.isSandboxEnvironment = { true } + XCTAssertTrue(DevMode.isActive(options(devServerURL: url))) + + DevMode.isSandboxEnvironment = { false } + XCTAssertFalse(DevMode.isActive(options(devServerURL: url))) + } +} diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift new file mode 100644 index 0000000000..d233f09435 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -0,0 +1,117 @@ +// +// 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" + ) + } +} 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") + } +} From 48b6b35ca73ae73ef0dfbedf1064cbe3f64eea83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:02:30 +0200 Subject: [PATCH 02/21] chore: fold dev mode changelog entry into staged 4.16.4 section Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf055a390..f156871ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## Unreleased +## 4.16.4 ### Enhancements - Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. -## 4.16.4 - ### Fixes - Fixes subscribers with an unexpired subscription being reported as `inactive` on cold launch when the App Store has no purchases to report. Refunded and expired App Store subscriptions still deactivate immediately. From 5d8c07683f0911a6f9a6d1c4b396bd9d8a825594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:08:23 +0200 Subject: [PATCH 03/21] chore: restage 4.16.4 release as 4.17.0 Dev mode is a new feature, so the staged release gets a minor bump instead of a patch. Bumps the version in all three places. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- Sources/SuperwallKit/Misc/Constants.swift | 2 +- SuperwallKit.podspec | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f156871ea5..3a38915338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. -## 4.16.4 +## 4.17.0 ### Enhancements 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/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" From 154f0a265aff98a12045ce08db34e5246b9dd6f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:27:25 +0200 Subject: [PATCH 04/21] review: gate dev links out of production and unfreeze the dev-mode cache Addresses pullfrog's review of 6519c3c: - handleDeepLink no longer claims a superwall_dev link when dev mode is off, so production apps keep routing such URLs down their handler chain. The pre-configuration storeDeepLink path only claims dev links once options are checkable. - A deep-link-supplied dev-server base must now be a host superwall dev could have printed (loopback, .local, private-network ranges) or match the developer-supplied devServerURL, so an arbitrary internet host can no longer be handed the paywall JS bridge. - Dev-mode paywalls skip the request-hash memoisation and fold the mount URL into cacheKey, so a transient server miss no longer pins the published paywall for the process and a moved server reloads the web view. - The debugger's withTimeout now genuinely resumes at the deadline instead of waiting out the slow product call and discarding it. - The cached-base move-to-front uses removeAll/insert instead of an irreflexive sort predicate. - Removes trailing whitespace flagged by SwiftLint. Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 24 +++-- Sources/SuperwallKit/DeepLinkRouter.swift | 7 +- .../DevServer/DevServerManifest.swift | 3 +- .../DevServer/DevServerPreview.swift | 76 +++++++++++-- .../Operators/RawPaywallResponse.swift | 4 + .../Request/PaywallRequestManager.swift | 12 ++- SuperwallKit.xcodeproj/project.pbxproj | 4 + .../DeepLink/DeepLinkRouterTests.swift | 9 ++ .../DevServer/DevServerPreviewTests.swift | 102 ++++++++++++++++++ 9 files changed, 219 insertions(+), 22 deletions(-) create mode 100644 Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dcd0b5891d..764ae4a2cb 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -125,7 +125,7 @@ final class DebugViewController: UIViewController { /// 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 @@ -364,20 +364,30 @@ final class DebugViewController: UIViewController { } } + /// Races the operation against a deadline and genuinely resumes at whichever + /// finishes first. A task group can't do this — it awaits every child, and + /// the product path has no cancellation checks to cut a slow call short — + /// so a missed deadline abandons the operation's unstructured task instead. private func withTimeout( seconds: Double, operation: @escaping @Sendable () async -> T ) async -> T? { - return await withTaskGroup(of: T?.self) { group in - group.addTask { await operation() } - group.addTask { + let stream = AsyncStream { continuation in + let operationTask = Task { + continuation.yield(await operation()) + continuation.finish() + } + Task { try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - return nil + operationTask.cancel() + continuation.yield(nil) + continuation.finish() } - let result = await group.next() ?? nil - group.cancelAll() + } + for await result in stream { return result } + return nil } /// Picks the dev-server surface the debugger opens with, if any. diff --git a/Sources/SuperwallKit/DeepLinkRouter.swift b/Sources/SuperwallKit/DeepLinkRouter.swift index db950a3f05..8564bc1cce 100644 --- a/Sources/SuperwallKit/DeepLinkRouter.swift +++ b/Sources/SuperwallKit/DeepLinkRouter.swift @@ -139,7 +139,12 @@ final class DeepLinkRouter { return true } - if DevServerPreview.outcomeForDeepLink(url: url) != nil { + // 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 } diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 20640d3690..57c2fdf10b 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -86,7 +86,8 @@ actor DevServerLocator { bases.insert(pinnedBase, at: 0) } if let cached = cached { - bases.sort { first, _ in first == cached.location.base } + bases.removeAll { $0 == cached.location.base } + bases.insert(cached.location.base, at: 0) } for base in bases { diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index 0c46780a70..a888560770 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -28,31 +28,85 @@ enum DevServerPreview { else { return nil } - let surfaceId = items.first(where: { $0.name == "superwall_dev_surface" })?.value + let surfaceId = items.first { $0.name == "superwall_dev_surface" }?.value return DeepLinkOutcome(base: base, surfaceId: surfaceId) } - static func handle(url: URL) -> Bool { + /// 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 } - Task { @MainActor in - await open(outcome: outcome) + 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 `devServerURL`, 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 } - return true } - @MainActor - private static func open(outcome: DeepLinkOutcome) async { - guard DevMode.isActive(Superwall.shared.options) else { + 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.devMode is off " + "in this build. Enable devMode to preview local paywalls in the app." ) - return + 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.devServerURL." + ) + 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( @@ -68,9 +122,9 @@ enum DevServerPreview { debugManager.devServer = (base: location.base, surfaces: location.manifest.surfaces) await debugManager.launchDebugger( withPaywallId: nil, - devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first(where: { + devSurfaceId: outcome.surfaceId ?? location.manifest.surfaces.first { $0.kind == "paywall" - })?.id + }?.id ) } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 6e7e161e8e..56e600507e 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -45,6 +45,10 @@ extension PaywallRequestManager { var paywall = paywall paywall.url = mountURL + // A changed cacheKey is what makes an already-cached view controller + // reload its web view; without it a moved dev server or a published + // fallback would present the stale page. + paywall.cacheKey = "dev:\(paywall.cacheKey):\(mountURL.absoluteString)" paywall.urlConfig = WebViewURLConfig( endpoints: [ WebViewEndpoint( diff --git a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift index 6ff30e7764..c177f12e67 100644 --- a/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift +++ b/Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift @@ -126,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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 9f0cda5c29..699a9b053c 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -544,6 +544,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 */; }; @@ -851,6 +852,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 = ""; }; @@ -2687,6 +2689,7 @@ 577D25646DA26238881BF6AB /* DevModeTests.swift */, A349A124DD1DF28EEF04592C /* DevServerManifestTests.swift */, A4FD16729844D83A3EAA02FC /* DevServerPaywallTests.swift */, + 6E0A1ED94DE7737BCB7D4D8C /* DevServerPreviewTests.swift */, ); path = DevServer; sourceTree = ""; @@ -3353,6 +3356,7 @@ 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 */, diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 3d2be91593..834ff769db 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 dev mode is off") + func storeDeepLink_devServerLink_devModeOff() { + 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/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift new file mode 100644 index 0000000000..fd2bd15841 --- /dev/null +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -0,0 +1,102 @@ +// +// DevServerPreviewTests.swift +// SuperwallKitTests +// + +import Foundation +import Testing +@testable import SuperwallKit + +@Suite(.serialized) +struct DevServerPreviewTests { + private func options( + devMode: Bool = true, + devServerURL: URL? = nil + ) -> SuperwallOptions { + let options = SuperwallOptions() + options.devMode = devMode + options.devServerURL = devServerURL + 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 dev mode is off") + func canHandle_devModeOff() throws { + let url = try #require(URL(string: "myapp://?superwall_dev=http://localhost:6100")) + #expect(!DevServerPreview.canHandle(url: url, options: options(devMode: false))) + } + + @Test("A dev link pointing at a local host is Superwall's when dev mode is on") + func canHandle_devModeOnLocalHost() 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 dev mode on") + func canHandle_devModeOnPublicHost() 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())) + } +} From 75da4e8f351d5871e339eb9ce895cebf7a92b082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:48:06 +0200 Subject: [PATCH 05/21] review: pin dev-server mount URLs to the manifest's origin A manifest fetched from a trusted base could still name an absolute URL on any origin, since URL(string:relativeTo:) ignores the base for absolute strings. mountURL now rejects any resolved URL whose scheme, host, or port differs from the base, covering both the request-pipeline and debugger callers. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerManifest.swift | 14 +++++++++++++- .../DevServer/DevServerManifestTests.swift | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 57c2fdf10b..5ba6e48b3e 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -36,7 +36,19 @@ struct DevServerManifest: Decodable, Equatable { } func mountURL(for surface: DevServerSurface, base: URL) -> URL? { - return URL(string: surface.url, relativeTo: base)?.absoluteURL + 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 { + return nil + } + return resolved } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index d233f09435..88ce07bb20 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -114,4 +114,20 @@ final class DevServerManifestTests: XCTestCase { "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) + } + } } From 4a032b8a71f3531c537cadcdf2801f918688a48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:25:38 +0200 Subject: [PATCH 06/21] review: log when a dev server surface is rejected as off-origin A representation mismatch (localhost vs 127.0.0.1, or a portless devServerURL against an explicit-port surface url) would otherwise disable the override with no trace, which is the one failure mode this subsystem otherwise always logs. Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerManifest.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 5ba6e48b3e..fa4d7a255a 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -46,6 +46,12 @@ struct DevServerManifest: Decodable, Equatable { 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 From d1f5e0df168ceca9efbba29dd2d007ee48b7e35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:04:19 +0200 Subject: [PATCH 07/21] chore(examples): scope Advanced app ATS to web content and local networking Matches the Basic app, and lets the dev server's plain-http localhost traffic through without the blanket arbitrary-loads exception. Co-Authored-By: Claude Fable 5 --- Examples/Advanced/Advanced/Info.plist | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 254d6de4c4fb010f00dbb663db53d54a6e4fb431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:46:01 +0200 Subject: [PATCH 08/21] chore: split DevServerManifest.swift into one file per type DevServerSurface, DevServerLocation, DevServerCandidates, and DevServerLocator move to their own files; DevServerManifest keeps the manifest model. No behavior change. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerCandidates.swift | 22 +++ .../DevServer/DevServerLocation.swift | 14 ++ .../DevServer/DevServerLocator.swift | 116 +++++++++++++++ .../DevServer/DevServerManifest.swift | 132 ------------------ .../DevServer/DevServerSurface.swift | 19 +++ SuperwallKit.xcodeproj/project.pbxproj | 16 +++ 6 files changed, 187 insertions(+), 132 deletions(-) create mode 100644 Sources/SuperwallKit/DevServer/DevServerCandidates.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerLocation.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerLocator.swift create mode 100644 Sources/SuperwallKit/DevServer/DevServerSurface.swift 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..97e12eb290 --- /dev/null +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -0,0 +1,116 @@ +// +// 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? + + 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.devServerURL to the Device URL superwall dev prints." + ) + return nil + } + + private var hasWarnedAboutTransportSecurity = false + + /// 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, + !hasWarnedAboutTransportSecurity + else { + 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 index fa4d7a255a..2ade494c12 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -9,15 +9,6 @@ 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]? -} - struct DevServerManifest: Decodable, Equatable { let surfaces: [DevServerSurface] @@ -57,126 +48,3 @@ struct DevServerManifest: Decodable, Equatable { return resolved } } - -struct DevServerLocation: Equatable { - let base: URL - let manifest: DevServerManifest -} - -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)") } - } -} - -actor DevServerLocator { - static let shared = DevServerLocator() - - private var cached: (location: DevServerLocation, fetchedAt: Date)? - private var lastMissAt: Date? - private var pinnedBase: URL? - - 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.devServerURL to the Device URL superwall dev prints." - ) - return nil - } - - private var hasWarnedAboutTransportSecurity = false - - /// 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, - !hasWarnedAboutTransportSecurity - else { - 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/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/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 699a9b053c..60dfa65223 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -125,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 */; }; @@ -308,6 +309,7 @@ 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 */; }; @@ -392,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 */; }; @@ -497,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 */; }; @@ -810,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 = ""; }; @@ -841,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 = ""; }; @@ -1112,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 = ""; }; @@ -1138,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 = ""; }; @@ -2605,9 +2613,13 @@ 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 = ""; @@ -3546,9 +3558,13 @@ 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 */, From 3482bf2e3b3e9f8db67617a66b0188fb9896f70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:43:52 +0200 Subject: [PATCH 09/21] style: invert the paywall picker's open guard into a positive early return Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 764ae4a2cb..2a3c6401da 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -444,7 +444,11 @@ final class DebugViewController: UIViewController { @objc func pressedPreview() { let devSurfaces = devServer?.surfaces ?? [] let published = publishedPaywalls - guard !devSurfaces.isEmpty || published.count > 1 || paywallDatabaseId == nil else { + // Nothing to pick from: no local surfaces, at most one published paywall, + // and that paywall is already showing. + if devSurfaces.isEmpty, + published.count <= 1, + paywallDatabaseId != nil { return } From 0d7ce5ed4d4c1669bb03630c2a1d41bd934e8321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:45:03 +0200 Subject: [PATCH 10/21] style: split the paywall picker gate into a three-branch predicate Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 2a3c6401da..dd3b25415c 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -441,16 +441,27 @@ final class DebugViewController: UIViewController { } } + /// 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() { - let devSurfaces = devServer?.surfaces ?? [] - let published = publishedPaywalls - // Nothing to pick from: no local surfaces, at most one published paywall, - // and that paywall is already showing. - if devSurfaces.isEmpty, - published.count <= 1, - paywallDatabaseId != nil { + if !canOpenPicker { return } + let devSurfaces = devServer?.surfaces ?? [] + let published = publishedPaywalls let picker = DebugPaywallPickerViewController( localSurfaceIds: devSurfaces.map { $0.id }, From 362950d30dc1bc91cd984d91079edd37f996a4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:49:37 +0200 Subject: [PATCH 11/21] style: state the picker gate positively with guard Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dd3b25415c..8a830d53a6 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -457,7 +457,7 @@ final class DebugViewController: UIViewController { } @objc func pressedPreview() { - if !canOpenPicker { + guard canOpenPicker else { return } let devSurfaces = devServer?.surfaces ?? [] From 11cd922973db9c47793a9cf12e744f3679034118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:58:56 +0200 Subject: [PATCH 12/21] style: group the ATS warning flag with the locator's other state Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerLocator.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 97e12eb290..530ada386a 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -15,6 +15,7 @@ actor 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 @@ -64,8 +65,6 @@ actor DevServerLocator { return nil } - private var hasWarnedAboutTransportSecurity = false - /// 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) { From 6fed06c8ea7c993cb7f3334c2d5d43cb06a7b608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:00:54 +0200 Subject: [PATCH 13/21] style: split the ATS warning gate into single-condition checks Co-Authored-By: Claude Fable 5 --- Sources/SuperwallKit/DevServer/DevServerLocator.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index 530ada386a..bb1e2d7b72 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -69,10 +69,10 @@ actor DevServerLocator { /// 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, - !hasWarnedAboutTransportSecurity - else { + guard code == NSURLErrorAppTransportSecurityRequiresSecureConnection else { + return + } + if hasWarnedAboutTransportSecurity { return } hasWarnedAboutTransportSecurity = true From c22e09843e5e92e6ef8c665ce43d12dcc227a4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:09:27 +0200 Subject: [PATCH 14/21] style: split the dev link parse into single-condition guards Also documents outcomeForDeepLink. Co-Authored-By: Claude Fable 5 --- .../DevServer/DevServerPreview.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index a888560770..0faa7368f4 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -18,14 +18,23 @@ enum DevServerPreview { 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), - let items = components.queryItems, - let raw = items.first(where: { $0.name == "superwall_dev" })?.value, - let base = URL(string: raw), - base.scheme == "http" || base.scheme == "https" - else { + 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 From cf2020c32004929c7bb076671d1c732895c37d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:28:01 +0200 Subject: [PATCH 15/21] docs: drop the superwall.lock binding detail from the dev mode changelog entry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a38915338..bb46980527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Bound paywalls resolve via the dev server's manifest (`superwall.lock`); dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. +- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. ### Fixes From e7518498cc0233eeb60c500a5d9b3cced4dcbdbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:32:18 +0200 Subject: [PATCH 16/21] review: drop the debugger's product-variables timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing products fail fast on their own — both fetchers throw noProductsFound without entering their retry ladder — so the 3s race only ever hedged degraded-network cases, at the cost of indirection. The debugger now awaits the store directly, like it does on develop. Co-Authored-By: Claude Fable 5 --- .../Debug/DebugViewController.swift | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 8a830d53a6..d8f49680e1 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -275,9 +275,7 @@ final class DebugViewController: UIViewController { ) var paywall = try await paywallRequestManager.getPaywall(from: request) - paywall.productVariables = await withTimeout(seconds: 3) { - await self.storeKitManager.getProductVariables(for: paywall) - } ?? [] + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) @@ -364,32 +362,6 @@ final class DebugViewController: UIViewController { } } - /// Races the operation against a deadline and genuinely resumes at whichever - /// finishes first. A task group can't do this — it awaits every child, and - /// the product path has no cancellation checks to cut a slow call short — - /// so a missed deadline abandons the operation's unstructured task instead. - private func withTimeout( - seconds: Double, - operation: @escaping @Sendable () async -> T - ) async -> T? { - let stream = AsyncStream { continuation in - let operationTask = Task { - continuation.yield(await operation()) - continuation.finish() - } - Task { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - operationTask.cancel() - continuation.yield(nil) - continuation.finish() - } - } - for await result in stream { - return result - } - return nil - } - /// Picks the dev-server surface the debugger opens with, if any. func selectDevSurface(id: String?) { guard let id = id else { @@ -417,9 +389,7 @@ final class DebugViewController: UIViewController { 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 withTimeout(seconds: 3) { - await self.storeKitManager.getProductVariables(for: paywall) - } ?? [] + paywall.productVariables = await storeKitManager.getProductVariables(for: paywall) self.paywall = paywall paywallIdentifier = paywall.identifier paywallDatabaseId = paywall.databaseId From 6240aa299fb4f4ae4f84e2c60032c2b28ac8be3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:24:41 +0200 Subject: [PATCH 17/21] style: positive-if and multiline-guard formatting in dev mode gates --- Sources/SuperwallKit/Config/ConfigManager.swift | 3 ++- Sources/SuperwallKit/Debug/DebugViewController.swift | 3 ++- Sources/SuperwallKit/DevServer/DevMode.swift | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Config/ConfigManager.swift b/Sources/SuperwallKit/Config/ConfigManager.swift index 8689eb1f6c..a56fa96c03 100644 --- a/Sources/SuperwallKit/Config/ConfigManager.swift +++ b/Sources/SuperwallKit/Config/ConfigManager.swift @@ -562,7 +562,8 @@ class ConfigManager { /// /// A developer can disable preloading of paywalls by setting ``SuperwallOptions/shouldPreloadPaywalls``. private func preloadPaywalls() async { - guard Superwall.shared.options.paywalls.shouldPreload, + guard + Superwall.shared.options.paywalls.shouldPreload, !DevMode.isActive(Superwall.shared.options) else { return diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index d8f49680e1..cd2435ff3f 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -224,7 +224,8 @@ final class DebugViewController: UIViewController { /// 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, + guard + devServer == nil, DevMode.isActive(Superwall.shared.options), let location = await DevServerLocator.shared.locate( devServerURL: Superwall.shared.options.devServerURL diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift index 21134330cf..e6a7994fe9 100644 --- a/Sources/SuperwallKit/DevServer/DevMode.swift +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -31,7 +31,7 @@ enum DevMode { } private static func warnAboutProduction() { - guard !hasWarnedAboutProduction else { + if hasWarnedAboutProduction { return } hasWarnedAboutProduction = true From b61b66e31b7518371b553282304ede8f3f8345d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:33 +0200 Subject: [PATCH 18/21] feat!: replace devMode and devServerURL with SuperwallOptions.devServer One knob instead of two: options.devServer = nil (off, the default), .default (find the server on localhost, for simulators), or .url(_:) (the Device URL superwall dev prints, for physical devices). Folding the URL into the option removes the 'setting the URL implies the mode' rule and every half-configured state. Objective-C gets enableDevServer()/enableDevServer(url:) veneers. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- .../Config/Options/SuperwallOptions.swift | 57 ++++++++++++++----- Sources/SuperwallKit/DevServer/DevMode.swift | 6 +- .../DevServer/DevServerLocator.swift | 2 +- .../DevServer/DevServerPreview.swift | 8 +-- .../DeepLink/DeepLinkRouterTests.swift | 4 +- .../DevServer/DevModeTests.swift | 15 +++-- .../DevServer/DevServerPreviewTests.swift | 20 +++---- 8 files changed, 69 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb46980527..b0a769b554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements -- Adds `SuperwallOptions.devMode` for development builds: with a `superwall dev` server running, every paywall renders from your live, local paywall code while configuration, placements, audience evaluation and assignment stay real. Simulators find the dev server on localhost automatically; on a physical device set `SuperwallOptions.devServerURL` to the Device URL `superwall dev` prints. Dev mode also activates test mode, disables preloading, and skips the test mode intro sheet. +- 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/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 525988b233..b3366f8b70 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -388,31 +388,58 @@ 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. /// - /// Every paywall the SDK would present then renders from the dev server's live, local - /// paywall code instead of its published version, while configuration, placements, - /// audience evaluation and assignment all stay real. On a simulator this finds the dev - /// server on `localhost` automatically; on a physical device set ``devServerURL`` to - /// the `Device` URL that `superwall dev` prints. + /// 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. /// - /// Dev mode also activates test mode (simulated purchases, product data from the + /// 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`). - public var devMode = false + @nonobjc public var devServer: DevServer? - /// Where ``devMode`` looks for the `superwall dev` server. Setting this implies ``devMode``. - /// - /// Defaults to `localhost` ports 6100–6104, which reaches a dev server running on the - /// same machine from a simulator. On a physical device set this to the `Device` URL's - /// origin that `superwall dev` prints, e.g. `http://192.168.1.10:6100`. - @nonobjc public var devServerURL: URL? + /// Objective-C only: connects to a `superwall dev` server found on `localhost`. + @available(swift, obsoleted: 1.0) + public func enableDevServer() { + devServer = .default + } - var isDevModeEnabled: Bool { - return devMode || devServerURL != nil + /// 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 diff --git a/Sources/SuperwallKit/DevServer/DevMode.swift b/Sources/SuperwallKit/DevServer/DevMode.swift index e6a7994fe9..c8f87a9dcb 100644 --- a/Sources/SuperwallKit/DevServer/DevMode.swift +++ b/Sources/SuperwallKit/DevServer/DevMode.swift @@ -20,7 +20,7 @@ enum DevMode { /// 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.isDevModeEnabled else { + guard options.isDevServerEnabled else { return false } guard isSandboxEnvironment() else { @@ -38,9 +38,9 @@ enum DevMode { Logger.debug( logLevel: .warn, scope: .superwallCore, - message: "SuperwallOptions.devMode is on in a production build, so it is being ignored: " + message: "SuperwallOptions.devServer is set in a production build, so it is being ignored: " + "paywalls load their published versions and purchases are real. " - + "Remove devMode before shipping." + + "Remove devServer before shipping." ) } } diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index bb1e2d7b72..c3e990d034 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -60,7 +60,7 @@ actor DevServerLocator { 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.devServerURL to the Device URL superwall dev prints." + + "set SuperwallOptions.devServer to the Device URL superwall dev prints." ) return nil } diff --git a/Sources/SuperwallKit/DevServer/DevServerPreview.swift b/Sources/SuperwallKit/DevServer/DevServerPreview.swift index 0faa7368f4..b72d18f1dd 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPreview.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPreview.swift @@ -53,7 +53,7 @@ enum DevServerPreview { /// 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 `devServerURL`, which is trusted input. Anything else + /// 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 { @@ -93,8 +93,8 @@ enum DevServerPreview { Logger.debug( logLevel: .warn, scope: .superwallCore, - message: "Scanned a superwall dev link, but SuperwallOptions.devMode is off " - + "in this build. Enable devMode to preview local paywalls in the app." + 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 } @@ -104,7 +104,7 @@ enum DevServerPreview { 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.devServerURL." + + "To use another host, set it as SuperwallOptions.devServer's url." ) return false } diff --git a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift index 834ff769db..1c4401500a 100644 --- a/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift +++ b/Tests/SuperwallKitTests/DeepLink/DeepLinkRouterTests.swift @@ -97,8 +97,8 @@ struct DeepLinkRouterTests { // MARK: - Dev Server Preview URLs - @Test("Returns false for a superwall_dev link when dev mode is off") - func storeDeepLink_devServerLink_devModeOff() { + @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) diff --git a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift index c35482f7c4..0fdaa1acb8 100644 --- a/Tests/SuperwallKitTests/DevServer/DevModeTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevModeTests.swift @@ -12,10 +12,9 @@ final class DevModeTests: XCTestCase { super.tearDown() } - private func options(devMode: Bool = false, devServerURL: URL? = nil) -> SuperwallOptions { + private func options(devServer: SuperwallOptions.DevServer? = nil) -> SuperwallOptions { let options = SuperwallOptions() - options.devMode = devMode - options.devServerURL = devServerURL + options.devServer = devServer return options } @@ -26,23 +25,23 @@ final class DevModeTests: XCTestCase { func test_isActiveInSandboxWhenTheToggleIsOn() { DevMode.isSandboxEnvironment = { true } - XCTAssertTrue(DevMode.isActive(options(devMode: 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(devMode: true))) + XCTAssertFalse(DevMode.isActive(options(devServer: .default))) } - func test_anExplicitDevServerUrlAlsoImpliesDevModeAndIsAlsoGated() throws { + func test_anExplicitDevServerUrlIsAlsoGated() throws { let url = try XCTUnwrap(URL(string: "http://192.168.1.10:6100")) DevMode.isSandboxEnvironment = { true } - XCTAssertTrue(DevMode.isActive(options(devServerURL: url))) + XCTAssertTrue(DevMode.isActive(options(devServer: .url(url)))) DevMode.isSandboxEnvironment = { false } - XCTAssertFalse(DevMode.isActive(options(devServerURL: url))) + XCTAssertFalse(DevMode.isActive(options(devServer: .url(url)))) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift index fd2bd15841..bfa03db849 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPreviewTests.swift @@ -10,12 +10,10 @@ import Testing @Suite(.serialized) struct DevServerPreviewTests { private func options( - devMode: Bool = true, - devServerURL: URL? = nil + devServer: SuperwallOptions.DevServer? = .default ) -> SuperwallOptions { let options = SuperwallOptions() - options.devMode = devMode - options.devServerURL = devServerURL + options.devServer = devServer return options } @@ -76,14 +74,14 @@ struct DevServerPreviewTests { // MARK: - canHandle - @Test("A dev link is not Superwall's when dev mode is off") - func canHandle_devModeOff() throws { + @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(devMode: false))) + #expect(!DevServerPreview.canHandle(url: url, options: options(devServer: nil))) } - @Test("A dev link pointing at a local host is Superwall's when dev mode is on") - func canHandle_devModeOnLocalHost() throws { + @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 } } @@ -91,8 +89,8 @@ struct DevServerPreviewTests { #expect(DevServerPreview.canHandle(url: url, options: options())) } - @Test("A dev link pointing at an internet host is refused even with dev mode on") - func canHandle_devModeOnPublicHost() throws { + @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 } } From ae9e8196995211bc6ea8d4e42af4222ba0a9d33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:35:51 +0200 Subject: [PATCH 19/21] chore: retrigger CI after a stuck Pullfrog run From d6f8f63235e831f56772e751564f3b31cc6db394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:00:51 +0200 Subject: [PATCH 20/21] fix(debugger): make Preview work for unpushed dev-server surfaces Preview presents via .fromIdentifier, and a local surface's synthetic dev: identifier has no backend counterpart, so the fetch 404ed into "There isn't a paywall configured to show in this context." The request pipeline now resolves dev: identifiers from the debugger's manifest before consulting statics or the network, which routes the full presentation and product pipeline through the local paywall. Verified end to end in the simulator: dev link -> picker -> Preview presents the local surface with loaded products. All 945 unit tests pass; the resolution glue itself is exercised by that manual flow since it needs a live debugger session. Co-Authored-By: Claude Fable 5 --- .../Operators/RawPaywallResponse.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 56e600507e..56dcd56943 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -69,6 +69,35 @@ extension PaywallRequestManager { return paywall } + /// 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 { @@ -78,7 +107,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 ) { From ee963aa166f9674b8f586427b5c06b6fc8e3b912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:21:30 +0200 Subject: [PATCH 21/21] feat(dev): serve matching local surfaces wholesale instead of patching published paywalls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the dev server has a surface for a paywall, the placement path now presents the synthesized local paywall — products and all — rather than the published paywall with a swapped URL. Mixing the two meant local pages asked for product references the published paywall didn't declare, rendering blank prices. Only the assignment's experiment and fetch timings carry over, keeping holdouts and analytics coherent; bound surfaces keep their real database id. Verified in the simulator: a placement now presents the local paywall with its own products and price, matching the debugger's preview. Co-Authored-By: Claude Fable 5 --- .../Operators/RawPaywallResponse.swift | 31 +++++++------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 56dcd56943..9fad08a314 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,30 +43,23 @@ extension PaywallRequestManager { return paywall } - var paywall = paywall - paywall.url = mountURL - // A changed cacheKey is what makes an already-cached view controller - // reload its web view; without it a moved dev server or a published - // fallback would present the stale page. - paywall.cacheKey = "dev:\(paywall.cacheKey):\(mountURL.absoluteString)" - paywall.urlConfig = WebViewURLConfig( - endpoints: [ - WebViewEndpoint( - url: mountURL, - timeout: 15, - percentage: 100 - ) - ], - maxAttempts: 1 - ) - paywall.manifest = nil + // 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) renders from \(mountURL.absoluteString)." + message: "Dev server override: paywall \(paywall.identifier) is served as local surface " + + "\(surface.id) from \(mountURL.absoluteString)." ) - return paywall + return devPaywall } /// Resolves a synthetic `dev:` identifier — a dev-server surface the