In the age of AI, many of us control our home computers remotely and let AI write code while we are out. Web development is easy: connect the computer to Tailscale or WireGuard, and you can open the page and debug from anywhere. Apps are different. App Store review takes days, and even TestFlight takes about half an hour before you can install a build on your phone. No matter how fast AI writes code, you get stuck here.
So I worked with AI on a way to develop on my Mac at home while I am out, with only a weak laptop or even just my phone, and deploy to the phone right after each change to test it, or even debug it directly. This post is the result: a setup that lets Xcode build, install, and launch an app on an iPhone that can only reach home through a VPN.
Requirements
- The phone must be on Wi-Fi, not only cellular: the background service on the phone that accepts remote deployments only starts when it is on Wi-Fi
- The phone’s WireGuard connects back to your home router
- The router can add a LAN IP and an address forwarding rule (the example below uses MikroTik RouterOS)
- I tested with Xcode 27.1 and iOS 27 (
devicectlis required). Older versions may work, but I have not tried them
How it works
Xcode finds the phone by listening for its “I’m here” broadcasts (Bonjour) on the local network. These broadcasts only travel within the same LAN; routers and VPNs do not forward them to other networks. So when the phone is on Wi-Fi somewhere else, its broadcasts stay on that network, the Mac never hears them, and Xcode thinks the phone is gone.
The VPN is not blocking the connection. The connection itself works over the VPN. What is missing is the “find the phone” step.
The fix: when the phone is away, the Mac announces “the phone is at <bridge IP>” (an IP on your LAN), and the router forwards connections to <bridge IP> into the VPN.
Xcode ──► <bridge IP>:49152 ──► router (forward) ──► WireGuard ──► phone
▲ a script on the Mac announces "the phone is here"
Three parts: a forwarding rule on the router, a background script on the Mac, and the pairing records captured while the phone is at home (the script records them automatically).
Step 0: Pair once at home
Connect the phone with a cable or put it on the same Wi-Fi, pair it, turn on Developer Mode, and check:
xcrun devicectl list devices # should show available (paired)
Remote deployment reuses this pairing. Without it, the bridge cannot work.
Step 1: Router
Pick an unassigned IP on your LAN as <bridge IP> and run on the router:
/ip address add address=<bridge IP>/32 interface=bridge comment=iphone-bridge
/ip firewall nat add chain=dstnat dst-address=<bridge IP> action=dst-nat \
to-addresses=<phone's WireGuard IP> comment=iphone-bridge
- The first line makes the router “own” this IP, so the Mac can reach it.
- The second line forwards connections for
<bridge IP>to the phone’s IP inside WireGuard. You can find this IP in the Addresses field of the WireGuard tunnel settings on the phone (for example10.0.0.2, without a suffix such as/32). It is not<bridge IP>, and it is not the LAN IP the phone gets at home.
Test: with the phone on Wi-Fi and WireGuard on, run this on the Mac:
nc -z -G 3 <bridge IP> 49152 && echo OK
If you see OK, the router, VPN, and phone are all connected. If not, first check that the phone’s Wi-Fi and WireGuard are connected.
Step 2: Script on the Mac
Create the folder ~/.iphone-bridge/ with two files.
config.json:
{
"bridge_ip": "<bridge IP>",
"bridge_host": "bridge.local",
"phone_host": "<phone name>.local",
"interface": "en0",
"port": 49152,
"interval": 30,
"records": {}
}
phone_host: the phone’s name on the LAN. While the phone is at home, rundns-sd -B _remotepairing._tcp local.to find the record name, then rundns-sd -L <record name> _remotepairing._tcp local.. The host name is the part afterreached atand before the port.interface: the network interface the Mac uses for the LAN. Wi-Fi is usuallyen0.bridge_host: any name you like.records: leave empty. The script fills it in while the phone is at home. It contains pairing information, so runchmod 600 config.jsonand keep it out of git and synced folders.
advertise.py:
- Checks every 30 seconds whether the phone is on the LAN.
- At home: saves the phone’s current pairing records (the record names keep changing, so it keeps the latest 4).
- Away (only after missing it twice in a row): uses the saved records to announce on the Mac that “the phone is at
<bridge IP>”. - While away, it also checks whether
<bridge IP>is reachable, and only logs when the state changes.
#!/usr/bin/env python3
"""Save the phone's pairing records while it is on the LAN; when it is away, pretend on the Mac that it is still here, pointing to the router's bridge IP."""
import json
import os
import re
import signal
import socket
import subprocess
import sys
import time
CFG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
SVC = "_remotepairing._tcp"
KEEP = 4 # max records to keep and advertise (the phone's record names keep changing)
def log(*a):
print(time.strftime("%F %T"), *a, flush=True)
def dns_sd(args, secs):
"""dns-sd never exits on its own: run it for a few seconds and collect the output."""
p = subprocess.Popen(["dns-sd", *args], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
time.sleep(secs)
p.terminate()
return p.communicate()[0]
def reachable(ip, port):
try:
socket.create_connection((ip, port), timeout=2).close()
return True
except OSError:
return False
def phone_home(c):
"""The phone name resolves to a LAN IP and the pairing port really connects (cached records cannot be trusted)."""
host = c["phone_host"].rstrip(".").lower() + "."
for line in dns_sd(["-G", "v4", c["phone_host"]], 2).splitlines():
f = line.split() # time Add flags interface host IP TTL
if (len(f) >= 6 and f[1] == "Add" and f[4].lower() == host
and f[5] != c["bridge_ip"] and reachable(f[5], c["port"])):
return True
return False
def collect(c):
"""Merge the phone's current real records into config, keeping only the latest KEEP."""
host = c["phone_host"].rstrip(".") + "."
names = set(re.findall(rf"\sAdd\s.*{SVC}\.\s+(\S+)", dns_sd(["-B", SVC, "local."], 3)))
records = dict(c["records"])
for n in sorted(names):
out = dns_sd(["-L", n, SVC, "local."], 2)
m = re.search(r"reached at .*\n\s*(identifier=.*)", out)
if f"reached at {host}" in out and m: # skip other Apple devices on the LAN
txt = m.group(1).split()
ident = txt[0].split("=", 1)[1]
records.pop(ident, None)
records[ident] = txt
records = dict(list(records.items())[-KEEP:])
if list(records.items()) != list(c["records"].items()):
c["records"] = records
with open(CFG, "w") as f:
json.dump(c, f, indent=2)
def advertise(c):
return [subprocess.Popen(
["dns-sd", "-i", c["interface"], "-P", "bridge-" + ident[:8], SVC, "local.",
str(c["port"]), c["bridge_host"], c["bridge_ip"], *txt],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for ident, txt in c["records"].items()]
def withdraw(procs):
for p in procs:
p.terminate()
for p in procs:
p.wait()
def main():
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) # let finally withdraw the fake records
procs, misses, route_ok = [], 0, None
try:
while True:
c = json.load(open(CFG))
if phone_home(c):
misses, route_ok = 0, None
if procs:
withdraw(procs)
procs = []
log("phone is home, adverts withdrawn")
collect(c)
else:
misses += 1 # only count as away after two misses in a row, to avoid false alarms
if misses >= 2 and c["records"] and not (procs and all(p.poll() is None for p in procs)):
withdraw(procs)
procs = advertise(c)
log("phone is away, adverts up ->", c["bridge_ip"])
if procs:
ok = reachable(c["bridge_ip"], c["port"])
if ok != route_ok: # only log when the state changes
log("bridge IP reachable (phone answers via VPN)" if ok else "bridge IP unreachable: check the router rules and the phone's WireGuard")
route_ok = ok
time.sleep(c["interval"])
finally:
withdraw(procs)
if __name__ == "__main__":
main()
Step 3: Keep it running
~/Library/LaunchAgents/com.example.iphone-bridge.plist (replace your-user):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.iphone-bridge</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/your-user/.iphone-bridge/advertise.py</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
<key>StandardOutPath</key><string>/Users/your-user/.iphone-bridge/advertise.log</string>
<key>StandardErrorPath</key><string>/Users/your-user/.iphone-bridge/advertise.log</string>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.iphone-bridge.plist
Once loaded, launchd does not read the plist again. If you change it later, unload and reload it for the change to take effect:
launchctl bootout gui/$(id -u)/com.example.iphone-bridge
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.iphone-bridge.plist
Step 4: Verify and deploy
Keep the phone at home on Wi-Fi for 1 to 2 minutes, and check that
recordsinconfig.jsonhas been filled in.Take the phone somewhere else, connect to Wi-Fi, and turn on WireGuard. After about a minute,
advertise.logshows:phone is away, adverts up -> <bridge IP> bridge IP reachable (phone answers via VPN)Confirm you can really reach the phone. The command below asks the phone for its current lock state and fails if it cannot connect. On success it prints
Current device lock state:followed by lines such aspasscodeRequiredandunlockedSinceBoot. Do not usedevicectl list devicesas proof; it may show a cached state and list the phone as available even when it cannot connect:UDID=<your device UDID> xcrun devicectl device info lockState --device $UDIDDeploy:
xcodebuild -project MyApp.xcodeproj -scheme MyApp -configuration Debug \ -destination "id=$UDID" -derivedDataPath /tmp/dd \ -allowProvisioningUpdates build xcrun devicectl device install app --device $UDID \ /tmp/dd/Build/Products/Debug-iphoneos/MyApp.app xcrun devicectl device process launch --device $UDID com.example.myapp
Installing updates the existing app, so the app’s data is kept.
Limitations
- You must pair at home first. This method only lets an existing pairing find the phone over the VPN.
- The phone must stay connected to both Wi-Fi and WireGuard. If either drops, the connection fails.
- Tailscale should work instead of WireGuard, but I have not tried it.