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