Skip to content

feat: add dev mode for previewing local paywalls - #511

Open
chroxify wants to merge 34 commits into
developfrom
christo/sw-framework-support
Open

feat: add dev mode for previewing local paywalls#511
chroxify wants to merge 34 commits into
developfrom
christo/sw-framework-support

Conversation

@chroxify

Copy link
Copy Markdown
Contributor

Changes in this pull request

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

@yusuftor
yusuftor marked this pull request as ready for review August 25, 2026 17:00
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

yusuftor and others added 3 commits August 25, 2026 19:02
…rk-support

# Conflicts:
#	SuperwallKit.xcodeproj/project.pbxproj
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev mode is a new feature, so the staged release gets a minor bump
instead of a patch. Bumps the version in all three places.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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.

Important

Two things reach production builds that shouldn't: DevServerPreview.handle reports a deep link as "handled by Superwall" before it checks whether dev mode is on, and the superwall_dev origin is attacker-suppliable while the paywall web view applies no origin check to its JS bridge. There is also a ## Unreleased heading the repo conventions forbid and a SwiftLint trailing-whitespace violation.

Reviewed changes — full initial review of the dev-mode feature: the new DevServer/ subsystem, its wiring into the paywall request pipeline, test mode and config, the debugger's new picker, and the four new test files.

  • DevMode gateisActive(_:) requires devMode/devServerURL AND DeviceHelper.isSandboxEnvironment (new), so an App Store production build ignores the option and logs a one-time warning.
  • DevServerManifest / DevServerLocator — decodes GET {base}/device/manifest.json, resolves a dashboard paywall to a local surface via a superwall.lock binding (or the single-paywall fallback), and walks localhost:6100…6104 unless devServerURL is set, with a 2s hit cache and 5s miss debounce.
  • Paywall overrideapplyDevServerOverrideIfNeeded rewrites url/urlConfig and nils manifest on every non-debugger paywall response; Paywall's stub init becomes internal and urlConfig/manifest become var.
  • superwall_dev deep link — parsed out of any inbound URL, pins the locator's base and opens the debugger on the named surface.
  • Test mode & config — dev mode force-enables test mode, skips paywall preloading, and replaces the test-mode intro modal with applyDefaultTestModeState (a faithful mirror of the modal's "no entitlements" branch).
  • Debugger pickerpressedPreview now presents a searchable, sectioned sheet (DebugPickerLogic + DebugPaywallPickerViewController) listing local surfaces and published paywalls, replacing the old alert.
  • Tests — pure-function coverage for the picker sections, manifest decoding/resolution, deep-link parsing, the DevMode production gate, and the synthesised Paywall.

⚠️ Host apps are told to ship an app-wide ATS downgrade for a dev-only feature

The devMode doc comment, the CHANGELOG entry and Examples/Basic/Basic/Info.plist all instruct apps to add NSAllowsArbitraryLoadsInWebContent alongside NSAllowsLocalNetworking. The first key disables App Transport Security for all web content in the app, permanently and in production, and is a documented App Review justification trigger — a steep price for a feature that only ever talks to localhost or a private-range IP.

Technical details
# ATS guidance should be the narrowest key that works, and scoped to debug builds

## Affected sites
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:402-404` — doc comment tells every host app to add both keys
- `CHANGELOG.md` — the shipped release note repeats the same instruction
- `Examples/Basic/Basic/Info.plist:16-22` — the example app models the broad key for customers to copy
- `Sources/SuperwallKit/DevServer/DevServerManifest.swift:130-134` — the runtime error message prints both keys as the fix

## Required outcome
- Confirm empirically whether `NSAllowsLocalNetworking` alone lets a `WKWebView` load `http://localhost:6100` and `http://192.168.x.x:6100`. If it does, drop `NSAllowsArbitraryLoadsInWebContent` from all four places.
- If the broad key really is required for the web view, say so explicitly in the docs and steer developers to a debug-only `Info.plist` (separate build configuration / `INFOPLIST_FILE` per config) rather than their shipping one.

## Open questions for the human
- Is there an existing Superwall docs page for `superwall dev` that carries this guidance? It should match whatever lands here.

ℹ️ The parts of dev mode that carry the risk have no test coverage

The four new test files cover pure functions only — manifest decoding, DebugPickerLogic.sections, deep-link parsing, the synthesised Paywall, and the DevMode production gate. The stateful pieces where the bugs flagged inline actually live are untested: DevServerLocator.locate (port walking, pin, hit/miss TTLs, candidate reordering), applyDevServerOverrideIfNeeded, and ConfigManager.applyDefaultTestModeState.

Technical details
# Add coverage for the stateful dev-mode paths

## Affected sites
- `Sources/SuperwallKit/DevServer/DevServerManifest.swift:62-163``DevServerLocator` has no test at all
- `Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift:33-66``applyDevServerOverrideIfNeeded` has no test
- `Sources/SuperwallKit/Config/ConfigManager.swift:737-749``applyDefaultTestModeState` has no test

## Required outcome
- A test that pins `applyDefaultTestModeState` to the same end state as `presentTestModeModal`'s "no entitlements selected" branch, so the two can't silently drift.
- A test over `DevServerLocator`'s candidate ordering that would fail if the `pin`/cached reordering regressed (this is what would have caught the invalid sort comparator flagged inline).
- A test that `applyDevServerOverrideIfNeeded` leaves the paywall untouched when no server is reachable, and rewrites `url`, `urlConfig` and `manifest` when one is.

## Suggested approach (optional)
- `DevServerLocator`'s network hop is the obstacle. Following the `DevMode.isSandboxEnvironment` precedent, an injectable fetch closure would make `locate` testable without a live server.

ℹ️ Nitpicks

  • All four new test files use XCTest. CLAUDE.md states this project uses Swift's Testing framework (@Test/#expect) for all unit tests and that new tests should always use it.
  • DevModeTests mutates the global DevMode.isSandboxEnvironment and restores it in tearDown. Several suites here already flake under parallel execution for exactly this reason — worth knowing if these start failing only in parallel runs.
  • DebugViewController.loadDevServerPreview calls DevServerLocator.shared.locate(...) again even though devServer was already resolved by ensureDevServer and carries the same base; only location.manifest is used from the second call.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerPreview.swift
Comment thread Sources/SuperwallKit/DevServer/DevServerPreview.swift Outdated
Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerManifest.swift Outdated
Comment thread CHANGELOG.md Outdated
Comment thread Sources/SuperwallKit/Debug/DebugViewController.swift Outdated

@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.

ℹ️ Delta since the last review is release staging only. Nothing new to flag; the earlier findings are still open.

Reviewed changes — the three commits added since my review of 6519c3c. No behavioral change in this delta.

  • Merge from develop (4f1779f) — brings in the SDK-configuration data-race fix already on the base branch.
  • CHANGELOG restaged (48b6b35, 5d8c076) — the ## Unreleased heading is gone; the dev-mode entry now sits under ## 4.17.0 as ### Enhancements, above the fixes that were previously staged as 4.16.4.
  • Version bumpConstants.swift (still on line 21), SuperwallKit.podspec and CHANGELOG.md all read 4.17.0 consistently.
  • project.pbxproj regenerated — wholesale identifier churn from xcodegen; the new DevServer/ group and all six new files are still wired into both targets.

ℹ️ The version restage went further than the convention prescribes

CLAUDE.md says that when develop's version is already above master's — 4.16.4 vs 4.16.3 here — a release is staged and you add entries to that section without bumping again. Instead the staged 4.16.4 was renamed to 4.17.0, which pulls two already-staged fixes into a minor release. That is arguably the better semver call for a PR adding new public API (devMode, devServerURL), so I am flagging it only so whoever owns the release train is aware the number moved rather than assuming a plain patch is still queued. No change requested.

ℹ️ Still open from the previous review

Nothing in this delta touched the earlier findings, so they carry forward unchanged: the ungated superwall_dev deep-link claim in production builds, the attacker-suppliable dev-server origin reaching the paywall JS bridge, the dev-server override being frozen into paywallsByHash, withTimeout not bounding wall-clock time, the invalid sort comparator in DevServerLocator, and the trailing whitespace at Sources/SuperwallKit/Debug/DebugViewController.swift:128 (verified still present at 5d8c076).

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

Addresses pullfrog's review of 6519c3c:

- handleDeepLink no longer claims a superwall_dev link when dev mode is
  off, so production apps keep routing such URLs down their handler
  chain. The pre-configuration storeDeepLink path only claims dev links
  once options are checkable.
- A deep-link-supplied dev-server base must now be a host superwall dev
  could have printed (loopback, .local, private-network ranges) or match
  the developer-supplied devServerURL, so an arbitrary internet host can
  no longer be handed the paywall JS bridge.
- Dev-mode paywalls skip the request-hash memoisation and fold the mount
  URL into cacheKey, so a transient server miss no longer pins the
  published paywall for the process and a moved server reloads the web
  view.
- The debugger's withTimeout now genuinely resumes at the deadline
  instead of waiting out the slow product call and discarding it.
- The cached-base move-to-front uses removeAll/insert instead of an
  irreflexive sort predicate.
- Removes trailing whitespace flagged by SwiftLint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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.

Important

The new isTrustedBase allowlist constrains the deep-link base but nothing re-applies that boundary to the mount URL the manifest names, so the origin guarantee its own doc comment states is not actually enforced. Everything else in this delta checks out: the withTimeout race, the cache-key/memoisation change and the comparator fix are all correct.

Reviewed changes — the single commit added since my review of 5d8c076 (154f0a2), which addresses all seven inline threads from the initial review.

  • Dev links gated out of productionDevServerPreview.handle now checks DevMode.isActive synchronously and returns false (still logging the warning) when dev mode is off, and DeepLinkRouter.isSuperwallURL claims the link only once Superwall.isInitialized and canHandle both agree. storeDeepLink runs both pre-configure and configured-but-not-yet-loaded, so the isInitialized gate is not vacuous.
  • Deep-link origin allowlistedisTrustedBase restricts a deep-link-supplied base to loopback, ::1, *.local, RFC1918/link-local ranges, or an exact scheme+host+port match on options.devServerURL. The four-numeric-octet parse correctly rejects 10.0.0.1.evil.example.com, and userinfo smuggling, case, trailing-dot FQDNs and decimal/octal IP forms all fail closed.
  • withTimeout rewritten as a real race — an AsyncStream whose first yield wins, so the caller resumes at the deadline instead of waiting out the operation and then discarding its result. No deadlock, hang or double-resume is reachable: the build closure runs synchronously, try? around Task.sleep guarantees the timeout branch always yields, and AsyncStream.Continuation is documented idempotent.
  • Dev-mode cache unfrozensaveRequestHash skips the paywallsByHash memoisation while dev mode is active (concurrent work still dedupes through activeTasks), and the override folds the mount URL into Paywall.cacheKey so a moved server or a published fallback takes .replacePaywall/.loadWebView instead of .updatePaywall.
  • Comparator and lint fixes — the non-strict-weak-ordering sort predicate in DevServerLocator became removeAll/insert, matching the pinnedBase block above it, and the trailing whitespace at DebugViewController.swift:128 is gone.
  • Tests — a new Swift Testing DevServerPreviewTests covering the trusted and untrusted host lists, devServerURL matching and canHandle, plus a DeepLinkRouterTests case that does fail against the pre-fix code.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerManifest.swift
A manifest fetched from a trusted base could still name an absolute URL
on any origin, since URL(string:relativeTo:) ignores the base for
absolute strings. mountURL now rejects any resolved URL whose scheme,
host, or port differs from the base, covering both the request-pipeline
and debugger callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Aug 25, 2026

Copy link
Copy Markdown

Pullfrog stalled

The agent stopped emitting events for 125s and was killed by the activity-timeout watchdog. The model produced no output at all before the stall — the request was sent but nothing came back. This is usually transient; re-running often succeeds.

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@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 — one minor suggestion inline.

Reviewed changes — the single commit added since my review of 154f0a2 (75da4e8), which applies the mount-URL origin pin from that review's only open thread.

  • Mount URLs pinned to the manifest's originDevServerManifest.mountURL(for:base:) now returns nil unless the resolved scheme, host and port all equal base's, so a manifest fetched from a trusted address can no longer name an arbitrary internet origin. One place, covering both the request-pipeline caller (RawPaywallResponse.swift:41) and the debugger (DebugViewController.swift:411).
  • Regression test addedtest_mountUrlRejectsSurfacesPointingOffTheDevServerOrigin asserts nil for absolute, protocol-relative and other-port surface urls. All three resolve non-nil against the pre-fix code, so the case genuinely fails without the guard rather than passing either way.
  • No over-rejection of legitimate manifests — checked against Foundation's URL semantics: a relative surface.url inherits base's scheme/host/port and still passes (test_mountUrlResolvesAgainstTheDevServerOrigin), as do an absolute same-origin url and a protocol-relative same-host url.
  • Threat scope is unchanged — worth stating plainly so the fix isn't read as more than it is: the pin stops a manifest from redirecting off-origin, not a hostile server actually resident at a trusted private address, which still reaches the paywall JS bridge over plain HTTP. That was the open question on the previous thread and the author has deferred it to the humans; no change requested here.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerManifest.swift
A representation mismatch (localhost vs 127.0.0.1, or a portless
devServerURL against an explicit-port surface url) would otherwise
disable the override with no trace, which is the one failure mode this
subsystem otherwise always logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 new issues found.

Reviewed changes — the single commit added since my review of 75da4e8 (4a032b8), which applies the only open suggestion from that review. Log-only delta.

  • Off-origin rejections are now traceableDevServerManifest.mountURL(for:base:) emits a .warn on .superwallCore before returning nil from the origin-pin guard, naming the surface id, its raw url, the resolved absolute URL and base. Both callers still treat nil as a silent no-op, but the nil itself now always carries a log from inside mountURL, which was the ask.
  • Diagnostic quality is right for the failure it covers — the message distinguishes the raw surface.url from what it resolved to, so the representation mismatches this guard can hit (localhost vs 127.0.0.1, or a devServerURL given without a port against an absolute :6100 surface url) read straight out of the log rather than looking like "no dev server found".
  • No behavioral change — the guard's condition, the nil return and every caller are untouched; the Logger.debug(logLevel:scope:message:) call matches the signature and style of the other log sites in this file and in DevMode. Nothing new to test.

All nine inline threads across my prior reviews are now resolved. The body-level observations from the initial review (the app-wide NSAllowsArbitraryLoadsInWebContent guidance, and the absence of coverage for DevServerLocator.locate / applyDevServerOverrideIfNeeded / applyDefaultTestModeState) are unchanged by this delta and remain the humans' call — I am not re-raising them here.

Pullfrog  | View workflow run | Using Claude Opus𝕏

…orking

Matches the Basic app, and lets the dev server's plain-http localhost
traffic through without the blanket arbitrary-loads exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 new issues found.

Reviewed changes — the single commit added since my review of 4a032b8 (d1f5e0d), which scopes the Advanced example app's App Transport Security exception.

  • Advanced example's ATS narrowedExamples/Advanced/Advanced/Info.plist drops the pre-existing app-wide NSAllowsArbitraryLoads in favour of NSAllowsArbitraryLoadsInWebContent + NSAllowsLocalNetworking, so the two example apps now model the same exception the devMode doc comment and the DevServerManifest runtime error message prescribe. Net tightening, not a loosening.
  • Nothing regressed by dropping the broad key — the Advanced app issues no plain-http:// request of its own (the only http:// occurrences are DOCTYPE DTD refs), and dev mode's two loads are both covered by the narrower pair. Both plists parse cleanly.
  • No source or config change — the delta touches one Info.plist; the SDK, tests and version metadata are untouched since 4a032b8.

ℹ️ The NSAllowsArbitraryLoadsInWebContent guidance looks like more than dev mode needs

At my initial review I flagged that host apps are told to ship a permanent web-content ATS downgrade for a dev-only feature, and left "does NSAllowsLocalNetworking alone suffice?" as an open question for the humans. This commit is in exactly that area, so here is what I could establish from Apple's documentation since — no change requested in this PR, and no anchor, because settling it takes a device test rather than a code edit.

Technical details
# Evidence that `NSAllowsLocalNetworking` alone may cover both of dev mode's loads

## Affected sites
- `Sources/SuperwallKit/Config/Options/SuperwallOptions.swift:402-404` — doc comment instructs host apps to add both keys
- `Sources/SuperwallKit/DevServer/DevServerManifest.swift:149-154` — runtime ATS error message prints both keys as the fix
- `Examples/Advanced/Advanced/Info.plist:20-23` and `Examples/Basic/Basic/Info.plist:16-22` — both examples model both keys

## What the documentation says
- ATS does not apply by default to loopback or unqualified hostnames such as `localhost` on any current iOS. iOS 17 removed the default exemption only for bare **IP literals** — precisely what `NSAllowsLocalNetworking` restores. (`NSAllowsLocalNetworking` key reference.)
- `NSAllowsArbitraryLoadsInWebContent` is documented as exempting `WKWebView` "without affecting your `URLSession` connections", so it cannot help the `GET /device/manifest.json` fetch in `DevServerManifest.fetchManifest` at all.
- `NSAllowsLocalNetworking` is listed under "Global Exceptions", which the `NSAppTransportSecurity` overview states "apply to all network connections made by your app" — there is no documented `WKWebView` carve-out from that scope.

## The gap I could not close
No Apple sentence says explicitly that the local-networking relaxation reaches `WKWebView`; the closest is the general global-exception line above. That is the one claim a device test would settle, and it is the claim the whole question turns on.

## Required outcome
- Decide, on device evidence, whether `NSAllowsArbitraryLoadsInWebContent` is actually needed for a `WKWebView` load of `http://<private-ip>:6100`. If it is not, drop it from all four sites above.
- If it genuinely is needed, say so explicitly in the `devMode` doc comment and steer developers to a debug-only `Info.plist` (a separate `INFOPLIST_FILE` per build configuration) rather than their shipping one.

## Open questions for the human
- Is there a `superwall dev` docs page carrying this guidance? Whatever lands here should match it.

Pullfrog  | View workflow run | Using Claude Opus𝕏

yusuftor and others added 9 commits August 26, 2026 14:46
DevServerSurface, DevServerLocation, DevServerCandidates, and
DevServerLocator move to their own files; DevServerManifest keeps the
manifest model. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eturn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also documents outcomeForDeepLink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…log entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Missing products fail fast on their own — both fetchers throw
noProductsFound without entering their retry ladder — so the 3s race
only ever hedged degraded-network cases, at the cost of indirection.
The debugger now awaits the store directly, like it does on develop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 new issues found.

Reviewed changes — everything added since my last submitted review at d1f5e0d: eight commits, only one of which has any behavioral surface. The seven refactor/style commits in between were each verified equivalent when they landed and are re-confirmed here.

  • Debugger's product-variables timeout dropped (e751849) — withTimeout and both of its call sites are gone; finishLoadingPreview (DebugViewController.swift:278) and loadDevServerPreview (:392) now await storeKitManager.getProductVariables(for:) directly. This is the second of the two remedies my original thread offered, and it holds up.
  • The fail-fast justification checks outProductsFetcherSK2.swift:57-80 throws noProductsFound as soon as Product.products(for:) returns empty for a non-empty id set, and its catch re-throws immediately for ProductFetchingError rather than retrying; ProductsFetcherSK1.swift:204-224 does the same. That is precisely dev mode's case — a surface naming products the store has never seen — so it returns straight away. An empty id set short-circuits in both fetchers too.
  • The one slow path left is the pre-existing one — a StoreKit request failure still retries 10× with 3s sleeps, which is the degraded-network case the race hedged. The debugger's published-paywall path has behaved exactly this way on develop all along, so the new dev-server path is now consistent with it rather than diverging; the "best-effort" comment above the call already promises an empty-variables render.
  • Nothing dangles from the removal — no remaining withTimeout reference anywhere in Sources or Tests, and the @escaping @Sendable closure that captured self and paywall is gone with it.
  • DevServerManifest.swift split one-type-per-file (254d6de) — DevServerSurface, DevServerManifest, DevServerLocation, DevServerCandidates and DevServerLocator now live in their own files, wired into project.pbxproj; verified lossless against the pre-split file, with the origin pin and its rejection log intact at DevServerManifest.swift:29-49.
  • Style-only churn (3482bf2, 0d7ce5e, 362950d, 11cd922, 6fed06c, c22e098) — the debugger's picker gate became a positively-stated canOpenPicker computed property, the locator's ATS warning latch moved in with its sibling state, and the ATS-gate and dev-link-parse compound guards were split into single-condition guards. Each verified equivalent; no condition, operator or call site moved.
  • CHANGELOG wording (cf2020c) — the dev-mode enhancement bullet drops the superwall.lock binding sentence. The behaviour it described still exists in DevServerManifest.surface(forPaywallDatabaseId:).

All nine inline threads from my prior reviews remain resolved. The two body-level observations still outstanding — the NSAllowsArbitraryLoadsInWebContent guidance and the absence of coverage for DevServerLocator.locate / applyDevServerOverrideIfNeeded / applyDefaultTestModeState — are untouched by this delta and remain the humans' call; I am not re-raising them.

Pullfrog  | View workflow run | Using Claude Opus𝕏

yusuftor and others added 3 commits August 26, 2026 17:24
One knob instead of two: options.devServer = nil (off, the default),
.default (find the server on localhost, for simulators), or .url(_:)
(the Device URL superwall dev prints, for physical devices). Folding
the URL into the option removes the 'setting the URL implies the mode'
rule and every half-configured state.

Objective-C gets enableDevServer()/enableDevServer(url:) veneers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yusuftor and others added 3 commits August 26, 2026 18:00
Preview presents via .fromIdentifier, and a local surface's synthetic
dev: identifier has no backend counterpart, so the fetch 404ed into
"There isn't a paywall configured to show in this context." The request
pipeline now resolves dev: identifiers from the debugger's manifest
before consulting statics or the network, which routes the full
presentation and product pipeline through the local paywall.

Verified end to end in the simulator: dev link -> picker -> Preview
presents the local surface with loaded products. All 945 unit tests
pass; the resolution glue itself is exercised by that manual flow since
it needs a live debugger session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g published paywalls

When the dev server has a surface for a paywall, the placement path now
presents the synthesized local paywall — products and all — rather than
the published paywall with a swapped URL. Mixing the two meant local
pages asked for product references the published paywall didn't declare,
rendering blank prices. Only the assignment's experiment and fetch
timings carry over, keeping holdouts and analytics coherent; bound
surfaces keep their real database id.

Verified in the simulator: a placement now presents the local paywall
with its own products and price, matching the debugger's preview.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… config

Adds PaywallInfo.isLocal, sent as is_local on every paywall event and
audience filter param, so the dashboard can flag a presentation that
came from a superwall dev server rather than a published paywall.

Local surfaces also stop presenting with a hardcoded modal style: the
manifest now carries the presentation config declared in config.ts, and
the SDK maps style/drawer/popup onto PaywallPresentationStyle. Anything
missing or unrecognised falls back to fullscreen, which is the framework
documented default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Important

The wholesale-replacement commit changed two things that need a decision before merge: a debugger opened from a dashboard preview link can list and preview a local surface but can't present it, and a dashboard paywall configured as gated becomes non-gated once dev mode overrides it.

Reviewed changes — everything added since my last review at e751849: four substantive commits, one style commit (6240aa2, multiline-guard formatting — verified equivalent) and one empty CI retrigger.

  • devMode/devServerURL replaced by SuperwallOptions.devServer (b61b66e) — a nested DevServer enum (.default / .url(_:)) with @available(swift, obsoleted: 1.0) shims for Objective-C. SuperwallOptions is @objcMembers, so enableDevServer() and enableDevServer(url:) really are ObjC-visible, and the explicit CodingKeys still omits the new property so it stays backend-invisible. Neither old name ever shipped, so this is not a customer-facing break; the CHANGELOG entry was updated to match.
  • Debugger can present unpushed surfaces (d6f8f63) — loadDevServerPreview now sets paywallIdentifier to the synthetic dev:<surfaceId>, and a new devServerPaywall(forId:) resolves that identifier back to a surface out of the debugger's manifest instead of asking the backend for it.
  • Local surfaces replace the published paywall wholesale (ee963aa) — applyDevServerOverrideIfNeeded no longer patches url/urlConfig/manifest/cacheKey onto the fetched paywall; it returns Paywall.devServer(surface:url:) and carries over only experiment and responseLoadingInfo.
  • isLocal and presentation config (75c29ba) — Paywall.isLocal / PaywallInfo.isLocal plus an is_local audience-filter param, and a DevServerSurface.Presentation block mapped onto PaywallPresentationStyle, with five new tests covering the mapping and its fullscreen fallback.
  • Not re-raised — I checked whether Product(entitlements: []) in Paywall.devServer breaks test-mode purchases. It doesn't: StoreKitManager.getProducts(forPaywall:isTestMode:) overwrites entitlements from the test-product catalog, and TestModeTransactionHandler grants from testModeManager.products by product-identifier match rather than from the paywall's products.

ℹ️ Nitpicks

  • Sources/SuperwallKit/DevServer/DevServerManifest.swift:7 still says "when SuperwallOptions/devMode is on" — the only surviving reference to the removed option name.
  • The CHANGELOG entry covers SuperwallOptions.devServer but not the other new public API in this delta: PaywallInfo.isLocal and the is_local audience-filter param, both of which developers can use directly.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerSurface.swift Outdated
yusuftor and others added 2 commits September 4, 2026 13:48
…rk-support

Conflicts:
- CHANGELOG.md: both sides added to the 4.17.0 Enhancements list. Kept
  both sets of entries; both sides were already on 4.17.0, so no bump.
- ConfigManager.swift merged cleanly but drifted: develop moved SDK-internal
  status writes to setSubscriptionStatus(assigned:), so the dev server's
  applyDefaultTestModeState now uses it too, matching presentTestModeModal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three-member tuple carried exactly the fields PaywallSummary already
has, and tripped SwiftLint's large_tuple rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 new issues in this delta — but the three findings from my review at 75c29ba are still open, so this isn't an approval.

Reviewed changes — the two commits added since my review at 75c29ba: a merge of develop and one refactor. No new findings; nothing in this delta touches the three open threads.

  • Merge of develop (79e8ed2) — brings in grantedEntitlements, the SubscriptionStatusPublishing rework and hasActiveCachedSubscription. The branch is now level with develop's tip (ad1da70); no conflict markers survive, and the PR diff against that base is still only the 31 dev-mode files, so no develop hunk was reverted in the resolution.
  • One PR-owned line adaptedConfigManager.applyDefaultTestModeState now calls Superwall.shared.setSubscriptionStatus(assigned: .inactive). That's the same transformation develop applied to presentTestModeModal and TestModeTransactionHandler, and assigned: is the writer SubscriptionStatusPublishing.swift:75 documents for SDK-internal callers, so dev mode's "seed the modal's no-entitlements state" mirror still holds exactly.
  • Test-mode override still beats granted entitlements — worth stating since the merge introduced the merge step: mergedSubscriptionStatus returns testModeManager.overriddenSubscriptionStatus before any grant is folded in, so a developer with grantedEntitlements set doesn't accidentally suppress paywalls under dev mode.
  • Version staging resolved itself4.17.0 now comes from develop, so Constants.swift and the podspec have dropped out of the PR diff entirely and the dev-mode CHANGELOG bullet simply joins develop's staged section. My earlier note about the restage is moot.
  • publishedPaywalls holds PaywallSummary (7689d10) — the three-member tuple carried exactly id/identifier/name, which is what PaywallSummary already is and what previewPaywalls already held, so the first branch stops mapping and the second builds the struct. Every consumer (canOpenPicker, the picker's map/firstIndex) reads the same three fields. Equivalent.
  • Worth noting, not re-raised — this merge is the first time applyDefaultTestModeState had to be hand-edited in lockstep with presentTestModeModal, which is precisely the drift the initial review's "nothing pins these two together" coverage note anticipated. It was done correctly here; I'm flagging only that the next such rewrite has the same manual step.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

… debugger's dev server

Addresses the three open Pullfrog findings on #511.

- The dev-server override synthesized the paywall wholesale, so featureGating
  came from the stub's .nonGated default. A gated dashboard paywall therefore
  unlocked its feature for a non-paying user under dev mode. It now carries
  the published paywall's gating over, and the devServer doc says so.
- One surface the SDK couldn't read aborted the whole manifest decode, so a
  single bad entry took dev mode down for every surface and logged it as "no
  dev server found". Surfaces decode per element now, a bad presentation block
  costs a surface its style rather than the surface, and a manifest that
  answers but doesn't parse says exactly that.
- Partly specified drawer geometry is legitimate: PaywallPresentationStyle
  documents a 70% default height. A drawer naming only some of its geometry
  now presents as a drawer. A popup has no documented default, so a partial
  one still falls back to fullscreen.
- ensureDevServer populated only the view controller's copy, so presenting a
  local surface from a debugger opened by a dashboard link resolved nil and
  404'd. It writes through to the debug manager now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Important

All three findings from my review at 75c29ba are properly fixed. The containment fix carries one regression, though: making surfaces optional removed the only thing that told a superwall dev server apart from anything else answering on a candidate port, so the port walk can now stop on the wrong process with no log at all.

Reviewed changes — the single commit added since my review at 7689d10 (eedda06), which closes all three open threads.

  • Feature gating stays with the dashboardapplyDevServerOverrideIfNeeded now carries featureGating across alongside experiment and responseLoadingInfo (RawPaywallResponse.swift:58). Paywall.featureGating is a var, so the assignment sticks and a gated dashboard paywall no longer unlocks its feature through PublicPresentation's .nonGated branch. The doc comment now states the split in both directions rather than leaving "configuration … stay real" to imply it.
  • The two devServer stores are written in one placeDebugViewController.ensureDevServer (:236-240) writes through to debugManager.devServer. The class is @MainActor and the property is @MainActor var, so the write is legal, and DebugManager still seeds the view controller's copy for the dev-link path — the two can no longer disagree, so a debugger opened from a dashboard link can present a local surface.
  • One unreadable surface no longer takes down the manifestDevServerManifest decodes [Throwable<DevServerSurface>] and compact-maps, matching the house idiom in Paywall.swift, and logs how many it skipped. DevServerSurface wraps only its presentation decode in try?, so a block the SDK can't read costs the surface its style, not its place in the list.
  • Partial geometry presentsDrawer/Popup fields are Double?. A drawer defaults to height: 70, which does match PaywallPresentationStyle.drawer's documented 70% and is consumed as height / 100 at PaywallViewController.swift:803; a popup without both dimensions still falls back to fullscreen. Eight new tests, and the changed drawerWithoutGeometry expectation is the correct one.
  • The decode failure is distinguishable from a dead portfetchManifest (DevServerLocator.swift:109-123) logs the DecodingError separately from the transport failure, so "no server found" no longer covers for a manifest that simply didn't parse.

ℹ️ Nitpicks

  • DevServerSurface's new memberwise init(kind:id:url:…) (DevServerSurface.swift:65-81) has no callers anywhere in Sources or Tests — both test helpers decode from JSON. DevServerManifest(surfaces:) earns its keep (RawPaywallResponse.swift:90); this one is only replacing an implicit init nothing used.
  • test_anUnknownFieldDoesNotDropTheSurface passes identically against the pre-change code — JSONDecoder has always ignored unknown keys — so it doesn't cover anything this commit introduced. The other three new manifest tests do.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerManifest.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerLocator.swift Outdated
yusuftor and others added 2 commits September 4, 2026 15:38
…able

The dev-server override synthesizes the paywall, so every field not named
was silently taking the local stub's default. featureGating was the first
one caught; these are the rest that change behaviour rather than looks.

The surface can't express any of them — the manifest carries only kind, id,
url, paywallId, identifier, products and presentation — so the dashboard is
the only source and there is nothing to override:

- computedPropertyRequests, or a local render lacks variables production
  resolves and you debug a template bug that doesn't exist
- introOfferEligibility, which drives displayed trial state and pricing
- surveys and localNotifications, dashboard behaviour that otherwise just
  stops happening

Inheritance now lives in Paywall.devServer(surface:url:inheriting:) rather
than as a list at the call site, so there is one place to add to.

Also fixes the regression from eedda06: making `surfaces` optional removed
the only field identifying the JSON as a superwall manifest, so any process
answering a candidate port with a JSON object ended the port walk silently.
The key is required again, the probe checks the HTTP status, and the
unreadable-manifest log no longer claims the responder is a dev server when
the probe can't know that. Drops an unused init and a vacuous test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A paywall's config.ts can declare notifications (SuperwallNotificationsConfig
in the CLI's runtime package), and they reach the SDK as
`schedule_notification` messages rather than through Paywall.localNotifications.
So notifications are not dashboard-only, and inheriting them was wrong:
NotificationScheduler dedupes on paywallId + type, so for a bound surface the
dashboard's copy could win that filter and fire instead of the local one you
are iterating on.

The other three inherited fields stand — the manifest still can't express
featureGating, computedPropertyRequests or introOfferEligibility.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 — one minor suggestion inline, plus a scope question worth a conscious decision.

Reviewed changes — the single commit added since my review at eedda06 (5181d83), which closes both open threads.

  • surfaces is a required key againDevServerManifest.init(from:) (:28) went back to try container.decode([Throwable<DevServerSurface>].self, forKey: .surfaces), so per-element containment survives while a body without the key can no longer end the port walk. test_missingSurfacesKeyDecodesAsEmpty is replaced by test_aBodyWithoutSurfacesIsNotAManifest and test_anEmptySurfaceListIsStillAManifest; both fail against the pre-change code.
  • The status now rules out a responder before the body doesfetchManifest (DevServerLocator.swift:135-138) rejects a non-2xx HTTPURLResponse. Checked against Apple's HTTPURLResponse documentation: every response for the http://localhost:PORT candidates really is an HTTPURLResponse, redirects are followed transparently, and .reloadIgnoringLocalCacheData rules out a cached substitute — so the as? cast can only miss under a test URLProtocol.
  • The decode-failure log no longer overclaimslogUnreadableManifest (:95-112) names the probed address rather than asserting a dev server is there, and only fires for a body that actually carries a top-level surfaces key. With surfaces required, an unrelated process now fails to decode and the walk moves on silently, which is the right trade.
  • Dashboard-owned behaviour moved into Paywall.devServer(surface:url:inheriting:)published supplies responseLoadingInfo, featureGating, computedPropertyRequests, localNotifications, surveys, introOfferEligibility and experiment, and applyDevServerOverrideIfNeeded stopped patching fields after construction. The ordering holds: getPaywallResponse sets experiment and responseLoadingInfo (RawPaywallResponse.swift:127-129) before getRawPaywall applies the override, so both are live when inherited. Four new tests, each of which fails without the inheritance.
  • Both nitpicks from eedda06 are goneDevServerSurface's uncalled memberwise init is deleted (grep confirms zero DevServerSurface( call sites; the test helper decodes from JSON), and test_anUnknownFieldDoesNotDropTheSurface is removed.

ℹ️ Inheriting surveys and localNotifications gives a dev preview production side effects

Both fields were always [] on a dev-mode paywall before this commit, so two paths that were structurally unreachable now open up: a survey shown during a local preview writes its one-shot assignment key and tracks a real survey_response carrying the published paywall's paywall_id, and a simulated test-mode purchase can schedule a real trial-expiry OS notification. That is a faithful reading of "dev mode changes how a paywall looks and never how it behaves" — I am surfacing it because it is new as of this commit and only you can say whether it is wanted.

Technical details
# Dev-mode previews now write survey assignments, real survey events and OS notifications

## Affected sites
- `Sources/SuperwallKit/DevServer/DevServerPaywall.swift:47` / `:87``surveys` inherited from `published`
- `Sources/SuperwallKit/DevServer/DevServerPaywall.swift:46` / `:83``localNotifications` inherited from `published`

## Chains traced
- `applyDevServerOverrideIfNeeded` only runs when `!request.isDebuggerLaunched` (`RawPaywallResponse.swift:19-21`), so `PaywallViewController.swift:1620-1636` passes `isDebuggerLaunched: false` into `SurveyManager.presentSurveyIfAvailable`.
- `SurveyManager.swift:51-53` therefore writes `SurveyAssignmentKey` to storage. Combined with the `hasSeenSurvey` check at `:41`, a developer who answers a survey while iterating locally will never see that survey again on that device.
- Selecting an option calls `Superwall.shared.track(surveyResponse)`. `Tracking.swift` has no test-mode, dev-mode or `isLocal` branch — it enqueues on `placementsQueue`. For a bound surface `databaseId` is `surface.paywallId`, i.e. the real dashboard id, so the response lands on the published paywall's analytics.
- Dev mode force-enables test mode (`TestModeManager.evaluateTestMode`), but a simulated purchase still returns `.purchased` and reaches `trackTransactionDidSucceed`, whose `.freeTrialStart` branch calls `NotificationScheduler.shared.scheduleNotifications` unconditionally (`TransactionManager.swift:1149-1164`).
- `paywall.isLocal` is set (`DevServerPaywall.swift:93`) but is never read by `SurveyManager`, `Tracking`, `TransactionManager` or `NotificationScheduler` — today it is purely descriptive.

## Required outcome
- A deliberate answer to: should a local preview be able to consume a real survey assignment and emit a real `survey_response` against the published paywall id?
- If the answer is no for surveys but yes for everything else, `isLocal` is the flag that already exists to express it.

## Open questions for the human
- The test-mode → `scheduleNotifications` half already behaves this way on `develop` for plain test mode, so it may be deliberate SDK-wide rather than dev-mode-specific. Worth confirming that read.

ℹ️ Nitpicks

  • The non-2xx early return (DevServerLocator.swift:135-138) returns nil with no log at all, so a superwall dev that is running but whose manifest handler 500s (a crash mid-rebuild, or a 401/403 from a proxy) now reports as "no superwall dev server was found at localhost:6100…6104". Before this commit that body reached the decode-failure log. Purely a diagnostic gap, and keeping it quiet does avoid noise from unrelated processes — but a one-line .warn naming the status would distinguish "nothing there" from "something there, erroring".

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated

@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 new issues in this delta. Not an approval only because the inline thread from my review at 5181d83 is still open.

Reviewed changes — the single commit added since my review at 5181d83 (8dee668), which reverses one of the seven fields that commit started inheriting.

  • localNotifications goes back to []Paywall.devServer(surface:url:inheriting:) no longer copies the published paywall's notifications (DevServerPaywall.swift:81-85), with an inline comment stating why. This lands exactly on the notification half of the scope question I raised at 5181d83; the survey half is untouched and still stands.
  • The dedupe reasoning checks out — a local paywall's config.ts notifications arrive as schedule_notification web messages (PaywallMessage.swift:121PaywallMessageHandler.swift:233Superwall.swift:1425-1430), and NotificationScheduler.scheduleNotifications (:68-77) drops any whose com.superwall.ios-<paywallId>-<type> identifier is already pending. The web path keys on paywallViewController.paywall.identifier and the transaction path on paywallInfo.identifier — the same value — so an inherited dashboard notification scheduled at .freeTrialStart really would have deduped the local one out.
  • Both consumers are coveredTransactionManager.swift:1157 and WebEntitlementRedeemer.swift:590 are the only readers of paywallInfo.localNotifications, and both now see an empty list for a dev-mode paywall.
  • The new test can failtest_doesNotInheritNotificationsTheLocalPaywallDeclaresItself asserts the empty list against a published stub that carries one, which is exactly what 5181d83 would have returned as count == 1. The assertion it replaced in test_inheritsDashboardOwnedBehaviourFromThePublishedPaywall was removed rather than inverted, so the two tests don't contradict.
  • Worth stating, not flagged — a bound surface whose local config.ts declares no notifications now gets none at all in dev mode, where production would fire the dashboard's. That is the deliberate "the local surface owns what it declares" trade and the code says so.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

… bindings

Matches the SDK to the manifest the shipped CLI actually sends: kind, id,
url, paywallId, paywallIds, identifier and products. Everything else a
paywall's config.ts declares reaches the SDK only after a push, so a
dev-served paywall has to take it from the published paywall it stands in
for rather than from a hardcoded stub value.

- presentation: a bound paywall was being forced to .fullscreen, losing the
  drawer or modal style the dashboard configured. The style now falls back to
  the published paywall's when the manifest declares none.
- background colours and isScrollEnabled inherit for the same reason; the
  hardcoded white also flashed on load in dark mode.
- paywallIds: superwall.lock can bind one surface to several paywalls and the
  CLI already sends the whole set, but matching read only the singular, so a
  multi-bound surface served its first paywall and fell through to published
  for the rest.
- featureGating and introductoryOfferEligibility are read off the surface
  first when present. Nothing sends them yet, so this is inert, but it means
  no matching SDK release is needed once they are carried.
- presentation defaults now mirror the CLI's DRAWER_DEFAULTS and
  POPUP_DEFAULTS (70/15, 80x60/15) instead of a guessed zero radius, so a
  partly specified block presents the same before and after a push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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.

Important

The new multi-paywall binding resolves the right surface but still stamps the first bound paywall's identity onto the synthesised Paywall, so two of three bound paywalls report the wrong paywall_id to the real placements queue and share one cached view controller. Separately, this commit makes the manifest's key-omission semantics load-bearing for feature gating, and I could not find any public evidence for the CLI contract it depends on.

Reviewed changes — the single commit added since my review at 8dee668 (0027657). First substantive behavior change since 5181d83.

  • One surface can bind to several paywallsDevServerSurface.paywallIds (DevServerSurface.swift:42) and a paywallId == id || paywallIds?.contains(id) match in DevServerManifest.surface(forPaywallDatabaseId:) (:49-66), covered by test_matchesASurfaceBoundToSeveralPaywalls.
  • config.ts now outranks the dashboard for gating and trial eligibility — raw-String? featureGating / introductoryOfferEligibility on the surface, mapped by gating(from:) and eligibility(from:), with precedence surface ?? published ?? safe default. Unrecognised strings deliberately return nil so a newer CLI can't guess. This partially reverses the fix for the gating finding I raised at 75c29ba — intentionally, and the code says why — so a config.ts declaring nonGated will now ungate a gated dashboard paywall in dev mode. Five new tests, each of which can fail.
  • More of what the manifest can't carry is inheritedpresentationStyle(for:) returns nil when the surface has no presentation block at all, so the dashboard's whole PaywallPresentationInfo stands; backgroundColorHex / backgroundColor / both dark variants and isScrollEnabled come from published too.
  • Partial popup/drawer geometry now resolves rather than degrading — a popup naming only height stays a popup instead of falling back to fullscreen, and the drawer's default corner radius moved from 0 to 15, both against new defaultPopup* / defaultDrawer* constants.
  • productItems(from:) extracted from the initialiser — pure move, verified equivalent.
  • Half of my open thread at DevServerPaywall.swift:88 is closedisScrollEnabled is inherited now. I've left the thread open because presentation.delay still resets to 0 whenever the manifest names a style (:49); it only follows published in the branch where the manifest is silent.

⚠️ The manifest contract this commit leans on isn't publicly verifiable

Two of the new behaviours are assertions about the separately-versioned superwall CLI rather than about this codebase: that omitting featureGating from a surface means "the developer declared nothing", and that five hardcoded numbers match the CLI's DRAWER_DEFAULTS/POPUP_DEFAULTS. The first is the one that matters — if the CLI resolves a default before writing the manifest, every bound surface silently ungates its dashboard paywall in dev mode, which is exactly the defect eedda06 fixed.

Technical details
# Manifest omission semantics and duplicated CLI defaults need author confirmation

## Affected sites
- `Sources/SuperwallKit/DevServer/DevServerPaywall.swift:39-41``gating(from: surface) ?? published?.featureGating ?? .nonGated` treats an absent `featureGating` key as "inherit"
- `Sources/SuperwallKit/DevServer/DevServerPaywall.swift:44-46` — same shape for `introductoryOfferEligibility`
- `Sources/SuperwallKit/DevServer/DevServerPaywall.swift:146-155` — five constants documented as mirroring the CLI's `DRAWER_DEFAULTS` / `POPUP_DEFAULTS`
- `Sources/SuperwallKit/DevServer/DevServerSurface.swift:39-42``paywallIds`, documented as "the CLI sends the first as `paywallId` and the whole set as `paywallIds`"

## What I could establish
- `superwall.com/docs/dashboard/guides/superwall-cli` documents no `dev` subcommand at all, and mentions no `config.ts`, `manifest.json`, `superwall.lock`, `featureGating` or `paywallIds`.
- `github.com/superwall/cli` (the repo that doc page links as canonical) has no `dev.ts` in `src/commands/`, and a GitHub code search for `DRAWER_DEFAULTS` in it returns zero files.
- So none of the four assertions above can be checked against a public artifact today. That is not evidence they are wrong — the CLI side is presumably unreleased — but it does mean the SDK is the only place they are written down.

## Required outcome
- Confirm against the actual CLI branch this ships with that a `config.ts` which never mentions gating produces a surface with **no** `featureGating` key, rather than a resolved `"nonGated"`. If it emits a resolved value, the precedence at `:39-41` has to change or the `eedda06` gating fix is undone for every bound surface.
- Decide whether the geometry defaults should live in the SDK at all. If the CLI already resolves them on push, having it resolve them in the dev manifest too would delete the duplicate and remove the drift risk entirely.

## Open questions for the human
- Is there a written schema for `/device/manifest.json` anywhere? Once the two sides each hold half the contract in comments, the next field added is where they diverge.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift
Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated
Comment thread Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift Outdated
yusuftor and others added 2 commits September 4, 2026 16:51
A paywall's config.ts settings — presentation, feature gating, intro offer
eligibility — reach the SDK in the snapshot that `superwall push` uploads,
not on the dev server's manifest. Verified against the shipped CLI (1.2.0):
buildDeviceManifest emits kind, id, url, paywallId, paywallIds, identifier
and products, and a running dev server returns exactly that for a config
that declares a drawer presentation.

So the surface-level decoding of those settings could never fire. This drops
DevServerSurface's presentation, featureGating and introductoryOfferEligibility
along with the style mapping and the drawer/popup geometry defaults that
mirrored the CLI's, leaving the manifest's real shape and a plain synthesized
Decodable. The settings come from the published paywall the surface stands
in for, falling back to safe defaults when there isn't one.

Removes ten tests that exercised the unreachable paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One surface can serve several dashboard paywalls — an explicit multi-way
superwall.lock binding, or the single-paywall fallback that matches any
database id. Identity was read off the surface, so every paywall it served
reported the same paywall_id and paywall_identifier. Those reach the real
placements queue, and PaywallManager.getViewController keys its cache on
identifier, so the paywalls collapsed onto one cached view controller and
one analytics identity.

The published paywall already carries the right identity, so it wins now;
the surface only supplies it for a `dev:` surface with no published
counterpart.

Also replaces a test whose isScrollEnabled assertion passed either way,
since Paywall.stub() already matched the fallback, with a published paywall
whose inheritable fields all differ from the fallbacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 new behavioral issues — this delta is a scope reduction and it lands well. Three comments now describe the opposite of what the code does, flagged inline.

Reviewed changes — the single commit added since my review at 0027657 (bff8c79). Pure scope reduction: it retires most of what 0027657 introduced, 4 files, +23/-310.

  • The manifest stops carrying paywall settingsDevServerSurface drops its nested Presentation/Drawer/Popup types, the raw featureGating / introductoryOfferEligibility strings and its explicit CodingKeys, leaving kind, id, url, paywallId, paywallIds, identifier, products on a plain synthesized Decodable. gating(from:), eligibility(from:), presentationStyle(for:) and the five defaultDrawer* / defaultPopup* constants go with them.
  • All three settings now come from published alonefeatureGating (DevServerPaywall.swift:39), introOfferEligibility (:42) and the whole PaywallPresentationInfo (:43-44), each with a safe default when there is no published counterpart.
  • This answers the open question from my last review directly — the commit message states the CLI contract was verified against the shipped superwall 1.2.0: buildDeviceManifest emits exactly those seven keys, and a running dev server returns them even for a config.ts declaring a drawer presentation. So the surface-level decoding could never fire, and the SDK no longer holds half a contract it can't check. That retires the ⚠️ section I raised at 0027657 in full.
  • Two of my open threads close, plus one from 5181d83 — the unknown-style-clobbers-the-dashboard path and the self-contradicting gating comment are gone with the code they described, and presentation.delay now rides in with the whole inherited presentation, which was the half of that older thread still outstanding. Replied and resolved.
  • Ten deleted tests all covered now-unreachable paths — and the containment coverage that matters survives: test_oneUnreadableSurfaceDoesNotDropTheRest still pins the Throwable<DevServerSurface> compact-map with an entry missing the required id.
  • Not flagged — an unbound surface (the debugger previewing a never-pushed paywall) now always presents fullscreen. Since the CLI never emitted presentation, the branch that would have said otherwise was dead code, so this isn't a behavior change in practice.

Still open from 0027657 and not re-raised here: the surface-derived identity for multi-bound surfaces (DevServerPaywall.swift:31-32) and the tautological isScrollEnabled assertion in DevServerPaywallTests.swift:98. Neither is touched by this commit.

ℹ️ Nitpicks

  • Tests/SuperwallKitTests/DevServer/DevServerPaywallTests.swift:106 — the // MARK: - Partly specified geometry heading now has no tests under it; DevServerManifestTests.swift:149-150 picked up a double blank line from the same deletion.
  • test_settingsTheSurfaceOmitsStillComeFromTheDashboard (DevServerPaywallTests.swift:220-227) is now a strict subset of the featureGating assertion in test_inheritsDashboardOwnedBehaviourFromThePublishedPaywall and the closing assertion in test_theLocalSurfaceStillOwnsWhatItRenders — three tests pinning the same published.featureGating = .gated.gated path.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Config/Options/SuperwallOptions.swift Outdated
Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated
Comment thread Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift Outdated
Three comments still said the local surface owns its presentation style,
including the public doc on SuperwallOptions.devServer. It hasn't since
bff8c79: presentation travels to the dashboard in the pushed snapshot, so
a dev-served paywall inherits it from the published paywall.

The public doc now also names the consequence a developer would otherwise
hit by surprise — a config.ts presentation, gating or eligibility change
isn't visible in dev mode until it's pushed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 new behavioral issues — the identity fix is the right one and it survives a targeted cache/persistence sweep. One doc claim is now broader than the code, flagged inline.

Reviewed changes — the two commits added since my review at bff8c79. (The incremental diff I was handed covered only a280567, so I diffed 694331b separately; it is the substantive one.)

  • Identity now follows the paywall being stood in for (694331b) — databaseId and identifier are published?.x ?? surface.x ?? "dev:…" (DevServerPaywall.swift:36-41), which closes the multi-binding thread from 0027657. In applyDevServerOverrideIfNeeded published is always non-nil, so the surface fallbacks now only serve the debugger's unbound-preview path, where they're still correct.
  • cacheKey deliberately stays synthetic, and that is what makes the aliasing safe: PaywallManagerLogic.handleCachedPaywall emits .replacePaywall + .loadWebView whenever newPaywall.cacheKey != oldPaywall.cacheKey, so a dev↔published swap on the now-shared view-controller cache key reloads rather than presenting stale bytes.
  • No other identity-keyed path breaks under it — I checked paywallsByHash/activeTasks (memoisation skipped in dev mode), removePaywalls(withIds:), PaywallArchiveManager (keyed on archive content, and dev paywalls pass no manifest:), Survey.assignmentKey, NotificationScheduler (dev paywalls carry localNotifications: [], so it early-returns), IntroOfferTokenManager, Storage/CoreDataManager, the public getPaywall entry points, and the dev:-prefix special case, which is only reachable when published == nil. All clean.
  • The rewritten test helper makes the inheritance assertions realpublished(databaseId:identifier:) builds a paywall whose every inheritable field differs from the fallback (.drawer(height: 42, cornerRadius: 7), delay: 250, #123456, isScrollEnabled: false, .gated, .ineligible), so test_inheritsEverythingTheManifestCannotCarry fails if any single inheritance is dropped. That also retires the tautological isScrollEnabled assertion and the pointless re-stub() I flagged at 0027657. test_takesItsIdentityFromThePaywallItStandsInFor pins 111→222 and 111→333 and fails against the pre-change code. Argument order checks out against Paywall.init.
  • Doc pass (a280567) — all three stale-doc threads from bff8c79 are closed; the config.ts-presentation promise is gone from the public doc, the function doc and the call-site comment.
  • Worth stating, not flagged — with the single-surface fallback, dev-mode events for every paywall in a one-surface project now carry the real dashboard paywall_id instead of the obviously synthetic dev:paywall/<id>. That's the intended correction, but it does widen the survey/analytics scope question I raised at 5181d83, which is still the humans' call.

ℹ️ Nitpicks

  • DevServerPaywall.swift:58name is still surface.id while databaseId and identifier are now the dashboard's, and paywall_name sits in audienceFilterParams() (PaywallInfo.swift:328) right next to paywall_id. If analytics correlation drove the change, name is the third field in that triple.
  • DevServerPaywallTests.swift:159XCTAssertNotEqual(first.identifier, second.identifier) is entailed by the two exact assertions above it; the comment carries the intent on its own.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/Config/Options/SuperwallOptions.swift
Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated
The rewritten docs said every dashboard-configured setting comes from the
published paywall, which overreaches: Paywall.devServer forces
localNotifications to [] so the local config.ts ones win. The public doc was
wrong in both directions — the dashboard's notifications never fire in dev
mode, and a config.ts notification change is visible without a push.

Both the public doc and the factory's now name the exception, and the
"belongs in the inherited list" rule reads as conditional on the local
paywall having no way of its own to say otherwise, which is the real test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 behavioral change in this delta — both doc threads are properly closed. One follow-on doc point inline.

Reviewed changes — the single commit added since my review at a280567 (e518f87), which takes both suggestions from that review. Doc-only: two comment hunks, no code moved.

  • The public devServer doc names the notification exceptionSuperwallOptions.swift:417-419 now states that the published paywall's local notifications are ignored while the ones your local config.ts declares fire without a push, which corrects the doc in both of the directions it was wrong.
  • That new promise checks out — the page's schedule_notification message becomes a LocalNotification at PaywallMessageHandler.swift:233 and goes straight to NotificationScheduler.scheduleNotifications at Superwall.swift:1425-1430. There is no dev-mode, test-mode or isLocal gate anywhere on that path, so a config.ts notification really does take effect on the next local render, and the added "because they reach the SDK from the paywall itself" clause is the accurate reason.
  • The forward-looking rule got sharperDevServerPaywall.swift:22-24 went from "if the dashboard configures it, it belongs in the inherited list" to "if the dashboard configures it and the local paywall has no way of its own to say otherwise". That conjunction is the test that actually picks out the right set, and it is what the inline comment below is about.
  • Nothing else changed — no source, test, project or CHANGELOG edit in this delta, and the Paywall(...) call itself is byte-identical to a280567.

The three long-standing body-level items — the app-wide NSAllowsArbitraryLoadsInWebContent guidance, the absence of coverage for DevServerLocator.locate / applyDevServerOverrideIfNeeded / applyDefaultTestModeState, and the survey side-effects scope question from 5181d83 — are untouched by this delta and remain yours to call. Not re-raising them here.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/DevServer/DevServerPaywall.swift Outdated
onDeviceCache was simply omitted from the Paywall(...) call, so it took the
init default .disabled. That is the right behaviour — a dev server reloads
the page on every edit and DependencyContainer feeds this into the web view,
so an enabled cache could serve a stale copy of the local page — but nothing
said so. A maintainer applying the rule the doc comment states would have
added it to the inherited list and quietly broken live reload.

It is now passed explicitly with the reason, named alongside
localNotifications as the second exception in the doc comment, and covered
by a test whose published paywall has the cache enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 new issues found.

Reviewed changes — the single commit added since my review at e518f87 (1af8f0a), which closes the only open thread. One source file and one test file; no behavior change.

  • The inheritance rule names both of its exceptionsDevServerPaywall.swift:20-23 went from "one exception" to "Two exceptions, both marked below: localNotifications … and onDeviceCache, which stays .disabled so a live-reloading local page is never served from the web view's cache."
  • onDeviceCache: .disabled is now passed explicitly (:87-90) rather than falling out of the init default, with a comment stating why. Behaviorally identical — Paywall.init's default is already .disabled (Paywall.swift:404) — and the argument order still matches the initialiser (featureGatingonDeviceCachelocalNotifications), so nothing about the call changed but its legibility.
  • The exception is now pinned by a test that can failpublished() gains onDeviceCache: .enabled (DevServerPaywallTests.swift:118) and test_neverServesALocalPageFromTheWebViewCache asserts .disabled, so anyone who "completes" the inherited list breaks a test instead of silently letting a cached copy beat a live reload.
  • The "two exceptions" claim holds exhaustively — I enumerated every dashboard-configured field in Paywall.init(from:) against the Paywall(...) call. Everything else is either inherited from published or falls under "the surface owns what renders" (name, cacheKey, buildId, url, urlConfig, htmlSubstitutions, products, manifest); the loading-info structs, productVariables, isFreeTrialAvailable and presentationSourceType are runtime-computed rather than dashboard config. localNotifications and onDeviceCache really are the only two.

Every pullfrog inline thread on this PR is now resolved. The three long-standing body-level items — the app-wide NSAllowsArbitraryLoadsInWebContent guidance, the absence of coverage for DevServerLocator.locate / applyDevServerOverrideIfNeeded / applyDefaultTestModeState, and the survey side-effects scope question from 5181d83 — are untouched by this delta and remain yours to call; I'm not re-raising them.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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.

2 participants