現在是 AI 時代,大家都在外面用 AI 遙控家裡的電腦寫程式。寫 web 很方便,電腦接個 Tailscale 或 WireGuard,人在外面也能直接開網頁 debug。但 App 就不行了:上架要等好幾天,走 TestFlight 也要半小時才能裝到手機上測,AI 寫得再快也卡在這裡。
所以我跟 AI 討論了一下,怎麼做到人在外面,手上只有一台效能很弱的筆電,甚至只有手機,也能遙控家裡的 Mac 開發,改完馬上佈署到手機上測試,甚至直接 debug。這篇就是討論出來的做法:讓 Xcode 在手機只能透過 VPN 連回來的時候,一樣能 build、安裝、啟動 App。
前提
- 手機必須連著 Wi-Fi,不能只用行動網路:手機上負責接受遠端佈署的背景服務,只有連上 Wi-Fi 才會啟動
- 手機的 WireGuard 連回家裡的路由器
- 路由器能新增一個區網 IP(LAN IP)和一條位址轉送規則(下面以 MikroTik RouterOS 為例)
- 我的測試環境是 Xcode 27.1、iOS 27(需要
devicectl);不一定要這麼新,但較舊的版本我沒試過
原理
Xcode 是靠聽手機在區網裡廣播的「我在這裡」(Bonjour)來找到它的。這種廣播只在同一個區網內傳遞,不會被路由器或 VPN 轉到別的網路。所以手機在別處的 Wi-Fi 時,它的廣播只留在那個區網,Mac 聽不到,Xcode 就以為它不見了。
問題不在 VPN 擋住連線,連線本身走 VPN 是通的,缺的是「找到手機」這一步。
解法:手機不在家時,Mac 自己宣告「手機在 <橋接IP>」(一個你區網內的 IP),路由器再把送到 <橋接IP> 的連線轉進 VPN。
Xcode ──► <橋接IP>:49152 ──► 路由器(轉送)──► WireGuard ──► 手機
▲ Mac 上的腳本宣告「手機在這裡」
三個零件:路由器的轉送規則、Mac 上的常駐腳本,以及手機在家時記下的配對資訊(腳本自動記)。
步驟 0:先在家配對一次
手機接線或連同一個 Wi-Fi,配對並開啟開發者模式,確認:
xcrun devicectl list devices # 要看到 available (paired)
遠端佈署是借用這個配對,沒配對過就無法橋接。
步驟 1:路由器
選一個區網內沒被分配出去的 IP 當 <橋接IP>,在路由器上執行:
/ip address add address=<橋接IP>/32 interface=bridge comment=iphone-bridge
/ip firewall nat add chain=dstnat dst-address=<橋接IP> action=dst-nat \
to-addresses=<手機的 WireGuard IP> comment=iphone-bridge
- 第一行讓路由器「擁有」這個 IP,Mac 才找得到它。
- 第二行把送到
<橋接IP>的連線,轉給手機在 WireGuard 裡的 IP。這個 IP 看手機上 WireGuard 隧道設定的 Addresses 欄位(例如10.0.0.2,不含/32之類的後綴)。它不是<橋接IP>,也不是手機在家時拿到的區網 IP。
測試:手機連著 Wi-Fi、開著 WireGuard,在 Mac 上執行
nc -z -G 3 <橋接IP> 49152 && echo OK
出現 OK 代表路由器、VPN、手機都通了。不通就先檢查手機的 Wi-Fi 和 WireGuard 有沒有連著。
步驟 2:Mac 上的腳本
建立資料夾 ~/.iphone-bridge/,放兩個檔案。
config.json:
{
"bridge_ip": "<橋接IP>",
"bridge_host": "bridge.local",
"phone_host": "<手機名稱>.local",
"interface": "en0",
"port": 49152,
"interval": 30,
"records": {}
}
phone_host:手機在區網的名稱。手機在家時執行dns-sd -B _remotepairing._tcp local.找到記錄名稱,再用dns-sd -L <記錄名稱> _remotepairing._tcp local.,輸出裡reached at後面、port 前面的就是。interface:Mac 連區網的網卡,Wi-Fi 通常是en0。bridge_host:隨便取。records:留空,手機在家時腳本會自己填。裡面是配對資訊,請chmod 600 config.json,不要放進 git 或同步資料夾。
advertise.py:
- 每 30 秒檢查手機在不在區網。
- 在家:記下手機目前的配對資訊(它的記錄名稱會一直換,所以保留最新 4 筆)。
- 不在家(連續兩次沒看到才算):用記下的資訊,在 Mac 上宣告「手機在
<橋接IP>」。 - 不在家時順便測
<橋接IP>通不通,狀態改變才寫 log。
#!/usr/bin/env python3
"""手機在區網時記下它的配對記錄;不在時,在 Mac 上假裝它還在,指向路由器的橋接 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 # 最多保留、廣播幾筆(手機的記錄名稱會一直換)
def log(*a):
print(time.strftime("%F %T"), *a, flush=True)
def dns_sd(args, secs):
"""dns-sd 不會自己結束:跑幾秒,收輸出。"""
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):
"""手機名稱解得出區網 IP,而且配對 port 真的連得上(快取的記錄不可信)。"""
host = c["phone_host"].rstrip(".").lower() + "."
for line in dns_sd(["-G", "v4", c["phone_host"]], 2).splitlines():
f = line.split() # 時間 Add flags 介面 主機 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):
"""把手機目前的真實記錄併進 config,只留最新 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: # 排除區網裡別的 Apple 裝置
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)) # 讓 finally 撤掉假記錄
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 # 連續兩次沒看到才算不在,避免偶發誤判
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: # 只在狀態改變時記一筆
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()
步驟 3:讓它常駐
~/Library/LaunchAgents/com.example.iphone-bridge.plist(把「你的帳號」換掉):
<?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/你的帳號/.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/你的帳號/.iphone-bridge/advertise.log</string>
<key>StandardErrorPath</key><string>/Users/你的帳號/.iphone-bridge/advertise.log</string>
</dict>
</plist>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.iphone-bridge.plist
launchd 載入後就不會再讀 plist。如果之後改了它,要先卸載再重新載入才會生效:
launchctl bootout gui/$(id -u)/com.example.iphone-bridge
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.iphone-bridge.plist
步驟 4:驗證與佈署
手機先在家連著 Wi-Fi,等 1~2 分鐘,確認
config.json的records被填進東西。手機移到別處,連上 Wi-Fi、開著 WireGuard。約一分鐘後,
advertise.log會出現:phone is away, adverts up -> <橋接IP> bridge IP reachable (phone answers via VPN)確認真的連得到手機:執行下面的指令,它會向手機詢問目前的鎖定狀態,連不到就會報錯;成功會印出
Current device lock state:和passcodeRequired、unlockedSinceBoot等幾行。不要用devicectl list devices當證據,它可能顯示快取的狀態,連不上也看得到 available:UDID=<你的裝置 UDID> xcrun devicectl device info lockState --device $UDID佈署:
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
安裝是覆蓋更新,App 裡的資料會保留。
限制
- 要先在家配對過;這個方法只是讓既有的配對在 VPN 下找得到手機。
- 手機要一直連著 Wi-Fi 和 WireGuard,任何一個斷了就連不上。
- 用 Tailscale 代替 WireGuard 應該也行,我沒試過。