From 6519c3cf78a505292a2877dc4bf2afb24ef5e22f Mon Sep 17 00:00:00 2001 From: Christo Todorov Date: Mon, 24 Aug 2026 15:41:35 +0200 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 From 75c29ba719c5cdce7bc714babade5b68289893b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:14 +0200 Subject: [PATCH 22/32] feat(dev): mark locally served paywalls and honour their presentation config Adds PaywallInfo.isLocal, sent as is_local on every paywall event and audience filter param, so the dashboard can flag a presentation that came from a superwall dev server rather than a published paywall. Local surfaces also stop presenting with a hardcoded modal style: the manifest now carries the presentation config declared in config.ts, and the SDK maps style/drawer/popup onto PaywallPresentationStyle. Anything missing or unrecognised falls back to fullscreen, which is the framework documented default. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 37 +++++++++++++- .../DevServer/DevServerSurface.swift | 18 +++++++ .../SuperwallKit/Models/Paywall/Paywall.swift | 8 +++ .../Paywall/Presentation/PaywallInfo.swift | 6 +++ .../DevServer/DevServerPaywallTests.swift | 51 ++++++++++++++++++- 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 20a0697d05..c71390859b 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -23,7 +23,7 @@ extension Paywall { ) } - return Paywall( + var paywall = Paywall( databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)", identifier: surface.identifier ?? "dev:\(surface.id)", name: surface.id, @@ -35,7 +35,10 @@ extension Paywall { maxAttempts: 1 ), htmlSubstitutions: "", - presentation: PaywallPresentationInfo(style: .modal, delay: 0), + presentation: PaywallPresentationInfo( + style: presentationStyle(for: surface), + delay: 0 + ), backgroundColorHex: "#FFFFFF", backgroundColor: .white, darkBackgroundColorHex: nil, @@ -51,5 +54,35 @@ extension Paywall { isScrollEnabled: true, introOfferEligibility: .automatic ) + paywall.isLocal = true + return paywall + } + + /// Maps a surface's `config.ts` presentation onto the SDK's styles. + /// The framework documents `fullscreen` as its default, so anything + /// missing or unrecognized lands there. + private static func presentationStyle( + for surface: DevServerSurface + ) -> PaywallPresentationStyle { + switch surface.presentation?.style { + case "modal": + return .modal + case "push": + return .push + case "noAnimation": + return .fullscreenNoAnimation + case "drawer": + if let drawer = surface.presentation?.drawer { + return .drawer(height: drawer.height, cornerRadius: drawer.cornerRadius) + } + return .fullscreen + case "popup": + if let popup = surface.presentation?.popup { + return .popup(height: popup.height, width: popup.width, cornerRadius: popup.cornerRadius) + } + return .fullscreen + default: + return .fullscreen + } } } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 974f35db1e..78f4ba5bcb 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -10,10 +10,28 @@ import Foundation struct DevServerSurface: Decodable, Equatable { + /// How the paywall asks to be presented, straight from its `config.ts`. + struct Presentation: Decodable, Equatable { + struct Drawer: Decodable, Equatable { + let height: Double + let cornerRadius: Double + } + struct Popup: Decodable, Equatable { + let width: Double + let height: Double + let cornerRadius: Double + } + + let style: String? + let drawer: Drawer? + let popup: Popup? + } + let kind: String let id: String let url: String let paywallId: String? let identifier: String? let products: [String: String]? + let presentation: Presentation? } diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 1033735cac..f1caa32739 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -87,6 +87,10 @@ struct Paywall: Codable { /// Indicates whether scrolling is enabled on the webview. var isScrollEnabled: Bool + /// Whether this paywall was synthesized from a `superwall dev` server + /// surface rather than fetched from the dashboard. + var isLocal = false + /// Indicates how intro offer eligiblity should be treat on products. Defaults to /// `.automatic`. let introOfferEligibility: IntroOfferEligibility @@ -189,6 +193,7 @@ struct Paywall: Codable { case surveys case manifest case isScrollEnabled + case isLocal case introductoryOfferEligibility case responseLoadStartTime @@ -305,6 +310,7 @@ struct Paywall: Codable { manifest = try values.decodeIfPresent(ArchiveManifest.self, forKey: .manifest) isScrollEnabled = try values.decodeIfPresent(Bool.self, forKey: .isScrollEnabled) ?? true + isLocal = try values.decodeIfPresent(Bool.self, forKey: .isLocal) ?? false introOfferEligibility = try values .decodeIfPresent(IntroOfferEligibility.self, forKey: .introductoryOfferEligibility) ?? .automatic } @@ -363,6 +369,7 @@ struct Paywall: Codable { try container.encodeIfPresent(manifest, forKey: .manifest) try container.encodeIfPresent(isScrollEnabled, forKey: .isScrollEnabled) + try container.encode(isLocal, forKey: .isLocal) try container.encodeIfPresent(introOfferEligibility, forKey: .introductoryOfferEligibility) } @@ -471,6 +478,7 @@ struct Paywall: Codable { surveys: surveys, presentation: presentation, isScrollEnabled: isScrollEnabled, + isLocal: isLocal, state: state, introOfferEligibility: introOfferEligibility ) diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index dac01c42d0..4c3f91bb60 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -128,6 +128,9 @@ public final class PaywallInfo: NSObject { /// Indicates whether scrolling of the webview is enabled. public let isScrollEnabled: Bool + /// Whether the paywall was served from a local `superwall dev` server. + public let isLocal: Bool + /// The state of the paywall, updated on paywall did dismiss. public let state: [String: Any] @@ -172,6 +175,7 @@ public final class PaywallInfo: NSObject { surveys: [Survey], presentation: PaywallPresentationInfo, isScrollEnabled: Bool, + isLocal: Bool = false, state: [String: Any], introOfferEligibility: IntroOfferEligibility ) { @@ -241,6 +245,7 @@ public final class PaywallInfo: NSObject { self.closeReason = closeReason self.isScrollEnabled = isScrollEnabled + self.isLocal = isLocal self.state = state self.introOfferEligibility = introOfferEligibility } @@ -325,6 +330,7 @@ public final class PaywallInfo: NSObject { "paywall_product_ids": productIds.joined(separator: ","), "is_free_trial_available": isFreeTrialAvailable as Any, "feature_gating": featureGatingBehavior.description as Any, + "is_local": isLocal, "presented_by": presentedBy as Any ] diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 656a90f9c2..737d2207d7 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -11,7 +11,8 @@ final class DevServerPaywallTests: XCTestCase { id: String = "pro", paywallId: String? = nil, identifier: String? = nil, - products: [String: String]? = nil + products: [String: String]? = nil, + presentation: String? = nil ) -> DevServerSurface { let json = """ { @@ -20,6 +21,7 @@ final class DevServerPaywallTests: XCTestCase { "url": "/preview/paywall/\(id)", \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") + \(presentation.map { "\"presentation\": \($0)," } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -72,4 +74,51 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.databaseId, "253583") XCTAssertEqual(paywall.identifier, "chatgpt-plus") } + + func test_isMarkedLocalSoEventsCanSaySo() { + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertTrue(paywall.isLocal) + let params = paywall.getInfo(fromPlacement: nil).audienceFilterParams() + XCTAssertEqual(params["is_local"] as? Bool, true) + } + + func test_presentsFullscreenWhenTheConfigSaysNothing() { + let paywall = Paywall.devServer(surface: surface(), url: url) + XCTAssertEqual(paywall.presentation.style, .fullscreen) + } + + func test_usesThePresentationStyleTheConfigDeclares() { + let modal = Paywall.devServer( + surface: surface(presentation: #"{"style": "modal"}"#), + url: url + ) + XCTAssertEqual(modal.presentation.style, .modal) + + let drawer = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420, "cornerRadius": 24}}"#), + url: url + ) + XCTAssertEqual(drawer.presentation.style, .drawer(height: 420, cornerRadius: 24)) + + let popup = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500, "cornerRadius": 16}}"#), + url: url + ) + XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) + } + + func test_fallsBackToFullscreenWhenAStyleIsUnknownOrIncomplete() { + let unknown = Paywall.devServer( + surface: surface(presentation: #"{"style": "hologram"}"#), + url: url + ) + XCTAssertEqual(unknown.presentation.style, .fullscreen) + + let drawerWithoutGeometry = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer"}"#), + url: url + ) + XCTAssertEqual(drawerWithoutGeometry.presentation.style, .fullscreen) + } } From 7689d10baae9b69e339720273598add8f2657371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:51:22 +0200 Subject: [PATCH 23/32] refactor(debugger): hold published paywalls as PaywallSummary The three-member tuple carried exactly the fields PaywallSummary already has, and tripped SwiftLint's large_tuple rule. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Debug/DebugViewController.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index cd2435ff3f..dc50830589 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -402,13 +402,13 @@ final class DebugViewController: UIViewController { /// 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)] { + private var publishedPaywalls: [PaywallSummary] { if !previewPaywalls.isEmpty { - return previewPaywalls.map { (id: $0.id, identifier: $0.identifier, name: $0.name) } + return previewPaywalls } let config = Superwall.shared.dependencyContainer.configManager?.config return (config?.paywalls ?? []).map { - (id: $0.databaseId, identifier: $0.identifier, name: $0.name) + PaywallSummary(id: $0.databaseId, identifier: $0.identifier, name: $0.name) } } From eedda06ee9c7376c686dfde7a22e2e75f26fc776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:18:14 +0200 Subject: [PATCH 24/32] review: keep gating real, contain manifest decode failures, share the debugger's dev server Addresses the three open Pullfrog findings on #511. - The dev-server override synthesized the paywall wholesale, so featureGating came from the stub's .nonGated default. A gated dashboard paywall therefore unlocked its feature for a non-paying user under dev mode. It now carries the published paywall's gating over, and the devServer doc says so. - One surface the SDK couldn't read aborted the whole manifest decode, so a single bad entry took dev mode down for every surface and logged it as "no dev server found". Surfaces decode per element now, a bad presentation block costs a surface its style rather than the surface, and a manifest that answers but doesn't parse says exactly that. - Partly specified drawer geometry is legitimate: PaywallPresentationStyle documents a 70% default height. A drawer naming only some of its geometry now presents as a drawer. A popup has no documented default, so a partial one still falls back to fullscreen. - ensureDevServer populated only the view controller's copy, so presenting a local surface from a debugger opened by a dashboard link resolved nil and 404'd. It writes through to the debug manager now. Co-Authored-By: Claude Opus 5 --- .../Config/Options/SuperwallOptions.swift | 7 ++- .../Debug/DebugViewController.swift | 6 +- .../DevServer/DevServerLocator.swift | 16 +++++- .../DevServer/DevServerManifest.swift | 30 ++++++++++ .../DevServer/DevServerPaywall.swift | 25 +++++++-- .../DevServer/DevServerSurface.swift | 55 +++++++++++++++++-- .../Operators/RawPaywallResponse.swift | 12 ++-- .../DevServer/DevServerManifestTests.swift | 53 ++++++++++++++++++ .../DevServer/DevServerPaywallTests.swift | 48 +++++++++++++++- 9 files changed, 230 insertions(+), 22 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index b3366f8b70..dd4f69b0d0 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -404,8 +404,11 @@ public final class SuperwallOptions: NSObject, Encodable { /// /// 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. + /// audience evaluation, assignment and feature gating all stay real. Paywalls without a + /// local counterpart still load their published versions. + /// + /// What the local surface does own is what it renders and how: its products and its + /// `config.ts` presentation style replace the published paywall's. /// /// 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. diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index dc50830589..f63fa7bb26 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -233,7 +233,11 @@ final class DebugViewController: UIViewController { else { return } - devServer = (base: location.base, surfaces: location.manifest.surfaces) + let located = (base: location.base, surfaces: location.manifest.surfaces) + devServer = located + // Presenting a `dev:` surface resolves it from the debug manager's copy, + // so both stores have to agree however the debugger was opened. + debugManager.devServer = located } func finishLoadingPreview() async { diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index c3e990d034..d354c85513 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -106,7 +106,21 @@ actor DevServerLocator { } task.resume() } - return try JSONDecoder().decode(DevServerManifest.self, from: data) + do { + return try JSONDecoder().decode(DevServerManifest.self, from: data) + } catch { + // A server answered; its manifest just didn't parse. Say so, or the + // caller's "no server found" log points at the wrong cause. + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "The superwall dev server at \(base.absoluteString) answered with a manifest " + + "this SDK couldn't read. Paywalls will load their published versions. " + + "Check that superwall dev and SuperwallKit are on compatible versions.", + error: error + ) + return nil + } } catch { warnIfBlockedByAppTransportSecurity(error, base: base) return nil diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 2ade494c12..9f29b81e74 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -12,6 +12,36 @@ import Foundation struct DevServerManifest: Decodable, Equatable { let surfaces: [DevServerSurface] + private enum CodingKeys: String, CodingKey { + case surfaces + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Per-element decoding: the CLI writing this manifest versions separately + // from the SDK, so one surface the SDK can't read must not take down the + // surfaces it can. + let decoded = try container.decodeIfPresent( + [Throwable].self, + forKey: .surfaces + ) ?? [] + surfaces = decoded.compactMap { try? $0.result.get() } + + let dropped = decoded.count - surfaces.count + if dropped > 0 { + Logger.debug( + logLevel: .warn, + scope: .superwallCore, + message: "Skipped \(dropped) of \(decoded.count) dev server surfaces that couldn't be " + + "read. Those paywalls will load their published versions." + ) + } + } + + init(surfaces: [DevServerSurface]) { + self.surfaces = surfaces + } + /// 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. diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index c71390859b..8ea576a50d 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -58,6 +58,11 @@ extension Paywall { return paywall } + /// The height a drawer takes when its `config.ts` doesn't name one, as a + /// percentage of the screen. Matches what `PaywallPresentationStyle/drawer` + /// documents. + private static let defaultDrawerHeight: Double = 70 + /// Maps a surface's `config.ts` presentation onto the SDK's styles. /// The framework documents `fullscreen` as its default, so anything /// missing or unrecognized lands there. @@ -72,13 +77,21 @@ extension Paywall { case "noAnimation": return .fullscreenNoAnimation case "drawer": - if let drawer = surface.presentation?.drawer { - return .drawer(height: drawer.height, cornerRadius: drawer.cornerRadius) - } - return .fullscreen + // A drawer that names only some of its geometry is still a drawer: + // PaywallPresentationStyle documents 70% of the screen as the default + // height, and an unset radius means no rounding. + let drawer = surface.presentation?.drawer + return .drawer( + height: drawer?.height ?? defaultDrawerHeight, + cornerRadius: drawer?.cornerRadius ?? 0 + ) case "popup": - if let popup = surface.presentation?.popup { - return .popup(height: popup.height, width: popup.width, cornerRadius: popup.cornerRadius) + // Unlike the drawer, a popup has no documented default size, so one + // without both dimensions falls back to fullscreen. + if let popup = surface.presentation?.popup, + let height = popup.height, + let width = popup.width { + return .popup(height: height, width: width, cornerRadius: popup.cornerRadius ?? 0) } return .fullscreen default: diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 78f4ba5bcb..df54c0bc27 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -11,15 +11,19 @@ import Foundation struct DevServerSurface: Decodable, Equatable { /// How the paywall asks to be presented, straight from its `config.ts`. + /// + /// The geometry is optional throughout: a `config.ts` may set only some of + /// it, and the CLI that writes this manifest versions separately from the + /// SDK, so a block the SDK can't fully read still presents. struct Presentation: Decodable, Equatable { struct Drawer: Decodable, Equatable { - let height: Double - let cornerRadius: Double + let height: Double? + let cornerRadius: Double? } struct Popup: Decodable, Equatable { - let width: Double - let height: Double - let cornerRadius: Double + let width: Double? + let height: Double? + let cornerRadius: Double? } let style: String? @@ -34,4 +38,45 @@ struct DevServerSurface: Decodable, Equatable { let identifier: String? let products: [String: String]? let presentation: Presentation? + + private enum CodingKeys: String, CodingKey { + case kind + case id + case url + case paywallId + case identifier + case products + case presentation + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + kind = try container.decode(String.self, forKey: .kind) + id = try container.decode(String.self, forKey: .id) + url = try container.decode(String.self, forKey: .url) + paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + identifier = try container.decodeIfPresent(String.self, forKey: .identifier) + products = try container.decodeIfPresent([String: String].self, forKey: .products) + // Presentation is a hint, not the surface itself. A block this SDK can't + // read costs the surface its style, not its ability to be served. + presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) + } + + init( + kind: String, + id: String, + url: String, + paywallId: String? = nil, + identifier: String? = nil, + products: [String: String]? = nil, + presentation: Presentation? = nil + ) { + self.kind = kind + self.id = id + self.url = url + self.paywallId = paywallId + self.identifier = identifier + self.products = products + self.presentation = presentation + } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 9fad08a314..3c19ef33f7 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -45,13 +45,17 @@ extension PaywallRequestManager { // 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. + // 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 + // Feature gating belongs to the dashboard, not to the paywall's code: + // dev mode previews how a paywall looks, and must never be what decides + // whether a non-paying user gets the feature. + devPaywall.featureGating = paywall.featureGating Logger.debug( logLevel: .info, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index 88ce07bb20..f78932c483 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -130,4 +130,57 @@ final class DevServerManifestTests: XCTestCase { XCTAssertNil(decoded.mountURL(for: surface, base: base), surface.id) } } + + // MARK: - Tolerating what the SDK can't read + + func test_oneUnreadableSurfaceDoesNotDropTheRest() throws { + // `id` is required, so the middle entry can't decode at all. + let decoded = try manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { "kind": "paywall", "url": "/preview/paywall/nameless" }, + { "kind": "paywall", "id": "max", "url": "/preview/paywall/max" } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro", "max"]) + } + + func test_malformedPresentationStillServesTheSurface() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "presentation": "not-an-object" + } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) + XCTAssertNil(decoded.surfaces[0].presentation) + } + + func test_anUnknownFieldDoesNotDropTheSurface() throws { + let decoded = try manifest(""" + { + "surfaces": [ + { + "kind": "paywall", + "id": "pro", + "url": "/preview/paywall/pro", + "somethingTheCliAddedLater": { "a": 1 } + } + ] + } + """) + XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) + } + + func test_missingSurfacesKeyDecodesAsEmpty() throws { + XCTAssertTrue(try manifest("{}").surfaces.isEmpty) + } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 737d2207d7..ae9c7db917 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -108,17 +108,59 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) } - func test_fallsBackToFullscreenWhenAStyleIsUnknownOrIncomplete() { + func test_fallsBackToFullscreenWhenAStyleIsUnknown() { let unknown = Paywall.devServer( surface: surface(presentation: #"{"style": "hologram"}"#), url: url ) XCTAssertEqual(unknown.presentation.style, .fullscreen) + } + + // MARK: - Partly specified geometry - let drawerWithoutGeometry = Paywall.devServer( + func test_drawerWithoutGeometryUsesTheDocumentedDefaults() { + let drawer = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer"}"#), url: url ) - XCTAssertEqual(drawerWithoutGeometry.presentation.style, .fullscreen) + // 70% of the screen is what PaywallPresentationStyle.drawer documents. + XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 0)) + } + + func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 0)) + + let radiusOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), + url: url + ) + XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) + } + + func test_popupWithoutBothDimensionsFallsBackToFullscreen() { + // A popup has no documented default size, so a partial one can't be honoured. + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .fullscreen) + + let noGeometry = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup"}"#), + url: url + ) + XCTAssertEqual(noGeometry.presentation.style, .fullscreen) + } + + func test_popupWithBothDimensionsDefaultsOnlyItsRadius() { + let popup = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), + url: url + ) + XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) } } From 5181d83e08a3b97c1304198b3aadee7290dcc27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:38:37 +0200 Subject: [PATCH 25/32] review: inherit dashboard-owned behaviour, keep the manifest identifiable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev-server override synthesizes the paywall, so every field not named was silently taking the local stub's default. featureGating was the first one caught; these are the rest that change behaviour rather than looks. The surface can't express any of them — the manifest carries only kind, id, url, paywallId, identifier, products and presentation — so the dashboard is the only source and there is nothing to override: - computedPropertyRequests, or a local render lacks variables production resolves and you debug a template bug that doesn't exist - introOfferEligibility, which drives displayed trial state and pricing - surveys and localNotifications, dashboard behaviour that otherwise just stops happening Inheritance now lives in Paywall.devServer(surface:url:inheriting:) rather than as a list at the call site, so there is one place to add to. Also fixes the regression from eedda06: making `surfaces` optional removed the only field identifying the JSON as a superwall manifest, so any process answering a candidate port with a JSON object ended the port walk silently. The key is required again, the probe checks the HTTP status, and the unreadable-manifest log no longer claims the responder is a dev server when the probe can't know that. Drops an unused init and a vacuous test. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerLocator.swift | 47 +++++--- .../DevServer/DevServerManifest.swift | 9 +- .../DevServer/DevServerPaywall.swift | 49 +++++++-- .../DevServer/DevServerSurface.swift | 18 ---- .../Operators/RawPaywallResponse.swift | 24 ++--- .../DevServer/DevServerManifestTests.swift | 23 ++-- .../DevServer/DevServerPaywallTests.swift | 101 ++++++++++++++++++ 7 files changed, 202 insertions(+), 69 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerLocator.swift b/Sources/SuperwallKit/DevServer/DevServerLocator.swift index d354c85513..cf2c7814a4 100644 --- a/Sources/SuperwallKit/DevServer/DevServerLocator.swift +++ b/Sources/SuperwallKit/DevServer/DevServerLocator.swift @@ -87,6 +87,30 @@ actor DevServerLocator { ) } + /// Logs a body that carries `surfaces` but wouldn't decode. + /// + /// The probe walks several ports and can't know what is listening on each, + /// so this neither claims the responder is a dev server nor stays silent + /// when it plainly is one whose manifest this SDK can't read. + private func logUnreadableManifest(data: Data, base: URL, error: Error) { + let json = try? JSONSerialization.jsonObject(with: data) + guard let object = json as? [String: Any], + object["surfaces"] != nil + else { + // Not a manifest at all — something else answered. The port walk moves + // on, and `locate` reports it if nothing else turns up. + return + } + Logger.debug( + logLevel: .error, + scope: .superwallCore, + message: "Something at \(base.absoluteString) answered /device/manifest.json with a " + + "manifest this SDK couldn't read. Those paywalls will load their published " + + "versions. Check that superwall dev and SuperwallKit are on compatible versions.", + error: error + ) + } + private func fetchManifest(from base: URL) async -> DevServerManifest? { guard let manifestURL = URL(string: "/device/manifest.json", relativeTo: base) else { return nil @@ -96,29 +120,26 @@ actor DevServerLocator { request.cachePolicy = .reloadIgnoringLocalCacheData do { - let data: Data = try await withCheckedThrowingContinuation { continuation in - let task = URLSession.shared.dataTask(with: request) { data, _, error in + let (data, response): (Data, URLResponse?) = try await withCheckedThrowingContinuation { continuation in + let task = URLSession.shared.dataTask(with: request) { data, response, error in if let data = data { - continuation.resume(returning: data) + continuation.resume(returning: (data, response)) } else { continuation.resume(throwing: error ?? URLError(.badServerResponse)) } } task.resume() } + // Something else on this port may answer an unknown path with a JSON + // error body, so the status has to rule that out before the body does. + if let http = response as? HTTPURLResponse, + !(200..<300).contains(http.statusCode) { + return nil + } do { return try JSONDecoder().decode(DevServerManifest.self, from: data) } catch { - // A server answered; its manifest just didn't parse. Say so, or the - // caller's "no server found" log points at the wrong cause. - Logger.debug( - logLevel: .error, - scope: .superwallCore, - message: "The superwall dev server at \(base.absoluteString) answered with a manifest " - + "this SDK couldn't read. Paywalls will load their published versions. " - + "Check that superwall dev and SuperwallKit are on compatible versions.", - error: error - ) + logUnreadableManifest(data: data, base: base, error: error) return nil } } catch { diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 9f29b81e74..1ab09a8d63 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -21,10 +21,11 @@ struct DevServerManifest: Decodable, Equatable { // Per-element decoding: the CLI writing this manifest versions separately // from the SDK, so one surface the SDK can't read must not take down the // surfaces it can. - let decoded = try container.decodeIfPresent( - [Throwable].self, - forKey: .surfaces - ) ?? [] + // Required: `surfaces` is the only thing that tells this JSON apart from + // whatever else might answer on a candidate port, so a body without it + // must fail rather than end the port walk. A project with no surfaces + // still sends `{"surfaces": []}`. + let decoded = try container.decode([Throwable].self, forKey: .surfaces) surfaces = decoded.compactMap { try? $0.result.get() } let dropped = decoded.count - surfaces.count diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 8ea576a50d..9ad1a21156 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -11,7 +11,21 @@ import Foundation import UIKit extension Paywall { - static func devServer(surface: DevServerSurface, url: URL) -> Paywall { + /// Builds the paywall a dev server surface presents. + /// + /// - Parameter published: the dashboard paywall this surface stands in for, + /// if any. The surface owns what renders and how — its bytes, products and + /// `config.ts` presentation style. Everything the dashboard configures that + /// a manifest can't express is inherited from `published` instead, so dev + /// mode changes how a paywall looks and never how it behaves. + /// + /// Anything added to `Paywall` later defaults to the local stub's value, so + /// if it is dashboard-owned behaviour it belongs in the inherited list below. + static func devServer( + surface: DevServerSurface, + url: URL, + inheriting published: Paywall? = nil + ) -> Paywall { let products = (surface.products ?? [:]) .sorted { $0.key < $1.key } .map { reference, identifier in @@ -23,11 +37,21 @@ extension Paywall { ) } + let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" + let identifier: String = surface.identifier ?? "dev:\(surface.id)" + let cacheKey = "dev:\(surface.id):\(url.absoluteString)" + let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() + let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated + let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] + let localNotifications: [LocalNotification] = published?.localNotifications ?? [] + let surveys: [Survey] = published?.surveys ?? [] + let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + var paywall = Paywall( - databaseId: surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)", - identifier: surface.identifier ?? "dev:\(surface.id)", + databaseId: databaseId, + identifier: identifier, name: surface.id, - cacheKey: "dev:\(surface.id):\(url.absoluteString)", + cacheKey: cacheKey, buildId: "dev", url: url, urlConfig: WebViewURLConfig( @@ -46,15 +70,28 @@ extension Paywall { productItems: products, productIds: products.map { $0.id }, appStoreProductIds: products.map { $0.id }, - responseLoadingInfo: .init(), + responseLoadingInfo: responseLoadingInfo, webviewLoadingInfo: .init(), productsLoadingInfo: .init(), shimmerLoadingInfo: .init(), paywalljsVersion: "", + // Feature gating decides whether a non-paying user gets the feature, so + // it can never come from local paywall code. + featureGating: featureGating, + // Dashboard-configured behaviour that fires around the paywall rather + // than inside it. + localNotifications: localNotifications, + // The variables the page reads: without these, a local render silently + // lacks computed properties that production resolves. + computedPropertyRequests: computedPropertyRequests, + surveys: surveys, isScrollEnabled: true, - introOfferEligibility: .automatic + // Drives displayed trial state and pricing, which is exactly what a + // local preview is checked against. + introOfferEligibility: introOfferEligibility ) paywall.isLocal = true + paywall.experiment = published?.experiment return paywall } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index df54c0bc27..940c7a8342 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -61,22 +61,4 @@ struct DevServerSurface: Decodable, Equatable { // read costs the surface its style, not its ability to be served. presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) } - - init( - kind: String, - id: String, - url: String, - paywallId: String? = nil, - identifier: String? = nil, - products: [String: String]? = nil, - presentation: Presentation? = nil - ) { - self.kind = kind - self.id = id - self.url = url - self.paywallId = paywallId - self.identifier = identifier - self.products = products - self.presentation = presentation - } } diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index 3c19ef33f7..cfd89a7088 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,19 +43,17 @@ extension PaywallRequestManager { return paywall } - // The local surface replaces the published paywall wholesale, so what - // presents is exactly what its config.ts declares — products included. - // 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 - // Feature gating belongs to the dashboard, not to the paywall's code: - // dev mode previews how a paywall looks, and must never be what decides - // whether a non-paying user gets the feature. - devPaywall.featureGating = paywall.featureGating + // The local surface replaces the published paywall's bytes, products and + // presentation, and inherits everything the dashboard configures that a + // manifest can't express — see Paywall.devServer(surface:url:inheriting:). + // 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. + let devPaywall = Paywall.devServer( + surface: surface, + url: mountURL, + inheriting: paywall + ) Logger.debug( logLevel: .info, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index f78932c483..222fb1cf60 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -164,23 +164,16 @@ final class DevServerManifestTests: XCTestCase { XCTAssertNil(decoded.surfaces[0].presentation) } - func test_anUnknownFieldDoesNotDropTheSurface() throws { - let decoded = try manifest(""" - { - "surfaces": [ - { - "kind": "paywall", - "id": "pro", - "url": "/preview/paywall/pro", - "somethingTheCliAddedLater": { "a": 1 } - } - ] + func test_aBodyWithoutSurfacesIsNotAManifest() { + // `surfaces` is what tells this JSON apart from anything else that might + // answer on a candidate port, so these must not decode — otherwise the + // port walk stops on the wrong process. + for body in ["{}", #"{"detail": "Not Found"}"#, #"{"error": {"code": 404}}"#] { + XCTAssertThrowsError(try manifest(body), body) } - """) - XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) } - func test_missingSurfacesKeyDecodesAsEmpty() throws { - XCTAssertTrue(try manifest("{}").surfaces.isEmpty) + func test_anEmptySurfaceListIsStillAManifest() throws { + XCTAssertTrue(try manifest(#"{"surfaces": []}"#).surfaces.isEmpty) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index ae9c7db917..937ce3bf6f 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -163,4 +163,105 @@ final class DevServerPaywallTests: XCTestCase { ) XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) } + + // MARK: - What the dashboard keeps owning + + /// The manifest can't express any of these, so a bound surface has to take + /// them from the paywall it stands in for or they vanish silently. + func test_inheritsDashboardOwnedBehaviourFromThePublishedPaywall() { + var published = Paywall.stub() + published.featureGating = .gated + published.surveys = [Survey.stub()] + published.localNotifications = [LocalNotification.stub()] + + let paywall = Paywall.devServer( + surface: surface(), + url: url, + inheriting: published + ) + + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.surveys.count, 1) + XCTAssertEqual(paywall.localNotifications.count, 1) + } + + func test_inheritsComputedPropertiesAndIntroOfferEligibility() throws { + let json = """ + { + "computedPropertyRequests": [ + { "type": "HOURS_SINCE", "eventName": "trigger1" } + ], + "introductoryOfferEligibility": "INELIGIBLE" + } + """ + // Decoded rather than hand-built so the test pins the real dashboard shape. + struct Fields: Decodable { + let computedPropertyRequests: [ComputedPropertyRequest] + let introductoryOfferEligibility: IntroOfferEligibility + } + let fields = try JSONDecoder().decode(Fields.self, from: Data(json.utf8)) + + var published = Paywall.stub() + published = Paywall( + databaseId: published.databaseId, + identifier: published.identifier, + name: published.name, + cacheKey: published.cacheKey, + buildId: published.buildId, + url: published.url, + urlConfig: published.urlConfig, + htmlSubstitutions: published.htmlSubstitutions, + presentation: published.presentation, + backgroundColorHex: published.backgroundColorHex, + backgroundColor: published.backgroundColor, + darkBackgroundColorHex: nil, + darkBackgroundColor: nil, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + computedPropertyRequests: fields.computedPropertyRequests, + isScrollEnabled: true, + introOfferEligibility: fields.introductoryOfferEligibility + ) + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.computedPropertyRequests.count, 1) + XCTAssertEqual(paywall.introOfferEligibility, fields.introductoryOfferEligibility) + } + + func test_anUnboundSurfaceKeepsTheSafeDefaults() { + // The debugger previews surfaces with no dashboard counterpart, so there + // is nothing to inherit and gating must stay off rather than guess. + let paywall = Paywall.devServer(surface: surface(), url: url) + + XCTAssertEqual(paywall.featureGating, .nonGated) + XCTAssertTrue(paywall.surveys.isEmpty) + XCTAssertTrue(paywall.localNotifications.isEmpty) + XCTAssertTrue(paywall.computedPropertyRequests.isEmpty) + XCTAssertEqual(paywall.introOfferEligibility, .automatic) + } + + func test_theLocalSurfaceStillOwnsWhatItRenders() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer( + surface: surface(products: ["plus": "local_product"], presentation: #"{"style": "modal"}"#), + url: url, + inheriting: published + ) + + // Inherited behaviour must not drag the published rendering along with it. + XCTAssertEqual(paywall.url, url) + XCTAssertEqual(paywall.productIds, ["local_product"]) + XCTAssertEqual(paywall.presentation.style, .modal) + XCTAssertTrue(paywall.isLocal) + XCTAssertNil(paywall.manifest) + } } From 8dee6689e7148c8bb1184efaddad2bb3e7b461ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:44:03 +0200 Subject: [PATCH 26/32] fix(dev): let the local paywall keep owning its notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paywall's config.ts can declare notifications (SuperwallNotificationsConfig in the CLI's runtime package), and they reach the SDK as `schedule_notification` messages rather than through Paywall.localNotifications. So notifications are not dashboard-only, and inheriting them was wrong: NotificationScheduler dedupes on paywallId + type, so for a bound surface the dashboard's copy could win that filter and fire instead of the local one you are iterating on. The other three inherited fields stand — the manifest still can't express featureGating, computedPropertyRequests or introOfferEligibility. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/DevServer/DevServerPaywall.swift | 10 ++++++---- .../DevServer/DevServerPaywallTests.swift | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 9ad1a21156..e3e369cf0a 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -43,7 +43,6 @@ extension Paywall { let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] - let localNotifications: [LocalNotification] = published?.localNotifications ?? [] let surveys: [Survey] = published?.surveys ?? [] let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic @@ -78,9 +77,12 @@ extension Paywall { // Feature gating decides whether a non-paying user gets the feature, so // it can never come from local paywall code. featureGating: featureGating, - // Dashboard-configured behaviour that fires around the paywall rather - // than inside it. - localNotifications: localNotifications, + // Deliberately not inherited: a local paywall declares its own + // notifications in config.ts, and they reach the SDK as + // `schedule_notification` messages rather than through this field. + // Inheriting the dashboard's would let a stale copy win the + // paywallId+type dedupe in NotificationScheduler. + localNotifications: [], // The variables the page reads: without these, a local render silently // lacks computed properties that production resolves. computedPropertyRequests: computedPropertyRequests, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 937ce3bf6f..39fbae1708 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -172,7 +172,6 @@ final class DevServerPaywallTests: XCTestCase { var published = Paywall.stub() published.featureGating = .gated published.surveys = [Survey.stub()] - published.localNotifications = [LocalNotification.stub()] let paywall = Paywall.devServer( surface: surface(), @@ -182,7 +181,18 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.featureGating, .gated) XCTAssertEqual(paywall.surveys.count, 1) - XCTAssertEqual(paywall.localNotifications.count, 1) + } + + func test_doesNotInheritNotificationsTheLocalPaywallDeclaresItself() { + // config.ts can declare notifications, and they arrive as + // `schedule_notification` messages. Inheriting the dashboard's would let + // a stale copy win NotificationScheduler's paywallId+type dedupe. + var published = Paywall.stub() + published.localNotifications = [LocalNotification.stub()] + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertTrue(paywall.localNotifications.isEmpty) } func test_inheritsComputedPropertiesAndIntroOfferEligibility() throws { From 002765772df7ecbbf01f7ea4d37ac756930c2a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:31:22 +0200 Subject: [PATCH 27/32] fix(dev): inherit what the manifest can't carry, honour multi-paywall bindings Matches the SDK to the manifest the shipped CLI actually sends: kind, id, url, paywallId, paywallIds, identifier and products. Everything else a paywall's config.ts declares reaches the SDK only after a push, so a dev-served paywall has to take it from the published paywall it stands in for rather than from a hardcoded stub value. - presentation: a bound paywall was being forced to .fullscreen, losing the drawer or modal style the dashboard configured. The style now falls back to the published paywall's when the manifest declares none. - background colours and isScrollEnabled inherit for the same reason; the hardcoded white also flashed on load in dark mode. - paywallIds: superwall.lock can bind one surface to several paywalls and the CLI already sends the whole set, but matching read only the singular, so a multi-bound surface served its first paywall and fell through to published for the rest. - featureGating and introductoryOfferEligibility are read off the surface first when present. Nothing sends them yet, so this is inert, but it means no matching SDK release is needed once they are carried. - presentation defaults now mirror the CLI's DRAWER_DEFAULTS and POPUP_DEFAULTS (70/15, 80x60/15) instead of a guessed zero radius, so a partly specified block presents the same before and after a push. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerManifest.swift | 10 +- .../DevServer/DevServerPaywall.swift | 129 +++++++++++++----- .../DevServer/DevServerSurface.swift | 24 ++++ .../DevServer/DevServerManifestTests.swift | 24 ++++ .../DevServer/DevServerPaywallTests.swift | 114 +++++++++++++--- 5 files changed, 245 insertions(+), 56 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerManifest.swift b/Sources/SuperwallKit/DevServer/DevServerManifest.swift index 1ab09a8d63..017bbe8a70 100644 --- a/Sources/SuperwallKit/DevServer/DevServerManifest.swift +++ b/Sources/SuperwallKit/DevServer/DevServerManifest.swift @@ -47,7 +47,15 @@ struct DevServerManifest: Decodable, Equatable { /// `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 }) { + let bound = surfaces.first { surface in + if surface.paywallId == databaseId { + return true + } + // superwall.lock can bind one surface to several paywalls; the CLI + // sends the first as `paywallId` and the whole set as `paywallIds`. + return surface.paywallIds?.contains(databaseId) ?? false + } + if let bound = bound { return bound } let paywalls = surfaces.filter { $0.kind == "paywall" } diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index e3e369cf0a..466534406e 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -26,25 +26,30 @@ extension Paywall { url: URL, inheriting published: Paywall? = nil ) -> 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: [] - ) - } + let products = productItems(from: surface) let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" let identifier: String = surface.identifier ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() - let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated + // The surface's own config.ts wins, then the dashboard's published + // setting, then the safe default. That order is what lets an unpushed + // config.ts edit take effect while a bound paywall still behaves like + // production until you change it. + let featureGating: FeatureGatingBehavior = gating(from: surface) + ?? published?.featureGating + ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] - let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + let introOfferEligibility: IntroOfferEligibility = eligibility(from: surface) + ?? published?.introOfferEligibility + ?? .automatic + let presentation: PaywallPresentationInfo + if let style = presentationStyle(for: surface) { + presentation = PaywallPresentationInfo(style: style, delay: 0) + } else { + presentation = published?.presentation ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) + } var paywall = Paywall( databaseId: databaseId, @@ -58,14 +63,11 @@ extension Paywall { maxAttempts: 1 ), htmlSubstitutions: "", - presentation: PaywallPresentationInfo( - style: presentationStyle(for: surface), - delay: 0 - ), - backgroundColorHex: "#FFFFFF", - backgroundColor: .white, - darkBackgroundColorHex: nil, - darkBackgroundColor: nil, + presentation: presentation, + backgroundColorHex: published?.backgroundColorHex ?? "#FFFFFF", + backgroundColor: published?.backgroundColor ?? .white, + darkBackgroundColorHex: published?.darkBackgroundColorHex, + darkBackgroundColor: published?.darkBackgroundColor, productItems: products, productIds: products.map { $0.id }, appStoreProductIds: products.map { $0.id }, @@ -87,7 +89,7 @@ extension Paywall { // lacks computed properties that production resolves. computedPropertyRequests: computedPropertyRequests, surveys: surveys, - isScrollEnabled: true, + isScrollEnabled: published?.isScrollEnabled ?? true, // Drives displayed trial state and pricing, which is exactly what a // local preview is checked against. introOfferEligibility: introOfferEligibility @@ -97,17 +99,72 @@ extension Paywall { return paywall } - /// The height a drawer takes when its `config.ts` doesn't name one, as a - /// percentage of the screen. Matches what `PaywallPresentationStyle/drawer` - /// documents. + /// The products a surface's `config.ts` declares, in a stable order. + private static func productItems(from surface: DevServerSurface) -> [Product] { + return (surface.products ?? [:]) + .sorted { $0.key < $1.key } + .map { reference, identifier in + Product( + name: reference, + type: .appStore(.init(id: identifier)), + id: identifier, + entitlements: [] + ) + } + } + + /// Maps a surface's `config.ts` feature gating onto the SDK's enum. + /// + /// Returns nil for anything unrecognised — including a CLI newer than this + /// SDK — so the caller falls back rather than guessing how a feature gates. + private static func gating(from surface: DevServerSurface) -> FeatureGatingBehavior? { + switch surface.featureGating { + case "gated": + return .gated + case "nonGated": + return .nonGated + default: + return nil + } + } + + /// Maps a surface's `config.ts` trial eligibility onto the SDK's enum, + /// returning nil for anything unrecognised. + private static func eligibility(from surface: DevServerSurface) -> IntroOfferEligibility? { + switch surface.introductoryOfferEligibility { + case "automatic": + return .automatic + case "alwaysEligible": + return .eligible + case "alwaysIneligible": + return .ineligible + default: + return nil + } + } + + /// Geometry a `config.ts` presentation block gets when it doesn't name its + /// own. These mirror the `superwall` CLI's DRAWER_DEFAULTS and + /// POPUP_DEFAULTS, which resolve the same values on push, so a partly + /// specified block presents identically before and after one. The drawer + /// height also matches what `PaywallPresentationStyle/drawer` documents. private static let defaultDrawerHeight: Double = 70 + private static let defaultDrawerCornerRadius: Double = 15 + private static let defaultPopupWidth: Double = 80 + private static let defaultPopupHeight: Double = 60 + private static let defaultPopupCornerRadius: Double = 15 /// Maps a surface's `config.ts` presentation onto the SDK's styles. /// The framework documents `fullscreen` as its default, so anything /// missing or unrecognized lands there. private static func presentationStyle( for surface: DevServerSurface - ) -> PaywallPresentationStyle { + ) -> PaywallPresentationStyle? { + if surface.presentation == nil { + // The manifest says nothing about presentation, so the dashboard's + // style stands rather than being replaced by a guess. + return nil + } switch surface.presentation?.style { case "modal": return .modal @@ -116,23 +173,21 @@ extension Paywall { case "noAnimation": return .fullscreenNoAnimation case "drawer": - // A drawer that names only some of its geometry is still a drawer: - // PaywallPresentationStyle documents 70% of the screen as the default - // height, and an unset radius means no rounding. + // A drawer that names only some of its geometry is still a drawer. + // These match the CLI's own DRAWER_DEFAULTS, so a partly specified + // drawer looks the same here as it will once it's pushed. let drawer = surface.presentation?.drawer return .drawer( height: drawer?.height ?? defaultDrawerHeight, - cornerRadius: drawer?.cornerRadius ?? 0 + cornerRadius: drawer?.cornerRadius ?? defaultDrawerCornerRadius ) case "popup": - // Unlike the drawer, a popup has no documented default size, so one - // without both dimensions falls back to fullscreen. - if let popup = surface.presentation?.popup, - let height = popup.height, - let width = popup.width { - return .popup(height: height, width: width, cornerRadius: popup.cornerRadius ?? 0) - } - return .fullscreen + let popup = surface.presentation?.popup + return .popup( + height: popup?.height ?? defaultPopupHeight, + width: popup?.width ?? defaultPopupWidth, + cornerRadius: popup?.cornerRadius ?? defaultPopupCornerRadius + ) default: return .fullscreen } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 940c7a8342..4e4d9c4729 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -35,18 +35,36 @@ struct DevServerSurface: Decodable, Equatable { let id: String let url: String let paywallId: String? + + /// Every dashboard paywall this surface is bound to, when `superwall.lock` + /// binds it to more than one. The CLI sends `paywallId` for the first and + /// this for the full set. + let paywallIds: [String]? let identifier: String? let products: [String: String]? let presentation: Presentation? + /// `config.ts` feature gating: "gated" or "nonGated". + /// + /// Kept as the raw string so a value this SDK doesn't recognise falls back + /// to the published paywall's setting instead of failing the surface. + let featureGating: String? + + /// `config.ts` trial eligibility: "automatic", "alwaysEligible" or + /// "alwaysIneligible". Raw for the same reason as `featureGating`. + let introductoryOfferEligibility: String? + private enum CodingKeys: String, CodingKey { case kind case id case url case paywallId + case paywallIds case identifier case products case presentation + case featureGating + case introductoryOfferEligibility } init(from decoder: Decoder) throws { @@ -55,10 +73,16 @@ struct DevServerSurface: Decodable, Equatable { id = try container.decode(String.self, forKey: .id) url = try container.decode(String.self, forKey: .url) paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) + paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) identifier = try container.decodeIfPresent(String.self, forKey: .identifier) products = try container.decodeIfPresent([String: String].self, forKey: .products) // Presentation is a hint, not the surface itself. A block this SDK can't // read costs the surface its style, not its ability to be served. presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) + featureGating = try container.decodeIfPresent(String.self, forKey: .featureGating) + introductoryOfferEligibility = try container.decodeIfPresent( + String.self, + forKey: .introductoryOfferEligibility + ) } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index 222fb1cf60..d4b45e31ac 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -176,4 +176,28 @@ final class DevServerManifestTests: XCTestCase { func test_anEmptySurfaceListIsStillAManifest() throws { XCTAssertTrue(try manifest(#"{"surfaces": []}"#).surfaces.isEmpty) } + + func test_matchesASurfaceBoundToSeveralPaywalls() { + // superwall.lock can bind one surface to several paywalls: the CLI sends + // the first as `paywallId` and the whole set as `paywallIds`. + // swiftlint:disable:next force_try + let decoded = try! manifest(""" + { + "surfaces": [ + { "kind": "paywall", "id": "pro", "url": "/preview/paywall/pro" }, + { + "kind": "paywall", + "id": "shared", + "url": "/preview/paywall/shared", + "paywallId": "111", + "paywallIds": ["111", "222", "333"] + } + ] + } + """) + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "111")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "222")?.id, "shared") + XCTAssertEqual(decoded.surface(forPaywallDatabaseId: "333")?.id, "shared") + XCTAssertNil(decoded.surface(forPaywallDatabaseId: "444")) + } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 39fbae1708..f7bb9e62b4 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -12,7 +12,9 @@ final class DevServerPaywallTests: XCTestCase { paywallId: String? = nil, identifier: String? = nil, products: [String: String]? = nil, - presentation: String? = nil + presentation: String? = nil, + featureGating: String? = nil, + introductoryOfferEligibility: String? = nil ) -> DevServerSurface { let json = """ { @@ -22,6 +24,10 @@ final class DevServerPaywallTests: XCTestCase { \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") \(presentation.map { "\"presentation\": \($0)," } ?? "") + \(featureGating.map { "\"featureGating\": \"\($0)\"," } ?? "") + \(introductoryOfferEligibility.map { + "\"introductoryOfferEligibility\": \"\($0)\"," + } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -83,7 +89,24 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(params["is_local"] as? Bool, true) } - func test_presentsFullscreenWhenTheConfigSaysNothing() { + func test_inheritsTheDashboardStyleWhenTheManifestSaysNothing() { + // The shipped manifest carries no presentation, so a bound paywall has to + // keep the style the dashboard configured rather than snap to fullscreen. + var published = Paywall.stub() + XCTAssertEqual(published.presentation.style, .modal) + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.presentation.style, .modal) + + // Visual settings the manifest can't carry come from there too. + published = Paywall.stub() + let visuals = Paywall.devServer(surface: surface(), url: url, inheriting: published) + XCTAssertEqual(visuals.backgroundColorHex, published.backgroundColorHex) + XCTAssertEqual(visuals.isScrollEnabled, published.isScrollEnabled) + } + + func test_presentsFullscreenWhenNothingDeclaresAStyle() { let paywall = Paywall.devServer(surface: surface(), url: url) XCTAssertEqual(paywall.presentation.style, .fullscreen) } @@ -118,13 +141,13 @@ final class DevServerPaywallTests: XCTestCase { // MARK: - Partly specified geometry - func test_drawerWithoutGeometryUsesTheDocumentedDefaults() { + func test_drawerWithoutGeometryUsesTheCliDefaults() { let drawer = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer"}"#), url: url ) - // 70% of the screen is what PaywallPresentationStyle.drawer documents. - XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 0)) + // Mirrors the CLI's DRAWER_DEFAULTS, which resolves the same values on push. + XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 15)) } func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { @@ -132,7 +155,7 @@ final class DevServerPaywallTests: XCTestCase { surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), url: url ) - XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 0)) + XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 15)) let radiusOnly = Paywall.devServer( surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), @@ -141,27 +164,28 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) } - func test_popupWithoutBothDimensionsFallsBackToFullscreen() { - // A popup has no documented default size, so a partial one can't be honoured. - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .fullscreen) - + func test_popupWithoutGeometryUsesTheCliDefaults() { + // Mirrors the CLI's POPUP_DEFAULTS rather than falling back to fullscreen, + // so a partly specified popup is still a popup. let noGeometry = Paywall.devServer( surface: surface(presentation: #"{"style": "popup"}"#), url: url ) - XCTAssertEqual(noGeometry.presentation.style, .fullscreen) + XCTAssertEqual(noGeometry.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) } - func test_popupWithBothDimensionsDefaultsOnlyItsRadius() { - let popup = Paywall.devServer( + func test_popupKeepsTheValuesItDoesNameAndDefaultsTheRest() { + let heightOnly = Paywall.devServer( + surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), + url: url + ) + XCTAssertEqual(heightOnly.presentation.style, .popup(height: 500, width: 80, cornerRadius: 15)) + + let sized = Paywall.devServer( surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), url: url ) - XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 0)) + XCTAssertEqual(sized.presentation.style, .popup(height: 500, width: 300, cornerRadius: 15)) } // MARK: - What the dashboard keeps owning @@ -274,4 +298,58 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertTrue(paywall.isLocal) XCTAssertNil(paywall.manifest) } + + // MARK: - The surface's own settings win + + func test_theSurfacesOwnGatingBeatsThePublishedPaywalls() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer( + surface: surface(featureGating: "nonGated"), + url: url, + inheriting: published + ) + + // An unpushed config.ts edit has to take effect, or dev mode shows the + // setting you just changed away from. + XCTAssertEqual(paywall.featureGating, .nonGated) + } + + func test_theSurfacesOwnEligibilityBeatsThePublishedPaywalls() { + let paywall = Paywall.devServer( + surface: surface(introductoryOfferEligibility: "alwaysIneligible"), + url: url, + inheriting: Paywall.stub() + ) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + } + + func test_settingsTheSurfaceOmitsStillComeFromTheDashboard() { + var published = Paywall.stub() + published.featureGating = .gated + + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + + XCTAssertEqual(paywall.featureGating, .gated) + } + + func test_aValueThisSdkDoesNotKnowFallsBackRatherThanGuessing() { + var published = Paywall.stub() + published.featureGating = .gated + + // A CLI newer than this SDK must not silently ungate a paywall. + let paywall = Paywall.devServer( + surface: surface(featureGating: "someFutureMode"), + url: url, + inheriting: published + ) + + XCTAssertEqual(paywall.featureGating, .gated) + } + + func test_anUnboundSurfaceStillHonoursItsOwnGating() { + let paywall = Paywall.devServer(surface: surface(featureGating: "gated"), url: url) + XCTAssertEqual(paywall.featureGating, .gated) + } } From bff8c7919bbc602aa93568257e19cd5fea6c4b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:51:58 +0200 Subject: [PATCH 28/32] refactor(dev): read paywall settings from the published paywall only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paywall's config.ts settings — presentation, feature gating, intro offer eligibility — reach the SDK in the snapshot that `superwall push` uploads, not on the dev server's manifest. Verified against the shipped CLI (1.2.0): buildDeviceManifest emits kind, id, url, paywallId, paywallIds, identifier and products, and a running dev server returns exactly that for a config that declares a drawer presentation. So the surface-level decoding of those settings could never fire. This drops DevServerSurface's presentation, featureGating and introductoryOfferEligibility along with the style mapping and the drawer/popup geometry defaults that mirrored the CLI's, leaving the manifest's real shape and a plain synthesized Decodable. The settings come from the published paywall the surface stands in for, falling back to safe defaults when there isn't one. Removes ten tests that exercised the unreachable paths. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 104 ++------------ .../DevServer/DevServerSurface.swift | 77 ++-------- .../DevServer/DevServerManifestTests.swift | 16 --- .../DevServer/DevServerPaywallTests.swift | 136 +----------------- 4 files changed, 23 insertions(+), 310 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 466534406e..390b7ca6d1 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -32,24 +32,16 @@ extension Paywall { let identifier: String = surface.identifier ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() - // The surface's own config.ts wins, then the dashboard's published - // setting, then the safe default. That order is what lets an unpushed - // config.ts edit take effect while a bound paywall still behaves like - // production until you change it. - let featureGating: FeatureGatingBehavior = gating(from: surface) - ?? published?.featureGating - ?? .nonGated + // A paywall's config.ts settings reach the SDK in the pushed snapshot, + // not the dev manifest, so they come from the published paywall this + // surface stands in for. Without one — a surface that has never been + // pushed — the safe defaults stand. + let featureGating: FeatureGatingBehavior = published?.featureGating ?? .nonGated let computedPropertyRequests: [ComputedPropertyRequest] = published?.computedPropertyRequests ?? [] let surveys: [Survey] = published?.surveys ?? [] - let introOfferEligibility: IntroOfferEligibility = eligibility(from: surface) - ?? published?.introOfferEligibility - ?? .automatic - let presentation: PaywallPresentationInfo - if let style = presentationStyle(for: surface) { - presentation = PaywallPresentationInfo(style: style, delay: 0) - } else { - presentation = published?.presentation ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) - } + let introOfferEligibility: IntroOfferEligibility = published?.introOfferEligibility ?? .automatic + let presentation = published?.presentation + ?? PaywallPresentationInfo(style: .fullscreen, delay: 0) var paywall = Paywall( databaseId: databaseId, @@ -112,84 +104,4 @@ extension Paywall { ) } } - - /// Maps a surface's `config.ts` feature gating onto the SDK's enum. - /// - /// Returns nil for anything unrecognised — including a CLI newer than this - /// SDK — so the caller falls back rather than guessing how a feature gates. - private static func gating(from surface: DevServerSurface) -> FeatureGatingBehavior? { - switch surface.featureGating { - case "gated": - return .gated - case "nonGated": - return .nonGated - default: - return nil - } - } - - /// Maps a surface's `config.ts` trial eligibility onto the SDK's enum, - /// returning nil for anything unrecognised. - private static func eligibility(from surface: DevServerSurface) -> IntroOfferEligibility? { - switch surface.introductoryOfferEligibility { - case "automatic": - return .automatic - case "alwaysEligible": - return .eligible - case "alwaysIneligible": - return .ineligible - default: - return nil - } - } - - /// Geometry a `config.ts` presentation block gets when it doesn't name its - /// own. These mirror the `superwall` CLI's DRAWER_DEFAULTS and - /// POPUP_DEFAULTS, which resolve the same values on push, so a partly - /// specified block presents identically before and after one. The drawer - /// height also matches what `PaywallPresentationStyle/drawer` documents. - private static let defaultDrawerHeight: Double = 70 - private static let defaultDrawerCornerRadius: Double = 15 - private static let defaultPopupWidth: Double = 80 - private static let defaultPopupHeight: Double = 60 - private static let defaultPopupCornerRadius: Double = 15 - - /// Maps a surface's `config.ts` presentation onto the SDK's styles. - /// The framework documents `fullscreen` as its default, so anything - /// missing or unrecognized lands there. - private static func presentationStyle( - for surface: DevServerSurface - ) -> PaywallPresentationStyle? { - if surface.presentation == nil { - // The manifest says nothing about presentation, so the dashboard's - // style stands rather than being replaced by a guess. - return nil - } - switch surface.presentation?.style { - case "modal": - return .modal - case "push": - return .push - case "noAnimation": - return .fullscreenNoAnimation - case "drawer": - // A drawer that names only some of its geometry is still a drawer. - // These match the CLI's own DRAWER_DEFAULTS, so a partly specified - // drawer looks the same here as it will once it's pushed. - let drawer = surface.presentation?.drawer - return .drawer( - height: drawer?.height ?? defaultDrawerHeight, - cornerRadius: drawer?.cornerRadius ?? defaultDrawerCornerRadius - ) - case "popup": - let popup = surface.presentation?.popup - return .popup( - height: popup?.height ?? defaultPopupHeight, - width: popup?.width ?? defaultPopupWidth, - cornerRadius: popup?.cornerRadius ?? defaultPopupCornerRadius - ) - default: - return .fullscreen - } - } } diff --git a/Sources/SuperwallKit/DevServer/DevServerSurface.swift b/Sources/SuperwallKit/DevServer/DevServerSurface.swift index 4e4d9c4729..0e41a519e8 100644 --- a/Sources/SuperwallKit/DevServer/DevServerSurface.swift +++ b/Sources/SuperwallKit/DevServer/DevServerSurface.swift @@ -6,83 +6,26 @@ // a locally served paywall or funnel, and the dashboard paywall it is // bound to via `superwall.lock`, if any. // +// The manifest carries identity and products only. Everything else a +// paywall's config.ts declares — presentation, feature gating, intro offer +// eligibility — travels to the dashboard in the pushed snapshot instead, so +// the SDK reads those from the published paywall the surface stands in for. +// import Foundation struct DevServerSurface: Decodable, Equatable { - /// How the paywall asks to be presented, straight from its `config.ts`. - /// - /// The geometry is optional throughout: a `config.ts` may set only some of - /// it, and the CLI that writes this manifest versions separately from the - /// SDK, so a block the SDK can't fully read still presents. - struct Presentation: Decodable, Equatable { - struct Drawer: Decodable, Equatable { - let height: Double? - let cornerRadius: Double? - } - struct Popup: Decodable, Equatable { - let width: Double? - let height: Double? - let cornerRadius: Double? - } - - let style: String? - let drawer: Drawer? - let popup: Popup? - } - let kind: String let id: String let url: String + + /// The dashboard paywall this surface is bound to via `superwall.lock`. let paywallId: String? - /// Every dashboard paywall this surface is bound to, when `superwall.lock` - /// binds it to more than one. The CLI sends `paywallId` for the first and - /// this for the full set. + /// Every paywall this surface is bound to, when the lock binds it to more + /// than one. The CLI sends the first as `paywallId` and the full set here. let paywallIds: [String]? + let identifier: String? let products: [String: String]? - let presentation: Presentation? - - /// `config.ts` feature gating: "gated" or "nonGated". - /// - /// Kept as the raw string so a value this SDK doesn't recognise falls back - /// to the published paywall's setting instead of failing the surface. - let featureGating: String? - - /// `config.ts` trial eligibility: "automatic", "alwaysEligible" or - /// "alwaysIneligible". Raw for the same reason as `featureGating`. - let introductoryOfferEligibility: String? - - private enum CodingKeys: String, CodingKey { - case kind - case id - case url - case paywallId - case paywallIds - case identifier - case products - case presentation - case featureGating - case introductoryOfferEligibility - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - kind = try container.decode(String.self, forKey: .kind) - id = try container.decode(String.self, forKey: .id) - url = try container.decode(String.self, forKey: .url) - paywallId = try container.decodeIfPresent(String.self, forKey: .paywallId) - paywallIds = try container.decodeIfPresent([String].self, forKey: .paywallIds) - identifier = try container.decodeIfPresent(String.self, forKey: .identifier) - products = try container.decodeIfPresent([String: String].self, forKey: .products) - // Presentation is a hint, not the surface itself. A block this SDK can't - // read costs the surface its style, not its ability to be served. - presentation = try? container.decodeIfPresent(Presentation.self, forKey: .presentation) - featureGating = try container.decodeIfPresent(String.self, forKey: .featureGating) - introductoryOfferEligibility = try container.decodeIfPresent( - String.self, - forKey: .introductoryOfferEligibility - ) - } } diff --git a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift index d4b45e31ac..a0837b37c2 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerManifestTests.swift @@ -147,22 +147,6 @@ final class DevServerManifestTests: XCTestCase { XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro", "max"]) } - func test_malformedPresentationStillServesTheSurface() throws { - let decoded = try manifest(""" - { - "surfaces": [ - { - "kind": "paywall", - "id": "pro", - "url": "/preview/paywall/pro", - "presentation": "not-an-object" - } - ] - } - """) - XCTAssertEqual(decoded.surfaces.map { $0.id }, ["pro"]) - XCTAssertNil(decoded.surfaces[0].presentation) - } func test_aBodyWithoutSurfacesIsNotAManifest() { // `surfaces` is what tells this JSON apart from anything else that might diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index f7bb9e62b4..28ce8ec54d 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -11,10 +11,7 @@ final class DevServerPaywallTests: XCTestCase { id: String = "pro", paywallId: String? = nil, identifier: String? = nil, - products: [String: String]? = nil, - presentation: String? = nil, - featureGating: String? = nil, - introductoryOfferEligibility: String? = nil + products: [String: String]? = nil ) -> DevServerSurface { let json = """ { @@ -23,11 +20,6 @@ final class DevServerPaywallTests: XCTestCase { "url": "/preview/paywall/\(id)", \(paywallId.map { "\"paywallId\": \"\($0)\"," } ?? "") \(identifier.map { "\"identifier\": \"\($0)\"," } ?? "") - \(presentation.map { "\"presentation\": \($0)," } ?? "") - \(featureGating.map { "\"featureGating\": \"\($0)\"," } ?? "") - \(introductoryOfferEligibility.map { - "\"introductoryOfferEligibility\": \"\($0)\"," - } ?? "") "products": \(products.map { dict in "{" + dict.map { "\"\($0.key)\": \"\($0.value)\"" }.sorted().joined(separator: ",") + "}" } ?? "null") @@ -111,83 +103,8 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.presentation.style, .fullscreen) } - func test_usesThePresentationStyleTheConfigDeclares() { - let modal = Paywall.devServer( - surface: surface(presentation: #"{"style": "modal"}"#), - url: url - ) - XCTAssertEqual(modal.presentation.style, .modal) - - let drawer = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420, "cornerRadius": 24}}"#), - url: url - ) - XCTAssertEqual(drawer.presentation.style, .drawer(height: 420, cornerRadius: 24)) - - let popup = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500, "cornerRadius": 16}}"#), - url: url - ) - XCTAssertEqual(popup.presentation.style, .popup(height: 500, width: 300, cornerRadius: 16)) - } - - func test_fallsBackToFullscreenWhenAStyleIsUnknown() { - let unknown = Paywall.devServer( - surface: surface(presentation: #"{"style": "hologram"}"#), - url: url - ) - XCTAssertEqual(unknown.presentation.style, .fullscreen) - } - // MARK: - Partly specified geometry - func test_drawerWithoutGeometryUsesTheCliDefaults() { - let drawer = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer"}"#), - url: url - ) - // Mirrors the CLI's DRAWER_DEFAULTS, which resolves the same values on push. - XCTAssertEqual(drawer.presentation.style, .drawer(height: 70, cornerRadius: 15)) - } - - func test_drawerKeepsTheValuesItDoesNameAndDefaultsTheRest() { - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"height": 420}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .drawer(height: 420, cornerRadius: 15)) - - let radiusOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "drawer", "drawer": {"cornerRadius": 24}}"#), - url: url - ) - XCTAssertEqual(radiusOnly.presentation.style, .drawer(height: 70, cornerRadius: 24)) - } - - func test_popupWithoutGeometryUsesTheCliDefaults() { - // Mirrors the CLI's POPUP_DEFAULTS rather than falling back to fullscreen, - // so a partly specified popup is still a popup. - let noGeometry = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup"}"#), - url: url - ) - XCTAssertEqual(noGeometry.presentation.style, .popup(height: 60, width: 80, cornerRadius: 15)) - } - - func test_popupKeepsTheValuesItDoesNameAndDefaultsTheRest() { - let heightOnly = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"height": 500}}"#), - url: url - ) - XCTAssertEqual(heightOnly.presentation.style, .popup(height: 500, width: 80, cornerRadius: 15)) - - let sized = Paywall.devServer( - surface: surface(presentation: #"{"style": "popup", "popup": {"width": 300, "height": 500}}"#), - url: url - ) - XCTAssertEqual(sized.presentation.style, .popup(height: 500, width: 300, cornerRadius: 15)) - } - // MARK: - What the dashboard keeps owning /// The manifest can't express any of these, so a bound surface has to take @@ -286,43 +203,18 @@ final class DevServerPaywallTests: XCTestCase { published.featureGating = .gated let paywall = Paywall.devServer( - surface: surface(products: ["plus": "local_product"], presentation: #"{"style": "modal"}"#), + surface: surface(products: ["plus": "local_product"]), url: url, inheriting: published ) - // Inherited behaviour must not drag the published rendering along with it. + // The surface owns what it serves: its URL and its own products. XCTAssertEqual(paywall.url, url) XCTAssertEqual(paywall.productIds, ["local_product"]) - XCTAssertEqual(paywall.presentation.style, .modal) XCTAssertTrue(paywall.isLocal) XCTAssertNil(paywall.manifest) - } - - // MARK: - The surface's own settings win - - func test_theSurfacesOwnGatingBeatsThePublishedPaywalls() { - var published = Paywall.stub() - published.featureGating = .gated - - let paywall = Paywall.devServer( - surface: surface(featureGating: "nonGated"), - url: url, - inheriting: published - ) - - // An unpushed config.ts edit has to take effect, or dev mode shows the - // setting you just changed away from. - XCTAssertEqual(paywall.featureGating, .nonGated) - } - - func test_theSurfacesOwnEligibilityBeatsThePublishedPaywalls() { - let paywall = Paywall.devServer( - surface: surface(introductoryOfferEligibility: "alwaysIneligible"), - url: url, - inheriting: Paywall.stub() - ) - XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + // Inherited behaviour rides along without dragging the published bytes in. + XCTAssertEqual(paywall.featureGating, .gated) } func test_settingsTheSurfaceOmitsStillComeFromTheDashboard() { @@ -334,22 +226,4 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.featureGating, .gated) } - func test_aValueThisSdkDoesNotKnowFallsBackRatherThanGuessing() { - var published = Paywall.stub() - published.featureGating = .gated - - // A CLI newer than this SDK must not silently ungate a paywall. - let paywall = Paywall.devServer( - surface: surface(featureGating: "someFutureMode"), - url: url, - inheriting: published - ) - - XCTAssertEqual(paywall.featureGating, .gated) - } - - func test_anUnboundSurfaceStillHonoursItsOwnGating() { - let paywall = Paywall.devServer(surface: surface(featureGating: "gated"), url: url) - XCTAssertEqual(paywall.featureGating, .gated) - } } From 694331b6d01fe5592e241ec4e8b270aa860af85b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:56:20 +0200 Subject: [PATCH 29/32] fix(dev): take identity from the paywall a surface stands in for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One surface can serve several dashboard paywalls — an explicit multi-way superwall.lock binding, or the single-paywall fallback that matches any database id. Identity was read off the surface, so every paywall it served reported the same paywall_id and paywall_identifier. Those reach the real placements queue, and PaywallManager.getViewController keys its cache on identifier, so the paywalls collapsed onto one cached view controller and one analytics identity. The published paywall already carries the right identity, so it wins now; the surface only supplies it for a `dev:` surface with no published counterpart. Also replaces a test whose isScrollEnabled assertion passed either way, since Paywall.stub() already matched the fallback, with a published paywall whose inheritable fields all differ from the fallbacks. Co-Authored-By: Claude Opus 5 --- .../DevServer/DevServerPaywall.swift | 13 ++- .../DevServer/DevServerPaywallTests.swift | 85 ++++++++++++++++--- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 390b7ca6d1..f3107ec641 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -28,8 +28,17 @@ extension Paywall { ) -> Paywall { let products = productItems(from: surface) - let databaseId: String = surface.paywallId ?? "dev:\(surface.kind)/\(surface.id)" - let identifier: String = surface.identifier ?? "dev:\(surface.id)" + // Identity comes from the paywall being stood in for, not the surface: + // one surface can serve several dashboard paywalls (an explicit multi-way + // binding, or the single-paywall fallback), and these reach analytics as + // paywall_id/paywall_identifier and key the view controller cache. Taking + // them from the surface would collapse every paywall it serves into one. + let databaseId: String = published?.databaseId + ?? surface.paywallId + ?? "dev:\(surface.kind)/\(surface.id)" + let identifier: String = published?.identifier + ?? surface.identifier + ?? "dev:\(surface.id)" let cacheKey = "dev:\(surface.id):\(url.absoluteString)" let responseLoadingInfo: LoadingInfo = published?.responseLoadingInfo ?? .init() // A paywall's config.ts settings reach the SDK in the pushed snapshot, diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index 28ce8ec54d..dba591162b 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -81,21 +81,82 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(params["is_local"] as? Bool, true) } - func test_inheritsTheDashboardStyleWhenTheManifestSaysNothing() { - // The shipped manifest carries no presentation, so a bound paywall has to - // keep the style the dashboard configured rather than snap to fullscreen. - var published = Paywall.stub() - XCTAssertEqual(published.presentation.style, .modal) + /// A published paywall whose inheritable fields all differ from the values + /// `Paywall.devServer` would otherwise fall back to, so an assertion on any + /// of them fails if the inheritance is dropped. + private func published( + databaseId: String = "db-1", + identifier: String = "pro_v3" + ) -> Paywall { + let stub = Paywall.stub() + return Paywall( + databaseId: databaseId, + identifier: identifier, + name: "Published Pro", + cacheKey: stub.cacheKey, + buildId: stub.buildId, + url: stub.url, + urlConfig: stub.urlConfig, + htmlSubstitutions: "", + presentation: PaywallPresentationInfo( + style: .drawer(height: 42, cornerRadius: 7), + delay: 250 + ), + backgroundColorHex: "#123456", + backgroundColor: .blue, + darkBackgroundColorHex: "#654321", + darkBackgroundColor: .black, + productItems: [], + productIds: [], + appStoreProductIds: [], + responseLoadingInfo: .init(), + webviewLoadingInfo: .init(), + productsLoadingInfo: .init(), + shimmerLoadingInfo: .init(), + paywalljsVersion: "", + featureGating: .gated, + isScrollEnabled: false, + introOfferEligibility: .ineligible + ) + } - let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published) + func test_inheritsEverythingTheManifestCannotCarry() { + // The shipped manifest carries none of these, so a bound paywall keeps + // what the dashboard configured rather than the stub's defaults. + let dashboard = published() + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: dashboard) + + XCTAssertEqual(paywall.presentation.style, .drawer(height: 42, cornerRadius: 7)) + XCTAssertEqual(paywall.presentation.delay, 250) + XCTAssertEqual(paywall.backgroundColorHex, "#123456") + XCTAssertEqual(paywall.darkBackgroundColorHex, "#654321") + XCTAssertFalse(paywall.isScrollEnabled) + XCTAssertEqual(paywall.featureGating, .gated) + XCTAssertEqual(paywall.introOfferEligibility, .ineligible) + } - XCTAssertEqual(paywall.presentation.style, .modal) + func test_takesItsIdentityFromThePaywallItStandsInFor() { + // One surface can serve several dashboard paywalls, and identity reaches + // analytics as paywall_id/paywall_identifier and keys the view controller + // cache — so it has to be the served paywall's, not the surface's. + let first = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "222", identifier: "pro_annual") + ) + XCTAssertEqual(first.databaseId, "222") + XCTAssertEqual(first.identifier, "pro_annual") + + let second = Paywall.devServer( + surface: surface(paywallId: "111", identifier: "surface_identifier"), + url: url, + inheriting: published(databaseId: "333", identifier: "pro_monthly") + ) + XCTAssertEqual(second.databaseId, "333") + XCTAssertEqual(second.identifier, "pro_monthly") - // Visual settings the manifest can't carry come from there too. - published = Paywall.stub() - let visuals = Paywall.devServer(surface: surface(), url: url, inheriting: published) - XCTAssertEqual(visuals.backgroundColorHex, published.backgroundColorHex) - XCTAssertEqual(visuals.isScrollEnabled, published.isScrollEnabled) + // Distinct identities, so they can't collapse onto one cached controller. + XCTAssertNotEqual(first.identifier, second.identifier) } func test_presentsFullscreenWhenNothingDeclaresAStyle() { From a2805673c60ec5a255700b132f520184f6f6a3e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:23:29 +0200 Subject: [PATCH 30/32] docs(dev): stop promising config.ts presentation reaches dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three comments still said the local surface owns its presentation style, including the public doc on SuperwallOptions.devServer. It hasn't since bff8c79: presentation travels to the dashboard in the pushed snapshot, so a dev-served paywall inherits it from the published paywall. The public doc now also names the consequence a developer would otherwise hit by surprise — a config.ts presentation, gating or eligibility change isn't visible in dev mode until it's pushed. Co-Authored-By: Claude Opus 5 --- .../SuperwallKit/Config/Options/SuperwallOptions.swift | 8 ++++++-- Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 10 +++++----- .../Paywall/Request/Operators/RawPaywallResponse.swift | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index dd4f69b0d0..17e64d3ee5 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -407,8 +407,12 @@ public final class SuperwallOptions: NSObject, Encodable { /// audience evaluation, assignment and feature gating all stay real. Paywalls without a /// local counterpart still load their published versions. /// - /// What the local surface does own is what it renders and how: its products and its - /// `config.ts` presentation style replace the published paywall's. + /// What the local surface owns is what it renders: its content and its products. + /// Everything else the dashboard configures — presentation style, feature gating, + /// intro offer eligibility, surveys — comes from the published paywall, because those + /// settings reach the SDK in the snapshot `superwall push` uploads rather than from + /// the dev server. So a `config.ts` change to any of them is not visible in dev mode + /// until you push it. /// /// 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. diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index f3107ec641..6afb110a73 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -14,13 +14,13 @@ extension Paywall { /// Builds the paywall a dev server surface presents. /// /// - Parameter published: the dashboard paywall this surface stands in for, - /// if any. The surface owns what renders and how — its bytes, products and - /// `config.ts` presentation style. Everything the dashboard configures that - /// a manifest can't express is inherited from `published` instead, so dev - /// mode changes how a paywall looks and never how it behaves. + /// if any. The surface owns what renders — its bytes and its products. + /// Everything else the dashboard configures, presentation included, is + /// inherited from `published`, so dev mode changes a paywall's content and + /// never its configuration. /// /// Anything added to `Paywall` later defaults to the local stub's value, so - /// if it is dashboard-owned behaviour it belongs in the inherited list below. + /// if the dashboard configures it, it belongs in the inherited list below. static func devServer( surface: DevServerSurface, url: URL, diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index cfd89a7088..60c388bb72 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -43,9 +43,9 @@ extension PaywallRequestManager { return paywall } - // The local surface replaces the published paywall's bytes, products and - // presentation, and inherits everything the dashboard configures that a - // manifest can't express — see Paywall.devServer(surface:url:inheriting:). + // The local surface replaces the published paywall's bytes and products, + // and inherits everything the dashboard configures that a manifest can't + // express — see Paywall.devServer(surface:url:inheriting:). // 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. From e518f876fef21a0851b3869d3b0de51f92d028d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:39:31 +0200 Subject: [PATCH 31/32] docs(dev): name local notifications as the exception to inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewritten docs said every dashboard-configured setting comes from the published paywall, which overreaches: Paywall.devServer forces localNotifications to [] so the local config.ts ones win. The public doc was wrong in both directions — the dashboard's notifications never fire in dev mode, and a config.ts notification change is visible without a push. Both the public doc and the factory's now name the exception, and the "belongs in the inherited list" rule reads as conditional on the local paywall having no way of its own to say otherwise, which is the real test. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/Config/Options/SuperwallOptions.swift | 4 ++++ Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 17e64d3ee5..c26c3ba006 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -414,6 +414,10 @@ public final class SuperwallOptions: NSObject, Encodable { /// the dev server. So a `config.ts` change to any of them is not visible in dev mode /// until you push it. /// + /// Local notifications are the one exception: the published paywall's are ignored and + /// the ones your local `config.ts` declares fire straight away, without a push, because + /// they reach the SDK from the paywall itself rather than from the dashboard. + /// /// 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. /// diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index 6afb110a73..b43367c8dc 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -16,11 +16,12 @@ extension Paywall { /// - Parameter published: the dashboard paywall this surface stands in for, /// if any. The surface owns what renders — its bytes and its products. /// Everything else the dashboard configures, presentation included, is - /// inherited from `published`, so dev mode changes a paywall's content and - /// never its configuration. + /// inherited from `published` — bar `localNotifications`, see below — so dev + /// mode changes a paywall's content and never its configuration. /// /// Anything added to `Paywall` later defaults to the local stub's value, so - /// if the dashboard configures it, it belongs in the inherited list below. + /// if the dashboard configures it and the local paywall has no way of its + /// own to say otherwise, it belongs in the inherited list below. static func devServer( surface: DevServerSurface, url: URL, From 1af8f0a2fe9aca71c572b90755dbfb15f6ebccb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:49:43 +0200 Subject: [PATCH 32/32] docs(dev): make the on-device cache exception explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onDeviceCache was simply omitted from the Paywall(...) call, so it took the init default .disabled. That is the right behaviour — a dev server reloads the page on every edit and DependencyContainer feeds this into the web view, so an enabled cache could serve a stale copy of the local page — but nothing said so. A maintainer applying the rule the doc comment states would have added it to the inherited list and quietly broken live reload. It is now passed explicitly with the reason, named alongside localNotifications as the second exception in the doc comment, and covered by a test whose published paywall has the cache enabled. Co-Authored-By: Claude Opus 5 --- Sources/SuperwallKit/DevServer/DevServerPaywall.swift | 11 +++++++++-- .../DevServer/DevServerPaywallTests.swift | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift index b43367c8dc..0ce4d64085 100644 --- a/Sources/SuperwallKit/DevServer/DevServerPaywall.swift +++ b/Sources/SuperwallKit/DevServer/DevServerPaywall.swift @@ -16,8 +16,11 @@ extension Paywall { /// - Parameter published: the dashboard paywall this surface stands in for, /// if any. The surface owns what renders — its bytes and its products. /// Everything else the dashboard configures, presentation included, is - /// inherited from `published` — bar `localNotifications`, see below — so dev - /// mode changes a paywall's content and never its configuration. + /// inherited from `published`, so dev mode changes a paywall's content and + /// never its configuration. Two exceptions, both marked below: + /// `localNotifications`, which the local paywall declares itself, and + /// `onDeviceCache`, which stays `.disabled` so a live-reloading local page + /// is never served from the web view's cache. /// /// Anything added to `Paywall` later defaults to the local stub's value, so /// if the dashboard configures it and the local paywall has no way of its @@ -81,6 +84,10 @@ extension Paywall { // Feature gating decides whether a non-paying user gets the feature, so // it can never come from local paywall code. featureGating: featureGating, + // Deliberately not inherited either: a dev server reloads the page on + // every edit, and DependencyContainer feeds this straight into the web + // view, so an enabled cache could serve a stale copy of the local page. + onDeviceCache: .disabled, // Deliberately not inherited: a local paywall declares its own // notifications in config.ts, and they reach the SDK as // `schedule_notification` messages rather than through this field. diff --git a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift index dba591162b..0b4a45d3a0 100644 --- a/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift +++ b/Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift @@ -115,6 +115,7 @@ final class DevServerPaywallTests: XCTestCase { shimmerLoadingInfo: .init(), paywalljsVersion: "", featureGating: .gated, + onDeviceCache: .enabled, isScrollEnabled: false, introOfferEligibility: .ineligible ) @@ -185,6 +186,13 @@ final class DevServerPaywallTests: XCTestCase { XCTAssertEqual(paywall.surveys.count, 1) } + func test_neverServesALocalPageFromTheWebViewCache() { + // The dashboard configures onDeviceCache, but a dev server reloads on + // every edit, so an inherited .enabled could serve a stale local page. + let paywall = Paywall.devServer(surface: surface(), url: url, inheriting: published()) + XCTAssertEqual(paywall.onDeviceCache, .disabled) + } + func test_doesNotInheritNotificationsTheLocalPaywallDeclaresItself() { // config.ts can declare notifications, and they arrive as // `schedule_notification` messages. Inheriting the dashboard's would let