Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/nativephp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 13 additions & 2 deletions resources/xcode/NativePHP/HotReloadServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion src/Commands/BuildIosAppCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions src/Concerns/HasHotReloadPort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Native\Mobile\Concerns;

trait HasHotReloadPort
{
/**
* TCP port the in-app hot-reload server listens on.
*
* On a simulator this is bound on the host itself (the simulator shares
* the host's localhost), and physical devices are tunnelled to the same
* host port by iproxy. Either way the port is host-wide, so two apps that
* want hot reload at the same time must be configured with different
* ports.
*
* The value is baked into the app's Info.plist at build time, so changing
* it requires a rebuild before the app will listen on the new port.
*
* iOS only — Android signals a reload by pushing a file into the app's
* storage over adb, so it never binds a port.
*/
protected function hotReloadPort(): int
{
// config() only falls back when the key is absent, so an app that has
// published the key but left it empty would otherwise yield port 0.
return (int) (config('nativephp.hot_reload.port') ?: 9999);
}
}
6 changes: 3 additions & 3 deletions src/Concerns/RunsIos.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

trait RunsIos
{
use ValidatesAppConfig;
use HasHotReloadPort, ValidatesAppConfig;

protected string $iosLogPath = 'nativephp/ios-build.log';

Expand Down Expand Up @@ -255,7 +255,7 @@ private function runOnSimulator(string $basePath, string $target, bool $verbose
shell_exec('open -a Simulator');

// Free the hot-reload port before (re)launching. Two stale holders can
// block the fresh app from binding 9999, which silently breaks hot
// block the fresh app from binding it, which silently breaks hot
// reload (triggers land on the wrong listener):
// 1. A previous app instance still running in the simulator.
// 2. A leftover host `iproxy` from an earlier device run — the
Expand All @@ -264,7 +264,7 @@ private function runOnSimulator(string $basePath, string $target, bool $verbose
Process::path($basePath)
->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');

Expand Down
23 changes: 13 additions & 10 deletions src/Concerns/WatchesIos.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

trait WatchesIos
{
use InteractsWithWatchTerminal, ManagesWatchman;
use HasHotReloadPort, InteractsWithWatchTerminal, ManagesWatchman;

/**
* UDID of the simulator or device being watched.
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand All @@ -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');
}
}

Expand All @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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())));
Expand Down
145 changes: 145 additions & 0 deletions tests/Feature/IosHotReloadPortTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\File;
use Native\Mobile\Commands\BuildIosAppCommand;
use ReflectionClass;
use Tests\TestCase;

/**
* The in-app hot-reload server binds a host-wide port — a simulator shares the
* host's localhost, and physical devices are tunnelled to the same host port by
* iproxy — so two apps that both want hot reload need different ports.
*
* Swift can't read the Laravel config, so `nativephp.hot_reload.port` is baked
* into Info.plist at build time and read back from the bundle at launch. If the
* key stops being written the app silently falls back to 9999 and reload
* triggers land on whichever app got there first.
*/
class IosHotReloadPortTest extends TestCase
{
protected string $plistPath;

protected function setUp(): void
{
parent::setUp();

$this->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('<key>NATIVEPHP_HOT_RELOAD_PORT</key>', $this->plist());
$this->assertStringContainsString('<string>9998</string>', $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('<string>9999</string>', $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('<string>9999</string>', $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('<string>9999</string>', $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('<string>9998</string>', $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(
"\t<key>NATIVEPHP_HOT_RELOAD_PORT</key>\n\t<string>9999</string>\n"
));

$this->assertSame(1, substr_count($this->plist(), 'NATIVEPHP_HOT_RELOAD_PORT'));
$this->assertStringContainsString('<string>9998</string>', $this->plist());
$this->assertStringNotContainsString('<string>9999</string>', $this->plist());
}

/** Write a minimal Info.plist, optionally with extra keys already in it. */
protected function writePlist(string $extraKeys = ''): string
{
File::put($this->plistPath, <<<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>
\t<key>CFBundleURLTypes</key>
\t<array>
\t\t<dict>
\t\t\t<key>CFBundleTypeRole</key>
\t\t\t<string>Viewer</string>
\t\t\t<key>CFBundleURLName</key>
\t\t\t<string>com.nativephp.app</string>
\t\t\t<key>CFBundleURLSchemes</key>
\t\t\t<array>
\t\t\t\t<string>nativephp</string>
\t\t\t</array>
\t\t</dict>
\t</array>
{$extraKeys}</dict>
</plist>
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);
}
}
Loading