diff --git a/Sources/SuperwallKit/Debug/DebugManager.swift b/Sources/SuperwallKit/Debug/DebugManager.swift index bb2ebe140f..69c50a1fe0 100644 --- a/Sources/SuperwallKit/Debug/DebugManager.swift +++ b/Sources/SuperwallKit/Debug/DebugManager.swift @@ -17,6 +17,7 @@ final class DebugManager { struct DeepLinkOutcome { let debugKey: String let paywallId: String? + let overrides: DebugPaywallOverrides } init( @@ -33,7 +34,10 @@ final class DebugManager { } storage.debugKey = outcome.debugKey Task { - await self.launchDebugger(withPaywallId: outcome.paywallId) + await self.launchDebugger( + withPaywallId: outcome.paywallId, + overrides: outcome.overrides + ) } return true } @@ -58,7 +62,11 @@ final class DebugManager { fromUrl: url, withName: .paywallId ) - return .init(debugKey: debugKey, paywallId: paywallId) + return .init( + debugKey: debugKey, + paywallId: paywallId, + overrides: DebugPaywallOverrides(url: url) + ) } /// Launches the debugger for you to preview paywalls. @@ -68,38 +76,48 @@ 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, + overrides: DebugPaywallOverrides = DebugPaywallOverrides() + ) async { if Superwall.shared.isPaywallPresented { await Superwall.shared.dismiss() - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, overrides: overrides) } 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, overrides: overrides) } else { await closeDebugger(animated: true) - await launchDebugger(withPaywallId: paywallDatabaseId) + await launchDebugger(withPaywallId: paywallDatabaseId, overrides: overrides) } } } @MainActor - func presentDebugger(withPaywallId paywallDatabaseId: String? = nil) async { + func presentDebugger( + withPaywallId paywallDatabaseId: String? = nil, + overrides: DebugPaywallOverrides = DebugPaywallOverrides() + ) async { isDebuggerLaunched = true if let viewController = viewController { if viewController.isBeingPresented { return } viewController.paywallDatabaseId = paywallDatabaseId + viewController.overrides = overrides await viewController.loadPreview() await UIViewController.topMostViewController?.present( viewController, animated: true ) } else { - let viewController = factory.makeDebugViewController(withDatabaseId: paywallDatabaseId) + let viewController = factory.makeDebugViewController( + withDatabaseId: paywallDatabaseId, + overrides: overrides + ) UIViewController.topMostViewController?.present( viewController, animated: true, diff --git a/Sources/SuperwallKit/Debug/DebugPaywallOverrides.swift b/Sources/SuperwallKit/Debug/DebugPaywallOverrides.swift new file mode 100644 index 0000000000..48cdd985ad --- /dev/null +++ b/Sources/SuperwallKit/Debug/DebugPaywallOverrides.swift @@ -0,0 +1,81 @@ +// +// DebugPaywallOverrides.swift +// SuperwallKit +// +// Created by Konrad Roj on 04/08/2026. +// + +import Foundation + +struct DebugPaywallOverrides: Equatable { + enum Appearance: String { + case light + case dark + case system + + var interfaceStyle: InterfaceStyle? { + switch self { + case .light: + return .light + case .dark: + return .dark + case .system: + return nil + } + } + } + + var freeTrialOverride: Bool? + var appearance: Appearance? + var localeIdentifier: String? + var shouldPresent: Bool + + var isEmpty: Bool { + freeTrialOverride == nil + && appearance == nil + && localeIdentifier == nil + && !shouldPresent + } + + init( + freeTrialOverride: Bool? = nil, + appearance: Appearance? = nil, + localeIdentifier: String? = nil, + shouldPresent: Bool = false + ) { + self.freeTrialOverride = freeTrialOverride + self.appearance = appearance + self.localeIdentifier = localeIdentifier + self.shouldPresent = shouldPresent + } + + init(url: URL) { + switch SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .trialState)?.lowercased() { + case "eligible": + freeTrialOverride = true + case "ineligible": + freeTrialOverride = false + default: + freeTrialOverride = nil + } + + if let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .appearance)?.lowercased() { + appearance = Appearance(rawValue: value) + } else { + appearance = nil + } + + if let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .locale), + !value.isEmpty { + localeIdentifier = value + } else { + localeIdentifier = nil + } + + if let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .present)?.lowercased() { + shouldPresent = ["true", "1", "yes"].contains(value) + } else { + shouldPresent = false + } + } +} diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index e02936f7a0..17b9f00ab8 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -124,8 +124,12 @@ final class DebugViewController: UIViewController { /// has a single paywall, in which case the picker declines to open. var previewPaywalls: [PaywallSummary] = [] var previewViewContent: UIView? + var overrides = DebugPaywallOverrides() private var cancellable: AnyCancellable? private var initialLocaleIdentifier: String? + private var initialInterfaceStyleOverride: InterfaceStyle? + private var didAppear = false + private var previewTask: Task? private unowned let storeKitManager: StoreKitManager private unowned let network: Network @@ -158,11 +162,38 @@ final class DebugViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() initialLocaleIdentifier = Superwall.shared.options.localeIdentifier + initialInterfaceStyleOverride = Superwall.shared.dependencyContainer.deviceHelper.interfaceStyleOverride + applyOverrides() addSubviews() - Task { await loadPreview() } + previewTask = Task { await loadPreview() } Task { await loadPreviewPaywalls() } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + didAppear = true + presentAutomaticallyIfNeeded() + } + + private func applyOverrides() { + if let localeIdentifier = overrides.localeIdentifier { + Superwall.shared.options.localeIdentifier = localeIdentifier + } + if let appearance = overrides.appearance { + Superwall.shared.setInterfaceStyle(to: appearance.interfaceStyle) + } + } + + private func presentAutomaticallyIfNeeded() { + guard didAppear, + overrides.shouldPresent, + paywall != nil else { + return + } + overrides.shouldPresent = false + loadAndShowPaywall(introOfferAvailable: overrides.freeTrialOverride ?? (paywall?.isFreeTrialAvailable ?? false)) + } + private func addSubviews() { view.addSubview(previewContainerView) view.addSubview(activityIndicator) @@ -241,7 +272,7 @@ final class DebugViewController: UIViewController { let request = factory.makePaywallRequest( placementData: nil, responseIdentifiers: .init(paywallId: paywallId), - overrides: nil, + overrides: overrides.freeTrialOverride.map { PaywallRequest.Overrides(isFreeTrial: $0) }, isDebuggerLaunched: true, presentationSourceType: nil ) @@ -250,10 +281,17 @@ final class DebugViewController: UIViewController { let productVariables = await storeKitManager.getProductVariables(for: paywall) paywall.productVariables = productVariables + // Debugger dismissed mid-load (previewTask cancelled): skip rendering/presenting. + guard !Task.isCancelled else { + return + } + self.paywall = paywall self.previewPickerButton.setTitle("\(paywall.name)", for: .normal) self.activityIndicator.stopAnimating() self.addPaywallPreview() + + presentAutomaticallyIfNeeded() } catch { Logger.debug( logLevel: .error, @@ -551,9 +589,13 @@ final class DebugViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + previewTask?.cancel() paywallManager.resetCache() debugManager.isDebuggerLaunched = false Superwall.shared.options.localeIdentifier = initialLocaleIdentifier + if overrides.appearance != nil { + Superwall.shared.setInterfaceStyle(to: initialInterfaceStyleOverride) + } } } diff --git a/Sources/SuperwallKit/Debug/SWDebugManagerLogic.swift b/Sources/SuperwallKit/Debug/SWDebugManagerLogic.swift index dfaac873f2..8cfbe8d182 100644 --- a/Sources/SuperwallKit/Debug/SWDebugManagerLogic.swift +++ b/Sources/SuperwallKit/Debug/SWDebugManagerLogic.swift @@ -12,6 +12,10 @@ enum SWDebugManagerLogic { case token case paywallId = "paywall_id" case superwallDebug = "superwall_debug" + case trialState = "trial_state" + case appearance + case locale + case present } static func getQueryItemValue( diff --git a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift index ba96227197..a43d470b93 100644 --- a/Sources/SuperwallKit/Dependencies/DependencyContainer.swift +++ b/Sources/SuperwallKit/Dependencies/DependencyContainer.swift @@ -334,7 +334,10 @@ extension DependencyContainer: ViewControllerFactory { } @MainActor - func makeDebugViewController(withDatabaseId id: String?) -> DebugViewController { + func makeDebugViewController( + withDatabaseId id: String?, + overrides: DebugPaywallOverrides + ) -> DebugViewController { let viewController = DebugViewController( storeKitManager: storeKitManager, network: network, @@ -344,6 +347,7 @@ extension DependencyContainer: ViewControllerFactory { factory: self ) viewController.paywallDatabaseId = id + viewController.overrides = overrides viewController.modalPresentationStyle = .overFullScreen return viewController } diff --git a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift index e7748fcb6f..dc277f638e 100644 --- a/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift +++ b/Sources/SuperwallKit/Dependencies/FactoryProtocols.swift @@ -20,7 +20,10 @@ protocol ViewControllerFactory: AnyObject { ) -> PaywallViewController @MainActor - func makeDebugViewController(withDatabaseId id: String?) -> DebugViewController + func makeDebugViewController( + withDatabaseId id: String?, + overrides: DebugPaywallOverrides + ) -> DebugViewController } protocol CacheFactory: AnyObject { diff --git a/Tests/SuperwallKitTests/Debug/DebugManagerTests.swift b/Tests/SuperwallKitTests/Debug/DebugManagerTests.swift new file mode 100644 index 0000000000..7b48463be5 --- /dev/null +++ b/Tests/SuperwallKitTests/Debug/DebugManagerTests.swift @@ -0,0 +1,60 @@ +// +// DebugManagerTests.swift +// SuperwallKit +// +// Created by Konrad Roj on 04/08/2026. +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +struct DebugManagerTests { + @Test func outcomeForDeepLink_notADebugLink() { + let url = URL(string: "myapp://?paywall_id=123")! + + let outcome = DebugManager.outcomeForDeepLink(url: url) + + #expect(outcome == nil) + } + + @Test func outcomeForDeepLink_missingToken() { + let url = URL(string: "myapp://?superwall_debug=true&paywall_id=123")! + + let outcome = DebugManager.outcomeForDeepLink(url: url) + + #expect(outcome == nil) + } + + @Test func outcomeForDeepLink_requiresDebugFlag() { + let url = URL(string: "myapp://?superwall_debug=false&token=abc")! + + let outcome = DebugManager.outcomeForDeepLink(url: url) + + #expect(outcome == nil) + } + + @Test func outcomeForDeepLink_minimalValidLink() { + let url = URL(string: "myapp://?superwall_debug=true&token=abc")! + + let outcome = DebugManager.outcomeForDeepLink(url: url) + + #expect(outcome?.debugKey == "abc") + #expect(outcome?.paywallId == nil) + #expect(outcome?.overrides.isEmpty == true) + } + + @Test func outcomeForDeepLink_carriesOverrides() { + let url = URL(string: "myapp://?superwall_debug=true&token=abc&paywall_id=123&trial_state=ineligible&appearance=dark&locale=de&present=true")! + + let outcome = DebugManager.outcomeForDeepLink(url: url) + + #expect(outcome?.debugKey == "abc") + #expect(outcome?.paywallId == "123") + #expect(outcome?.overrides.freeTrialOverride == false) + #expect(outcome?.overrides.appearance == .dark) + #expect(outcome?.overrides.localeIdentifier == "de") + #expect(outcome?.overrides.shouldPresent == true) + } +} diff --git a/Tests/SuperwallKitTests/Debug/DebugPaywallOverridesTests.swift b/Tests/SuperwallKitTests/Debug/DebugPaywallOverridesTests.swift new file mode 100644 index 0000000000..0df829b40d --- /dev/null +++ b/Tests/SuperwallKitTests/Debug/DebugPaywallOverridesTests.swift @@ -0,0 +1,155 @@ +// +// DebugPaywallOverridesTests.swift +// SuperwallKit +// +// Created by Konrad Roj on 04/08/2026. +// +// swiftlint:disable all + +import Foundation +import Testing +@testable import SuperwallKit + +struct DebugPaywallOverridesTests { + @Test func parse_noOverrides_isEmpty() { + let url = URL(string: "myapp://?superwall_debug=true&token=abc&paywall_id=123")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.isEmpty) + #expect(overrides.freeTrialOverride == nil) + #expect(overrides.appearance == nil) + #expect(overrides.localeIdentifier == nil) + #expect(overrides.shouldPresent == false) + } + + @Test func parse_trialState_eligible() { + let url = URL(string: "myapp://?trial_state=eligible")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.freeTrialOverride == true) + } + + @Test func parse_trialState_ineligible() { + let url = URL(string: "myapp://?trial_state=ineligible")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.freeTrialOverride == false) + } + + @Test func parse_trialState_caseInsensitive() { + let url = URL(string: "myapp://?trial_state=ELIGIBLE")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.freeTrialOverride == true) + } + + @Test func parse_trialState_unknownIsIgnored() { + let url = URL(string: "myapp://?trial_state=maybe")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.freeTrialOverride == nil) + } + + @Test func parse_appearance_light() { + let url = URL(string: "myapp://?appearance=light")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.appearance == .light) + #expect(overrides.appearance?.interfaceStyle == .light) + } + + @Test func parse_appearance_dark() { + let url = URL(string: "myapp://?appearance=dark")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.appearance == .dark) + #expect(overrides.appearance?.interfaceStyle == .dark) + } + + @Test func parse_appearance_systemClearsInterfaceStyle() { + let url = URL(string: "myapp://?appearance=system")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.appearance == .system) + #expect(overrides.appearance?.interfaceStyle == nil) + #expect(overrides.isEmpty == false) + } + + @Test func parse_appearance_unknownIsIgnored() { + let url = URL(string: "myapp://?appearance=sepia")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.appearance == nil) + } + + @Test func parse_locale() { + let url = URL(string: "myapp://?locale=de")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.localeIdentifier == "de") + } + + @Test func parse_locale_emptyIsIgnored() { + let url = URL(string: "myapp://?locale=")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.localeIdentifier == nil) + } + + @Test func parse_present_true() { + let url = URL(string: "myapp://?present=true")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.shouldPresent == true) + } + + @Test func parse_present_false() { + let url = URL(string: "myapp://?present=false")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.shouldPresent == false) + } + + @Test(arguments: ["true", "TRUE", "1", "yes", "YES"]) + func parse_present_truthyValues(value: String) { + let url = URL(string: "myapp://?present=\(value)")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.shouldPresent == true) + } + + @Test(arguments: ["false", "0", "no", "nope", ""]) + func parse_present_falsyValues(value: String) { + let url = URL(string: "myapp://?present=\(value)")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.shouldPresent == false) + } + + @Test func parse_combined() { + let url = URL(string: "myapp://?superwall_debug=true&token=abc&paywall_id=123&trial_state=ineligible&appearance=dark&locale=de&present=true")! + + let overrides = DebugPaywallOverrides(url: url) + + #expect(overrides.freeTrialOverride == false) + #expect(overrides.appearance == .dark) + #expect(overrides.localeIdentifier == "de") + #expect(overrides.shouldPresent == true) + #expect(overrides.isEmpty == false) + } +} diff --git a/Tests/SuperwallKitTests/Debug/SWDebugManagerLogicTests.swift b/Tests/SuperwallKitTests/Debug/SWDebugManagerLogicTests.swift index 9185ebed2e..59ba1d8e5d 100644 --- a/Tests/SuperwallKitTests/Debug/SWDebugManagerLogicTests.swift +++ b/Tests/SuperwallKitTests/Debug/SWDebugManagerLogicTests.swift @@ -87,4 +87,48 @@ struct SWDebugManagerLogicTests { // Then #expect(value == nil) } + + @Test func getQueryItemValue_trialState() { + // Given + let url = URL(string: "myapp://?superwall_debug=true&trial_state=eligible")! + + // When + let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .trialState) + + // Then + #expect(value == "eligible") + } + + @Test func getQueryItemValue_appearance() { + // Given + let url = URL(string: "myapp://?superwall_debug=true&appearance=dark")! + + // When + let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .appearance) + + // Then + #expect(value == "dark") + } + + @Test func getQueryItemValue_locale() { + // Given + let url = URL(string: "myapp://?superwall_debug=true&locale=de")! + + // When + let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .locale) + + // Then + #expect(value == "de") + } + + @Test func getQueryItemValue_present() { + // Given + let url = URL(string: "myapp://?superwall_debug=true&present=true")! + + // When + let value = SWDebugManagerLogic.getQueryItemValue(fromUrl: url, withName: .present) + + // Then + #expect(value == "true") + } } diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/CheckDebuggerPresentationOperatorTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/CheckDebuggerPresentationOperatorTests.swift index e4cd87166c..10837cb241 100644 --- a/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/CheckDebuggerPresentationOperatorTests.swift +++ b/Tests/SuperwallKitTests/Paywall/Presentation/Internal Presentation/Operators/CheckDebuggerPresentationOperatorTests.swift @@ -40,7 +40,10 @@ final class CheckDebuggerPresentationTests { @Test func checkDebuggerPresentation_debuggerLaunched_presentingOnDebugger() async { let dependencyContainer = DependencyContainer() - let debugViewController = await dependencyContainer.makeDebugViewController(withDatabaseId: "abc") + let debugViewController = await dependencyContainer.makeDebugViewController( + withDatabaseId: "abc", + overrides: DebugPaywallOverrides() + ) let request = PresentationRequest.stub() .setting(\.flags.isDebuggerLaunched, to: true) .setting(\.presenter, to: debugViewController)