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); + } +}