Install Xcode-signed apps on a paired iPhone after it roams beyond the Mac's local network.
RoamPair creates a self-hosted IPv6 packet path between a Mac and an iPhone,
then keeps an authenticated Apple RemotePairing/RSD session alive across a
Wi-Fi-to-cellular network change. Xcode remains responsible for building and
signing the app. The roampair CLI transfers the resulting .app, reports
upload and installation progress, and verifies the installed bundle through
InstallationProxy.
Important
RoamPair does not register the iPhone as a native Xcode Run Destination.
Use Xcode or xcodebuild to produce a signed device build, then install that
build with roampair install.
Warning
This is an experimental, single-user developer tool built on Apple-controlled RemotePairing behavior. It is not a general-purpose VPN, a production remote device farm, or a supported Apple deployment mechanism.
- A Mac and iPhone can exchange IPv6 packets through a self-hosted WSS relay.
- A RemotePairing session warmed while the iPhone is on Wi-Fi can remain useful after the iPhone switches to another Wi-Fi network or cellular data.
- Signed
.appbundles can be uploaded, installed, and verified without TestFlight once the warm session is active. - Upload metadata, app name, version, build number, bytes, percentage, stage, and effective throughput are available from the CLI and iOS app.
- The iOS app shows the live Mac bridge stage and only asks for Wi-Fi when the Mac reports that a cold RemotePairing listener must be warmed.
- The Mac CLI renders a connection QR code. The iOS app scans it, stores the relay configuration, and starts the VPN without manual credential entry.
- A state-aware Next step section tells the user the single action required to connect, warm, authenticate, install, recover, or open the installed app.
- Initial USB trust, Developer Mode, and a RemotePairing record are required.
- A cold RemotePairing start on cellular data is not supported. Join any Wi-Fi network to warm or reacquire the listener first.
- The currently advertised RemotePairing port can change after iOS restarts or recreates the service. The profile must use the port shown by the iOS app.
- iOS may terminate the Packet Tunnel or RemotePairing listener at any time.
- Reinstalling RoamPair itself interrupts its own tunnel and can make the client command fail even when the new app reaches the phone.
- The relay accepts one Mac peer and one iOS peer per device ID. It has no user database, peer keys, ACLs, NAT traversal, or multi-tenant isolation.
flowchart LR
APP[Signed .app] --> CLI[roampair CLI]
CLI --> DAEMON[macOS install daemon]
DAEMON --> RSD[RemotePairing + RSD]
RSD --> UTUN[macOS utun]
UTUN <-->|IPv6 packet batches over WSS| RELAY[Self-hosted relay]
RELAY <--> TUNNEL[iOS Packet Tunnel]
TUNNEL --> IOS[iPhone RemotePairing listener]
| Component | Location | Responsibility |
|---|---|---|
| Relay server | server/ |
Authenticate a shared token, pair peers by device ID, and forward opaque binary frames. |
| iOS app and tunnel | apple/ios/ |
Discover the local RemotePairing service, own fd42:6462:7269::2/64, and exchange packets with the relay. |
| macOS bridge | apple/macos/ |
Own fd42:6462:7269::1/64 on a utun interface and exchange packets with the relay. |
| Shared transport | apple/shared/ |
Define framing, batching, diagnostics, relay transport, and install-status messages. |
| Installer CLI and daemon | cmd/roampair/, internal/linkcore/ |
Maintain RemotePairing/RSD, serialize installs, and verify installed bundles. |
The relay never sees Apple pairing keys and never terminates Apple protocols.
The iOS and macOS peers preserve IP packet boundaries inside bounded WebSocket
frames. See docs/architecture.md for the transport,
session, and trust boundaries.
- An Apple-silicon Mac running macOS 14 or later.
- Xcode with iOS 17 or later support and XcodeGen.
- An iPhone running iOS 17 or later, unlocked with Developer Mode enabled.
- A one-time trusted USB connection between that Mac and iPhone.
- An Apple development team that can sign a Packet Tunnel Provider extension.
pymobiledevice3oruvfor the one-time RemotePairing bootstrap.- A public HTTPS/WSS endpoint capable of running the relay container.
Install the local bootstrap tools with Homebrew if needed:
brew install xcodegen uvBuild the Mac bridge and give it the public relay endpoint:
swift build --package-path apple -c release --product roampair-mac
apple/.build/release/roampair-mac setup \
wss://roampair.example.com/v1/relay primary-iphoneThe CLI generates a 256-bit token and stores the complete Mac configuration at
~/Library/Application Support/RoamPair/config.json with owner-only
permissions. The CLI owns this file; do not create or edit it manually and do
not create a Mac environment file. Running setup again updates the relay URL
or device ID while preserving the existing token. Retrieve that token with:
apple/.build/release/roampair-mac tokenRotate it only intentionally. After rotation, update the relay and scan the new QR code on the iPhone before expecting either peer to reconnect:
apple/.build/release/roampair-mac rotate-tokenBuild and run the relay locally or deploy server/Dockerfile
to a container host:
docker build -t roampair-relay ./server
docker run --rm -p 8080:8080 \
-e ROAMPAIR_TOKEN='paste-the-generated-token' \
roampair-relayVerify it before continuing:
curl https://roampair.example.com/healthz
# {"ok":true}The public endpoint must support WebSocket upgrades at /v1/relay. Zeabur
deployment instructions are available in
docs/deploy-zeabur.md.
Note
Pre-RoamPair DeviceBridge images used DEVICEBRIDGE_TOKEN. The current relay
reads only ROAMPAIR_TOKEN. Redeploy the current server/ image when
migrating; keeping an old image and adding the new variable is not enough.
Find the connected iPhone's CoreDevice identifier, then build and install:
xcrun devicectl list devices
ROAMPAIR_BUNDLE_ID=com.example.yourname.roampair \
ROAMPAIR_DEVELOPMENT_TEAM=YOUR_TEAM_ID \
./scripts/install-ios.sh <COREDEVICE_IDENTIFIER>Alternatively, run make generate-ios, open apple/ios/RoamPair.xcodeproj, set
your development team and unique bundle ID, and run the RoamPair scheme.
Open RoamPair on the iPhone. It does not need a relay URL or token embedded in the build.
./scripts/run-bridge.shThe CLI displays a QR code containing the relay URL, long-lived token, device
ID, and configuration version. In RoamPair on the iPhone, tap Scan Mac QR
code, scan the terminal, and approve the VPN configuration when iOS asks. The
app stores the values in UserDefaults and connects automatically. The CLI
prints the relay and device ID below the QR code but never prints the token in
plain text.
run-bridge.sh asks for administrator access, stops earlier roampair-mac
instances, and then creates one fresh utun. Do not run additional bridge
processes in parallel: an older interface can retain the route to the iPhone
even while a newer process owns the active relay peer.
Keep the phone on Wi-Fi until the app shows a RemotePairing host and port and the bridge becomes active. Later launches reuse the saved configuration, but the CLI continues to show a QR code for recovery or connecting another install.
In another terminal, verify the overlay path:
The script uses sudo because creating and configuring the utun interface
requires root privileges.
ping6 -c 4 fd42:6462:7269::2Keep the trusted iPhone connected over USB and unlocked for this one-time step:
./scripts/bootstrap-pairing.shThe pairing record is normally stored under ~/.pymobiledevice3/. It contains
private key material. RoamPair reads it in place and requires owner-only file
permissions.
./scripts/install.sh
roampair="$HOME/Library/Application Support/RoamPair/bin/roampair"
profile="$HOME/Library/Application Support/RoamPair/iphone.json"
"$roampair" configure \
--profile "$profile" \
--device-label "development iphone" \
--remote-identifier "REMOTE_PAIRING_IDENTIFIER" \
--target-bridge-ip "fd42:6462:7269::2" \
--pair-record "$HOME/.pymobiledevice3/remote_REMOTE_PAIRING_IDENTIFIER.plist" \
--remote-pairing-port REMOTE_PAIRING_PORT_FROM_IOS_APP
"$roampair" doctor --profile "$profile"
"$roampair" launch-agent install --profile "$profile"
"$roampair" watch --profile "$profile"Wait until the state is active. The iPhone can then leave Wi-Fi while the
authenticated session remains alive.
xcodebuild \
-project YourApp.xcodeproj \
-scheme YourApp \
-configuration Debug \
-destination 'generic/platform=iOS' \
-derivedDataPath "$PWD/.build/RoamPairDerivedData" \
build
"$roampair" install \
--profile "$profile" \
"$PWD/.build/RoamPairDerivedData/Build/Products/Debug-iphoneos/YourApp.app"The command emits newline-delimited JSON. A successful result is returned only after transfer completion and InstallationProxy readback of the bundle ID.
# Mac bridge configuration
apple/.build/release/roampair-mac token
apple/.build/release/roampair-mac setup \
wss://roampair.example.com/v1/relay primary-iphone
apple/.build/release/roampair-mac rotate-token
# Installer daemon
"$roampair" status --profile "$profile"
"$roampair" watch --profile "$profile"
"$roampair" stop --profile "$profile"
"$roampair" launch-agent remove --profile "$profile"When the session cannot be reacquired, check these boundaries in order:
GET /healthzreturns HTTP 200 from the exact public relay domain.- Both peers use the same token and device ID.
- The Mac runs exactly one
roampair-macprocess and the IPv6 route uses its currentutuninterface. ping6 fd42:6462:7269::2succeeds from the Mac.- The iOS app displays a RemotePairing listener and matching port.
- The pairing record identifier and profile identifier match.
roampair doctorsucceeds before attempting an install.
For relay triage, HTTP 404 normally means the public domain points at the wrong service, HTTP 401 means the running relay rejected the token, and HTTP 426 from an authenticated non-WebSocket request confirms authentication succeeded but an Upgrade header was intentionally absent.
make test-bridge # Go relay tests and Swift transport tests
make test-netstack # Optional gVisor experiment tests
make generate-ios # Generate the Xcode project with XcodeGen
make test # Full pinned Link Core and repository verificationThe unused gVisor forwarding prototype is retained under
experiments/gvisor-netstack/ for research. It
is not linked into the normal iOS build.
The relay uses TLS plus one shared bearer token. That is appropriate only for a
private, self-hosted experiment. Use a long random token, keep pairing records
owner-only, and do not expose a plaintext relay. See SECURITY.md
for the threat model and private vulnerability reporting guidance.
RoamPair is a self-hosted evolution of
xcode-deploy-link-deploy-beyond-local-network,
which demonstrated the persistent RemotePairing/RSD installation path over a
Tailscale network. RoamPair replaces that reachability dependency with its own
minimal relay and Packet Tunnel transport.
The project also builds on or learns from:
danielpaulus/go-iosfor Apple device, RemotePairing, RSD, ZipConduit, and InstallationProxy support.tailscale/tailscalefor the model of stable virtual addressing across changing physical networks. RoamPair does not include Tailscale code or implement a tailnet.google/gvisorandgolang/mobilefor the optional userspace-netstack experiment.pymobiledevice3for creating the additional RemotePairing record used during bootstrap.
See THIRD_PARTY_NOTICES.md for license details.
Issues and focused pull requests are welcome. Review
CONTRIBUTING.md, SECURITY.md, and the
MIT License before participating.
