Capture App Store-ready screenshots from the iOS Simulator with simctl only — no XCUITest, no flaky test runners, no hangs.
Your app implements a tiny #if DEBUG launch-argument handler; simshot does the rest: build, boot simulators, override the status bar, launch each scene, capture, and resize to App Store sizes.
simshot shoot --project Kanapp.xcodeproj --scheme Kanapp \
--bundle-id dev.kichiemon.kanapp \
--devices iphone-17-pro-max,ipad-pro-13 \
--langs ja,en --shots shots.json --resizefastlane snapshot drives the UI through XCUITest, which means maintaining test targets, fighting flaky element queries, and dealing with runner crashes. simshot inverts the model: the app itself navigates to each scene (reading a documented launch-argument protocol), and simshot just drives simctl with a hard timeout on every step.
| simshot | fastlane snapshot | |
|---|---|---|
| UI automation | None — simctl only |
XCUITest runner |
| Hangs | Hard timeout on every command | Can hang CI |
| Ruby | No | Ruby + fastlane + gems |
| Test target | #if DEBUG handler in your app |
Dedicated UI test target |
| Status bar override | Built in | Plugin |
| Resize to App Store sizes | Built in (--resize) |
Separate tooling |
| Multi-language | --langs ja,en |
Per-locale setup |
| Dependency footprint | Zero | Dozens of gems |
xcodebuildbuilds the app for the generic simulator (or use an existing.appwith--app-path).- For each device × language:
simctl bootstatus <udid> -bboots (and waits).simctl status_bar <udid> override --time "9:41" --batteryState charged --batteryLevel 100 ...prettifies the status bar.- The app is installed (previous install removed first for a clean state).
- For each shot, simshot launches the app with the scene protocol args, waits for the scene to settle, then takes
simctl io <udid> screenshot.
- Raw captures land in
<output>/raw/<device>/<lang>/, and (with--resize) App Store-ready copies — alpha flattened, resized to the matching size — in<output>/<device>/<lang>/.
Every external command runs through a timeout + retry wrapper, so a crashed app or stuck simulator can never hang CI.
npx skills add kichiemon/simshotInstalls simshot as a reusable agent skill (for Claude Code / opencode and other skill-aware agents). Once installed, skill-aware agents can use simshot automatically — just ask it to capture App Store screenshots, and the agent builds and runs simshot shoot for you (see skills/simshot/SKILL.md).
Target a specific agent, for example OpenAI Codex:
npx skills add kichiemon/simshot -a codexThis installs the skill into .agents/skills/ (Codex's project skill directory). The skill is kept in sync across three locations — skills/simshot/SKILL.md (canonical), .claude/skills/simshot/SKILL.md (Claude Code), and .agents/skills/simshot/SKILL.md (Codex and other .agents/skills/ agents) — so all skill-aware agents get identical instructions.
git clone https://github.com/kichiemon/simshot.git
cd simshot
swift build -c release
# binary at .build/release/simshot — symlink or copy it into your PATH
ln -s "$(pwd)/.build/release/simshot" /usr/local/bin/simshotGrab the prebuilt binary for your architecture directly from GitHub Releases — no Swift toolchain required:
# Apple Silicon (arm64)
curl -sL https://github.com/kichiemon/simshot/releases/download/v0.1.1/simshot-macos-arm64 -o /usr/local/bin/simshot && chmod +x /usr/local/bin/simshot
# Intel (x86_64)
curl -sL https://github.com/kichiemon/simshot/releases/download/v0.1.1/simshot-macos-x86_64 -o /usr/local/bin/simshot && chmod +x /usr/local/bin/simshotThe repo ships a flake.nix that fetches the prebuilt single binary (no source build):
nix run github:kichiemon/simshot # run without installing
nix profile install github:kichiemon/simshot # install into your profilemise installs simshot from the GitHub Releases assets. The github backend is recommended (it also verifies SLSA provenance / artifact attestations):
mise use -g github:kichiemon/simshot # install into your global config
simshot --version # v0.1.1The legacy ubi backend (mise use ubi:kichiemon/simshot) resolves the same simshot-macos-{os}-{arch} release assets and also works, but it is deprecated in mise and will be removed in mise 2027.1.0.
simshot ships a tap formula that installs the prebuilt release binary. First time:
brew tap kichiemon/homebrew-tap
brew install simshotFormula source:
kichiemon/homebrew-tap(Formula/simshot.rb):
class Simshot < Formula
desc "App Store screenshot capture CLI (simctl only, no XCUITest)"
homepage "https://github.com/kichiemon/simshot"
url "https://github.com/kichiemon/simshot/releases/download/v0.1.1/simshot-macos-arm64.tar.gz"
sha256 "31b12b58967281db42b016eb5388da9b577857c651e9cb170be07fde44e5c14e"
license "MIT"
version "0.1.1"
def install
bin.install "simshot"
end
endnpm i -g simshot
npx simshot shootRequires Node.js 14+ on macOS (arm64/x86_64). The package downloads the matching prebuilt single binary from GitHub Releases in postinstall (see npm/) and verifies its sha256. The npm package is prepared but not yet published (publishing requires an npm account) — until then use the curl one-liner, Homebrew, Nix, or mise.
-
Add the scene protocol handler to your app (5 minutes).
-
Verify the environment and list simulators:
simshot doctor # diagnose xcode-select / Xcode / simctl / runtimes simshot devices # list available simulators
-
Create a
shots.json(seeexamples/shots.json) or use--scenes:simshot shoot --project MyApp.xcodeproj --scheme MyApp \ --bundle-id com.example.myapp \ --devices iphone-17-pro-max,ipad-pro-13 \ --langs ja,en --scenes home,detail,settings \ --output appstore --resize
Screenshots land in appstore/<device>/<lang>/NN_name.png.
| Command | Description |
|---|---|
simshot shoot <options> |
Build, boot, capture, and resize screenshots. |
simshot devices |
List available simulators (name + UDID). |
simshot doctor |
Diagnose the local Xcode / simulator environment (exit 1 on problems). |
simshot init |
Generate a commented simshot.yml config scaffold. |
simshot version |
Print the version. |
simshot help |
Show help. |
Checks xcode-select, Xcode, simctl, iOS runtimes, and available simulators, and exits 1 if anything is broken. Run it when setting up a new machine or before filing a bug.
$ simshot doctor
✅ xcode-select: /Applications/Xcode.app/Contents/Developer
✅ Xcode: Xcode 26.5
✅ simctl: found
✅ iOS runtimes: 7 installed
✅ Available simulators: 36 available
✅ All checks passed.
Generates a commented simshot.yml scaffold that documents the options you'd pass to simshot shoot. Interactive by default; --yes writes it with placeholder values.
simshot init --yes # → simshot.yml (commented template)BUILD
--project <path.xcodeproj> Xcode project to build (or --workspace)
--workspace <path.xcworkspace> Xcode workspace to build
--scheme <name> Scheme to build
--app-path <path.app> Skip building; use an existing .app bundle
REQUIRED
--bundle-id <id> App bundle identifier
--devices <list> Simulator names or UDIDs (comma-separated)
SHOTS
--shots <config.json> Shot config file ({ "shots": [...] })
--scenes <list> Shortcut: one shot per scene, e.g. home,trace
--wait <secs> Default settle time per shot for --scenes (default: 6)
LOCALIZATION
--langs <list> Languages (default: en), e.g. ja,en
--locales <map> lang=locale overrides, e.g. ja=ja_JP,en=en_US
OUTPUT
--output <dir> Output directory (default: appstore)
--resize Write App Store-ready copies (alpha removed)
--derived-data <dir> DerivedData path (default: ~/.simshot/DerivedData)
SIMULATOR
--timeout <secs> Timeout for external commands (default: 300)
--status-bar-time <t> Status bar clock (default: 9:41)
--status-bar-battery <n> Status bar battery % (default: 100)
--no-ui-testing Do not pass --ui-testing to the app
--no-clean Do not uninstall before installing
--keep-running Do not shut down simulators afterwards
MISC
--verbose, -v Verbose output
--help, -h Show help
--devices accepts either device names or UDIDs:
simshot shoot ... --devices iphone-17-pro-max,ipad-pro-13
simshot shoot ... --devices 67DF6727-31BC-4246-9FC0-313A22FB2A6CNames are matched case-insensitively and hyphen/dash-insensitively. When the same name exists on several iOS runtimes, the newest runtime wins. Output subdirectories use the name you passed (or the device slug when using a UDID).
shots.json gives full control over names, scenes, and settle times. Every field except name and scene is optional.
{
"shots": [
{ "name": "04_home.png", "scene": "home", "wait": 8 },
{ "name": "01_trace.png", "scene": "trace", "strokes": 1, "wait": 6 },
{ "name": "06_store.png", "scene": "store", "wait": 8 },
{ "name": "07_store_tip.png", "scene": "store", "scrollBottom": true, "wait": 8 }
]
}| Field | Type | Default | Meaning |
|---|---|---|---|
name |
string | — | Output filename, e.g. 04_home.png |
scene |
string | — | Scene name passed as --screenshot-scene |
strokes |
int | nil |
Passed as --screenshot-strokes |
scrollBottom |
bool | false |
Passes --screenshot-scroll-bottom |
wait |
int | 6 |
Seconds to wait after launch before capturing |
uiTesting |
bool | true |
Whether to pass --ui-testing |
The file may be either a bare array of shots or { "shots": [...] }.
simshot launches your app with a fixed argument set. Your app owns the scene names and what they do — simshot only passes them through.
| Argument | Meaning |
|---|---|
--ui-testing |
Tell the app this is an automated session (skip onboarding etc.). |
--screenshot-scene <scene> |
Navigate to the named scene on launch. |
--screenshot-strokes <N> |
Perform N interactions (e.g. draw N strokes). Optional. |
--screenshot-scroll-bottom |
Scroll the scene to the bottom before capturing. Optional. |
In addition, simshot always passes -AppleLanguages (lang) and -AppleLocale locale so the app renders in the requested language.
import SwiftUI
struct HomeView: View {
@State private var path: [Character] = []
@State private var showSettings = false
var body: some View {
NavigationStack(path: $path) {
content
.onAppear {
#if DEBUG
handleScreenshotScene()
#endif
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
}
#if DEBUG
/// Screenshot-capture support: interpret simshot's launch arguments.
private func handleScreenshotScene() {
let args = ProcessInfo.processInfo.arguments
guard let index = args.firstIndex(of: "--screenshot-scene"),
index + 1 < args.count else { return }
let scene = args[index + 1]
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch scene {
case "detail":
if let first = characters.first {
path = [first]
}
case "settings":
showSettings = true
default:
break // "home" is the default screen
}
}
}
#endif
}Because the handler lives behind #if DEBUG, it never ships in a release build.
--resize writes App Store-ready copies to <output>/<device>/<lang>/. simshot:
- Detects the raw capture's aspect ratio and picks the matching App Store size (exact dimension match first — this disambiguates same-ratio devices like the iPad Pro 13" and iPad 10.2").
- Flattens any alpha channel onto white.
- Resizes with high-quality interpolation (LANCZOS-equivalent) and writes PNG.
Supported targets:
| Size | Device |
|---|---|
| 1320×2868 | iPhone 16 Pro Max / 17 Pro Max (6.9") |
| 1290×2796 | iPhone 15 Pro Max (6.7") |
| 1242×2688 | iPhone 11 Pro Max (6.5") |
| 2064×2752 | iPad Pro 13-inch |
| 2048×2732 | iPad Pro 12.9-inch |
| 2266×1488 | iPad Pro 11-inch |
| 2160×1620 | iPad 10.2-inch |
If no target matches (within 1% aspect ratio), simshot warns and keeps the raw capture.
appstore/
├── raw/
│ └── iphone-17-pro-max/
│ ├── ja/
│ │ ├── 04_home.png
│ │ └── 01_trace.png
│ └── en/
│ └── 04_home.png
└── iphone-17-pro-max/ # --resize: App Store-ready copies
└── ja/
└── 04_home.png
- Timeout: every external command (xcodebuild, simctl, …) is killed if it exceeds
--timeout. A hung simulator can never stall a CI job. - Retries:
launch,install, andscreenshotretry on failure. - Per-device isolation: a failing device is reported and skipped without aborting the rest of the matrix.
- Clean state: the app is uninstalled before install, and
--keep-running/--no-cleanare opt-outs if you want to preserve state.
swift build # build
swift test # run tests
swift format lint --recursive Sources Tests # code style
swift run simshot shoot --help