Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6519c3c
feat: add dev mode for previewing local paywalls
chroxify Aug 24, 2026
4f1779f
Merge remote-tracking branch 'origin/develop' into christo/sw-framewo…
yusuftor Aug 25, 2026
48b6b35
chore: fold dev mode changelog entry into staged 4.16.4 section
yusuftor Aug 25, 2026
5d8c076
chore: restage 4.16.4 release as 4.17.0
yusuftor Aug 25, 2026
154f0a2
review: gate dev links out of production and unfreeze the dev-mode cache
yusuftor Aug 25, 2026
75da4e8
review: pin dev-server mount URLs to the manifest's origin
yusuftor Aug 25, 2026
4a032b8
review: log when a dev server surface is rejected as off-origin
yusuftor Aug 25, 2026
d1f5e0d
chore(examples): scope Advanced app ATS to web content and local netw…
yusuftor Aug 26, 2026
254d6de
chore: split DevServerManifest.swift into one file per type
yusuftor Aug 26, 2026
3482bf2
style: invert the paywall picker's open guard into a positive early r…
yusuftor Aug 26, 2026
0d7ce5e
style: split the paywall picker gate into a three-branch predicate
yusuftor Aug 26, 2026
362950d
style: state the picker gate positively with guard
yusuftor Aug 26, 2026
11cd922
style: group the ATS warning flag with the locator's other state
yusuftor Aug 26, 2026
6fed06c
style: split the ATS warning gate into single-condition checks
yusuftor Aug 26, 2026
c22e098
style: split the dev link parse into single-condition guards
yusuftor Aug 26, 2026
cf2020c
docs: drop the superwall.lock binding detail from the dev mode change…
yusuftor Aug 26, 2026
e751849
review: drop the debugger's product-variables timeout
yusuftor Aug 26, 2026
6240aa2
style: positive-if and multiline-guard formatting in dev mode gates
yusuftor Aug 26, 2026
b61b66e
feat!: replace devMode and devServerURL with SuperwallOptions.devServer
yusuftor Aug 26, 2026
ae9e819
chore: retrigger CI after a stuck Pullfrog run
yusuftor Aug 26, 2026
d6f8f63
fix(debugger): make Preview work for unpushed dev-server surfaces
yusuftor Aug 26, 2026
ee963aa
feat(dev): serve matching local surfaces wholesale instead of patchin…
yusuftor Aug 26, 2026
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion Examples/Advanced/Advanced/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>UIAppFonts</key>
Expand Down
7 changes: 7 additions & 0 deletions Examples/Basic/Basic/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>UIAppFonts</key>
<array>
<string>Rubik-Regular.ttf</string>
Expand Down
31 changes: 28 additions & 3 deletions Sources/SuperwallKit/Config/ConfigManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
24 changes: 19 additions & 5 deletions Sources/SuperwallKit/Debug/DebugManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -68,38 +72,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,
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,
animated: true
)
} else {
let viewController = factory.makeDebugViewController(withDatabaseId: paywallDatabaseId)
viewController.devServer = devServer
viewController.selectDevSurface(id: devSurfaceId)
UIViewController.topMostViewController?.present(
viewController,
animated: true,
Expand Down
172 changes: 172 additions & 0 deletions Sources/SuperwallKit/Debug/DebugPaywallPickerViewController.swift
Original file line number Diff line number Diff line change
@@ -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 ?? "")
}
}
Loading
Loading