Docs
shotfleet documentation
Localized App Store and Google Play screenshots from one English Maestro flow: capture, check, export to fastlane.
Last checked against version 0.1.0 on 2026-10-02.
Requirements
- macOS on Apple silicon only. The installer refuses Intel Macs.
- Maestro 2.x and Java 17+ (Maestro needs it).
- Xcode for iOS, the Android SDK for Android.
- Android needs API 33+ emulators for per-app languages. shotfleet finds the SDK in
ANDROID_HOME,ANDROID_SDK_ROOT,~/Library/Android/sdk, Homebrew'sandroid-commandlinetools, or next to anadbon your PATH, and uses any installed arm64google_apisorgoogle_apis_playstoreimage of API 33 or newer (or thesystem_imagefrom the config).shotfleet doctorprints thesdkmanagercommands for whatever is missing. - The installed program brings its own Python. Running from source needs Python 3.11+.
Install
curl -fsSL https://shotfleet.com/install.sh | sh
shotfleet activate <your key> # from your Gumroad receipt email (check works without one)
shotfleet doctor # checks Maestro, Java, Xcode, Android SDK
The installer downloads the Apple silicon archive, compares it with its published SHA-256 checksum, and puts a link to shotfleet on your PATH. shotfleet check is free for everyone, and run is free for up to 2 languages. A licence unlocks every language and export; see licence and refunds.
Quickstart
Once Maestro and a simulator build exist, in your project folder:
shotfleet init
shotfleet init path/to/MyApp.app path/to/app-debug.apk # or name a simulator build (Debug-iphonesimulator) and/or an APK yourself
shotfleet init without arguments only reads folders, never builds. For iOS it takes the newest Debug-iphonesimulator app in Xcode's default DerivedData folder (~/Library/Developer/Xcode/DerivedData) whose info.plist points at an Xcode project in this folder (or one folder below it, or above it). For Android it takes the newest APK under app/build/outputs/apk/debug (or <module>/build/outputs/apk/debug, one folder below). It prints which build it chose and how old it is. If it finds nothing it exits with the exact xcodebuild and ./gradlew assembleDebug commands to run; then run shotfleet init again.
- Edit the generated
flow.yaml: Maestro steps in English. Navigate by the text users see and add atakeScreenshot:for each store image. NoappIdis needed: shotfleet uses each build's own id, so one flow serves iOS and Android. - Try one language:
shotfleet run --locales en - Run them all:
shotfleet run(finds./shotfleet.toml; both platforms if the file has both,--platform iosfor one). - Review
out/index.html(out-ios/andout-android/if the config has both platforms). It is a grid with a row per language and a column per screen, and problems are outlined in red. - Export to fastlane:
shotfleet export out --fastlane fastlane/(out-iosorout-androidif the config has both). That writesdeliver'sscreenshots/<locale>/orsupply'smetadata/android/<locale>/images/phoneScreenshots/. Languages that failed a check are skipped, so a bad screenshot never reaches a listing.
shotfleet run --locales en
shotfleet run
shotfleet run --platform ios
shotfleet export out --fastlane fastlane/
Uploading
shotfleet stops at fastlane's folders; fastlane uploads them. Screenshots only, nothing else touched:
# App Store: replaces the screenshots of every language folder in fastlane/screenshots
fastlane deliver --skip_binary_upload --skip_metadata --overwrite_screenshots --force
# Google Play: uploads metadata/android/<locale>/images/phoneScreenshots
fastlane supply --skip_upload_apk --skip_upload_aab --skip_upload_metadata --skip_upload_changelogs --skip_upload_images
Run shotfleet check fastlane/ right before uploading: export already skips languages that failed, and the check also catches folders you edited by hand.
How it works
shotfleet reads the translations compiled into your app and rewrites every selector in the flow to match any translation of that English label. If "Next" has two keys, German matches (Nächste|Weiter). Selectors that use id: pass through untouched.
- iOS: strings come from
*.lproj/*.stringsinside the.app; the language is set with launch arguments-AppleLanguages (de); N simulators, iPhone 17 Pro Max (1320x2868, App Store 6.9"); the status bar is overridden withsimctl status_bar(9:41, full battery); one sharded Maestro run. - Android: strings come from
aapt2 dump resourcesof the APK, keeping only languages the app itself ships; the language is a per-app locale set withcmd locale set-app-locales(Android 13+); N read-only emulators at 1080x1920; SystemUI demo mode (9:41, full battery, no notifications); rounds of one language per emulator.
Fresh state: each language starts from a clean install. On iOS that is clearState; on Android shotfleet clears the app data itself, because clearState would also erase the app language.
Check screenshots from any tool
Already have screenshots from fastlane snapshot or screengrab, StoreScreens or by hand? Check them without capturing anything:
shotfleet check fastlane/ --app path/to/MyApp.app # deliver: screenshots/<locale>/
shotfleet check fastlane/ --app path/to/app-release.apk # supply: metadata/android/<locale>/images/phoneScreenshots/
Store codes are mapped back to your app's languages (de-DE to de, no to nb, zh-CN to zh-Hans). The report (check.json and index.html) goes to ./shotfleet-check (change it with --report), and your fastlane folders are never touched. After capture or on an existing folder, every screenshot's text is compared against your app's own strings. On iOS, shotfleet reads the text straight from the app while each screenshot is on screen; otherwise, and for screenshots made by other tools, it uses macOS's built-in text recognition. It reports:
- Wrong language: the German folder actually shows English or Japanese strings.
- Copies: the same image in two languages.
- Missing: a language that did not produce every screenshot.
- Translation collisions, before anything runs: for example
tr: 'Next' -> 'İleri' also means 'Advanced'. A tap could hit the wrong button there, so you anchor that step withbelow:or use an override. - Store rules: sizes each store accepts (every App Store display class, Play's 320 to 3840 px), at most 10 (App Store) or 8 (Play) per listing, no transparency, a 6.9" or 6.5" iPhone set, a 13" iPad set when the app runs on iPad.
- Languages your app does not have: a store listing (say Traditional Chinese) whose screenshots cannot be in that language because the app is not translated into it.
check without --app relies on language detection, which cannot judge short screens; pass --app for the full check.
Config
The whole config can be this (shotfleet init writes it):
[ios]
app = "path/to/MyApp.app"
[android] # optional: the same file and flow serve both platforms
apk = "path/to/app-debug.apk"
Everything else is read from the build or has a default; set it only to change it. A typo or a wrong type is refused with a one-line message before anything runs.
| Key | Section | Default | What it does |
|---|---|---|---|
app | ios | none | The simulator build (Debug-iphonesimulator). |
apk | android | none | The debug APK. |
flow | ios, android | flow.yaml | The English Maestro flow, shared by both platforms (an appId: header is set for you). |
out | ios, android | out, or out-ios and out-android when the file has both platforms | Output folder. |
parallel | ios, android | 4 | Simulators (emulators on Android). |
locales | ios, android | every language the app ships | Languages to capture, for example ["de", "ja"]. |
base_locale | ios, android | en | The language your flow is written in. |
strings | ios, android | read from the build | Translation files as globs, relative to the config (see below). |
bundle_id | ios | read from the app | The bundle id. On Android the package is read from the APK. |
device_type | ios | the 6.9" iPhone (com.apple.CoreSimulator.SimDeviceType.iPhone-17-Pro-Max) | The simulator device type. |
runtime | ios | newest installed | The simulator runtime, for example com.apple.CoreSimulator.SimRuntime.iOS-26-5. |
device_types | ios | none | Several sizes from one flow, for example ["iPhone 17 Pro Max", "iPad Pro 13-inch (M5)"]; output goes to out/<device>/, check out and export out handle both, and the App Store takes both in one folder. |
devices | android | phone | ["phone", "tablet"]: phone 1080x1920 and tablet 1440x2560 (both 9:16, so Play can recommend the listing); output goes to out/<device>/, exported to phoneScreenshots and tenInchScreenshots. |
[ios]
flow = "flow.yaml"
out = "out"
parallel = 4
locales = ["de", "ja"]
base_locale = "de"
device_types = ["iPhone 17 Pro Max", "iPad Pro 13-inch (M5)"]
[android]
devices = ["phone", "tablet"]
Flutter, React Native, .NET MAUI, Unity and translation-platform exports
These frameworks do not keep their translations in a form shotfleet can read inside the built app, so point it at the source files instead. Add strings to either section (globs, relative to the config):
strings = "lib/l10n/*.arb" # Flutter
strings = "src/locales/*/translation.json" # React Native / Expo with i18next
strings = ["Resources/Strings/*.resx"] # .NET MAUI
Supported: .arb, i18next and react-intl .json (nested keys, _one and _other plurals), .xcstrings, .strings, .stringsdict, Android and Compose strings.xml, XLIFF (.xlf, .xliff), .resx, gettext .po, .properties, and CSV with one column per language (Unity Localization, spreadsheets). ICU plurals and placeholders (%d, %1$@, {name}, {{name}}) are understood. The language comes from @@locale, the file name (app_de.arb, Resources.de.resx) or the folder (locales/de/, values-de/). check takes the same files:
shotfleet check fastlane/ --strings 'lib/l10n/*.arb'
Hooks
Nothing here is on by default: a config without these tables behaves exactly as above. Hooks run your own commands (/bin/sh, in the config's folder, output on stderr) around a run and an export:
[hooks]
before_run = "make build-sim" # a string, or a list of commands run in order
after_run = 'curl -sS -X POST -H "Content-type: application/json" "$SLACK_WEBHOOK_URL" --data "{\"text\":\"shotfleet $SHOTFLEET_PLATFORM: $SHOTFLEET_PROBLEMS problem(s)\"}"'
before_export = "git diff --quiet" # a non-zero exit stops the export
after_export = 'fastlane deliver --skip_binary_upload --skip_metadata --overwrite_screenshots --screenshots_path "$SHOTFLEET_FASTLANE/screenshots"'
# hook_timeout_s = 1800 # default 600 per command
A failing before_* hook stops the command (exit 2); a failing after_* hook makes the exit code 1, so CI notices. Variables: SHOTFLEET_EVENT, _VERSION, _CONFIG, _PLATFORM, _OUT, _BUNDLE_ID; before_run and after_run also get _LOCALES; after_run gets _EXIT, _PROBLEMS, _REPORT (<out>/check.json); the export hooks get _FASTLANE, _REPORT (and _EXIT after the export). --no-hooks skips them for a dry run.
Overrides
Overrides replace what shotfleet would tap for one language. They are the fix for a collision warning ('Next' -> 'İleri' also means 'Advanced') and for labels that are not in your app's strings:
[overrides.tr] # the language as shotfleet names it: tr, pt-BR, zh-Hans...
"Next" = "İleri" # literal text
"Advanced" = "re:Gelişmiş.*" # "re:" = a regular expression
"Search" = "Ara"
Inputs
Inputs set the text a flow types, per language. Write inputText: ${name} in the flow and give the values in the config:
[inputs] # typed in every language that has no value of its own
name = "Alex"
[inputs.ja] # the language as shotfleet names it; pt covers pt-BR too
name = "田中"
The value goes into that language's flow as a Maestro env: entry. This works on iOS; Android runs type the [inputs] defaults only, because Maestro hands any flow to any emulator. The values are tested as flow text, not yet run on a device, so check the first screenshots in a script like Japanese or Arabic. Text you type is not translated by itself: it stays the same in every language unless you set [inputs] (iOS only).
Plugins and the rest
shotfleet foo runs shotfleet-foo from your PATH (like git and gh), when foo is not a built-in command. run, check and export take --json and use stable exit codes, so they script like any other tool; SHOTFLEET_OCR or --ocr-command swaps the text reader; strings = "glob" swaps the translation source.
In CI
run, check and export take --json: the result goes to stdout as JSON (exit, screenshots, problems for run and check; exit, exported, skipped for export) and progress goes to stderr. Exit codes: 0 all good, 1 problems found, 2 setup error (the message says what to fix), 130 interrupted.
Checking needs only a Mac (it uses macOS text recognition), with no simulators, so it fits a pull-request job on GitHub's macOS runners. check is free and needs no licence. This recipe has not been run on GitHub yet; the command itself is the one tested above.
# .github/workflows/screenshots.yml
on: pull_request
jobs:
check-screenshots:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- run: xcodebuild -scheme MyApp -sdk iphonesimulator -configuration Debug -derivedDataPath build build
- run: curl -fsSL https://shotfleet.com/install.sh | sh # Apple silicon runner
- run: shotfleet check fastlane/ --app build/Build/Products/Debug-iphonesimulator/MyApp.app --json > check.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: screenshot-check
path: |
check.json
shotfleet-check/
To use run or export in CI, set SHOTFLEET_KEY to your licence key (keep it in a secret). It is checked online on each run and uses none of your 3 activations; if the licence server cannot be reached the job is not failed. With an invalid key, export refuses and run captures only 2 languages.
With AI agents
shotfleet mcp is an MCP server with check, run and doctor tools. Add it to Claude Code with claude mcp add shotfleet -- shotfleet mcp, or to any MCP client as the command shotfleet mcp.
There are also three agent skills that ship with the program: shotfleet (install, set up, run, check and troubleshoot), shotfleet-ci (add shotfleet check to CI) and shotfleet-review (turn a check report into a fix list). Copy them into a skills folder:
shotfleet skill install
shotfleet skill install --project
shotfleet skill install --dir <folder>
shotfleet skill install copies them into ~/.claude/skills, --project into ./.claude/skills, and --dir <folder> into another agent's skills folder. Skills that are already there are kept unless you pass --force.
Limits
- Where strings come from:
.strings,.stringsdictand String Catalogs on iOS (including those in frameworks, Swift packages and a Watch app),strings.xmland<plurals>on Android, and Compose Multiplatform resources on both. Other frameworks read source files (see above). Labels with numbers ("Round 2 of 12") are matched and translated with the same numbers, in every plural form. - Flutter and React Native work from your translation files (tested on real translation files; not yet run on a real Flutter or React Native app).
- If you pass
init,runorchecka Flutter, React Native, .NET MAUI or Unity build, shotfleet tells you what to add: ahint:on stderr with thestrings =line for your translation files, and a warning for a Flutter debug build (DEBUG banner) or a React Native Debug build (Metro dev server). It detects these builds from file names in the bundle, so it may miss some versions, and it never stops a run. - Text recognition is macOS's. It reads 33 languages, including Latin, Cyrillic, Arabic, Thai, Devanagari, Chinese, Japanese and Korean scripts. For scripts it cannot read (Greek, Hebrew, Armenian, Georgian, most Indic and Southeast Asian scripts),
checkstill catches screens in another language, copies and missing screens, and says which screens it could not read. - If macOS text recognition stops answering (its
ANECompilerServiceoccasionally gets stuck until a restart),checkswitches to Tesseract automatically when it is installed (brew install tesseract tesseract-lang), and otherwise tells you to restart. iOS runs are not affected: they read the app's own text. - For full verification of those scripts, give
checkan OCR that reads them, for example Tesseract (about 700 MB):--ocr-command "tesseract {image} stdout -l {tesseract}"(or setSHOTFLEET_OCR). It is used only for languages macOS cannot read;{lang}is the language code if your tool wants that instead. launchAppoptions must be written as a block, not inline{ ... }.- Permission prompts ("Allow While Using App", "Don't Allow") are drawn by iOS and Android in the device language, which stays English even while your app runs in German. Write those taps in English; shotfleet keeps the English text matchable. Better still, skip the prompts: on iOS start the flow with a
launchAppblock that haspermissions: all: allow(shotfleet adds its language arguments to your options). On Android shotfleet installs with every permission granted. - Dialogs that appear late (disclaimers, consent screens): dismiss them in a loop until your first real screen is visible, not with a fixed wait. Longer languages push buttons off-screen: add
scrollUntilVisiblebefore tapping them. - shotfleet uploads nothing to a store: fastlane does that (see Uploading above).
Manuel Lorenzo Parejo · Licence and refunds · Privacy