-
Notifications
You must be signed in to change notification settings - Fork 58
feat(debug): preview paywalls in a specific state via deep link #499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
| ) | ||
|
|
@@ -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, | ||
|
|
@@ -551,9 +584,13 @@ final class DebugViewController: UIViewController { | |
|
|
||
| override func viewDidDisappear(_ animated: Bool) { | ||
| super.viewDidDisappear(animated) | ||
| previewTask?.cancel() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So exiting mid-load still runs the whole tail: 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) | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assigning
overrideshere only half-works:applyOverrides()isviewDidLoad-gated, so on this reuse branchlocale,appearanceandattr_*are silently dropped whiletrial_stateandpresentare still honoured vialoadPreview()→presentAutomaticallyIfNeeded(). TodaylaunchDebuggeralways nils the view controller throughcloseDebuggerfirst so the branch is effectively unreachable, but the assignment implies otherwise — worth either applying the overrides here too or dropping the line.