Capture raw screenshots
Take the raw screenshots StoreShot needs from an iOS Simulator, an Android emulator or device, an Expo, React Native or Flutter web build, or a Maestro flow, with a clean status bar.
StoreShot turns raw app screenshots into store images: it adds the background, the caption and the device at every size each store requires. This page shows how to take those raw screenshots with the tools you already have. Nothing here needs a StoreShot key; upload the files afterwards (next steps).
The StoreShot CLI (2.60.0 or later) wraps these
recipes in one command, storeshot capture ios|android|web, with device presets, a status bar
cleanup that is always undone, and a captions.json that storeshot set reads.
What to capture
- One unedited screenshot per screen, in the order the listing should show them, all in portrait.
- PNG straight from the simulator, emulator or browser. JPEG and WebP work too, HEIC does not; see the upload limits.
- Any phone or tablet size works: StoreShot renders every store size from it. An image whose short
edge is under 300 px or over 2600 px, or that is much longer and thinner than a phone, is refused
with
NOT_A_SCREENSHOT. - A tidy status bar (9:41, full signal and battery) and no personal data on screen.
iOS Simulator
Needs macOS with Xcode. Boot a simulator (open the Simulator app, or run
xcrun simctl boot "iPhone 16 Pro"), open the screen you want, then:
# The booted simulators: with several, use a UDID from this list instead of "booted"
xcrun simctl list devices booted
# A clean status bar: 9:41, full signal and battery
xcrun simctl status_bar booted override --time 9:41 --dataNetwork wifi --wifiMode active \
--wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100
mkdir -p screens
xcrun simctl io booted screenshot --type=png screens/01-map.png
# Put the status bar back
xcrun simctl status_bar booted clear
- With several simulators booted,
bootedpicks one of them: pass a UDID from the list instead. status_bar … clearremoves every override, including ones you set yourself.- Some simulator runtimes ignore status bar overrides (reported for iOS 16.1, 16.2 and 17): check the PNG.
- Simulator sizes such as 1320 × 2868 (iPhone 16 Pro Max) need not be App Store sizes: StoreShot renders those.
- Physical iPhones and iPads have no command line screenshot tool: use the simulator.
Android emulator or device
Needs the Android SDK platform-tools (adb) on your PATH, and an emulator or a device with USB
debugging on. Demo mode gives a clean status bar:
adb devices
SERIAL=emulator-5554 # a serial from the list above
# A clean status bar: demo mode with 9:41, full signal and battery, no notification icons
adb -s "$SERIAL" shell settings put global sysui_demo_allowed 1
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command enter
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false
mkdir -p screens
adb -s "$SERIAL" exec-out screencap -p > screens/01-map.png
# Leave demo mode
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit
adb exec-outpasses the PNG bytes unchanged. Windows PowerShell’s>re-encodes them: there, runadb shell screencap -p /sdcard/screen.pngandadb pull /sdcard/screen.pnginstead.- Demo mode differs between Android versions and device makers, and some ignore parts of it. Physical devices may need “Allow demo mode” in the developer options.
- The last command ends demo mode, also one you had turned on yourself.
adb shell settings delete global sysui_demo_allowedremoves the permission again.
Web builds with Playwright
An Expo, React Native or Flutter web build can be captured in a browser at the exact pixel size of a StoreShot device. Build and serve it first:
- Expo or React Native:
npx expo export -p web, then servedist/(for example withnpx serve dist). - Flutter:
flutter build web, then servebuild/web/.
Install Playwright in your project (npm install -D playwright, then
npx playwright install chromium), save this as capture-web.mjs, list your screens in pages,
and run it with BASE_URL=http://localhost:3000 node capture-web.mjs:
// One PNG per page at the exact pixel size of a StoreShot device (viewport × pixel ratio).
// BASE_URL=http://localhost:3000 DEVICE=app_store_1290x2796 node capture-web.mjs
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
const PRESETS = {
app_store_1284x2778: { width: 428, height: 926, dpr: 3 },
app_store_1290x2796: { width: 430, height: 932, dpr: 3 },
app_store_1179x2556: { width: 393, height: 852, dpr: 3 },
app_store_2064x2752: { width: 1032, height: 1376, dpr: 2 },
app_store_1668x2388: { width: 834, height: 1194, dpr: 2 },
google_play_1080x1920: { width: 360, height: 640, dpr: 3 },
google_play_1080x2400: { width: 360, height: 800, dpr: 3 },
google_play_1080x2160: { width: 360, height: 720, dpr: 3 },
google_play_1200x1920: { width: 600, height: 960, dpr: 2 },
google_play_1600x2560: { width: 800, height: 1280, dpr: 2 },
};
// The screens to capture, in listing order (Flutter's default URLs look like /#/settings).
const pages = [
{ path: '/', name: '01-home' },
{ path: '/settings', name: '02-settings' },
];
const base = process.env.BASE_URL ?? 'http://localhost:3000';
const preset = PRESETS[process.env.DEVICE ?? 'app_store_1290x2796'];
const out = process.env.OUT ?? 'screens';
// Expo/React Native web: a testID such as [data-testid="home-screen"]; Flutter web: flutter-view
const waitFor = process.env.WAIT_FOR ?? 'body';
mkdirSync(out, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: preset.width, height: preset.height },
deviceScaleFactor: preset.dpr,
isMobile: true,
hasTouch: true,
reducedMotion: 'reduce',
serviceWorkers: 'block',
});
for (const { path, name } of pages) {
const page = await context.newPage();
await page.goto(new URL(path, base).href, { waitUntil: 'load' });
await page.waitForSelector(waitFor, { state: 'visible' });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(Number(process.env.DELAY_MS ?? 300));
await page.screenshot({ path: `${out}/${name}.png`, animations: 'disabled', caret: 'hide' });
await page.close();
}
await browser.close();
The viewport and pixel ratio of each StoreShot device (DEVICE):
| Device | Viewport (CSS px) | Pixel ratio | PNG |
|---|---|---|---|
app_store_1284x2778 | 428 × 926 | 3 | 1284 × 2778 |
app_store_1290x2796 | 430 × 932 | 3 | 1290 × 2796 |
app_store_1179x2556 | 393 × 852 | 3 | 1179 × 2556 |
app_store_2064x2752 | 1032 × 1376 | 2 | 2064 × 2752 |
app_store_1668x2388 | 834 × 1194 | 2 | 1668 × 2388 |
google_play_1080x1920 | 360 × 640 | 3 | 1080 × 1920 |
google_play_1080x2400 (deprecated) | 360 × 800 | 3 | 1080 × 2400 |
google_play_1080x2160 | 360 × 720 | 3 | 1080 × 2160 |
google_play_1200x1920 | 600 × 960 | 2 | 1200 × 1920 |
google_play_1600x2560 | 800 × 1280 | 2 | 1600 × 2560 |
Waiting for the screen:
- The script waits for the
loadevent, for theWAIT_FORselector, for web fonts, thenDELAY_MS(300 by default).networkidleis not reliable with development servers. - Expo and React Native web render
testIDasdata-testid: useWAIT_FOR='[data-testid="home-screen"]'. - Flutter web: use
WAIT_FOR=flutter-view(older releases:flt-glass-pane) andDELAY_MS=1000, because the canvas paints after the element exists.
Limitations:
- A web build is not the native app: fonts, platform controls, safe areas and some plugins differ. Prefer the simulator or emulator for the final listing.
- Only screens with their own URL open this way. For screens behind taps or a login, add those
steps to the script before
page.screenshot, or use Maestro. - Flutter’s default URLs contain
#: use paths such as/#/settings.
Maestro flows
Maestro drives the app on a simulator or emulator, so it reaches screens
behind taps. Add takeScreenshot where you want a raw screenshot:
appId: com.example.trailmate
---
- launchApp
- takeScreenshot: 01-map
- tapOn: 'Offline maps'
- takeScreenshot: 02-offline
Run the flow and collect the PNGs:
maestro test --test-output-dir maestro-out flow.yaml
mkdir -p screens
find maestro-out -name '*.png' -exec cp {} screens/ \;
ls screens
- Maestro writes the screenshots into the
takeScreenshotfolder of its output; where that folder is depends on the Maestro version and on--test-output-dir, so the commands search for them. - Maestro does not change the status bar: run the iOS or Android commands above before and after the flow.
Next: upload and create a set
Upload each screenshot and create a screenshot set with one caption per screen:
- with
curl: steps 3–6 of the quickstart for AI agents; - from an AI coding tool: the MCP server (
upload_screenshot, thencreate_screenshot_set); - from a terminal or CI: the CLI (
storeshot set, thenstoreshot pull); - from TypeScript or JavaScript: the SDK.