DevOps

Automating App Store Screenshots with Simulator and Python

April 2026 · 10 min read

Every app in the App Store needs screenshots. Apple requires them for each device size you support, and reviewers expect them to accurately represent the current version. For a single app, this is tedious. For a portfolio of 27 apps, it is a full production pipeline or nothing gets done.

We capture all screenshots using the iOS Simulator, Python scripts, and macOS native tools. No Fastlane, no third-party services, no screenshot framing tools. The pipeline runs on a single Mac, takes about 20 minutes per app, and produces pixel-perfect images at the exact dimensions Apple requires.

This post covers the full pipeline: Simulator setup, SCREENSHOT_MODE injection for preparing the UI, window-level capture with screencapture -l, the dimension requirements, and the batch script that ties it together.

Why Not Fastlane Snapshot?

Fastlane's snapshot tool is the standard answer for screenshot automation. It uses XCUITest to drive the UI and captures frames at each step. For a single app with a stable UI test suite, it works well.

For our portfolio, Fastlane was not a fit. Writing and maintaining XCUITest scripts for 27 apps is more engineering effort than the screenshots are worth. Our apps share a common Swift package, so the UI patterns are consistent — but each app has its own data model, content, and navigation. An XCUITest suite per app would be brittle and expensive to maintain.

Instead, we use a much simpler approach: inject a flag via UserDefaults that puts the app into a screenshot-ready state, launch it in the Simulator, and capture the window from the outside. No UI tests, no test runner, no flaky tap coordinates.

SCREENSHOT_MODE: Preparing the UI from Outside

The trick is a UserDefaults flag called SCREENSHOT_MODE. When the app detects this flag at launch, it skips onboarding, loads sample data, hides any transient UI (tooltips, badges, "what's new" dialogs), and presents the ideal state for screenshots.

// In the app's launch sequence
if UserDefaults.standard.bool(forKey: "SCREENSHOT_MODE") {
    skipOnboarding()
    loadSampleData()
    hideTransientUI()
}

The flag is injected from outside the app using xcrun simctl:

xcrun simctl spawn booted defaults write \
    com.techconcepts.expense SCREENSHOT_MODE -bool true

# Also skip onboarding
xcrun simctl spawn booted defaults write \
    com.techconcepts.expense onboarding.completed -bool true

After setting the defaults, we terminate and relaunch the app so it picks up the new values:

xcrun simctl terminate booted com.techconcepts.expense
xcrun simctl launch booted com.techconcepts.expense

This approach is roughly five times faster than UI automation for screenshot pipelines. There are no timing issues, no flaky element lookups, no "button not found" failures. The app simply launches into its screenshot-ready state.

1320x2868
iPhone 16 Pro Max required size
27
Apps in the screenshot pipeline
0
XCUITest scripts needed

Capturing the Window: screencapture -l

macOS has a built-in command-line screenshot tool: screencapture. The -l flag captures a specific window by its window ID, which avoids capturing the menu bar, dock, or other Simulator chrome.

screencapture -l <windowID> /tmp/screenshot.png

The challenge is finding the correct window ID. The Simulator window and the app content are the same window, but you need the ID of the specific Simulator window — not the Simulator's preferences pane or device picker.

We find the window ID using CGWindowListCopyWindowInfo via a small Python helper that filters for windows owned by the Simulator process with the correct dimensions:

import subprocess, json

result = subprocess.run(
    ["python3", "-c", """
import Quartz, json
windows = Quartz.CGWindowListCopyWindowInfo(
    Quartz.kCGWindowListOptionOnScreenOnly,
    Quartz.kCGNullWindowID
)
for w in windows:
    if 'Simulator' in str(w.get('kCGWindowOwnerName', '')):
        bounds = w.get('kCGWindowBounds', {})
        if bounds.get('Height', 0) > 800:
            print(json.dumps({
                'id': w['kCGWindowNumber'],
                'w': bounds['Width'],
                'h': bounds['Height']
            }))
"""],
    capture_output=True, text=True
)
# Parse the window ID from output
window_info = json.loads(result.stdout.strip().split('\n')[0])
window_id = window_info['id']

One important detail: the Simulator window can drift off-screen after focus changes or AppleScript interactions. Before capturing, we raise the window and set its position explicitly:

osascript -e 'tell application "System Events" to tell process "Simulator"
    perform action "AXRaise" of window 1
    set position of window 1 to {0, 50}
end tell'

Without this step, screencapture -l sometimes captures a window that is partially or fully off-screen, producing a blank or clipped image.

Device Dimensions That Apple Actually Accepts

Apple has specific pixel dimensions for each device class. Using the wrong size results in a rejection during the screenshot upload phase — not at review time, but at upload time, which is at least a fast failure.

The required dimensions for the most common device classes:

# iPhone 16 Pro Max (required for all new submissions)
1320 x 2868 pixels (portrait)
2868 x 1320 pixels (landscape)

# iPad Pro 13" (if supporting iPad)
2048 x 2732 pixels (portrait)

# macOS (APP_DESKTOP)
2880 x 1800 pixels
1440 x 900 pixels

The iPhone 16 Pro Max at 1320x2868 is the current requirement for the largest iPhone class. Using an iPhone 16 Pro (1206x2622) produces images that Apple rejects — the dimensions must match exactly.

For macOS apps, the accepted sizes are 2880x1800 and 1440x900. Non-standard macOS dimensions cause a silent validation failure — the upload appears to succeed but the screenshot never passes Apple's processing step.

