Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
75c29ba
feat(dev): mark locally served paywalls and honour their presentation…
yusuftor Aug 27, 2026
79e8ed2
Merge remote-tracking branch 'origin/develop' into christo/sw-framewo…
yusuftor Sep 4, 2026
7689d10
refactor(debugger): hold published paywalls as PaywallSummary
yusuftor Sep 4, 2026
eedda06
review: keep gating real, contain manifest decode failures, share the…
yusuftor Sep 4, 2026
5181d83
review: inherit dashboard-owned behaviour, keep the manifest identifi…
yusuftor Sep 4, 2026
8dee668
fix(dev): let the local paywall keep owning its notifications
yusuftor Sep 4, 2026
0027657
fix(dev): inherit what the manifest can't carry, honour multi-paywall…
yusuftor Sep 4, 2026
bff8c79
refactor(dev): read paywall settings from the published paywall only
yusuftor Sep 4, 2026
694331b
fix(dev): take identity from the paywall a surface stands in for
yusuftor Sep 4, 2026
a280567
docs(dev): stop promising config.ts presentation reaches dev mode
yusuftor Sep 4, 2026
e518f87
docs(dev): name local notifications as the exception to inheritance
yusuftor Sep 4, 2026
1af8f0a
docs(dev): make the on-device cache exception explicit
yusuftor Sep 4, 2026
1e97fc2
feat(dev): present a local paywall the way its config.ts says
chroxify Sep 9, 2026
7ba938d
feat(dev): let config.ts pick intro offer eligibility
yusuftor Sep 10, 2026
a7e0db7
fix(dev): rebuild the paywall when config.ts settings change
yusuftor Sep 10, 2026
bfa0d23
docs(dev): name intro offer eligibility as config.ts's
yusuftor Sep 10, 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup

- Adds `grantedEntitlements` so you can grant entitlements from your own backend, which the SDK merges with device and web entitlements.
- Changes `$subscriptionStatus` from a `@Published` publisher to an `AnyPublisher`. Subscribing to it works as before, but it can no longer be the target of `assign(to:)`.
- Dev mode now presents a locally served paywall the way its `config.ts` says: presentation style, feature gating, intro offer eligibility, scrolling and background colours come from your local code instead of the last pushed version, so changing them is visible without a push. Settings `config.ts` cannot express, and any it leaves out, still come from the published paywall. Needs a `superwall dev` server new enough to send them.
- 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 @@ -439,8 +439,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 @@ -557,7 +561,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 @@ -723,6 +730,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.setSubscriptionStatus(assigned: .inactive)
storage.save(false, forType: IsTestModeActiveSubscription.self)
}

@MainActor
private func presentTestModeModal(reason: TestModeReason, config: Config) async {
guard
Expand Down
70 changes: 70 additions & 0 deletions Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,76 @@ 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, assignment and the enforcement of feature gating all stay real.
/// Paywalls without a local counterpart still load their published versions.
///
/// What the local surface owns is what it renders and the settings its `config.ts`
/// declares: content, products, presentation style, feature gating, intro offer
/// eligibility, scrolling and background colours all come from your local code, so
/// changing them shows on the next presentation without a push. What `config.ts`
/// cannot express — surveys, computed properties — comes from the published paywall
/// the surface stands in for, and so does any setting your `config.ts` leaves out.
///
/// A dev server older than this SDK sends no settings at all, in which case every
/// setting comes from the published paywall.
///
/// Two exceptions. Local notifications are always the local paywall's: the published
/// ones are ignored and the ones your `config.ts` declares fire without a push, because
/// they reach the SDK from the paywall itself rather than from the dashboard. And the
/// on-device cache stays off however either side configures it, so a page you are
/// editing is never served from the web view's cache.
///
/// 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
Loading
Loading