diff --git a/config/nativephp.php b/config/nativephp.php
index ec94fc3a..3ea416cc 100644
--- a/config/nativephp.php
+++ b/config/nativephp.php
@@ -386,6 +386,20 @@
|--------------------------------------------------------------------------
*/
'hot_reload' => [
+ /*
+ * TCP port the in-app hot-reload server listens on.
+ *
+ * The port is host-wide — a simulator shares the host's localhost, and
+ * physical devices are tunnelled to the same host port by iproxy — so
+ * give each app its own port if you want two of them hot-reloading at
+ * the same time. Baked into Info.plist at build time; changing it
+ * needs a rebuild.
+ *
+ * iOS only. Android signals a reload by pushing a file into the app's
+ * storage over adb, so it never binds a port and ignores this value.
+ */
+ 'port' => 9999,
+
'watch_paths' => [
'app',
'resources',
diff --git a/resources/xcode/NativePHP/HotReloadServer.swift b/resources/xcode/NativePHP/HotReloadServer.swift
index be7703c7..dfc9fcf0 100644
--- a/resources/xcode/NativePHP/HotReloadServer.swift
+++ b/resources/xcode/NativePHP/HotReloadServer.swift
@@ -247,7 +247,18 @@ class HotReloadCoordinator {
class HotReloadServer {
private var listener: NWListener?
- private let port: NWEndpoint.Port = 9999
+ /// Port the reload listener binds, injected into Info.plist at build time
+ /// from `nativephp.hot_reload.port`. Falls back to the historic default so
+ /// apps built before the key existed keep working.
+ private let port: NWEndpoint.Port = {
+ guard let raw = Bundle.main.object(forInfoDictionaryKey: "NATIVEPHP_HOT_RELOAD_PORT"),
+ let value = UInt16("\(raw)"),
+ let port = NWEndpoint.Port(rawValue: value) else {
+ return 9999
+ }
+
+ return port
+ }()
private let queue = DispatchQueue(label: "HotReloadServer")
private var retryCount = 0
private let maxRetries = 15
@@ -262,7 +273,7 @@ class HotReloadServer {
do {
let params = NWParameters.tcp
// SO_REUSEADDR: lets us rebind immediately if a just-terminated
- // previous instance left port 9999 in TIME_WAIT.
+ // previous instance left the port in TIME_WAIT.
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: port)
diff --git a/src/Commands/BuildIosAppCommand.php b/src/Commands/BuildIosAppCommand.php
index 18d7efa2..41abb415 100644
--- a/src/Commands/BuildIosAppCommand.php
+++ b/src/Commands/BuildIosAppCommand.php
@@ -8,6 +8,7 @@
use Native\Mobile\Concerns\ChecksLatestBuildNumber;
use Native\Mobile\Concerns\CleansEnvFile;
use Native\Mobile\Concerns\DisplaysMarketingBanners;
+use Native\Mobile\Concerns\HasHotReloadPort;
use Native\Mobile\Concerns\InstallsAppIcon;
use Native\Mobile\Concerns\InstallsSplashScreen;
use Native\Mobile\Concerns\ValidatesAppConfig;
@@ -22,7 +23,7 @@
class BuildIosAppCommand extends Command
{
- use ChecksLatestBuildNumber, CleansEnvFile, DisplaysMarketingBanners, InstallsAppIcon, InstallsSplashScreen, ValidatesAppConfig;
+ use ChecksLatestBuildNumber, CleansEnvFile, DisplaysMarketingBanners, HasHotReloadPort, InstallsAppIcon, InstallsSplashScreen, ValidatesAppConfig;
private bool $verbose;
@@ -515,6 +516,16 @@ private function updateInfoPlistFile(string $filePath, string $appId, ?string $d
// Handle UIUserInterfaceStyle
$this->updateInterfaceStyle($dom, $rootDict, $plistData);
+ // Handle NATIVEPHP_HOT_RELOAD_PORT — the in-app hot-reload server
+ // reads its listen port from here, so it has to be baked in at
+ // build time rather than read from the synced Laravel config.
+ $hotReloadPort = (string) $this->hotReloadPort();
+ if (isset($plistData['NATIVEPHP_HOT_RELOAD_PORT'])) {
+ $plistData['NATIVEPHP_HOT_RELOAD_PORT']['valueNode']->nodeValue = $hotReloadPort;
+ } else {
+ $this->addPlistKeyValue($dom, $rootDict, 'NATIVEPHP_HOT_RELOAD_PORT', 'string', $hotReloadPort);
+ }
+
// Handle BIFROST_APP_ID
$bifrostAppId = env('BIFROST_APP_ID');
if ($bifrostAppId) {
diff --git a/src/Concerns/HasHotReloadPort.php b/src/Concerns/HasHotReloadPort.php
new file mode 100644
index 00000000..ae750c0e
--- /dev/null
+++ b/src/Concerns/HasHotReloadPort.php
@@ -0,0 +1,28 @@
+run('xcrun simctl terminate '.$target.' '.config('nativephp.app_id'));
Process::path($basePath)
- ->run('lsof -ti tcp:9999 | xargs kill -9 2>/dev/null');
+ ->run('lsof -ti tcp:'.$this->hotReloadPort().' | xargs kill -9 2>/dev/null');
$this->fixProductBundleName($basePath, 'build/Build/Products/Debug-iphonesimulator/NativePHP-simulator.app');
diff --git a/src/Concerns/WatchesIos.php b/src/Concerns/WatchesIos.php
index 448e5148..52da5ee3 100644
--- a/src/Concerns/WatchesIos.php
+++ b/src/Concerns/WatchesIos.php
@@ -9,7 +9,7 @@
trait WatchesIos
{
- use InteractsWithWatchTerminal, ManagesWatchman;
+ use HasHotReloadPort, InteractsWithWatchTerminal, ManagesWatchman;
/**
* UDID of the simulator or device being watched.
@@ -136,7 +136,7 @@ function (array $changedFiles) use ($basePath, $viteHotFile) {
private function startIosWatchingDevice(string $target, string $appId): void
{
- // Start iproxy to forward port 9999 from the device to localhost over USB
+ // Start iproxy to forward the hot-reload port from the device to localhost over USB
// This allows triggerIosReload() to reach the device's HotReloadServer
if ($this->startIproxyForwarding($target)) {
$this->info('USB port forwarding active - reload triggers will reach the device');
@@ -308,7 +308,9 @@ private function triggerIosReload(): void
// Connect to the hot reload server to trigger a reload
// For simulators this reaches the server directly (shared network)
// For physical devices, iproxy forwards this to the device over USB
- $socket = @fsockopen('127.0.0.1', 9999, $errno, $errstr, 1);
+ $port = $this->hotReloadPort();
+
+ $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 1);
if ($socket) {
// Hold the connection open long enough for iproxy to forward
@@ -318,7 +320,7 @@ private function triggerIosReload(): void
} else {
// Transient rather than a scrollback line: the app being down is a
// state, not an event, so repeating it once per save is just noise.
- $this->watchActivity("reload failed — nothing listening on port 9999 ({$errstr})", 'yellow');
+ $this->watchActivity("reload failed — nothing listening on port {$port} ({$errstr})", 'yellow');
}
}
@@ -330,15 +332,16 @@ private function startIproxyForwarding(string $target): bool
return false;
}
- // Kill any existing processes on port 9999
- Process::run('lsof -ti:9999 | xargs kill 2>/dev/null');
+ // Kill any existing processes on the hot-reload port
+ $port = $this->hotReloadPort();
+ Process::run("lsof -ti:{$port} | xargs kill 2>/dev/null");
usleep(500000);
// Start iproxy in background for USB port forwarding
// v2 syntax: iproxy -u UDID LOCAL_PORT:DEVICE_PORT
$escapedTarget = escapeshellarg($target);
$logFile = base_path('nativephp/iproxy.log');
- exec("{$iproxyPath} -u {$escapedTarget} 9999:9999 > {$logFile} 2>&1 & echo \$!", $output);
+ exec("{$iproxyPath} -u {$escapedTarget} {$port}:{$port} > {$logFile} 2>&1 & echo \$!", $output);
$pid = (int) ($output[0] ?? 0);
if ($pid <= 0) {
@@ -353,7 +356,7 @@ private function startIproxyForwarding(string $target): bool
// register_shutdown_function does NOT run when the watcher is stopped
// with Ctrl-C (SIGINT) — the usual way — so iproxy would be orphaned
- // and keep holding port 9999, breaking the next run's hot reload.
+ // and keep holding the hot-reload port, breaking the next run.
// Install signal handlers that tear it down before exiting.
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
@@ -497,8 +500,8 @@ private function getIosExcludePatterns(): array
private function killHotReloadServers(): void
{
- // Find processes listening on port 9999
- $result = Process::run(['lsof', '-ti:9999']);
+ // Find processes listening on the hot-reload port
+ $result = Process::run(['lsof', '-ti:'.$this->hotReloadPort()]);
if ($result->successful()) {
$pids = array_filter(explode("\n", trim($result->output())));
diff --git a/tests/Feature/IosHotReloadPortTest.php b/tests/Feature/IosHotReloadPortTest.php
new file mode 100644
index 00000000..454399a5
--- /dev/null
+++ b/tests/Feature/IosHotReloadPortTest.php
@@ -0,0 +1,145 @@
+plistPath = sys_get_temp_dir().'/nativephp_hot_reload_port_test_'.uniqid().'.plist';
+ }
+
+ protected function tearDown(): void
+ {
+ File::delete($this->plistPath);
+
+ parent::tearDown();
+ }
+
+ public function test_the_configured_port_is_written_to_the_plist(): void
+ {
+ config(['nativephp.hot_reload.port' => 9998]);
+
+ $this->updatePlist($this->writePlist());
+
+ $this->assertStringContainsString('NATIVEPHP_HOT_RELOAD_PORT', $this->plist());
+ $this->assertStringContainsString('9998', $this->plist());
+ }
+
+ /** An app whose published config predates the key still has to build. */
+ public function test_an_unset_port_falls_back_to_the_default(): void
+ {
+ config(['nativephp.hot_reload.port' => null]);
+
+ $this->updatePlist($this->writePlist());
+
+ $this->assertStringContainsString('9999', $this->plist());
+ }
+
+ /** Published-but-empty would otherwise cast to port 0. */
+ public function test_an_empty_port_falls_back_to_the_default(): void
+ {
+ config(['nativephp.hot_reload.port' => '']);
+
+ $this->updatePlist($this->writePlist());
+
+ $this->assertStringContainsString('9999', $this->plist());
+ }
+
+ public function test_a_zero_port_falls_back_to_the_default(): void
+ {
+ config(['nativephp.hot_reload.port' => 0]);
+
+ $this->updatePlist($this->writePlist());
+
+ $this->assertStringContainsString('9999', $this->plist());
+ }
+
+ public function test_a_string_port_is_written_as_an_integer(): void
+ {
+ config(['nativephp.hot_reload.port' => '9998']);
+
+ $this->updatePlist($this->writePlist());
+
+ $this->assertStringContainsString('9998', $this->plist());
+ }
+
+ /**
+ * The plist is updated in place rather than regenerated, so changing the
+ * port between builds must overwrite the key — a duplicate would leave the
+ * app reading whichever copy Bundle.main returns first.
+ */
+ public function test_an_existing_key_is_updated_rather_than_duplicated(): void
+ {
+ config(['nativephp.hot_reload.port' => 9998]);
+
+ $this->updatePlist($this->writePlist(
+ "\tNATIVEPHP_HOT_RELOAD_PORT\n\t9999\n"
+ ));
+
+ $this->assertSame(1, substr_count($this->plist(), 'NATIVEPHP_HOT_RELOAD_PORT'));
+ $this->assertStringContainsString('9998', $this->plist());
+ $this->assertStringNotContainsString('9999', $this->plist());
+ }
+
+ /** Write a minimal Info.plist, optionally with extra keys already in it. */
+ protected function writePlist(string $extraKeys = ''): string
+ {
+ File::put($this->plistPath, <<
+
+
+
+ \tCFBundleURLTypes
+ \t
+ \t\t
+ \t\t\tCFBundleTypeRole
+ \t\t\tViewer
+ \t\t\tCFBundleURLName
+ \t\t\tcom.nativephp.app
+ \t\t\tCFBundleURLSchemes
+ \t\t\t
+ \t\t\t\tnativephp
+ \t\t\t
+ \t\t
+ \t
+ {$extraKeys}
+
+ PLIST);
+
+ return $this->plistPath;
+ }
+
+ protected function updatePlist(string $path): void
+ {
+ $command = new BuildIosAppCommand;
+
+ (new ReflectionClass($command))
+ ->getMethod('updateInfoPlistFile')
+ ->invoke($command, $path, 'com.nativephp.test', 'nativephp');
+ }
+
+ protected function plist(): string
+ {
+ return File::get($this->plistPath);
+ }
+}