Screenshots & Assets4 min read

A paywall got captured in the screenshots

A paywall crept into screenshots from a 16-locale auto-capture pipeline. Which tab got contaminated differed per locale, so I couldn't pin the repro. Four causes overlapped, but the most important finding was in verification — the size gate checks 'is there a screen', not 'is it the right screen'.

#screenshots#ios#simulator#gotchas
Concept diagram: a locale-by-tab grid with paywall-contaminated cells scattered with no pattern, and below it 'size gate: all pass'. A paywall is a normal-sized app screen, so the gate can't filter it out.
The gate checks 'is there a screen', not 'is it the right screen'.

There's a pipeline that auto-captures App Store screenshots across 16 locales × several tabs — boot the simulator per locale, walk the tabs, shoot. And a paywall was mixed in. In the today-tab slot, in the insights-tab slot. Which tab got contaminated differed per locale, so I couldn't pin the repro.

Four causes were overlapping

Fix one and the symptom shrank, then came back in another shape.

① A value-first paywall's auto-present racing entitlement resolution. There's a "show value first, then present the paywall" pattern, and even forcing Pro with forceEntitlement(.pro) in capture mode, entitlement resolution is async, so there's a moment where it's .free. In that moment the paywall shows. And TabView pre-renders adjacent tabs, so the neighbor gets contaminated too.

② Sheet stickiness. Present the capture paywall as a .sheet and, when simctl launch foregrounds an already-running app without terminating it, the previous paywall lingers and lands in the next tab's capture.

③ Duplicate DerivedData. Picking the app path with find ... | head -1 grabbed a stale directory and installed the old binary, so there was a window where code changes didn't apply.

④ Stale frames. Capture right after a cold boot, before the first launch has rendered, and you get the previous frame — the previous locale's paywall.

The root fix was to unconditionally guard every auto-present paywall path with !isScreenshotMode. Don't rely on forceEntitlement — that's the side that loses the race. Instead of forcing state, block the path itself. And render the capture paywall as a root view, not a sheet. If the launch argument decides the root, there's no way to contaminate it.

The most important finding was in verification

The pipeline had a file-size gate, but it only filters black screens and the home springboard. It can't catch "the wrong screen." A paywall is a normal app screen at a normal size, so it sails through automated verification.

So I added a step that builds a montage of all 16 locales per screen type and eyeballs the lot. That visual QA is the only safety net. Better to accept that even an automated pipeline has a point that can't be verified automatically.

The same conclusion carries into a sister post → the simulator ignored the language flag. There the folder is named ja but the contents are English — again the size gate misses it and only visual QA catches it.

Code

// ❌ relies on forceEntitlement — races async resolution
if entitlement == .free { presentValueFirstPaywall() }
 
// ✅ block the path itself
if !AppLaunchArgs.isScreenshotMode && entitlement == .free {
    presentValueFirstPaywall()
}
# force a cold start on every launch
xcrun simctl launch --terminate-running-process "$UDID" "$BID" ...
# against duplicate DerivedData — newest mtime, not head -1
find ... -name "*.app" | xargs stat -f "%m %N" | sort -rn | head -1

Verification: the size-gate only filters black screens and the springboard; a PIL montage of 16 locales per screen type does the full visual QA. App in this story: Throne.

Honestly

  • While discovering the four causes one by one, I judged "fixed it now" each time, then it reappeared in another locale. If the repro is probabilistic, so is your fix verification. Building the full montage first would have been much faster.
  • Trusting the size gate ran long. It passed, so I read the capture as fine — but what the gate checks is "is there a screen." The price of using a verification tool without checking its scope.
  • A background job getting killed around 7 minutes forced me to chunk captures in fours. I didn't dig the root cause; I worked around it with chunks.

That verification your auto-capture pipeline passes at the end — is it looking at the right screen, or just that there is a screen? One paywall slips quietly through the gap.

Related