Skip to content

feat: load bundled paywalls and static config from Superwall.bundle - #546

Open
chroxify wants to merge 1 commit into
developfrom
christo/bundled-paywalls
Open

chroxify wants to merge 1 commit into
developfrom
christo/bundled-paywalls

Conversation

@chroxify

Copy link
Copy Markdown
Contributor

What

If the app ships a Superwall.bundle, written by superwall bundle (superwall/superwall#14), the SDK uses it as a fallback for both config and paywall documents:

  • Config: fetchConfiguration treats the bundled static config as the cached config when nothing is cached yet. It waits up to 1s for fresh config, the existing enableConfigRefresh path, falls back to the bundled one, then refreshes in the background.
  • Paywalls: PaywallViewController.loadWebView now tries, in order: the manifest archive, then a bundled archive, then the network. PaywallArchiveManager.getBundledArchiveURL(for:) matches the paywall's url and url_config endpoints against the bundle. On the first match it builds a .webarchive in Caches/paywalls/bundled/ from the bundled document and resources, using the existing WebArchive types, then loads it with loadFileURL.

Decisions

  • No new option. The bundle's presence in the main bundle is the opt-in. BundledPaywalls.shared reads Superwall.bundle once. Both ConfigManager and PaywallArchiveManager take it as an injectable init parameter with .shared as the default.
  • Bundled paywalls match by exact URL. Paywall URLs are content-addressed (hash plus sw_cache_key). Once a paywall is republished, fresh config points to a new URL, the bundled copy no longer matches, and the paywall loads from the network. A stale bundle can't pin users to an old paywall once they're online.
  • Matching doesn't need manifest. The live static configs I checked have manifest: null for every paywall, including framework ones. Hanging this on the existing manifest cache would have done nothing in practice.
  • Behaviour change for apps that ship a bundle. The bundled config enables the cached-config fallback even when enableConfigRefresh is off. On later launches with a cached config, the SDK waits 1s and then uses the cache, instead of retrying the network indefinitely. When it starts from a cached or bundled config, the background refresh passes oldConfig, so it runs regardless of the flag. Apps without a bundle behave exactly as before.
  • Errors are logged, not swallowed. An unreadable bundle.json or static_config.json, or a newer bundle format (version > 1), logs a warning and ignores the bundle. A bundled archive that fails to build logs a warning and falls back to the network.

Not covered

  • StoreKit products offline: when StoreKit has nothing to return, product variables are empty and purchasing waits for the network.
  • Android and the cross-platform SDKs. The docs list them as unsupported.

Verified

  • New BundledPaywallsTests (7 tests): no bundle, config decode and endpoint matching, web archive contents, newer format ignored, archive cached and served only for the bundled URL, offline first launch uses the bundled config, online first launch prefers fresh config.
  • ConfigManagerTests and ConfigManagerEarlyPublishTests still pass, 32/32 together with the new suite.
  • A throwaway test, not committed, loaded the real bundle the CLI wrote for Headless SW halo. The config decoded (build OCwtkmzAbCPnJmCQo41IE), and the web archive came out at 5 MB with all 53 subresources.
  • SuperwallKit.xcodeproj is not in the diff: CI regenerates it with XcodeGen, which picks up the two new files.

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Oct 12, 2026

Copy link
Copy Markdown

This PR does not match any of the 1 configured review trigger rule.

@maple-review-bot

maple-review-bot Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Maple review

🔴 Confidence 4/10 · risky as written
Seven tests cover bundle decoding and initial config selection, but omit .always fallback and background refresh with the flag disabled.
quality 80/100 · 2 warnings · tests partial · risk medium · 2/2 new units observable

Adds bundled static-config fallback and URL-matched paywall archives. Needs changes before merge: archive construction blocks the UI thread, and the bundled fallback is bypassed for .always manifests.

  • BundledPaywalls decodes bundle metadata, config, documents, and resources.
  • ConfigManager enables timed cached-config fallback when bundled config exists.
  • PaywallArchiveManager caches webarchives built from bundled assets.

Findings

🟠 Warning · F1 · loadWebView builds bundled archives on the main thread

performance · Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:486

On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. viewDidLoad calls loadWebView at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.

Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.
🟠 Warning · F2 · loadWebView skips bundled paywalls for .always manifests

correctness · Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486

A paywall with manifest.use == .always returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.

Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.
🤖 Prompt to fix all 2 findings with an AI agent
Findings from an automated review of commit 8a83e723f6ad04d379dd7c3d93353fd049e4704d. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · performance · Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:486
`loadWebView` builds bundled archives on the main thread
On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. `viewDidLoad` calls `loadWebView` at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.
Suggested fix: Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.

---

F2 · Warning · correctness · Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486
`loadWebView` skips bundled paywalls for `.always` manifests
A paywall with `manifest.use == .always` returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.
Suggested fix: Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.
What was checked
  • No-bundle feature-flag behavior preserves all three optional Boolean cases.
  • refreshConfiguration(oldConfig:) bypasses the disabled refresh flag for bundled fallback.
  • Archive encoding retains document URL, MIME type, UTF-8, and resource data.
Observability coverage: 2 of 2 changes observable
Change Kind Observable Evidence
Bundled config fallback configuration yes Existing ConfigRefresh events record build ID, cached status, retries, and duration; refresh failures use Logger.
Bundled paywall archive loading cache yes Existing PaywallWebviewLoad lifecycle surrounds loading; bundle decode and archive construction failures use Logger with errors.

8a83e72 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple-review-bot to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 inline notes from Maple's review. The score and summary are in the review comment above.


if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) {
if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest)
?? paywallArchiveManager?.getBundledArchiveURL(for: paywall) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

