Apple ships xcrun simctl with Xcode. It works. It’s also a museum piece.
The tool is what you use when you need to control an iOS simulator from the terminal: boot, install, launch, take screenshots, manage state. Every tutorial from the last decade shows the same pattern: list simulators, copy a UDID, paste it into the next command. You can get away with names when they’re unique, but the UDID copy-paste is what the docs, Stack Overflow answers, and most CI scripts still show.
xcrun simctl list devices
# Find the UDID from 40 lines of grouped output...
xcrun simctl boot 8F9690AC-FCDE-4913-9BD2-E54B3CC9F6C1
xcrun simctl install 8F9690AC-FCDE-4913-9BD2-E54B3CC9F6C1 ./MyApp.app
xcrun simctl launch 8F9690AC-FCDE-4913-9BD2-E54B3CC9F6C1 com.example.MyApp
Four commands. Four UDID copies. Zero structured output. This post shows what a modern version of this workflow looks like.
What’s wrong with simctl
simctl isn’t broken. It’s just aged. The design hasn’t changed since it was introduced.
UDIDs are the default mental model. simctl accepts device names when they’re unambiguous (xcrun simctl boot "iPhone 16 Pro" works) and supports a booted shortcut, but the moment you have duplicate names, two booted simulators, or a CI script targeting specific iOS versions, you’re back to listing and copying UDIDs. The convenience only works until it doesn’t.
Unstructured list output. xcrun simctl list returns formatted text grouped by runtime. Parsing it in a script means grep and awk. The --json flag exists but returns a deeply nested structure that’s awkward to filter.
No validation. Pass a bad UDID and some commands fail silently. Others print cryptic errors. The tool assumes you know what you’re doing.
Split across tools. simctl handles simulators. devicectl handles physical devices. They have completely different syntax for similar operations. Install to a simulator versus install to a device requires different flags, different arguments, different mental models.
The FlowDeck way
One command set. Simulators and devices use the same patterns. Names instead of UDIDs. JSON on every command.
List simulators
flowdeck simulator list
flowdeck simulator list --platform iOS
flowdeck simulator list --available-only
flowdeck simulator list --json
Structured output, filterable by platform, JSON-first for automation.
Boot, shutdown, erase
# Reference by name
flowdeck simulator boot "iPhone 16 Pro"
flowdeck simulator shutdown "iPhone 16 Pro"
flowdeck simulator erase "iPhone 16 Pro"
simctl accepts names too when they’re unambiguous. The difference is what happens when they aren’t. FlowDeck resolves ambiguity predictably (platform, runtime, and availability all factor in) so scripts keep working when you add a second “iPhone 16 Pro” for a different iOS version.
Create and delete simulators
flowdeck simulator create --name "Test iPhone" \
--device-type "iPhone 16 Pro" \
--runtime "iOS 18.1"
flowdeck simulator delete "Test iPhone"
flowdeck simulator delete --unavailable
That last flag is the one that saves hours. --unavailable deletes every simulator referencing a runtime you’ve since uninstalled, which is the main reason your ~/Library/Developer/CoreSimulator folder is 40GB.
Run apps
flowdeck run -w App.xcworkspace -s MyApp -S "iPhone 16"
flowdeck apps
flowdeck logs <app-id>
flowdeck stop <app-id>
flowdeck run handles build, install, and launch in one command. No separate install step. No figuring out the .app path in derived data. Launching returns an app ID that you use to stream logs or terminate the app.
Screenshots and UI
flowdeck ui simulator screen --output screen.png
flowdeck ui simulator screen --json
Screenshot and full accessibility tree in one call, returned as structured data. Not just an image file, actual UI element data you can query.
Manage runtimes
flowdeck simulator runtime list # Installed
flowdeck simulator runtime available # Downloadable
flowdeck simulator runtime install iOS 18.0
simctl added runtime management in Xcode 15 with xcrun simctl runtime add. It works. The syntax is harder to remember, the arguments take image paths or identifiers, and listing what’s actually installable requires a separate list subcommand with different flags. FlowDeck’s interface is simpler: list what you have, list what you can get, install by version.
Side-by-side
| Task | simctl | FlowDeck |
|---|---|---|
| List simulators | xcrun simctl list devices |
flowdeck simulator list |
| Boot by name | Works when name is unique; ambiguous names need UDID | flowdeck simulator boot "iPhone 16" |
| Install runtime | xcrun simctl runtime add (Xcode 15+) |
flowdeck simulator runtime install iOS 18.0 |
| Install + launch app | simctl install then simctl launch (two commands, path lookup) |
flowdeck run (build, install, launch in one) |
| JSON output | Nested structure on list only |
Every command |
| Device + simulator parity | Different syntax | Same commands |
The interactive mode shortcut
For interactive simulator work, flowdeck -i gives you the full terminal UI for picking simulators, booting them, running apps, and watching logs. No commands to memorize. Keyboard shortcuts for every operation.
Here’s a quick look at interactive mode in action:
What this is and isn’t
FlowDeck calls simctl and devicectl under the hood. It doesn’t replace Apple’s tools, it gives you a better interface to them. If you need a simctl feature FlowDeck doesn’t expose, you can still call simctl directly. The two coexist.
Native Swift. Runs locally. No telemetry.
Further reading
- How to run iOS tests from the terminal
- How to set up Claude Code and Codex for iOS development
- CLI documentation
Try FlowDeck free for 7 days.
One CLI for builds, simulators, tests, logs, and UI automation. Native Swift. Runs locally. No telemetry.
$59/yr after trial · Zero telemetry