feat: add dev mode for previewing local paywalls - #511
Conversation
|
PR author is not in the allowed authors list. |
…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>
There was a problem hiding this comment.
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.
DevModegate —isActive(_:)requiresdevMode/devServerURLANDDeviceHelper.isSandboxEnvironment(new), so an App Store production build ignores the option and logs a one-time warning.DevServerManifest/DevServerLocator— decodesGET {base}/device/manifest.json, resolves a dashboard paywall to a local surface via asuperwall.lockbinding (or the single-paywall fallback), and walkslocalhost:6100…6104unlessdevServerURLis set, with a 2s hit cache and 5s miss debounce.- Paywall override —
applyDevServerOverrideIfNeededrewritesurl/urlConfigand nilsmanifeston every non-debugger paywall response;Paywall's stubinitbecomes internal andurlConfig/manifestbecomevar. superwall_devdeep 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 picker —
pressedPreviewnow 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
DevModeproduction gate, and the synthesisedPaywall.
⚠️ 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.mdstates this project uses Swift's Testing framework (@Test/#expect) for all unit tests and that new tests should always use it. DevModeTestsmutates the globalDevMode.isSandboxEnvironmentand restores it intearDown. Several suites here already flake under parallel execution for exactly this reason — worth knowing if these start failing only in parallel runs.DebugViewController.loadDevServerPreviewcallsDevServerLocator.shared.locate(...)again even thoughdevServerwas already resolved byensureDevServerand carries the same base; onlylocation.manifestis used from the second call.
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ 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## Unreleasedheading is gone; the dev-mode entry now sits under## 4.17.0as### Enhancements, above the fixes that were previously staged as 4.16.4. - Version bump —
Constants.swift(still on line 21),SuperwallKit.podspecandCHANGELOG.mdall read 4.17.0 consistently. project.pbxprojregenerated — wholesale identifier churn fromxcodegen; the newDevServer/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).
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>
There was a problem hiding this comment.
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 production —
DevServerPreview.handlenow checksDevMode.isActivesynchronously and returnsfalse(still logging the warning) when dev mode is off, andDeepLinkRouter.isSuperwallURLclaims the link only onceSuperwall.isInitializedandcanHandleboth agree.storeDeepLinkruns both pre-configureand configured-but-not-yet-loaded, so theisInitializedgate is not vacuous. - Deep-link origin allowlisted —
isTrustedBaserestricts a deep-link-supplied base to loopback,::1,*.local, RFC1918/link-local ranges, or an exact scheme+host+port match onoptions.devServerURL. The four-numeric-octet parse correctly rejects10.0.0.1.evil.example.com, and userinfo smuggling, case, trailing-dot FQDNs and decimal/octal IP forms all fail closed. withTimeoutrewritten as a real race — anAsyncStreamwhose 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?aroundTask.sleepguarantees the timeout branch always yields, andAsyncStream.Continuationis documented idempotent.- Dev-mode cache unfrozen —
saveRequestHashskips thepaywallsByHashmemoisation while dev mode is active (concurrent work still dedupes throughactiveTasks), and the override folds the mount URL intoPaywall.cacheKeyso a moved server or a published fallback takes.replacePaywall/.loadWebViewinstead of.updatePaywall. - Comparator and lint fixes — the non-strict-weak-ordering
sortpredicate inDevServerLocatorbecameremoveAll/insert, matching thepinnedBaseblock above it, and the trailing whitespace atDebugViewController.swift:128is gone. - Tests — a new Swift Testing
DevServerPreviewTestscovering the trusted and untrusted host lists,devServerURLmatching andcanHandle, plus aDeepLinkRouterTestscase that does fail against the pre-fix code.
Claude Opus | 𝕏
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 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.
|
There was a problem hiding this comment.
ℹ️ 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 origin —
DevServerManifest.mountURL(for:base:)now returnsnilunless the resolved scheme, host and port all equalbase'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 added —
test_mountUrlRejectsSurfacesPointingOffTheDevServerOriginassertsnilfor absolute, protocol-relative and other-port surface urls. All three resolve non-nilagainst 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
URLsemantics: a relativesurface.urlinheritsbase'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.
Claude Opus | 𝕏
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>
There was a problem hiding this comment.
✅ 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 traceable —
DevServerManifest.mountURL(for:base:)emits a.warnon.superwallCorebefore returningnilfrom the origin-pin guard, naming the surface id, its rawurl, the resolved absolute URL andbase. Both callers still treatnilas a silent no-op, but thenilitself now always carries a log from insidemountURL, which was the ask. - Diagnostic quality is right for the failure it covers — the message distinguishes the raw
surface.urlfrom what it resolved to, so the representation mismatches this guard can hit (localhostvs127.0.0.1, or adevServerURLgiven without a port against an absolute:6100surface url) read straight out of the log rather than looking like "no dev server found". - No behavioral change — the guard's condition, the
nilreturn and every caller are untouched; theLogger.debug(logLevel:scope:message:)call matches the signature and style of the other log sites in this file and inDevMode. 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.
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>
There was a problem hiding this comment.
✅ 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 narrowed —
Examples/Advanced/Advanced/Info.plistdrops the pre-existing app-wideNSAllowsArbitraryLoadsin favour ofNSAllowsArbitraryLoadsInWebContent+NSAllowsLocalNetworking, so the two example apps now model the same exception thedevModedoc comment and theDevServerManifestruntime 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 onlyhttp://occurrences areDOCTYPEDTD 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 since4a032b8.
ℹ️ 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.Claude Opus | 𝕏
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>
There was a problem hiding this comment.
✅ 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) —withTimeoutand both of its call sites are gone;finishLoadingPreview(DebugViewController.swift:278) andloadDevServerPreview(:392) nowawait 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 out —
ProductsFetcherSK2.swift:57-80throwsnoProductsFoundas soon asProduct.products(for:)returns empty for a non-empty id set, and itscatchre-throws immediately forProductFetchingErrorrather than retrying;ProductsFetcherSK1.swift:204-224does 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
developall 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
withTimeoutreference anywhere inSourcesorTests, and the@escaping @Sendableclosure that capturedselfandpaywallis gone with it. DevServerManifest.swiftsplit one-type-per-file (254d6de) —DevServerSurface,DevServerManifest,DevServerLocation,DevServerCandidatesandDevServerLocatornow live in their own files, wired intoproject.pbxproj; verified lossless against the pre-split file, with the origin pin and its rejection log intact atDevServerManifest.swift:29-49.- Style-only churn (
3482bf2,0d7ce5e,362950d,11cd922,6fed06c,c22e098) — the debugger's picker gate became a positively-statedcanOpenPickercomputed 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 thesuperwall.lockbinding sentence. The behaviour it described still exists inDevServerManifest.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.
Claude Opus | 𝕏
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>
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>
There was a problem hiding this comment.
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/devServerURLreplaced bySuperwallOptions.devServer(b61b66e) — a nestedDevServerenum (.default/.url(_:)) with@available(swift, obsoleted: 1.0)shims for Objective-C.SuperwallOptionsis@objcMembers, soenableDevServer()andenableDevServer(url:)really are ObjC-visible, and the explicitCodingKeysstill 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) —loadDevServerPreviewnow setspaywallIdentifierto the syntheticdev:<surfaceId>, and a newdevServerPaywall(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) —applyDevServerOverrideIfNeededno longer patchesurl/urlConfig/manifest/cacheKeyonto the fetched paywall; it returnsPaywall.devServer(surface:url:)and carries over onlyexperimentandresponseLoadingInfo. isLocaland presentation config (75c29ba) —Paywall.isLocal/PaywallInfo.isLocalplus anis_localaudience-filter param, and aDevServerSurface.Presentationblock mapped ontoPaywallPresentationStyle, with five new tests covering the mapping and its fullscreen fallback.- Not re-raised — I checked whether
Product(entitlements: [])inPaywall.devServerbreaks test-mode purchases. It doesn't:StoreKitManager.getProducts(forPaywall:isTestMode:)overwrites entitlements from the test-product catalog, andTestModeTransactionHandlergrants fromtestModeManager.productsby product-identifier match rather than from the paywall's products.
ℹ️ Nitpicks
Sources/SuperwallKit/DevServer/DevServerManifest.swift:7still says "whenSuperwallOptions/devModeis on" — the only surviving reference to the removed option name.- The CHANGELOG entry covers
SuperwallOptions.devServerbut not the other new public API in this delta:PaywallInfo.isLocaland theis_localaudience-filter param, both of which developers can use directly.
Claude Opus | 𝕏
…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>
There was a problem hiding this comment.
ℹ️ No new issues in this delta — but the three findings from my review at
75c29baare 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 ingrantedEntitlements, theSubscriptionStatusPublishingrework andhasActiveCachedSubscription. 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 adapted —
ConfigManager.applyDefaultTestModeStatenow callsSuperwall.shared.setSubscriptionStatus(assigned: .inactive). That's the same transformation develop applied topresentTestModeModalandTestModeTransactionHandler, andassigned:is the writerSubscriptionStatusPublishing.swift:75documents 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:
mergedSubscriptionStatusreturnstestModeManager.overriddenSubscriptionStatusbefore any grant is folded in, so a developer withgrantedEntitlementsset doesn't accidentally suppress paywalls under dev mode. - Version staging resolved itself —
4.17.0now comes fromdevelop, soConstants.swiftand 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. publishedPaywallsholdsPaywallSummary(7689d10) — the three-member tuple carried exactlyid/identifier/name, which is whatPaywallSummaryalready is and whatpreviewPaywallsalready held, so the first branch stops mapping and the second builds the struct. Every consumer (canOpenPicker, the picker'smap/firstIndex) reads the same three fields. Equivalent.- Worth noting, not re-raised — this merge is the first time
applyDefaultTestModeStatehad to be hand-edited in lockstep withpresentTestModeModal, 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.
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>
There was a problem hiding this comment.
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 dashboard —
applyDevServerOverrideIfNeedednow carriesfeatureGatingacross alongsideexperimentandresponseLoadingInfo(RawPaywallResponse.swift:58).Paywall.featureGatingis avar, so the assignment sticks and a gated dashboard paywall no longer unlocks its feature throughPublicPresentation's.nonGatedbranch. The doc comment now states the split in both directions rather than leaving "configuration … stay real" to imply it. - The two
devServerstores are written in one place —DebugViewController.ensureDevServer(:236-240) writes through todebugManager.devServer. The class is@MainActorand the property is@MainActor var, so the write is legal, andDebugManagerstill 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 manifest —
DevServerManifestdecodes[Throwable<DevServerSurface>]and compact-maps, matching the house idiom inPaywall.swift, and logs how many it skipped.DevServerSurfacewraps only itspresentationdecode intry?, so a block the SDK can't read costs the surface its style, not its place in the list. - Partial geometry presents —
Drawer/Popupfields areDouble?. A drawer defaults toheight: 70, which does matchPaywallPresentationStyle.drawer's documented 70% and is consumed asheight / 100atPaywallViewController.swift:803; a popup without both dimensions still falls back to fullscreen. Eight new tests, and the changeddrawerWithoutGeometryexpectation is the correct one. - The decode failure is distinguishable from a dead port —
fetchManifest(DevServerLocator.swift:109-123) logs theDecodingErrorseparately from the transport failure, so "no server found" no longer covers for a manifest that simply didn't parse.
ℹ️ Nitpicks
DevServerSurface's new memberwiseinit(kind:id:url:…)(DevServerSurface.swift:65-81) has no callers anywhere inSourcesorTests— 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_anUnknownFieldDoesNotDropTheSurfacepasses identically against the pre-change code —JSONDecoderhas always ignored unknown keys — so it doesn't cover anything this commit introduced. The other three new manifest tests do.
Claude Opus | 𝕏
…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>
There was a problem hiding this comment.
ℹ️ 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.
surfacesis a required key again —DevServerManifest.init(from:)(:28) went back totry 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_missingSurfacesKeyDecodesAsEmptyis replaced bytest_aBodyWithoutSurfacesIsNotAManifestandtest_anEmptySurfaceListIsStillAManifest; both fail against the pre-change code.- The status now rules out a responder before the body does —
fetchManifest(DevServerLocator.swift:135-138) rejects a non-2xxHTTPURLResponse. Checked against Apple'sHTTPURLResponsedocumentation: every response for thehttp://localhost:PORTcandidates really is anHTTPURLResponse, redirects are followed transparently, and.reloadIgnoringLocalCacheDatarules out a cached substitute — so theas?cast can only miss under a testURLProtocol. - The decode-failure log no longer overclaims —
logUnreadableManifest(: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-levelsurfaceskey. Withsurfacesrequired, 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:)—publishedsuppliesresponseLoadingInfo,featureGating,computedPropertyRequests,localNotifications,surveys,introOfferEligibilityandexperiment, andapplyDevServerOverrideIfNeededstopped patching fields after construction. The ordering holds:getPaywallResponsesetsexperimentandresponseLoadingInfo(RawPaywallResponse.swift:127-129) beforegetRawPaywallapplies the override, so both are live when inherited. Four new tests, each of which fails without the inheritance. - Both nitpicks from
eedda06are gone —DevServerSurface's uncalled memberwise init is deleted (grep confirms zeroDevServerSurface(call sites; the test helper decodes from JSON), andtest_anUnknownFieldDoesNotDropTheSurfaceis 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) returnsnilwith no log at all, so asuperwall devthat 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.warnnaming the status would distinguish "nothing there" from "something there, erroring".
Claude Opus | 𝕏
There was a problem hiding this comment.
ℹ️ No new issues in this delta. Not an approval only because the inline thread from my review at
5181d83is 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.
localNotificationsgoes 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 at5181d83; the survey half is untouched and still stands.- The dedupe reasoning checks out — a local paywall's
config.tsnotifications arrive asschedule_notificationweb messages (PaywallMessage.swift:121→PaywallMessageHandler.swift:233→Superwall.swift:1425-1430), andNotificationScheduler.scheduleNotifications(:68-77) drops any whosecom.superwall.ios-<paywallId>-<type>identifier is already pending. The web path keys onpaywallViewController.paywall.identifierand the transaction path onpaywallInfo.identifier— the same value — so an inherited dashboard notification scheduled at.freeTrialStartreally would have deduped the local one out. - Both consumers are covered —
TransactionManager.swift:1157andWebEntitlementRedeemer.swift:590are the only readers ofpaywallInfo.localNotifications, and both now see an empty list for a dev-mode paywall. - The new test can fail —
test_doesNotInheritNotificationsTheLocalPaywallDeclaresItselfasserts the empty list against apublishedstub that carries one, which is exactly what5181d83would have returned ascount == 1. The assertion it replaced intest_inheritsDashboardOwnedBehaviourFromThePublishedPaywallwas removed rather than inverted, so the two tests don't contradict. - Worth stating, not flagged — a bound surface whose local
config.tsdeclares 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.
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>
There was a problem hiding this comment.
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 paywalls —
DevServerSurface.paywallIds(DevServerSurface.swift:42) and apaywallId == id || paywallIds?.contains(id)match inDevServerManifest.surface(forPaywallDatabaseId:)(:49-66), covered bytest_matchesASurfaceBoundToSeveralPaywalls. config.tsnow outranks the dashboard for gating and trial eligibility — raw-String?featureGating/introductoryOfferEligibilityon the surface, mapped bygating(from:)andeligibility(from:), with precedencesurface ?? 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 at75c29ba— intentionally, and the code says why — so aconfig.tsdeclaringnonGatedwill 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 inherited —
presentationStyle(for:)returns nil when the surface has nopresentationblock at all, so the dashboard's wholePaywallPresentationInfostands;backgroundColorHex/backgroundColor/ both dark variants andisScrollEnabledcome frompublishedtoo. - Partial popup/drawer geometry now resolves rather than degrading — a popup naming only
heightstays a popup instead of falling back to fullscreen, and the drawer's default corner radius moved from0to15, both against newdefaultPopup*/defaultDrawer*constants. productItems(from:)extracted from the initialiser — pure move, verified equivalent.- Half of my open thread at
DevServerPaywall.swift:88is closed —isScrollEnabledis inherited now. I've left the thread open becausepresentation.delaystill resets to0whenever the manifest names a style (:49); it only followspublishedin 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.Claude Opus | 𝕏
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>
There was a problem hiding this comment.
ℹ️ 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 settings —
DevServerSurfacedrops its nestedPresentation/Drawer/Popuptypes, the rawfeatureGating/introductoryOfferEligibilitystrings and its explicitCodingKeys, leavingkind,id,url,paywallId,paywallIds,identifier,productson a plain synthesizedDecodable.gating(from:),eligibility(from:),presentationStyle(for:)and the fivedefaultDrawer*/defaultPopup*constants go with them. - All three settings now come from
publishedalone —featureGating(DevServerPaywall.swift:39),introOfferEligibility(:42) and the wholePaywallPresentationInfo(: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
superwall1.2.0:buildDeviceManifestemits exactly those seven keys, and a running dev server returns them even for aconfig.tsdeclaring 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 at0027657in 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, andpresentation.delaynow rides in with the whole inheritedpresentation, 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_oneUnreadableSurfaceDoesNotDropTheReststill pins theThrowable<DevServerSurface>compact-map with an entry missing the requiredid. - 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 geometryheading now has no tests under it;DevServerManifestTests.swift:149-150picked up a double blank line from the same deletion.test_settingsTheSurfaceOmitsStillComeFromTheDashboard(DevServerPaywallTests.swift:220-227) is now a strict subset of thefeatureGatingassertion intest_inheritsDashboardOwnedBehaviourFromThePublishedPaywalland the closing assertion intest_theLocalSurfaceStillOwnsWhatItRenders— three tests pinning the samepublished.featureGating = .gated→.gatedpath.
Claude Opus | 𝕏
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>
There was a problem hiding this comment.
ℹ️ 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) —databaseIdandidentifierarepublished?.x ?? surface.x ?? "dev:…"(DevServerPaywall.swift:36-41), which closes the multi-binding thread from0027657. InapplyDevServerOverrideIfNeededpublishedis always non-nil, so the surface fallbacks now only serve the debugger's unbound-preview path, where they're still correct. cacheKeydeliberately stays synthetic, and that is what makes the aliasing safe:PaywallManagerLogic.handleCachedPaywallemits.replacePaywall+.loadWebViewwhenevernewPaywall.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 nomanifest:),Survey.assignmentKey,NotificationScheduler(dev paywalls carrylocalNotifications: [], so it early-returns),IntroOfferTokenManager,Storage/CoreDataManager, the publicgetPaywallentry points, and thedev:-prefix special case, which is only reachable whenpublished == nil. All clean. - The rewritten test helper makes the inheritance assertions real —
published(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), sotest_inheritsEverythingTheManifestCannotCarryfails if any single inheritance is dropped. That also retires the tautologicalisScrollEnabledassertion and the pointless re-stub()I flagged at0027657.test_takesItsIdentityFromThePaywallItStandsInForpins 111→222 and 111→333 and fails against the pre-change code. Argument order checks out againstPaywall.init. - Doc pass (
a280567) — all three stale-doc threads frombff8c79are closed; theconfig.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_idinstead of the obviously syntheticdev:paywall/<id>. That's the intended correction, but it does widen the survey/analytics scope question I raised at5181d83, which is still the humans' call.
ℹ️ Nitpicks
DevServerPaywall.swift:58—nameis stillsurface.idwhiledatabaseIdandidentifierare now the dashboard's, andpaywall_namesits inaudienceFilterParams()(PaywallInfo.swift:328) right next topaywall_id. If analytics correlation drove the change,nameis the third field in that triple.DevServerPaywallTests.swift:159—XCTAssertNotEqual(first.identifier, second.identifier)is entailed by the two exact assertions above it; the comment carries the intent on its own.
Claude Opus | 𝕏
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>
There was a problem hiding this comment.
ℹ️ 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
devServerdoc names the notification exception —SuperwallOptions.swift:417-419now states that the published paywall's local notifications are ignored while the ones your localconfig.tsdeclares 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_notificationmessage becomes aLocalNotificationatPaywallMessageHandler.swift:233and goes straight toNotificationScheduler.scheduleNotificationsatSuperwall.swift:1425-1430. There is no dev-mode, test-mode orisLocalgate anywhere on that path, so aconfig.tsnotification 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 sharper —
DevServerPaywall.swift:22-24went 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 toa280567.
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.
Claude Opus | 𝕏
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>
There was a problem hiding this comment.
✅ 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 exceptions —
DevServerPaywall.swift:20-23went from "one exception" to "Two exceptions, both marked below:localNotifications… andonDeviceCache, which stays.disabledso a live-reloading local page is never served from the web view's cache." onDeviceCache: .disabledis 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 (featureGating→onDeviceCache→localNotifications), so nothing about the call changed but its legibility.- The exception is now pinned by a test that can fail —
published()gainsonDeviceCache: .enabled(DevServerPaywallTests.swift:118) andtest_neverServesALocalPageFromTheWebViewCacheasserts.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 thePaywall(...)call. Everything else is either inherited frompublishedor falls under "the surface owns what renders" (name,cacheKey,buildId,url,urlConfig,htmlSubstitutions, products,manifest); the loading-info structs,productVariables,isFreeTrialAvailableandpresentationSourceTypeare runtime-computed rather than dashboard config.localNotificationsandonDeviceCachereally 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.
Claude Opus | 𝕏

Changes in this pull request
Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues.