Skip to content

Repository files navigation

RoamPair logo

RoamPair

Install Xcode-signed apps on a paired iPhone after it roams beyond the Mac's local network.

Test MIT License Platform Status

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.

What works

  • 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 .app bundles 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.

Known limits

  • 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.

Architecture

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]
Loading
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.

Requirements

  • 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.
  • pymobiledevice3 or uv for 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 uv

Quick start

1. Configure the Mac CLI

Build 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-iphone

The 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 token

Rotate 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-token

2. Deploy the relay

Build 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-relay

Verify 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.

3. Build and install the iOS app

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.

4. Start the macOS packet bridge and scan

./scripts/run-bridge.sh

The 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::2

5. Create the RemotePairing record

Keep the trusted iPhone connected over USB and unlocked for this one-time step:

./scripts/bootstrap-pairing.sh

The pairing record is normally stored under ~/.pymobiledevice3/. It contains private key material. RoamPair reads it in place and requires owner-only file permissions.

6. Install and configure the installer CLI

./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.

7. Build and install your app

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.

Operations

# 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:

  1. GET /healthz returns HTTP 200 from the exact public relay domain.
  2. Both peers use the same token and device ID.
  3. The Mac runs exactly one roampair-mac process and the IPv6 route uses its current utun interface.
  4. ping6 fd42:6462:7269::2 succeeds from the Mac.
  5. The iOS app displays a RemotePairing listener and matching port.
  6. The pairing record identifier and profile identifier match.
  7. roampair doctor succeeds 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.

Development

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 verification

The unused gVisor forwarding prototype is retained under experiments/gvisor-netstack/ for research. It is not linked into the normal iOS build.

Security

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.

Related projects and acknowledgements

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-ios for Apple device, RemotePairing, RSD, ZipConduit, and InstallationProxy support.
  • tailscale/tailscale for the model of stable virtual addressing across changing physical networks. RoamPair does not include Tailscale code or implement a tailnet.
  • google/gvisor and golang/mobile for the optional userspace-netstack experiment.
  • pymobiledevice3 for 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.

About

Self-hosted bridge for installing Xcode-signed apps on a paired iPhone across Wi-Fi and cellular networks.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages