| name | xcode-remote |
| description | Build an iOS-simulator or macOS app on this build host and launch it natively on your laptop over the LAN, then read back logs and crashes. Use after editing iOS/macOS (Swift/Xcode) code when you need to actually run the app and observe it, since the app must run on your machine, not here. |
xcode-remote
You are on the build host. Xcode is here, but you watch apps run on your laptop. xcode-remote bridges that: it builds locally, ships the app to the laptop over SSH, launches it there, and returns logs + crashes as JSON.
Invocation
xcode-remote <cmd> (installed on PATH at /opt/homebrew/bin/xcode-remote).
- If "command not found" (a spawned agent with a minimal PATH), use the absolute path:
~/Developer/xcode-remote/.venv/bin/xcode-remote <cmd>.
When to use
- After editing iOS or macOS app code and you want to verify it builds, launches, and behaves.
- To re-read logs after you interact with the running app.
Commands
xcode-remote run — build → ship → launch on the laptop → return JSON.
xcode-remote run --device — same, but install/launch on a real iPhone.
xcode-remote logs [--last 30s] — pull recent logs for the running app.
xcode-remote logs --device — fetch best-effort logs for the device app.
xcode-remote stop — terminate the app on the laptop.
xcode-remote stop --device — attempt to stop the device app (best-effort; see limitations below).
xcode-remote doctor — preflight (laptop reachable? simctl + sim present?).
- Add
--project-dir <repo root> when not already inside the project.
- Add
--dry-run to print the full command plan without side effects (skips the
laptop preflight; resolves real app paths). macOS dry-runs fully; iOS dry-run
stops where it needs a live simulator.
Configuration (two layers, deep-merged; project wins)
Reading the JSON result
{ "build": "ok" | {"errors":[{"file","line","message"}], "diagnostic":"...", "stage":"..."},
"launch": {"pid","device","platform"} | "failed" | null,
"logs": ["..."], "crash": null | {"signal","reason","thread0":["..."]} }
build.errors present → fix those compiler errors and run again. Do not proceed.
build.diagnostic present (errors empty) → a non-compiler failure (no project
found, linker/toolchain/signing). stage is showBuildSettings or build.
Read the diagnostic; it's the raw xcodebuild output tail.
crash present → the app crashed; use signal + thread0 to locate the fault.
launch == "failed" → run doctor; laptop may be asleep or missing the sim runtime.
- Clean run →
build:"ok", launch.pid set, crash:null. Report the pid and any
notable logs to the user.
Notes
- macOS apps launch via
open; the captured pid is the running process. Logs use
the executable name (from EXECUTABLE_NAME), not the bundle name.
- One-time laptop setup (already done): Remote Login on, this machine's SSH key
authorized, Xcode installed.
Device mode (--device / platform = "ios-device")
Build happens on the build host (signed for device); install/launch happens on the laptop
via xcrun devicectl which talks to the iPhone over USB/wireless.
Signing on a headless build host (REQUIRED)
The build host runs as a launchd Background session (no GUI/Aqua). Xcode automatic signing
(-allowProvisioningUpdates) always fails there with "No Accounts" because Apple
account enumeration is bound to the GUI session. You must use manual signing.
Add a [device] section to .xcode-remote.toml. The minimal config (this is all you need):
[device]
team = "ABCDE12345"
Division of signing responsibility
xcode-remote passes only one globally-safe build setting to xcodebuild:
That is it. CODE_SIGN_IDENTITY, CODE_SIGN_STYLE, and PROVISIONING_PROFILE_SPECIFIER
are never emitted as global build settings.
Why not inject CODE_SIGN_IDENTITY globally?
Command-line build settings apply to every target in the build graph, including
SPM library and macro targets. Those targets use automatic signing. Passing a specific
CODE_SIGN_IDENTITY globally causes xcodebuild to fail with:
"conflicting provisioning settings: automatically signed but a conflicting code
signing identity has been manually specified"
CODE_SIGN_STYLE=Manual triggers the same failure. DEVELOPMENT_TEAM is the only
signing setting that auto-signed SPM/macro targets tolerate as a global override.
The app target must declare all other signing settings itself — e.g. in Tuist's
Project.swift or a target-level xcconfig:
settings: .settings(base: [
"CODE_SIGN_STYLE": "Manual",
"PROVISIONING_PROFILE_SPECIFIER": "match Development com.example.app",
"CODE_SIGN_IDENTITY": "3D598D9FBBFF38913CDB6E2F7331D2F36A12E57E",
])
Do not use "Apple Development" as CODE_SIGN_IDENTITY in your app target —
it is a display-name match and is ambiguous when the build host has more than one
matching cert ("profile doesn't include signing certificate"). Always pin the exact
SHA-1 fingerprint.
Finding the right SHA-1: set provisioning_profile in the [device] section
and xcode-remote will print an advisory at build time telling you exactly which SHA-1
to pin in your app target:
[device]
team = "ABCDE12345"
provisioning_profile = "match Development com.example.myapp"
The tool scans .mobileprovision files on the build host (Xcode 16+ path first:
~/Library/Developer/Xcode/UserData/Provisioning Profiles/, then legacy
~/Library/MobileDevice/Provisioning Profiles/), decodes the CMS envelope with
security cms -D -i <file>, computes the SHA-1 of the embedded DeveloperCertificates
entry, and prints it as an advisory. provisioning_profile is not forwarded to
xcodebuild — it is used only for this diagnostic.
code_sign_style and code_sign_identity are kept for back-compat but do not affect
build args. code_sign_style = "manual" still triggers manual mode detection
(suppresses -allowProvisioningUpdates); code_sign_identity is ignored for injection.
The provisioning profile must already exist on the build host's keychain/profile store
(e.g. via fastlane match development or manual download from developer.apple.com).
Device resolution: paired state, not tunnel state
xcrun devicectl list devices reports connection status in two fields:
connectionProperties.tunnelState — may be "disconnected" for a healthy Wi-Fi device
connectionProperties.pairingState — "paired" for any device trusted by this laptop
The tool selects devices by pairingState == "paired" and ignores tunnelState.
A real paired iPhone over localNetwork reports tunnelState: "disconnected" but is
fully usable by devicectl for install/launch. The old tunnelState filter wrongly
rejected these devices.
Install diagnostics and transient-reset retry
Wireless installs occasionally fail with a transient NW reset (NWError 54,
Connection reset by peer, or lost connection). The tool retries up to 3 times
automatically. If all three attempts fail, the error message includes the raw devicectl
stderr PLUS the hint:
"device may be locked or on flaky Wi-Fi — unlock the iPhone and prefer a USB
connection (localNetwork transport is unreliable)"
If you see that hint: unlock the device screen, wait a moment, and re-run. Using a
USB cable instead of Wi-Fi eliminates the reset entirely.
One-time prerequisites (must be done manually)
- Developer Portal: the iPhone must be registered in the Apple Developer portal
(Xcode → Devices & Simulators on the laptop, or developer.apple.com). The build host
never sees the phone, so it can't auto-register it.
- Developer Mode: on the iPhone, go to Settings → Privacy & Security →
Developer Mode and enable it. Required for
devicectl to talk to the device.
- Trust the laptop: connect the iPhone via USB and tap "Trust" on the device
when prompted by the laptop. Wireless pairing works after that.
- Provisioning profile on the build host: install the matching profile and certificate
in the build host's keychain (e.g.
fastlane match development).
v1 limitations
- Logs: best-effort only. The tool tails a console log file created during
launch. For richer logs, use Console.app on the laptop (filter by device) or
Xcode's device log viewer. If the file is empty,
logs --device returns a
human-readable note instead of crashing.
- Crash reports: not captured. Device crash reports live on the phone and
require Xcode or
idevicecrashreport to extract. find_crash always returns
null for device mode.
stop --device: devicectl terminate requires a PID, which is not persisted
across CLI invocations. stop --device is a no-op across invocations. The process
will be terminated by --terminate-existing on the next run --device.
How devicectl commands are wired
| Action | Command (runs on laptop via SSH) |
|---|
| List devices | xcrun devicectl list devices --json-output <tmpfile> |
| Install | xcrun devicectl device install app --device <udid> <app-path> |
| Launch | xcrun devicectl device process launch --device <udid> --terminate-existing --json-output <tmpfile> <bundle-id> |
| Terminate | xcrun devicectl device process terminate --device <udid> --pid <pid> |