DEV Community

kichiemon
kichiemon

Posted on Originally published at zenn.dev

App Store screenshots from simctl, without an XCUITest target

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. xcodebuild builds for the simulator
  2. simctl bootstatus waits for boot
  3. simctl status_bar overrides the time and battery to 9:41 / 100%
  4. the app launches with launch arguments that name the screen
  5. simctl io <udid> screenshot captures the display
  6. --resize writes 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
}
Enter fullscreen mode Exit fullscreen mode

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 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)