Capture and prepare App Store / Google Play screenshots for any React Native / Expo app. Drives iOS Simulator and Android device/emulator via deep links, locks the status bar to a clean marketing state, captures the standard set of screens per locale, and resizes to store-target dimensions. Use when the user asks to (re)generate or refresh store screenshots, add a new locale, add a new screen, or upload screenshots to App Store Connect or Google Play. Also trigger for adjacent phrasing like "marketing screenshots", "store listing screenshots", "screen capture for the app stores", or when the user mentions xcrun simctl, adb screencap, or paths like screenshots/<locale>/<device>/.
End-to-end runbook for marketing screenshots that ship to the iOS App Store and Google Play. The skill does not hard-code app identity — it discovers the deep-link scheme + iOS bundle ID + Android package from the project's Expo config, and takes everything else as parameters.
Required tooling
Platform
Needed
iOS
Xcode CLI (xcrun simctl)
Android
Android Platform Tools (adb)
Resize
ImageMagick 7+ (magick)
Detect
jq and (optional) npx expo for app.config.{ts,js} projects
Upload
Python 3.9+ with requests, pyjwt[crypto] (App Store) and google-auth (Play)
Upload one (locale, image-type) folder to Google Play via the Publisher API.
Each script accepts -h-style usage on bad input. Read the file headers for full arg lists.
How to drive the skill
Capture runs in three phases: phase 1 takes the unauth screens (sign-in, sign-up, etc.) while the demo account is signed out, then you manually sign in across all devices, then phase 2 takes the auth-required screens. This avoids round-tripping through the sign-in flow during automation and keeps both states clean.
Script paths in the bash blocks below are written relative to the skill root (assets/...). Resolve them to wherever your agent installed the skill before running.
Pre-flight
Build & install the app on the target sim/device (a release-style build looks best).
Verify the installed build is up to date. A stale dev client or a cached .app/.apk from weeks ago will either crash on missing native modules (NativeModule.X is null) or — worse — quietly render an old UI, and you won't notice until the screenshots ship. Cold-launch the app and visually confirm it matches today's source before capturing. If it doesn't, rebuild and reinstall (for Expo: pnpm --filter <app> prebuild --clean && pnpm --filter <app> ios && pnpm --filter <app> android, or whatever your pipeline is).
Start in a signed-out state on every device. If the demo account is already signed in, sign out first — phase 1 needs the unauth screens.
Have demo account credentials ready. You'll be asked to sign in manually between phase 1 and phase 2.
Set the in-app language to match the <locale> you're capturing (or rely on system locale if the app inherits it).
If the simulator can't run the app, fall back to a real device. Apps that depend on native modules Expo Go doesn't ship (BLE, custom Stripe SDK, push, certain camera/payments pipelines) often won't run in Expo Go and may not run on a freshly built sim either. Plan:
Android — adb targets emulators and physical phones identically; every script here works against a plugged-in Pixel/Galaxy/etc. just by running adb devices first. If multiple devices are attached, pass -s <serial> to the capture/status-bar scripts.
iOS — xcrun simctl is simulator-only. For a real iPhone, the path is Xcode-driven (xcrun devicectl device install, Xcode for deep-link launch, xcrun devicectl device screenshot on Xcode 16+) and not wired into these scripts. Prefer rebuilding the dev client or installing a release .app to the simulator instead.
If detection fails (custom config plugin, monorepo quirks), set the three env vars by hand.
Split screens by auth state. Two bash arrays of NN slug deep-path rows. The NN ordering keeps both arrays disjoint so filenames sort correctly together.
For Expo Router projects only: rather than guessing deep-link paths from memory, dump every route under app/ (or src/app/) first so you don't miss anything the team added since the last screenshot pass:
Expo Router collapses (group) segments out of the user-visible URL — app/(auth)/sign-in.tsx deep-links as /sign-in. The (group) column is a useful auth-state hint ((auth), (app), (tabs) usually gate; (public), (onboarding) usually don't), but confirm against the matching _layout.tsx where redirect logic actually lives. For non-Expo-Router apps, read the project's own router config.
Set up — lock status bars on all devices once. Persists across app launches and across both phases.
bash
LOCALE=en-US
IPHONE_UDID=<...>; IPAD_UDID=<...> # `xcrun simctl list devices` to find them
bash assets/ios-status-bar.sh "$IPHONE_UDID"
bash assets/ios-status-bar.sh "$IPAD_UDID"
bash assets/android-status-bar.sh enter
capture_set() {
local udid="$1" device="$2" platform="$3"; shift 3
for row in "$@"; do
read -r nn slug path <<<"$row"
local out="screenshots/$LOCALE/$device/$nn-$device-$slug.png"
if [[ "$platform" == ios ]]; then
bash assets/ios-capture.sh "$udid" "$APP_SCHEME://$path" "$out"
else
bash assets/android-capture.sh "$APP_SCHEME://$path" "$out" "$ANDROID_PACKAGE"
fi
done
}
Phase 1 — capture unauth screens. App must be signed out on every device.
Manual sign-in. Open the simulator/emulator windows, complete the sign-in flow with the demo account on iPhone, iPad, and Android. Confirm you land on the post-sign-in home page on all three before continuing. (Status bar stays locked — no need to re-run step 4.)
Per-device summary. Drop a summary.md into each device folder so reviewers and future-you can tell at a glance which hardware/OS produced these and which screen each PNG corresponds to. Run after step 8 so the recorded resolution reflects the resized output.
The script auto-detects model + OS from simctl/adb and reads the resolution off any PNG already in the folder. Pass - for the Android target when only one device/emulator is attached, otherwise pass the serial.
Uploading to the stores
Both upload scripts upload one (locale, device-or-image-type) directory per invocation. Re-running replaces the contents of that slot — pass --keep-existing to append instead. Loop in shell to cover multiple locales/devices.
App Store Connect — upload-app-store.py
Pre-reqs:
Generate an App Store Connect API key (App Store Connect → Users and Access → Integrations → App Store Connect API). Save the .p8, the Key ID, and the Issuer ID.
The target app must have an editable iOS appStoreVersion (PREPARE_FOR_SUBMISSION, METADATA_REJECTED, etc.). The script refuses to touch READY_FOR_SALE or in-review versions.
App Store Connect locale codes differ from the BCP-47 tags used in the screenshots tree — most notably zh-CN → zh-Hans, zh-TW → zh-Hant. Map before invoking.
Image-type values: phoneScreenshots, sevenInchScreenshots, tenInchScreenshots, tvScreenshots, wearScreenshots. Each slot caps at 8 images on Play; the script does not enforce that — the commit step will fail if you exceed it.
The script opens an edit, replaces the (locale, image-type) slot, then commits. If the commit fails, the edit is abandoned automatically by Play after a short TTL — re-run.
Notes & gotchas
Deep link form: xcrun simctl openurl and adb shell am start ... -d both want a full URL. With Expo Router, paths nest under the scheme as <scheme>:///<path> (note the triple slash — empty host).
Multiple Android devices attached: forward -s <serial> to android-status-bar.sh and android-capture.sh; both pass remaining args through to adb.
adb exec-out screencap -p > file corrupts bytes on shells that translate CRLF. The capture script uses screencap to a remote path then adb pull, which is byte-safe.
simctl status_bar booted targets whichever simulator is currently booted — convenient when only one sim is running.
Idempotency: resize.sh skips files already at the target size, so re-running is cheap.
Adding a new locale
Switch the in-app language (Settings → Language) or restart the sim/emulator with that locale.
Pre-create the directory: mkdir -p screenshots/<new>/{iphone,ipad,android-phone}.
Re-run the loops with LOCALE=<new>.
Sanity-check one screenshot per device before the full sweep.
Adding a new screen
Add an Expo Router path (or whatever your app's deep-link router uses) that renders the new screen cleanly under a deep link.