To ensure correct dimensions, we always boot the Simulator with the exact device type:

xcrun simctl boot "iPhone 16 Pro Max"

The Critical "Terminate Everything" Step

A subtle but important requirement: before capturing screenshots for an app, you need to terminate all other apps in the Simulator. If another app is running in the background, the Simulator's window may show a split view, a multitasking state, or simply have the wrong app in the foreground.

# Terminate ALL bundle IDs before each capture
xcrun simctl terminate booted com.techconcepts.wattora
xcrun simctl terminate booted com.techconcepts.expense
xcrun simctl terminate booted com.techconcepts.phygitaltimer
# ... terminate all known bundle IDs

# Now install and launch only the target app
xcrun simctl install booted build/MyApp.app
xcrun simctl launch booted com.techconcepts.targetapp

We keep a list of all bundle IDs in the portfolio and terminate each one before starting a new screenshot session. The terminate command is silent if the app is not running, so it is safe to call for every known bundle ID.

Handling macOS Menu Bar Apps

Menu bar apps like AI Battery present a unique screenshot challenge. The app lives in the menu bar — there is no main window. The popover is a separate window from the status bar icon, so you need to capture the popover window specifically.

The approach: trigger the popover to open, then find its window ID by filtering for windows owned by the app process that have a non-zero height but no title (popovers are untitled windows). The screencapture -l call then captures just the popover.

For macOS menu bar apps that use NSPopover, the screenshot-mode flag can also trigger the popover to open automatically at launch, removing the need for any user interaction.

The Fallback: HTML Mockup Screenshots via Playwright

Sometimes native screencapture is not available. macOS security restrictions (Screen Recording permission not granted) can block the capture entirely. In CI environments or locked-down machines, the permission prompt never appears.

Our fallback: render an HTML mockup of the app's key screens and screenshot them with Playwright. The mockup uses the same colors, fonts, and layout as the real app. It is not pixel-perfect, but it is good enough for App Store listings and far better than no screenshots at all.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page(
        viewport={"width": 1320, "height": 2868},
        device_scale_factor=1
    )
    page.goto(f"file:///tmp/mockup-expense.html")
    page.screenshot(path="/tmp/expense-screenshot-1.png")
    browser.close()

The mockup HTML files are maintained alongside each app. When the real UI changes significantly, the mockups need updating too — but this is a lightweight task compared to maintaining XCUITest suites.

The Batch Pipeline

The complete pipeline for one app looks like this:

  1. Boot Simulator with the correct device type (iPhone 16 Pro Max)
  2. Install the app via simctl install
  3. Inject SCREENSHOT_MODE via simctl spawn booted defaults write
  4. Terminate all apps then launch the target app
  5. Wait for launch (2-3 seconds for the UI to settle)
  6. Find the window ID via CGWindowListCopyWindowInfo
  7. Raise and position the Simulator window
  8. Capture with screencapture -l <windowID>
  9. Repeat for each screen state (navigate to different tabs or views)
  10. Verify output dimensions match Apple's requirements

For navigating between screens without XCUITest, we either use multiple SCREENSHOT_MODE flags (SCREENSHOT_SCREEN_1, SCREENSHOT_SCREEN_2) that the app checks to display different views, or we use AppleScript to simulate taps at known coordinates. The flags approach is more reliable; the tap approach is more flexible.

The full batch run across all 27 apps takes about 9 hours if run sequentially (20 minutes per app, including build time). In practice, we run it in batches of 3-5 apps and review the output between batches. Most apps need screenshots updated only when there is a significant UI change, so a typical session covers 5-8 apps.

Key Takeaways

  • SCREENSHOT_MODE via UserDefaults is the simplest approach. No XCUITest, no Fastlane, no flaky UI automation. The app launches into the right state every time.
  • Use screencapture -l with the window ID to capture just the Simulator content. Filter CGWindowListCopyWindowInfo by the Simulator process name and window dimensions.
  • iPhone 16 Pro Max (1320x2868) is the required size. Using the wrong device produces images Apple rejects at upload time.
  • macOS screenshots must be exactly 2880x1800 or 1440x900. Non-standard dimensions silently fail validation.
  • Terminate all apps before each capture session. Background apps cause Simulator state issues.
  • Raise and position the window before capture. Windows drift off-screen after focus changes.
  • HTML mockups via Playwright are a viable fallback when native capture is blocked by macOS permissions.

Download: Screenshot Pipeline Script

The Python script we use for batch screenshot capture: Simulator boot, SCREENSHOT_MODE injection, window ID discovery, and screencapture. Ready to adapt for your apps.

Related Posts

XcodeGen for Multi-Platform Projects

One YAML per app, zero committed .xcodeproj files. project.yml for iOS, macOS, and visionOS.

ASO: Keyword Research for 27 Apps

The 100-character limit, locale contamination, and batch keyword validation.

Evgeny Goncharov - Founder of TechConcepts, ex-Yandex, ex-EY, Darden MBA

Evgeny Goncharov

Founder, TechConcepts

I build automation tools and custom software for businesses. Previously at Yandex (Search) and EY (Advisory). Darden MBA. Based in Madrid.

About me LinkedIn GitHub
← All blog posts

Need help with iOS or macOS app architecture?

15 minutes. No pitch. Just honest advice on whether I can help.

Book a Call