
The iOS Simulator is a macOS process, not a window. xcrun simctl — the command-line interface to CoreSimulator — can list and boot devices, install an app, launch by bundle ID, write a PNG of the framebuffer, and stream unified logs without a display server attached. From an SSH session on a remote Mac you can drive the whole headless loop and pipe output back to your laptop. This guide gives the commands, the real gotchas (UDIDs, simulator-vs-device builds, macOS lacking timeout), and how to pick a Figment plan that matches the Xcode toolchain you actually need.
Simulator.app is a GUI wrapper around the CoreSimulator service and its per-runtime bundles. xcrun simctl talks to that service directly, so boot, install, launch, io, and spawn all work from a non-graphical session. A simulator booted this way runs real UIKit/SwiftUI code against the installed iOS runtime; what you lose is interactive touch, Instruments' GUI, and the visionOS simulator specifically — Apple's Xcode system requirements state that developing for visionOS requires a Mac with Apple silicon.
Two constraints matter before you SSH in:
.ipa from TestFlight or a Developer ID build is compiled against the iphoneos SDK and signed for a device profile. The simulator runs iphonesimulator-SDK .app bundles — the platform binary is different. Even on an Apple silicon host, you cannot simctl install a device IPA's Payload/*.app. Build a simulator variant (-sdk iphonesimulator) and transfer that .app directory..app bundles for the matching iOS majors. Xcode 27, visionOS, and ARM-only toolchains need Apple silicon. Confirm your exact Xcode/iOS runtime pair against Apple's current release notes before committing.SSH into the physical M-series or i3 Mac you arranged in your quote. (The $29 Virtual Intel Mac is accessed through its browser-console desktop terminal — run the same simctl commands there; there is no inbound SSH on that VM today.) Throughout this guide, remote-mac is a placeholder for the hostname or user@host you were given; do not assume any Figment alias is pre-wired on your workstation — set it up in your own ~/.ssh/config if you want a short name.
# What's installed?
ssh remote-mac 'xcrun simctl list devices available'
Pick a device line and capture its real UDID — a 36-character value shaped like XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX. Storing it once avoids ambiguity when several device types share a display name:
UDID=$(ssh remote-mac "xcrun simctl list devices available -j" \
| jq -r '[.devices[][] | select(.name=="iPhone 16")][0].udid')
echo "$UDID"
ssh remote-mac "xcrun simctl boot $UDID"
ssh remote-mac "xcrun simctl list devices | grep $UDID"
simctl boot returns non-zero if the device is already booted; check state first (simctl list devices | grep "$UDID") rather than blanket-swallowing the error.
Build locally or on the remote Mac with the simulator destination, then copy the resulting .app bundle up. Use a per-job mktemp -d so parallel runs never collide, and clean up with a trap tied to that run's directory — not a blanket rm -rf /tmp/anything.
# On your laptop (or the remote Mac), with the real scheme name from your project:
xcodebuild -scheme "$MY_SCHEME" \
-configuration Debug \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$PWD/dd" \
build
# Copy the simulator .app to a unique scratch directory on the remote Mac:
WORK=$(ssh remote-mac 'mktemp -d /tmp/sim.XXXXXXXX')
trap 'ssh remote-mac "rm -rf $WORK"' EXIT
scp -r "$PWD/dd/Build/Products/Debug-iphonesimulator/MyApp.app" \
"remote-mac:$WORK/"
ssh remote-mac "xcrun simctl install $UDID $WORK/MyApp.app"
ssh remote-mac "xcrun simctl launch $UDID com.example.MyApp"
simctl launch prints the PID on success — pass it to lldb process attach --pid if you need to debug.
xcrun simctl spawn <udid> log stream writes the unified log to stdout; filter locally with grep --line-buffered (without --line-buffered, block buffering can stall visible output for seconds).
ssh remote-mac "xcrun simctl spawn $UDID log stream --level debug" \
| grep --line-buffered "MyApp"
macOS does not ship GNU timeout. For a bounded capture, either stop the stream manually with Ctrl-C, install gtimeout from Homebrew's coreutils, or background the command and kill it:
# Option A: install coreutils once on your laptop, then:
gtimeout 30 ssh remote-mac "xcrun simctl spawn $UDID log stream --level debug" \
> "sim-$(date +%Y%m%d-%H%M%S).log" || true
# Option B: no extra install, portable:
ssh remote-mac "xcrun simctl spawn $UDID log stream --level debug" > sim.log &
LOG_PID=$!
sleep 30
kill "$LOG_PID" 2>/dev/null || true
Screenshots come straight from the framebuffer:
ssh remote-mac "xcrun simctl io $UDID screenshot $WORK/shot.png"
scp "remote-mac:$WORK/shot.png" ./shot.png
To wait past a launch animation, launch, sleep a few seconds, then shoot. When a run finishes, shut down only the UDIDs you own — never simctl shutdown all on a shared host, because other developers (or CI matrix jobs) may be using other devices:
ssh remote-mac "xcrun simctl shutdown $UDID"
simctl-headless.shA compact helper you can source once per project. It wires the pieces above with the owned-UDID discipline, portable buffering, and job-scoped scratch dirs — no giant orchestration script, no hidden global state.
# simctl-headless.sh — source this file; export REMOTE=user@host in your shell.
: "${REMOTE:?Set REMOTE=user@host}"
sim_udid() { ssh "$REMOTE" "xcrun simctl list devices available -j" \
| jq -r --arg n "$1" '[.devices[][] | select(.name==$n)][0].udid'; }
sim_work() { ssh "$REMOTE" 'mktemp -d /tmp/sim.XXXXXXXX'; }
sim_boot() { ssh "$REMOTE" "xcrun simctl boot $1" \
|| ssh "$REMOTE" "xcrun simctl list devices | grep $1"; }
sim_install() { ssh "$REMOTE" "xcrun simctl install $1 $2"; } # $1=UDID $2=app
sim_launch() { ssh "$REMOTE" "xcrun simctl launch $1 $2"; } # $1=UDID $2=bundle
sim_shot() { ssh "$REMOTE" "xcrun simctl io $1 screenshot $2/shot.png"; \
scp "$REMOTE:$2/shot.png" ./shot.png; }
sim_shutdown() { ssh "$REMOTE" "xcrun simctl shutdown $1"; } # $1=UDID only
sim_cleanup() { ssh "$REMOTE" "rm -rf $1"; } # $1=mktemp dir
Each function takes the UDID (and scratch dir where relevant) as an argument, so there is no shared "booted" state, no path is reused across jobs, and nothing targets devices you don't own. Treat it as a starting point — adapt names to your project before running against real builds.
| Task | Headless simctl over SSH | Xcode GUI via VNC |
|---|---|---|
xcodebuild test in CI | Best fit | Overkill |
| Screenshot capture | simctl io screenshot | Supported |
| Ad-hoc tap/gesture work | Limited (simctl io help) | Easier |
| Instruments profiling | Not via simctl | Required |
| visionOS simulator | Apple silicon host required | Apple silicon + GUI |
For CI pipelines and automated acceptance tests, simctl over SSH is the simpler path. Switch to a VNC Xcode session only when you need Instruments, interactive debugging, or the visionOS simulator.
A fair cost framing for this specific workflow: a solo developer who only needs headless simctl on an Intel-supported Xcode pays $29/mo on Figment's Virtual Intel Mac. CloudMac's listed M4/16 GB/256 GB monthly rate is $99.99 — about 3.4× more — for Apple silicon hardware you may not need for this task. Figment's M-series starts at $95/mo, but that entry price is not an M4/16 GB spec; the exact hardware is set in the quote. If a competitor's like-for-like plan is cheaper on confirmed specs, say so plainly and compare what each plan includes.
Confirm your Xcode/iOS runtime pair and request a Mac at /mac-hosting/start.
Related guides: