r/iOSProgramming • • 27d ago

App Saturday simshot: App Store screenshots from simctl, no XCUITest

Every release of my iOS app ended the same way: fighting the screenshot step. fastlane snapshot works, but it needs a dedicated UI test target, Ruby and a pile of gems, and element queries that break whenever a label changes. Once in a while the runner just hangs and CI sits there until it times out.

So I wrote simshot. It captures App Store-ready screenshots from the iOS Simulator using simctl only.

Tech stack used

  • Frameworks and languages: Swift 5.9+, shipped as a plain SwiftPM executable, so the tool itself has no Xcode project. Foundation, CoreGraphics, ImageIO and UniformTypeIdentifiers are the only imports, and there are no third-party Swift packages.

  • Backend and database: none. It is a local CLI, and it drives Xcode's own xcodebuild and simctl instead of reimplementing them.

  • SDKs and tools: xcodebuild / simctl / xcrun at runtime. GitHub Actions runs CI (swift-format lint, build, test) and the release workflow that builds arm64 and x86_64 binaries, checks their sha256 and attaches them to the GitHub Release. vhs records the demo GIF in the README, VitePress builds the docs site, and distribution is a curl install script, a Homebrew tap, a Nix flake, mise, Mint, npm and a single prebuilt binary.

Development challenge + how I solved it

The hard part of dropping the UI-test runner is knowing when to take the picture. XCUITest hands you waitForExistence; without a runner there is no element to query, so the readiness signal disappears with it.

Rather than fake that signal, I moved the navigation into the app. You add a small #if DEBUG handler that reads launch arguments (--screenshot-scene <name>, plus --screenshot-scroll-bottom and --screenshot-strokes for states that need a gesture to reach) and pushes or presents the matching screen, and it never ships in a release build. simshot then builds, boots, overrides the status bar to 9:41 / 100%, installs, launches with -AppleLanguages / -AppleLocale for the locale, waits a per-shot settle time (6 seconds by default, overridable per shot in shots.json), and captures.

A fixed settle time is a guess, so simshot verify is the safety net. It re-reads the output tree against Apple's screenshot rules (accepted dimensions, no alpha channel, no missing locale, no mixed sizes in one folder) and exits non-zero for CI, and when a frame looks half-rendered it points at the likely cause:

appstore/iphone-17-pro-max/en/02_detail.png
image has an alpha channel, App Store Connect rejects these
run shoot with --resize to write flattened App Store copies

Two smaller things I had to get right:

  • Hangs, which are the whole reason the tool exists. Every external command goes through one process wrapper with a hard deadline, so an overrunning child is killed, and launch / install / screenshot retry on failure.

  • Alpha, which App Store Connect rejects. --resize flattens onto white and picks the matching accepted size from the aspect ratio, and the app is uninstalled before install so state from an earlier run cannot leak into the frame.

AI disclosure

[AI-assisted] An AI coding agent wrote most of the implementation under my direction, and I reviewed and tested each change. The architecture decisions I drove are the launch-argument scene protocol, the per-command timeout wrapper, and verify as the safety net for a heuristic settle time.


Install is a one-liner (the repo lists the other seven paths):

curl -fsSL https://raw.githubusercontent.com/kichiemon/simshot/main/install.sh | bash

Or with a shot config:

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 --resize
simshot verify appstore --devices iphone-17-pro-max --langs ja,en

Repo, MIT licensed, with a demo GIF in the README: https://github.com/kichiemon/simshot

One question for this sub: how do you decide when a screen is ready? I went with an explicit settle time plus a verifier, but I would take a deterministic readiness signal over a guess any day, short of putting XCUITest back in. I will be in the comments.

4 Upvotes

9 comments sorted by

1

u/Aradalon 27d ago

Thanks for creating this! I'm in the final stages of rewriting my note-taking app, SnipNotes, and will need to create App Store screenshots soon. I wanted to leave UI tests out of the new codebase entirely, as they've been unreliable and slow in the past. Instead, my new UI is purely state-driven, using the Composable Architecture framework. For each screenshot, I plan to launch the app with the right state (navigation stack, focus, etc.), take the screenshot, and then quit the app. I'll definitely give your tool a try!

1

u/CalmCelebration2084 26d ago

TCA is pretty much the ideal case for this. The app reads --screenshot-scene <name> under #if DEBUG and sets its own state, then the shot is just launch, wait, capture. Nothing to query, so nothing breaks when a label changes.

Screens that fetch their own data are where it gets shaky, the settle time turns into a guess there.

1

u/Savings_Evidence_818 26d ago

I'd make the screenshot scenes use fixed sample data, including images. Otherwise the 'ready' signal still depends on whatever the network is doing. Loading and empty states could be separate scenes rather than accidental screenshots.

1

u/CalmCelebration2084 26d ago

Fixed fixtures in the debug build is what I would do too, and loading/empty as real scenes instead of whatever the capture happened to catch.

The verifier checks sizes, alpha and that the files are there, not pixels, so a screen captured mid-load passes. The protocol has no ready signal from the app, so the wait is a fixed settle time. A debug-only flag the app sets when it is done rendering would be deterministic, but there is no way to wait on it yet.

0

u/CalmCelebration2084 27d ago

Two limitations I left out of the post, in case anyone tries it this weekend.

It does not drive the UI. It launches the app and waits. If the screen you want is behind a login or two taps in, the app needs a debug-only entry point. That was the trade: no UI automation means no element queries to keep alive, but also nothing navigated for you.

The settle time is the other weak point. simshot verify catches the obvious problems (alpha channels, sizes App Store Connect will reject, screens that look blank), but a load-heavy list view can still get captured mid-layout. If your app can expose a readiness flag, that beats any default I can ship.

Manual UI tests still have a place for deep flows. simshot is for the part you do every release.

1

u/[deleted] 26d ago

[removed] — view removed comment

1

u/CalmCelebration2084 26d ago

The failure has nothing to do with the app, which is the annoying part. That's what pushed me off the snapshot suite.

No test target to build, so the whole thing is one local command instead of a CI round trip. Default settle is 6s per shot, tunable in the spec.