loadWebView builds bundled archives on the main thread

F1 · Warning · performance

On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. viewDidLoad calls loadWebView at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.

Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.
🤖 Prompt to fix with an AI agent
In `Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:486`: `loadWebView` builds bundled archives on the main thread.

On the first load of each bundled paywall, this synchronous call reads the document and every resource, encodes the entire binary plist, and writes it to disk before returning. `viewDidLoad` calls `loadWebView` at line 333, so all of this work blocks UI rendering and interaction; the cost grows with the bundled assets.

Suggested fix: Build and cache the archive off the main actor, then return to the main actor to call `loadWebViewFromArchive`; keep the existing network fallback on failure.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

}

if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest) {
if let webArchiveURL = paywallArchiveManager?.getCachedArchiveURL(manifest: paywall.manifest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Warning

loadWebView skips bundled paywalls for .always manifests

F2 · Warning · correctness

A paywall with manifest.use == .always returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.

Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.
🤖 Prompt to fix with an AI agent
In `Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486`: `loadWebView` skips bundled paywalls for `.always` manifests.

A paywall with `manifest.use == .always` returns from the earlier branch at lines 472–482 and never reaches this bundled lookup. On an offline first launch, the manifest download fails and that branch loads the network URL instead, so a matching bundled document is not shown.

Suggested fix: Use the bundled archive as the fallback when `getArchiveURL(forManifest:)` returns nil in the `.always` branch, before calling `webView.loadURL`.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ℹ️ No critical issues. Two rough edges in the bundled archive path are worth a look.

Reviewed changes

I reviewed the initial commit, which adds a Superwall.bundle fallback for config and paywall documents.

  • BundledPaywalls: reads bundle.json and static_config.json from the app's Superwall.bundle once, matches paywalls by exact URL, and builds a WebArchive from the bundled files.
  • Config fallback: ConfigManager.fetchConfiguration uses the bundled config as the cached config when nothing is cached. When a bundle is present, it always takes the 1s‑timeout path and then refreshes in the background with oldConfig, so the refresh runs whether or not enableConfigRefresh is set.
  • Paywall loading: PaywallViewController.loadWebView tries the manifest archive first, then a bundled archive cached under Caches/paywalls/bundled/, then the network.
  • Tests: new BundledPaywallsTests cover decoding, matching, archive contents, version gating, archive caching, and offline and online first launches.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using claude-opus-5.5 | 𝕏

return archiveURL
}
do {
try fileSystemManager.write(archive: bundledPaywalls.webArchive(for: bundled))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

loadWebView runs on the main actor, so the first match builds the archive on the main thread. That means reading every bundled file into memory, binary‑plist‑encoding it, and writing it to disk, and your halo bundle came to 5 MB with 53 subresources. This most likely happens during the post‑config preload on first launch, which is exactly when the onboarding UI is on screen. The manifest path does the same work off the main thread inside WebArchiveManager.getArchiveURL(forManifest:).

Technical details
# Bundled archive is built synchronously on the main thread

## Affected sites
- Sources/SuperwallKit/Paywall/Archive Manager/PaywallArchiveManager.swift:94-95 — `bundledPaywalls.webArchive(for:)` + `fileSystemManager.write(archive:)` run inline
- Sources/SuperwallKit/Paywall/View Controller/PaywallViewController.swift:485-486 — called synchronously from MainActor `loadWebView()` (reached from `PaywallManager` preload, `viewDidLoad` and the reload paths)

## Required outcome
- File reads, plist encoding and the write happen off the main thread; `loadWebView` stays non-blocking.
- The cheap "already built" check can stay synchronous so later loads keep the fast path.

## Suggested approach (optional)
- Split into a sync `existingBundledArchiveURL(for:)` (match + `archiveExists`) and an `async` builder run from a detached/utility task, mirroring the `shouldAlwaysUseWebArchive` branch in `loadWebView` that awaits `getArchiveURL` and then falls back to `webView.loadURL(from:)`.
- Alternatively warm the bundled archives from `preloadArchive(paywall:)` (already async) so `loadWebView` normally only hits the existence check.

.deletingPathExtension()
.appendingPathExtension("webarchive")
let fileSystemManager = WebArchiveFileSytemManager(archiveURL: archiveURL)
if fileSystemManager.archiveExists {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The only check that the cached archive is good is archiveExists, and WebArchiveFileSytemManager.write uses a non‑atomic plistData.write(to:). If the app is killed partway through the write, or the disk fills up, a truncated .webarchive is left behind and served on every later launch until iOS purges Caches. Offline, that leaves the paywall with nothing to fall back to. Writing with .atomic would fix this path and the existing manifest path.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant