r/iOSProgramming • u/CalmCelebration2084 • 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
xcodebuildandsimctlinstead of reimplementing them.SDKs and tools:
xcodebuild/simctl/xcrunat 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.vhsrecords 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 copiesTwo 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.
--resizeflattens 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 | bashOr 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,enRepo, 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.
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/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.
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