AI の時代になり、外出先から AI に家のパソコンを操作させてコードを書くのが当たり前になりました。Web 開発なら簡単で、パソコンを Tailscale や WireGuard につないでおけば、外からでもそのままページを開いてデバッグできます。しかしアプリはそうはいきません。App Store の審査には数日かかり、TestFlight でもスマホにインストールしてテストできるまで 30 分ほどかかります。AI がどれだけ速くコードを書いても、ここで止まってしまいます。
そこで AI と相談して、外出先で性能の低いノートパソコン、あるいはスマホしか手元になくても、家の Mac を遠隔操作して開発し、修正したらすぐスマホにデプロイしてテスト、さらには直接デバッグまでできる方法を考えました。この記事はその結果です。スマホが VPN 経由でしか家に戻れない状態でも、Xcode でビルド、インストール、アプリの起動ができるようにします。
前提
- スマホは Wi-Fi に接続している必要があります。モバイル通信だけでは不可です。リモートデプロイを受け付けるスマホ側のバックグラウンドサービスは、Wi-Fi 接続時にしか起動しません
- スマホの WireGuard が家のルーターに接続している
- ルーターで LAN の IP アドレスとアドレス転送ルールを 1 つ追加できる(以下は MikroTik RouterOS の例)
- 私のテスト環境は Xcode 27.1、iOS 27 です(
devicectlが必要)。ここまで新しくなくてもよいかもしれませんが、古いバージョンは試していません
しくみ
Xcode は、スマホが LAN 内に流す「ここにいるよ」という告知(Bonjour)を聞いてスマホを見つけます。この告知は同じ LAN の中でしか届かず、ルーターや VPN がほかのネットワークに転送することはありません。そのため、スマホが別の場所の Wi-Fi にいると、告知はそのネットワークに留まり、Mac には聞こえず、Xcode はスマホが消えたと判断します。
問題は VPN が接続を遮断していることではありません。接続自体は VPN 経由で通ります。足りないのは「スマホを見つける」ステップです。
解決策:スマホが家にいないときは、Mac 自身が「スマホは <ブリッジIP> にいる」(LAN 内の IP)と告知し、ルーターが <ブリッジIP> への接続を VPN に転送します。
Xcode ──► <ブリッジIP>:49152 ──► ルーター(転送)──► WireGuard ──► スマホ
▲ Mac 上のスクリプトが「スマホはここ」と告知
部品は 3 つです。ルーターの転送ルール、Mac 上で常駐するスクリプト、そしてスマホが家にいるときに記録したペアリング情報(スクリプトが自動で記録)です。
ステップ 0:まず家で一度ペアリングする
スマホをケーブルでつなぐか同じ Wi-Fi に接続し、ペアリングして開発者モードを有効にしたら、次を確認します。
xcrun devicectl list devices # available (paired) と表示されること
リモートデプロイはこのペアリングを借りて行います。ペアリングしていないとブリッジできません。
ステップ 1:ルーター
LAN 内でまだ割り当てられていない 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
- 1 行目でルーターがこの IP を「持つ」ようにし、Mac から見つけられるようにします。
- 2 行目で
<ブリッジIP>宛ての接続を、WireGuard 内のスマホの IP に転送します。この IP は、スマホの WireGuard トンネル設定の Addresses 欄で確認できます(例:10.0.0.2。/32などの接尾辞は含めません)。<ブリッジIP>でも、スマホが家で取得する LAN の IP でもありません。
テスト:スマホを Wi-Fi に接続し WireGuard をオンにした状態で、Mac で次を実行します。
nc -z -G 3 <ブリッジIP> 49152 && echo OK
OK と表示されれば、ルーター、VPN、スマホがすべてつながっています。つながらない場合は、まずスマホの Wi-Fi と WireGuard が接続されているか確認してください。
ステップ 2:Mac 上のスクリプト
フォルダ ~/.iphone-bridge/ を作り、2 つのファイルを置きます。
config.json:
{
"bridge_ip": "<ブリッジIP>",
"bridge_host": "bridge.local",
"phone_host": "<スマホの名前>.local",
"interface": "en0",
"port": 49152,
"interval": 30,
"records": {}
}
phone_host:LAN 上でのスマホの名前。スマホが家にいるときにdns-sd -B _remotepairing._tcp local.でレコード名を見つけ、dns-sd -L <レコード名> _remotepairing._tcp local.を実行します。出力のreached atの後ろ、ポートの前の部分がそれです。interface:Mac が LAN に接続しているネットワークインターフェース。Wi-Fi なら通常en0です。bridge_host:好きな名前で構いません。records:空のままにします。スマホが家にいるときにスクリプトが自動で埋めます。中身はペアリング情報なので、chmod 600 config.jsonを実行し、git や同期フォルダには入れないでください。
advertise.py:
- 30 秒ごとにスマホが LAN にいるかを確認します。
- 家にいるとき:スマホの現在のペアリング情報を記録します(レコード名は変わり続けるので、最新の 4 件を残します)。
- 家にいないとき(2 回連続で見えなかった場合のみ):記録した情報を使い、Mac 上で「スマホは
<ブリッジIP>にいる」と告知します。 - 家にいない間は
<ブリッジIP>に到達できるかも確認し、状態が変わったときだけログを書きます。
#!/usr/bin/env python3
"""スマホが LAN にいるときはペアリングレコードを記録し、いないときは 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):
"""スマホの名前が LAN の IP に解決でき、ペアリングのポートに実際に接続できる(キャッシュされたレコードは信用できない)。"""
host = c["phone_host"].rstrip(".").lower() + "."
for line in dns_sd(["-G", "v4", c["phone_host"]], 2).splitlines():
f = line.split() # 時刻 Add フラグ インターフェース ホスト 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: # LAN 内のほかの 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 # 2 回連続で見えなかったときだけ不在と判断し、誤判定を避ける
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 をオンにします。1 分ほどで
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
インストールは上書き更新なので、アプリ内のデータは保持されます。
制限
- 先に家でペアリングしておく必要があります。この方法は、既存のペアリングで VPN 越しにスマホを見つけられるようにするだけです。
- スマホは Wi-Fi と WireGuard の両方に接続し続ける必要があり、どちらかが切れると接続できません。
- WireGuard の代わりに Tailscale を使っても動くはずですが、試していません。