Every release ends the same way: two device classes, two languages, six screens each. Screenshots take longer than they should, and they are the step that keeps breaking.
I used fastlane snapshot for a while. It works, but it pulls a UI test target, Ruby, fastlane and a pile of gems into the repo to press buttons in a simulator. And since it drives the UI through XCUITest, the failures are the annoying kind: staticTexts["Foo"] that resolves locally and does not resolve on the CI machine, and a runner that hangs every so often until the job times out.
So I wrote simshot. Same job, simctl only, no test runner.
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
Running the UI is what makes it fragile
Snapshot has to drive the app from the outside, because that is what a UI test runner does. Element queries, waits, taps, one failure per screen that happens to load slowly. What I actually want is much smaller than a test: show the app a specific screen, wait, take a picture.
simshot inverts it. The app navigates itself, and the CLI only touches simctl:
-
xcodebuildbuilds for the simulator -
simctl bootstatuswaits for boot -
simctl status_baroverrides the time and battery to 9:41 / 100% - the app launches with launch arguments that name the screen
-
simctl io <udid> screenshotcaptures the display -
--resizewrites App Store-sized copies with the alpha channel flattened
The launch arguments are the whole interface between the tool and your app:
| Argument | Meaning |
|---|---|
--ui-testing |
capture session (skip onboarding, for example) |
--screenshot-scene <name> |
navigate to a named scene |
--screenshot-strokes <n> |
perform n gestures (drawing app strokes) |
--screenshot-scroll-bottom |
scroll to the bottom before capture |
-AppleLanguages and -AppleLocale are always passed too, so --langs ja,en captures both languages without any app-side localization code for the screenshots.
The app side is a few lines behind #if DEBUG
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
private func handleScreenshotScene() {
let args = ProcessInfo.processInfo.arguments
guard let i = args.firstIndex(of: "--screenshot-scene"), i + 1 < args.count else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
switch args[i + 1] {
case "detail": if let first = characters.first { path = [first] }
case "settings": showSettings = true
default: break // "home" is the initial screen
}
}
}
#endif
}
Scene names are yours. simshot never needs to know what detail means, and because the handler sits behind #if DEBUG it is not in the release build.
Per-screen timing lives in shots.json when the defaults are wrong:
{
"shots": [
{ "name": "01_home.png", "scene": "home", "wait": 8 },
{ "name": "02_trace.png", "scene": "trace", "strokes": 1, "wait": 6 },
{ "name": "03_store.png", "scene": "store", "scrollBottom": true }
]
}
The weak spot
Without a runner there is nothing to query, so simshot cannot know when a screen is finished rendering. It waits a per-shot settle time (6 seconds by default) and captures. That is a guess, and a list that loads slowly can still get photographed mid-layout.
Two things compensate. Every external command runs under a hard timeout with retries, so a hung xcodebuild or a wedged simulator is killed instead of hanging CI. And simshot verify appstore re-reads the output tree against Apple's rules (accepted dimensions, no alpha, no missing locale, no mixed sizes in one folder), exits non-zero for CI, and says what it thinks went wrong:
❌ 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
If you can expose a readiness flag from the app, you can drop the wait instead of tuning it.
Where it sits next to fastlane
| simshot | fastlane snapshot | |
|---|---|---|
| UI automation | none, simctl only | XCUITest runner |
| Hangs | hard timeout on every command | can hang CI |
| Ruby | none | Ruby + fastlane + gems |
| Test target | a #if DEBUG handler in your app |
dedicated UI test target |
| Status bar override, resize, multiple languages | built in | plugin or separate tooling |
fastlane is a good tool and I am not arguing against it. But carrying an XCUITest target and a Ruby toolchain purely to take pictures is a lot of surface area for the job.
Install
curl -fsSL https://raw.githubusercontent.com/kichiemon/simshot/main/install.sh | bash
or brew tap kichiemon/homebrew-tap && brew install simshot. The README also covers Nix, mise, Mint, npm, a prebuilt binary and building from source. macOS 13+, no third-party dependencies. simshot doctor tells you if the Xcode and runtime setup is off before you waste a run on it.
Repo: https://github.com/kichiemon/simshot — issues and questions are welcome.
Top comments (0)