Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions Sources/SuperwallKit/Debug/DebugManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class DebugManager {
struct DeepLinkOutcome {
let debugKey: String
let paywallId: String?
let overrides: DebugPaywallOverrides
}

init(
Expand All @@ -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
}
Expand All @@ -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.
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assigning overrides here only half-works: applyOverrides() is viewDidLoad-gated, so on this reuse branch locale, appearance and attr_* are silently dropped while trial_state and present are still honoured via loadPreview()presentAutomaticallyIfNeeded(). Today launchDebugger always nils the view controller through closeDebugger first so the branch is effectively unreachable, but the assignment implies otherwise — worth either applying the overrides here too or dropping the line.

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,
Expand Down
81 changes: 81 additions & 0 deletions Sources/SuperwallKit/Debug/DebugPaywallOverrides.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
41 changes: 39 additions & 2 deletions Sources/SuperwallKit/Debug/DebugViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

private unowned let storeKitManager: StoreKitManager
private unowned let network: Network
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
)
Expand All @@ -254,6 +285,8 @@ final class DebugViewController: UIViewController {
self.previewPickerButton.setTitle("\(paywall.name)", for: .normal)
self.activityIndicator.stopAnimating()
self.addPaywallPreview()

presentAutomaticallyIfNeeded()
} catch {
Logger.debug(
logLevel: .error,
Expand Down Expand Up @@ -551,9 +584,13 @@ final class DebugViewController: UIViewController {

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
previewTask?.cancel()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cancel() only sets a flag: nothing in loadPreview() / finishLoadingPreview() checks Task.isCancelled, and both network hops suspend on an unstructured Task's .valueCustomURLSession.swift:93-100 (Task.retrying(...).value) and PaywallRequestManager.swift:78/:102 (activeTasksawait task.value) — which is immune to the caller's cancellation.

So exiting mid-load still runs the whole tail: addPaywallPreview() attaches a fresh PaywallViewController + WKWebView as a child of the dismissed controller after resetCache(), and with present=true presentAutomaticallyIfNeeded() still passes its guard (didAppear is never reset) and calls loadAndShowPaywall(presenter: self) on a detached presenter. The PR description's "the in-flight preview load is cancelled on dismiss" doesn't hold as written.

Technical details
# `previewTask?.cancel()` cannot interrupt the preview load

## Affected sites
- `Sources/SuperwallKit/Debug/DebugViewController.swift:587``previewTask?.cancel()`. Sets the flag and nothing observes it.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:240-299``loadPreview()` / `finishLoadingPreview()`. Zero `Task.isCancelled` / `try Task.checkCancellation()` calls, including after the three awaits (`network.resolvePaywallIdentifier` at `:255`, `paywallRequestManager.getPaywall` at `:279`, `storeKitManager.getProductVariables` at `:281`).
- `Sources/SuperwallKit/Network/Custom URL Session/CustomURLSession.swift:93-100``try await Task.retrying(...).value`, where `Task+Retrying.swift:24` builds a detached `Task(priority:) { }`. Per the stdlib `Task.cancel()` contract, cancellation reaches only *structured* children, and `await task.value` does not throw on the awaiting task's own cancellation.
- `Sources/SuperwallKit/Paywall/Request/PaywallRequestManager.swift:78`, `:102` — same shape: the fetch lives in an unstructured `Task` stored in `activeTasks` and coalesced across callers, awaited via `.value`.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:287-289``addPaywallPreview()` then `presentAutomaticallyIfNeeded()`, both reached unconditionally. The file contains no `removeFromParent()` anywhere, so the child controller added here is retained by the dismissed `DebugViewController` for its lifetime.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:172-176`, `:187-195``didAppear` is set in `viewDidAppear` and never reset in `viewDidDisappear` (`:585-593`), so the `present=true` latch survives dismissal.
- `Sources/SuperwallKit/Paywall/Presentation/Internal/Operators/CheckDebuggerPresentation.swift:24-29` — guards on `request.presenter is DebugViewController` only, never on whether that instance is still attached, so the stale presentation is allowed through.
- `Sources/SuperwallKit/Debug/DebugViewController.swift:393`, `:437` — the picker and localization-picker reloads spawn `Task { await self?.loadPreview() }` without assigning `previewTask`, so even a working cancel would miss them.

## Required outcome
- Dismissing the debugger must prevent `addPaywallPreview()` and `presentAutomaticallyIfNeeded()` from running for a load that was in flight at dismissal, for every path that starts a preview load — not just the `viewDidLoad` one.
- Whatever the mechanism, `loadAndShowPaywall` must not be reachable with `self` detached from the window hierarchy.

## Suggested approach (optional)
- Since the network layer is deliberately unstructured, the cheapest honest fix is an explicit checkpoint rather than relying on task cancellation: guard the mutation tail in `finishLoadingPreview()` on `!Task.isCancelled` (or on `viewIfLoaded?.window != nil`), and add the same condition to `presentAutomaticallyIfNeeded()`'s guard alongside `didAppear`.
- Alternatively reset `didAppear = false` in `viewDidDisappear` — that alone closes the `present=true` half, though it leaves the orphaned child controller from `addPaywallPreview()`.
- If the intent is only to stop the auto-present and not the fetch, dropping `previewTask` and the `cancel()` in favour of the window/`didAppear` check would be less misleading than a cancel that has no effect.

## Open questions for the human
- Is `present=true` expected to be usable in an automated harness that can tear the debugger down mid-load (Appium/XCUITest), or is dismissal-during-load considered out of scope for the QA flow?

paywallManager.resetCache()
debugManager.isDebuggerLaunched = false
Superwall.shared.options.localeIdentifier = initialLocaleIdentifier
if overrides.appearance != nil {
Superwall.shared.setInterfaceStyle(to: initialInterfaceStyleOverride)
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions Sources/SuperwallKit/Debug/SWDebugManagerLogic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion Sources/SuperwallKit/Dependencies/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -344,6 +347,7 @@ extension DependencyContainer: ViewControllerFactory {
factory: self
)
viewController.paywallDatabaseId = id
viewController.overrides = overrides
viewController.modalPresentationStyle = .overFullScreen
return viewController
}
Expand Down
5 changes: 4 additions & 1 deletion Sources/SuperwallKit/Dependencies/FactoryProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions Tests/SuperwallKitTests/Debug/DebugManagerTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading