diff --git a/resources/jump/router.php b/resources/jump/router.php
index 77b59fb..6ab38ec 100644
--- a/resources/jump/router.php
+++ b/resources/jump/router.php
@@ -9,11 +9,12 @@
* to handle Jump server requests without requiring Workerman.
*
* Configuration is passed via environment variables:
- * - JUMP_ZIP_PATH: Path to the app.zip bundle
* - JUMP_DISPLAY_HOST: The host IP to display in QR code
* - JUMP_HTTP_PORT: The HTTP port the server is running on
* - JUMP_LARAVEL_PORT: The Laravel dev server port to proxy to
* - JUMP_BASE_PATH: The Laravel base path for autoloading
+ * - JUMP_WS_PORT: The WebSocket bridge port
+ * - JUMP_VITE_PORT: The Vite dev server port
*/
// Suppress all error output to prevent corrupting binary responses
@@ -21,12 +22,10 @@
ini_set('display_errors', '0');
// Get configuration from environment
-$zipPath = getenv('JUMP_ZIP_PATH');
$displayHost = getenv('JUMP_DISPLAY_HOST');
$httpPort = getenv('JUMP_HTTP_PORT');
$laravelPort = getenv('JUMP_LARAVEL_PORT') ?: 8000;
$basePath = getenv('JUMP_BASE_PATH');
-$offlineMode = getenv('JUMP_OFFLINE_MODE') === '1';
// Parse request FIRST - before loading any autoloaders
$uri = $_SERVER['REQUEST_URI'];
@@ -81,44 +80,49 @@ function jumpLog($message)
exit;
}
-// Handle ZIP download endpoint
-if ($path === '/jump/download') {
- if (! $zipPath || ! file_exists($zipPath)) {
- http_response_code(500);
- echo 'App bundle not available. Please restart the server.';
- exit;
- }
-
- $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
- $deviceName = parseDeviceName($userAgent);
- $fileSizeBytes = filesize($zipPath);
- $fileSize = formatBytes($fileSizeBytes);
- jumpLog("{$deviceName} downloading ({$fileSize})");
-
- // Clean ALL output buffers
- while (ob_get_level()) {
- ob_end_clean();
- }
-
- // Minimal headers - match what Workerman's withFile() sends
- header('Content-Type: application/zip');
- header('Content-Length: '.$fileSizeBytes);
- header('Content-Disposition: attachment; filename="app.zip"');
-
- // Use readfile - simplest approach
- readfile($zipPath);
+// Handle deep link redirect — camera scans HTTP URL, browser redirects to jump:// app
+if ($path === '/jump/open') {
+ $deepLink = "jump://connect?host={$displayHost}&port={$httpPort}";
+ $appName = getenv('APP_NAME') ?: 'Laravel';
+ header('Content-Type: text/html; charset=UTF-8');
+ echo <<
+
+
+
+Opening {$appName} in Jump...
+
+
+
+
+HTML;
exit;
}
// Handle info endpoint
if ($path === '/jump/info') {
header('Content-Type: application/json');
- echo json_encode([
+
+ // Get WebSocket port from environment (set by JumpCommand)
+ $wsPort = getenv('JUMP_WS_PORT') ?: null;
+
+ $info = [
'name' => 'NativePHP Server',
'app_name' => getenv('APP_NAME') ?: 'Laravel',
'version' => '1.0.0',
'type' => 'nativephp-server',
- ]);
+ ];
+
+ // Include WebSocket port for hybrid mode
+ if ($wsPort) {
+ $info['ws_port'] = (string) $wsPort;
+ }
+
+ echo json_encode($info);
exit;
}
@@ -137,11 +141,9 @@ function jumpLog($message)
$appName = getenv('APP_NAME') ?: 'Laravel';
- // Create JSON data for the QR code
- $qrData = json_encode([
- 'host' => $displayHost,
- 'port' => (string) $httpPort,
- ]);
+ // Create deep link URL for the QR code
+ // jump:// scheme opens the Jump app directly when scanned with the camera
+ $qrData = "jump://connect?host={$displayHost}&port={$httpPort}";
// Generate QR code
$result = (new Builder(
@@ -152,10 +154,8 @@ function jumpLog($message)
$qrCodeDataUri = $result->getDataUri();
- // Generate HTML - use offline version if flag is set
- global $offlineMode;
- $viewFile = $offlineMode ? 'qr-offline.blade.php' : 'qr.blade.php';
- $viewPath = __DIR__.'/views/'.$viewFile;
+ // Generate HTML
+ $viewPath = __DIR__.'/views/qr.blade.php';
if (file_exists($viewPath)) {
// Read blade file and do simple variable substitution
@@ -179,6 +179,33 @@ function jumpLog($message)
}
}
+// Proxy Vite dev server requests (HMR, assets, websocket)
+// Vite paths: /@vite/, /@fs/, /@id/, /resources/ (dev assets), hot-update files
+$hotFilePath = $basePath.'/public/hot';
+$viteRunning = file_exists($hotFilePath);
+// Read the actual Vite dev server URL from the hot file (e.g. "http://[::1]:5174")
+$vitePort = 5173;
+if ($viteRunning) {
+ $hotContent = trim(file_get_contents($hotFilePath));
+ if (preg_match('/:(\d+)\/?$/', $hotContent, $m)) {
+ $vitePort = (int) $m[1];
+ }
+}
+
+if ($viteRunning) {
+ $isViteRequest = str_starts_with($path, '/@vite')
+ || str_starts_with($path, '/@fs')
+ || str_starts_with($path, '/@id')
+ || str_starts_with($path, '/resources/')
+ || str_starts_with($path, '/node_modules/')
+ || str_contains($path, '.hot-update.');
+
+ if ($isViteRequest) {
+ proxyToVite($vitePort);
+ exit;
+ }
+}
+
// Proxy all other requests to Laravel
proxyToLaravel($laravelPort);
@@ -883,6 +910,75 @@ function generateQrHtml($appName, $qrCodeDataUri, $displayHost, $port)
HTML;
}
+/**
+ * Proxy request to Vite dev server
+ */
+function proxyToVite($vitePort)
+{
+ $method = $_SERVER['REQUEST_METHOD'];
+ $uri = $_SERVER['REQUEST_URI'];
+
+ // Try the hot file origin first (may be IPv6 [::1]), fall back to 127.0.0.1
+ global $hotFilePath;
+ $viteHost = "http://127.0.0.1:{$vitePort}";
+ if (isset($hotFilePath) && file_exists($hotFilePath)) {
+ $viteHost = rtrim(trim(file_get_contents($hotFilePath)), '/');
+ }
+ $viteUrl = "{$viteHost}{$uri}";
+
+ $headers = [];
+ foreach ($_SERVER as $key => $value) {
+ if (strpos($key, 'HTTP_') === 0) {
+ $headerName = str_replace('_', '-', substr($key, 5));
+ if (in_array(strtolower($headerName), ['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'host'])) {
+ continue;
+ }
+ $headers[] = "{$headerName}: {$value}";
+ }
+ }
+ if (isset($_SERVER['CONTENT_TYPE'])) {
+ $headers[] = 'Content-Type: '.$_SERVER['CONTENT_TYPE'];
+ }
+
+ $ch = curl_init($viteUrl);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_HEADER, true);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 10);
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+ curl_close($ch);
+
+ if ($response === false) {
+ http_response_code(502);
+ echo 'Vite dev server not reachable on port '.$vitePort;
+
+ return;
+ }
+
+ $responseHeaders = substr($response, 0, $headerSize);
+ $responseBody = substr($response, $headerSize);
+
+ http_response_code($httpCode);
+
+ $headerLines = explode("\r\n", $responseHeaders);
+ foreach ($headerLines as $headerLine) {
+ if (empty($headerLine) || strpos($headerLine, 'HTTP/') === 0) {
+ continue;
+ }
+ if (stripos($headerLine, 'transfer-encoding:') === 0) {
+ continue;
+ }
+ header($headerLine);
+ }
+
+ echo $responseBody;
+}
+
/**
* Proxy request to Laravel development server
*/
@@ -893,6 +989,7 @@ function proxyToLaravel($laravelPort)
$laravelUrl = "http://127.0.0.1:{$laravelPort}{$uri}";
// Build headers to forward
+ global $displayHost, $httpPort;
$headers = [];
foreach ($_SERVER as $key => $value) {
if (strpos($key, 'HTTP_') === 0) {
@@ -908,6 +1005,12 @@ function proxyToLaravel($laravelPort)
$headers[] = 'Content-Type: '.$_SERVER['CONTENT_TYPE'];
}
+ // Tell Laravel the real host so it generates correct URLs (redirects, assets, etc.)
+ $headers[] = "Host: {$displayHost}:{$httpPort}";
+ $headers[] = "X-Forwarded-Host: {$displayHost}:{$httpPort}";
+ $headers[] = 'X-Forwarded-Proto: http';
+ $headers[] = 'X-Forwarded-Port: '.$httpPort;
+
// Get request body for POST/PUT/PATCH
$body = null;
if (in_array($method, ['POST', 'PUT', 'PATCH'])) {
@@ -948,7 +1051,10 @@ function proxyToLaravel($laravelPort)
// Set response code
http_response_code($httpCode);
- // Forward response headers (skip some)
+ $laravelOrigin = "http://127.0.0.1:{$laravelPort}";
+ $jumpOrigin = "http://{$displayHost}:{$httpPort}";
+
+ // Forward response headers, rewriting any redirect URLs
$headerLines = explode("\r\n", $responseHeaders);
foreach ($headerLines as $headerLine) {
if (empty($headerLine) || strpos($headerLine, 'HTTP/') === 0) {
@@ -958,9 +1064,30 @@ function proxyToLaravel($laravelPort)
if (stripos($headerLine, 'transfer-encoding:') === 0) {
continue;
}
+ // Rewrite Location headers that still point to Laravel's internal address
+ if (stripos($headerLine, 'location:') === 0) {
+ $headerLine = str_replace($laravelOrigin, $jumpOrigin, $headerLine);
+ }
header($headerLine);
}
- // Output response body
+ // Rewrite Vite dev server URLs so the phone routes them through the Jump proxy.
+ // Vite can generate URLs with localhost, 127.0.0.1, [::], or the network IP.
+ global $vitePort;
+ if ($vitePort) {
+ $viteOrigins = [
+ "http://localhost:{$vitePort}",
+ "http://127.0.0.1:{$vitePort}",
+ "http://[::1]:{$vitePort}",
+ "http://[::]:{$vitePort}",
+ "http://{$displayHost}:{$vitePort}",
+ ];
+ $responseBody = str_replace(
+ $viteOrigins,
+ "http://{$displayHost}:{$httpPort}",
+ $responseBody
+ );
+ }
+
echo $responseBody;
}
diff --git a/resources/jump/websocket-server.php b/resources/jump/websocket-server.php
new file mode 100644
index 0000000..c090bcc
--- /dev/null
+++ b/resources/jump/websocket-server.php
@@ -0,0 +1,239 @@
+ [ws_port] [bridge_port] start [-d]
+ */
+
+// Parse arguments
+$args = array_slice($argv, 1);
+$positional = [];
+foreach ($args as $arg) {
+ if ($arg === 'start' || $arg === 'stop' || $arg === 'restart' || $arg === '-d' || $arg === '-g') {
+ continue;
+ }
+ $positional[] = $arg;
+}
+
+$basePath = $positional[0] ?? getenv('JUMP_BASE_PATH');
+$wsPort = $positional[1] ?? getenv('JUMP_WS_PORT') ?: '3001';
+$bridgePort = $positional[2] ?? getenv('JUMP_BRIDGE_PORT') ?: '3002';
+
+if (! $basePath || ! file_exists($basePath.'/vendor/autoload.php')) {
+ fwrite(STDERR, "[Jump] Error: base_path not provided or vendor/autoload.php not found\n");
+ exit(1);
+}
+
+require_once $basePath.'/vendor/autoload.php';
+
+use Workerman\Connection\TcpConnection;
+use Workerman\Worker;
+
+// Make basePath available globally for file watcher
+$GLOBALS['basePath'] = $basePath;
+
+// Single WebSocket worker — the TCP server is created inside onWorkerStart
+// so both run in the SAME process and can share $deviceConnections
+$wsWorker = new Worker("websocket://0.0.0.0:{$wsPort}");
+$wsWorker->count = 1;
+$wsWorker->name = 'JumpBridge';
+
+// Shared state (same process)
+$deviceConnections = [];
+$pendingCalls = [];
+
+$wsWorker->onConnect = function (TcpConnection $connection) use (&$deviceConnections) {
+ $deviceConnections[$connection->id] = $connection;
+ jumpLog('Device connected via WebSocket (total: '.count($deviceConnections).')');
+};
+
+$wsWorker->onMessage = function (TcpConnection $connection, $data) use (&$pendingCalls) {
+ $message = json_decode($data, true);
+ if (! $message || ! isset($message['type'])) {
+ return;
+ }
+
+ switch ($message['type']) {
+ case 'bridge_response':
+ $requestId = $message['id'] ?? null;
+ if ($requestId && isset($pendingCalls[$requestId])) {
+ $tcpConnection = $pendingCalls[$requestId];
+ unset($pendingCalls[$requestId]);
+
+ $response = json_encode([
+ 'id' => $requestId,
+ 'result' => $message['result'] ?? [],
+ 'error' => $message['error'] ?? null,
+ ]);
+
+ $packed = pack('N', strlen($response)).$response;
+ $tcpConnection->send($packed);
+ }
+ break;
+
+ case 'native_event':
+ jumpLog("Native event: {$message['event']}");
+ break;
+
+ case 'pong':
+ break;
+ }
+};
+
+$wsWorker->onClose = function (TcpConnection $connection) use (&$deviceConnections, &$pendingCalls) {
+ unset($deviceConnections[$connection->id]);
+ jumpLog('Device disconnected (remaining: '.count($deviceConnections).')');
+
+ if (empty($deviceConnections)) {
+ foreach ($pendingCalls as $requestId => $tcpConnection) {
+ $error = json_encode([
+ 'id' => $requestId,
+ 'error' => 'Device disconnected',
+ ]);
+ $packed = pack('N', strlen($error)).$error;
+ $tcpConnection->send($packed);
+ }
+ $pendingCalls = [];
+ }
+};
+
+// Create the TCP server inside onWorkerStart so it runs in the SAME process
+$wsWorker->onWorkerStart = function () use (&$deviceConnections, &$pendingCalls, $bridgePort) {
+ $tcpBuffers = [];
+
+ // Internal TCP server for PHP bridge calls
+ $tcpServer = new Worker("tcp://127.0.0.1:{$bridgePort}");
+
+ $tcpServer->onConnect = function (TcpConnection $connection) use (&$tcpBuffers) {
+ $tcpBuffers[$connection->id] = '';
+ };
+
+ $tcpServer->onMessage = function (TcpConnection $connection, $data) use (&$deviceConnections, &$pendingCalls, &$tcpBuffers) {
+ $tcpBuffers[$connection->id] = ($tcpBuffers[$connection->id] ?? '').$data;
+ $buffer = &$tcpBuffers[$connection->id];
+
+ while (strlen($buffer) >= 4) {
+ $unpacked = unpack('N', substr($buffer, 0, 4));
+ $messageLength = $unpacked[1];
+
+ if (strlen($buffer) < 4 + $messageLength) {
+ break;
+ }
+
+ $messageData = substr($buffer, 4, $messageLength);
+ $buffer = substr($buffer, 4 + $messageLength);
+
+ $message = json_decode($messageData, true);
+ if (! $message || ! isset($message['type'])) {
+ continue;
+ }
+
+ if ($message['type'] === 'bridge_call') {
+ if (empty($deviceConnections)) {
+ $error = json_encode([
+ 'id' => $message['id'] ?? 'unknown',
+ 'error' => 'No device connected',
+ ]);
+ $packed = pack('N', strlen($error)).$error;
+ $connection->send($packed);
+
+ continue;
+ }
+
+ $pendingCalls[$message['id']] = $connection;
+ $encoded = json_encode($message);
+ foreach ($deviceConnections as $deviceConnection) {
+ $deviceConnection->send($encoded);
+ }
+ }
+ }
+ };
+
+ $tcpServer->onClose = function (TcpConnection $connection) use (&$pendingCalls, &$tcpBuffers) {
+ unset($tcpBuffers[$connection->id]);
+ foreach ($pendingCalls as $requestId => $pendingConnection) {
+ if ($pendingConnection === $connection) {
+ unset($pendingCalls[$requestId]);
+ }
+ }
+ };
+
+ $tcpServer->listen();
+ jumpLog("TCP bridge listening on 127.0.0.1:{$bridgePort}");
+
+ // Keepalive ping
+ \Workerman\Timer::add(15, function () use (&$deviceConnections) {
+ $ping = json_encode(['type' => 'ping']);
+ foreach ($deviceConnections as $connection) {
+ $connection->send($ping);
+ }
+ });
+
+ // File watcher for live reload
+ $lastModTimes = [];
+ $watchPaths = ['app', 'resources', 'routes', 'config'];
+ $watchExtensions = ['php', 'blade.php', 'js', 'css', 'ts', 'vue'];
+
+ \Workerman\Timer::add(1, function () use (&$deviceConnections, &$lastModTimes, $watchPaths, $watchExtensions) {
+ global $basePath;
+ if (empty($deviceConnections)) {
+ return;
+ }
+
+ $changed = false;
+ foreach ($watchPaths as $dir) {
+ $fullPath = $basePath.'/'.$dir;
+ if (! is_dir($fullPath)) {
+ continue;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($fullPath, \RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ $ext = $file->getExtension();
+ $path = $file->getPathname();
+
+ // Check blade.php specifically
+ $isWatched = in_array($ext, $watchExtensions) || str_ends_with($path, '.blade.php');
+ if (! $isWatched) {
+ continue;
+ }
+
+ $mtime = $file->getMTime();
+ if (isset($lastModTimes[$path]) && $lastModTimes[$path] < $mtime) {
+ $changed = true;
+ $relativePath = str_replace($basePath.'/', '', $path);
+ jumpLog("Changed: {$relativePath}");
+ }
+ $lastModTimes[$path] = $mtime;
+ }
+ }
+
+ if ($changed) {
+ $reload = json_encode(['type' => 'reload']);
+ foreach ($deviceConnections as $connection) {
+ $connection->send($reload);
+ }
+ jumpLog('Sent reload to '.count($deviceConnections).' device(s)');
+ }
+ });
+};
+
+function jumpLog($message)
+{
+ fwrite(STDERR, "[Jump] {$message}\n");
+}
+
+Worker::runAll();
diff --git a/src/Commands/JumpCommand.php b/src/Commands/JumpCommand.php
index 6e28df2..0255830 100644
--- a/src/Commands/JumpCommand.php
+++ b/src/Commands/JumpCommand.php
@@ -10,13 +10,14 @@
class JumpCommand extends Command
{
protected $signature = 'native:jump
- {platform? : Target platform (android/a or ios/i)}
{--host=0.0.0.0 : The host address to serve the application on}
{--ip= : The IP address to display in the QR code (overrides auto-detection)}
{--http-port= : The HTTP port to serve on}
- {--laravel-port=8000 : The Laravel dev server port to proxy to}
- {--no-mdns : Disable mDNS service advertisement}
- {--S|skip-build : Skip building the app bundle if app.zip already exists}';
+ {--ws-port= : The WebSocket bridge port}
+ {--bridge-port= : The internal TCP bridge port}
+ {--no-serve : Do not start artisan serve automatically (use if running your own server)}
+ {--laravel-port= : The Laravel dev server port (auto-detected when artisan serve is managed)}
+ {--no-mdns : Disable mDNS service advertisement}';
protected $description = 'Start the NativePHP development server for testing mobile apps';
@@ -24,39 +25,22 @@ class JumpCommand extends Command
private string $displayHost;
- private string $platform;
+ private $laravelProcess = null;
+
+ private array $laravelPipes = [];
public function handle()
{
intro('NativePHP Jump Server');
- // Get platform from argument (android/a, ios/i) or prompt
- $platform = $this->argument('platform');
-
- if ($platform && in_array(strtolower($platform), ['android', 'a', 'ios', 'i'])) {
- $this->platform = match (strtolower($platform)) {
- 'android', 'a' => 'android',
- 'ios', 'i' => 'ios',
- };
- } else {
- $this->platform = select(
- label: 'Select target platform',
- options: ['android' => 'Android', 'ios' => 'iOS'],
- );
- }
-
- // Run npm build for the selected platform
- $this->runNpmBuild();
-
// Kill existing servers
$this->killExistingServers();
// Configuration
$host = $this->option('host');
$httpPort = $this->option('http-port') ?? config('nativephp.server.http_port', 3000);
- $this->laravelPort = $this->option('laravel-port') ?? 8000;
- // Auto-find available port
+ // Auto-find available port for the Jump proxy server
$httpPort = $this->findAvailablePort($httpPort);
if ($httpPort === null) {
$this->error('Cannot start server: No available HTTP port found.');
@@ -64,17 +48,28 @@ public function handle()
return self::FAILURE;
}
- // Check if we should open browser
- $openQr = config('nativephp.server.open_browser', true);
-
- // Pre-build the Laravel bundle ZIP
- $buildPath = storage_path('app/native-build');
- $zipPath = $buildPath.'/app.zip';
+ // Start or detect the Laravel dev server
+ if ($this->option('no-serve')) {
+ // User is running their own artisan serve
+ $this->laravelPort = (int) ($this->option('laravel-port') ?? 8000);
+ if (! $this->isPortInUse($this->laravelPort)) {
+ $this->warn("No server detected on port {$this->laravelPort}. Start one with: php artisan serve --port={$this->laravelPort}");
+ }
+ } else {
+ // Auto-start artisan serve on an available port
+ $desiredLaravelPort = (int) ($this->option('laravel-port') ?? 8000);
+ $this->laravelPort = $this->findAvailablePort($desiredLaravelPort, 100, [$httpPort]);
+ if ($this->laravelPort === null) {
+ $this->error('Cannot start server: No available port for artisan serve.');
- if (! is_dir($buildPath)) {
- mkdir($buildPath, 0755, true);
+ return self::FAILURE;
+ }
+ $this->startLaravelServer($this->laravelPort);
}
+ // Check if we should open browser
+ $openQr = config('nativephp.server.open_browser', true);
+
// Get the local IP for dev server config
$ipOption = $this->option('ip');
if ($ipOption) {
@@ -98,31 +93,17 @@ public function handle()
}
}
- // Check if we should skip building
- $skipBuild = $this->option('skip-build') && file_exists($zipPath);
-
- if ($skipBuild) {
- $bundleSize = $this->formatBytes(filesize($zipPath));
- $this->components->twoColumnDetail('Using existing bundle', $bundleSize);
- } else {
- // Delete existing bundle to ensure fresh build
- if (file_exists($zipPath)) {
- unlink($zipPath);
- }
- $bundleResult = $this->createZipWithProgress($zipPath, $this->displayHost);
-
- if (! $bundleResult) {
- $this->error('Failed to create Laravel bundle. Cannot start server.');
+ // Start WebSocket bridge server — find ports that don't collide with HTTP or Laravel
+ $usedPorts = [$httpPort, $this->laravelPort];
+ $wsPort = (int) ($this->option('ws-port') ?? $this->findAvailablePort(3001, 100, $usedPorts));
+ $usedPorts[] = $wsPort;
+ $bridgePort = (int) ($this->option('bridge-port') ?? $this->findAvailablePort(3002, 100, $usedPorts));
+ $this->startBridgeServer($wsPort, $bridgePort);
+ $this->components->twoColumnDetail('Bridge WebSocket', "ws://{$this->displayHost}:{$wsPort}/jump/ws");
+ $this->components->twoColumnDetail('Bridge TCP', "tcp://127.0.0.1:{$bridgePort}");
- return self::FAILURE;
- }
-
- $bundleSize = file_exists($zipPath) ? $this->formatBytes(filesize($zipPath)) : 'unknown';
- $this->components->twoColumnDetail('Bundle created', $bundleSize);
- }
-
- // Start PHP built-in server
- $this->startPhpServer($host, $httpPort, $zipPath, $openQr);
+ // Start PHP built-in server (serves QR page + proxies to Laravel)
+ $this->startPhpServer($host, $httpPort, $openQr, $bridgePort, $wsPort);
return self::SUCCESS;
}
@@ -130,7 +111,7 @@ public function handle()
/**
* Start PHP's built-in development server with the Jump router
*/
- private function startPhpServer(string $host, int $httpPort, string $zipPath, bool $openQr): void
+ private function startPhpServer(string $host, int $httpPort, bool $openQr, int $bridgePort = 3002, int $wsPort = 3001): void
{
$routerPath = __DIR__.'/../../resources/jump/router.php';
@@ -142,10 +123,12 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
// Build environment variables for the router
$env = [
- 'JUMP_ZIP_PATH' => $zipPath,
'JUMP_DISPLAY_HOST' => $this->displayHost,
'JUMP_HTTP_PORT' => (string) $httpPort,
'JUMP_LARAVEL_PORT' => (string) $this->laravelPort,
+ 'JUMP_BRIDGE_PORT' => (string) $bridgePort,
+ 'JUMP_WS_PORT' => (string) $wsPort,
+ 'JUMP_VITE_PORT' => (string) config('nativephp.server.vite_port', 5173),
'JUMP_BASE_PATH' => base_path(),
'APP_NAME' => config('app.name', 'Laravel'),
];
@@ -157,11 +140,7 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
$fullEnv = array_filter($fullEnv, fn ($v) => is_string($v) || is_numeric($v));
$this->displayServerInfo($host, $httpPort, $this->laravelPort);
-
- // Auto-open browser with QR code
- if ($openQr) {
- $this->openBrowser($host, $httpPort);
- }
+ $this->displayTerminalQrCode($this->displayHost, $httpPort);
// Build the PHP server command
$phpBinary = PHP_BINARY;
@@ -198,9 +177,10 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
// Handle signals for graceful shutdown
if (function_exists('pcntl_signal')) {
- pcntl_signal(SIGINT, function () use ($process, &$pipes) {
+ $shutdown = function () use ($process, &$pipes) {
$this->newLine();
- $this->components->info('Shutting down server...');
+ $this->components->info('Shutting down...');
+ $this->stopLaravelServer();
if (is_resource($pipes[1])) {
fclose($pipes[1]);
}
@@ -209,17 +189,9 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
}
proc_terminate($process);
exit(0);
- });
- pcntl_signal(SIGTERM, function () use ($process, &$pipes) {
- if (is_resource($pipes[1])) {
- fclose($pipes[1]);
- }
- if (is_resource($pipes[2])) {
- fclose($pipes[2]);
- }
- proc_terminate($process);
- exit(0);
- });
+ };
+ pcntl_signal(SIGINT, $shutdown);
+ pcntl_signal(SIGTERM, $shutdown);
}
// Main loop - read output from the server
@@ -250,6 +222,16 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
}
}
+ // Drain Laravel server output to prevent pipe buffer from filling
+ if ($this->laravelProcess && is_resource($this->laravelProcess)) {
+ if (is_resource($this->laravelPipes[1] ?? null)) {
+ fgets($this->laravelPipes[1]);
+ }
+ if (is_resource($this->laravelPipes[2] ?? null)) {
+ fgets($this->laravelPipes[2]);
+ }
+ }
+
// Handle signals if available
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
@@ -260,399 +242,207 @@ private function startPhpServer(string $host, int $httpPort, string $zipPath, bo
}
// Cleanup
+ $this->stopLaravelServer();
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
}
/**
- * Format PHP server output for cleaner display
+ * Start the WebSocket bridge server for hybrid mode.
+ * Runs as a background process alongside the HTTP server.
*/
- private function formatServerOutput(string $output): void
+ private function startBridgeServer(int $wsPort, int $bridgePort): void
{
- $output = trim($output);
- if (empty($output)) {
- return;
- }
-
- // PHP built-in server format: [Date Time] Client:Port [Status]: Method Path
- if (preg_match('/\[.+\]\s+(\d+\.\d+\.\d+\.\d+):(\d+)\s+\[(\d+)\]:\s+(\w+)\s+(.+)/', $output, $matches)) {
- $status = $matches[3];
- $method = $matches[4];
- $path = $matches[5];
-
- // Skip certain paths
- if (str_contains($path, '/jump/')) {
- return; // Our internal endpoints
- }
-
- // Color code by status
- if ($status >= 400) {
- $this->line("{$method} {$path} [{$status}]>");
- } elseif ($status >= 300) {
- $this->line("{$method} {$path} [{$status}]>");
- }
- // Don't log successful requests to reduce noise
- }
- }
+ $serverPath = __DIR__.'/../../resources/jump/websocket-server.php';
- private function runNpmBuild(): void
- {
- $mode = $this->platform;
+ if (! file_exists($serverPath)) {
+ $this->warn('WebSocket bridge server script not found, skipping hybrid mode support.');
- // Check if package.json exists
- if (! file_exists(base_path('package.json'))) {
return;
}
- $buildOutput = [];
- $buildExitCode = 0;
+ $phpBinary = PHP_BINARY;
- $this->components->task("Building assets for {$mode}", function () use ($mode, &$buildOutput, &$buildExitCode) {
- $command = "npm run build -- --mode={$mode}";
- exec('cd '.escapeshellarg(base_path())." && {$command} 2>&1", $buildOutput, $buildExitCode);
+ // Run in background (not Workerman daemon mode — it breaks the event loop)
+ $cmd = sprintf(
+ '%s %s %s %d %d start > /dev/null 2>&1 &',
+ escapeshellarg($phpBinary),
+ escapeshellarg($serverPath),
+ escapeshellarg(base_path()),
+ $wsPort,
+ $bridgePort
+ );
- return $buildExitCode === 0;
- });
+ exec($cmd);
- if ($buildExitCode !== 0 && ! empty($buildOutput)) {
- $this->line(implode("\n", array_slice($buildOutput, -5)));
- }
+ // Give it a moment to start
+ usleep(500000);
}
- private function createZipWithProgress($zipPath, $devHost = null): bool
+ /**
+ * Start Laravel's artisan serve as a background process.
+ */
+ private function startLaravelServer(int $port): void
{
- $source = realpath(base_path());
- $buildPath = storage_path('app/native-build');
-
- if (! is_dir($buildPath)) {
- mkdir($buildPath, 0755, true);
- }
+ $phpBinary = PHP_BINARY;
+ $artisan = base_path('artisan');
- $tempDir = $buildPath.DIRECTORY_SEPARATOR.'temp-'.uniqid();
-
- // Exclude directories - match run command's pattern
- $excludedDirs = [
- '.git',
- '.idea',
- '.vscode',
- 'node_modules',
- 'storage',
- 'nativephp/ios',
- 'nativephp/android',
- 'vendor/nativephp/mobile/resources',
- 'vendor/endroid',
- 'output',
- 'tests',
- '.github',
+ $descriptorSpec = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
];
- try {
- // Phase 1: Copy files with progress
- $this->copyFilesWithProgress($source, $tempDir, $excludedDirs);
-
- // Copy and clean .env file
- if (file_exists($source.DIRECTORY_SEPARATOR.'.env')) {
- $envPath = $tempDir.DIRECTORY_SEPARATOR.'.env';
- copy($source.DIRECTORY_SEPARATOR.'.env', $envPath);
- $this->cleanEnvFile($envPath);
- }
-
- // Copy native.php bootstrap file
- $nativePhpSource = __DIR__.'/../../resources/jump/native/native.php';
- $nativePhpDest = $tempDir.'/native.php';
- if (file_exists($nativePhpSource)) {
- copy($nativePhpSource, $nativePhpDest);
- }
-
- // Copy artisan.php wrapper
- $artisanPhpSource = __DIR__.'/../../resources/jump/native/artisan.php';
- $artisanPhpDest = $tempDir.'/artisan.php';
- if (file_exists($artisanPhpSource)) {
- copy($artisanPhpSource, $artisanPhpDest);
- }
-
- // Create required Laravel directories
- $requiredDirs = [
- 'bootstrap/cache',
- 'storage/framework/cache',
- 'storage/framework/sessions',
- 'storage/framework/views',
- 'storage/logs',
- ];
- foreach ($requiredDirs as $dir) {
- $dirPath = $tempDir.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $dir);
- if (! is_dir($dirPath)) {
- mkdir($dirPath, 0755, true);
- }
- file_put_contents($dirPath.DIRECTORY_SEPARATOR.'.gitkeep', '');
- }
-
- // Add dev server config
- if ($devHost) {
- $devServerConfig = [
- 'host' => $devHost,
- 'port' => 3000,
- 'connectedAt' => date('c'),
- ];
- file_put_contents(
- $tempDir.'/storage/framework/native_dev_server.json',
- json_encode($devServerConfig, JSON_PRETTY_PRINT)
- );
- }
-
- // Phase 2: Create zip with progress bar
- $this->createZipWithProgressBar($tempDir, $zipPath, $excludedDirs);
-
- // Phase 3: Cleanup
- $this->cleanupTempDir($tempDir);
-
- if (! file_exists($zipPath) || filesize($zipPath) === 0) {
- throw new \Exception('ZIP file was not created or is empty');
- }
+ $cmd = sprintf(
+ '%s %s serve --port=%d --host=127.0.0.1 --no-interaction',
+ escapeshellarg($phpBinary),
+ escapeshellarg($artisan),
+ $port
+ );
- return true;
- } catch (\Exception $e) {
- $this->error('Failed to create bundle: '.$e->getMessage());
- if (is_dir($tempDir)) {
- $this->cleanupTempDir($tempDir);
- }
+ $this->laravelProcess = proc_open($cmd, $descriptorSpec, $this->laravelPipes, base_path());
- return false;
- }
- }
+ if (! is_resource($this->laravelProcess)) {
+ $this->error('Failed to start artisan serve');
- private function copyFilesWithProgress($source, $destination, $excludedDirs = []): void
- {
- if (! is_dir($destination)) {
- mkdir($destination, 0755, true);
- }
-
- if (PHP_OS_FAMILY === 'Windows') {
- $this->copyWithRobocopy($source, $destination, $excludedDirs);
- } else {
- $this->copyWithRsync($source, $destination, $excludedDirs);
- }
- }
-
- private function copyWithRobocopy($source, $destination, $excludedDirs = []): void
- {
- $excludeArgs = '';
- foreach ($excludedDirs as $dir) {
- $dir = ltrim($dir, '/\\');
- $dir = str_replace('/', '\\', $dir);
- $excludeArgs .= " /XD \"{$source}\\{$dir}\"";
+ return;
}
- $exitCode = 0;
- $this->components->task('Copying files', function () use ($source, $destination, $excludeArgs, &$exitCode) {
- $cmd = "robocopy \"{$source}\" \"{$destination}\" /MIR /NFL /NDL /NJH /NJS /NP /R:0 /W:0{$excludeArgs}";
- exec($cmd, $output, $exitCode);
+ // Set pipes to non-blocking so we don't hang
+ stream_set_blocking($this->laravelPipes[1], false);
+ stream_set_blocking($this->laravelPipes[2], false);
+ fclose($this->laravelPipes[0]);
- return $exitCode < 8;
- });
-
- if ($exitCode >= 8) {
- throw new \Exception("Robocopy failed with exit code {$exitCode}");
+ // Wait for Laravel to actually start listening
+ $maxWait = 50; // 5 seconds max
+ for ($i = 0; $i < $maxWait; $i++) {
+ usleep(100000); // 100ms
+ if ($this->isPortInUse($port)) {
+ break;
+ }
}
- }
-
- private function copyWithRsync($source, $destination, $excludedDirs = []): void
- {
- $excludedDirs[] = 'vendor/*/vendor';
- $excludedDirs[] = 'vendor/nativephp/mobile/vendor';
- $excludeFlags = implode(' ', array_map(fn ($d) => "--exclude='".ltrim($d, '/\\')."'", $excludedDirs));
-
- $exitCode = 0;
- $this->components->task('Copying files', function () use ($source, $destination, $excludeFlags, &$exitCode) {
- $cmd = "rsync -aL {$excludeFlags} \"{$source}/\" \"{$destination}/\"";
- exec($cmd, $output, $exitCode);
- return $exitCode === 0;
- });
-
- if ($exitCode !== 0) {
- throw new \Exception("rsync failed with exit code {$exitCode}");
+ if (! $this->isPortInUse($port)) {
+ $this->warn('Laravel server may not have started correctly on port '.$port);
}
- }
-
- private function createZipWithProgressBar($source, $destination, $excludedDirs = []): void
- {
- $source = realpath($source);
- if (PHP_OS_FAMILY === 'Windows') {
- $this->createZipWith7Zip($source, $destination, $excludedDirs);
- } else {
- $this->createZipWithZipArchive($source, $destination, $excludedDirs);
- }
+ $this->components->twoColumnDetail('Laravel server', "http://127.0.0.1:{$port}");
}
- private function createZipWithZipArchive($source, $destination, $excludedDirs = []): void
+ /**
+ * Stop the managed Laravel server process.
+ */
+ private function stopLaravelServer(): void
{
- $this->components->task('Creating zip archive', function () use ($source, $destination, $excludedDirs) {
- $zip = new \ZipArchive;
- $result = $zip->open($destination, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
-
- if ($result !== true) {
- return false;
+ if ($this->laravelProcess && is_resource($this->laravelProcess)) {
+ if (is_resource($this->laravelPipes[1] ?? null)) {
+ fclose($this->laravelPipes[1]);
}
-
- $files = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($source),
- \RecursiveIteratorIterator::SELF_FIRST
- );
-
- foreach ($files as $file) {
- $filePath = $file->getRealPath();
- $relativePath = substr($filePath, strlen($source) + 1);
-
- $shouldSkip = false;
- foreach ($excludedDirs as $excluded) {
- if (strpos($relativePath, $excluded.DIRECTORY_SEPARATOR) === 0 || $relativePath === $excluded) {
- $shouldSkip = true;
- break;
- }
- }
-
- if ($shouldSkip || $file->getFilename() === '.' || $file->getFilename() === '..') {
- continue;
- }
-
- $zipPath = str_replace('\\', '/', $relativePath);
- if (is_file($filePath)) {
- $zip->addFile($filePath, $zipPath);
- } elseif (is_dir($filePath)) {
- $zip->addEmptyDir($zipPath);
- }
- }
-
- $requiredDirs = [
- 'bootstrap/cache',
- 'storage/framework/cache',
- 'storage/framework/sessions',
- 'storage/framework/views',
- 'storage/logs',
- ];
-
- foreach ($requiredDirs as $dir) {
- if (! $zip->statName($dir)) {
- $zip->addEmptyDir($dir);
- }
+ if (is_resource($this->laravelPipes[2] ?? null)) {
+ fclose($this->laravelPipes[2]);
}
-
- $zip->close();
-
- return true;
- });
- }
-
- private function createZipWith7Zip($source, $destination, $excludedDirs = []): void
- {
- $sevenZip = config('nativephp.android.7zip-location', 'C:\\Program Files\\7-Zip\\7z.exe');
-
- if (! file_exists($sevenZip)) {
- $this->error("7-Zip not found at: {$sevenZip}");
- $this->line('Install 7-Zip from https://7-zip.org or set NATIVEPHP_7ZIP_LOCATION in your .env');
- throw new \Exception('7-Zip not found');
- }
-
- if (file_exists($destination)) {
- unlink($destination);
- }
-
- $exitCode = 0;
- $this->components->task('Creating zip archive', function () use ($sevenZip, $source, $destination, &$exitCode) {
- $cmd = "\"{$sevenZip}\" a -tzip \"{$destination}\" \"{$source}\\*\" -xr!node_modules";
- exec($cmd, $output, $exitCode);
-
- return $exitCode === 0;
- });
-
- if ($exitCode !== 0) {
- throw new \Exception("7-Zip failed with exit code {$exitCode}");
- }
-
- if (! file_exists($destination) || filesize($destination) === 0) {
- throw new \Exception('7-Zip failed to create the archive');
+ proc_terminate($this->laravelProcess);
+ proc_close($this->laravelProcess);
+ $this->laravelProcess = null;
}
}
- private function cleanEnvFile($envPath)
+ /**
+ * Format PHP server output for cleaner display
+ */
+ private function formatServerOutput(string $output): void
{
- if (! file_exists($envPath)) {
+ $output = trim($output);
+ if (empty($output)) {
return;
}
- $content = file_get_contents($envPath);
- $lines = explode("\n", $content);
- $cleanedLines = [];
+ // PHP built-in server format: [Date Time] Client:Port [Status]: Method Path
+ if (preg_match('/\[.+\]\s+(\d+\.\d+\.\d+\.\d+):(\d+)\s+\[(\d+)\]:\s+(\w+)\s+(.+)/', $output, $matches)) {
+ $status = $matches[3];
+ $method = $matches[4];
+ $path = $matches[5];
- foreach ($lines as $line) {
- if (preg_match('/^(DB_|MAIL_|AWS_|PUSHER_|REDIS_)/', trim($line))) {
- continue;
+ // Skip certain paths
+ if (str_contains($path, '/jump/')) {
+ return; // Our internal endpoints
}
- $cleanedLines[] = $line;
- }
- file_put_contents($envPath, implode("\n", $cleanedLines));
- }
-
- private function cleanupTempDir($tempDir)
- {
- if (! is_dir($tempDir)) {
- return;
- }
-
- if (PHP_OS_FAMILY === 'Windows') {
- exec("rmdir /s /q \"{$tempDir}\" 2>NUL", $output, $exitCode);
- if ($exitCode === 0) {
- return;
+ // Color code by status
+ if ($status >= 400) {
+ $this->line("{$method} {$path} [{$status}]>");
+ } elseif ($status >= 300) {
+ $this->line("{$method} {$path} [{$status}]>");
}
- } else {
- exec('rm -rf '.escapeshellarg($tempDir));
-
- return;
+ // Don't log successful requests to reduce noise
}
-
- $this->recursiveDelete($tempDir);
}
- private function recursiveDelete($dir)
+ private function displayServerInfo($host, $httpPort, $laravelPort)
{
- if (! is_dir($dir)) {
- return;
- }
-
- $files = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
- \RecursiveIteratorIterator::CHILD_FIRST
- );
-
- foreach ($files as $file) {
- if ($file->isDir()) {
- rmdir($file->getRealPath());
- } else {
- unlink($file->getRealPath());
- }
- }
-
- rmdir($dir);
+ $this->components->twoColumnDetail('Server running', 'Press Ctrl+C to stop');
}
- private function formatBytes($bytes)
+ /**
+ * Display a QR code in the terminal using Unicode block characters.
+ * Scannable with the phone's native camera — opens the Jump app via deep link.
+ */
+ private function displayTerminalQrCode(string $host, int $port): void
{
- $units = ['B', 'KB', 'MB', 'GB'];
- $bytes = max($bytes, 0);
- $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
- $pow = min($pow, count($units) - 1);
- $bytes /= (1 << (10 * $pow));
+ try {
+ if (! class_exists(\Endroid\QrCode\Builder\Builder::class)) {
+ return;
+ }
- return round($bytes, 2).' '.$units[$pow];
- }
+ $qrData = "jump://connect?host={$host}&port={$port}";
+
+ $result = (new \Endroid\QrCode\Builder\Builder(
+ data: $qrData,
+ size: 300,
+ margin: 2,
+ ))->build();
+
+ $matrix = $result->getMatrix();
+ $size = $matrix->getBlockCount();
+
+ $this->newLine();
+ $this->line(' Scan with your camera to open in Jump>');
+ $this->newLine();
+
+ // Render two rows at a time using Unicode half-block characters:
+ // ▀ (upper half) = top black, bottom white
+ // ▄ (lower half) = top white, bottom black
+ // █ (full block) = both black
+ // (space) = both white
+ for ($y = 0; $y < $size; $y += 2) {
+ $line = ' '; // left margin
+ for ($x = 0; $x < $size; $x++) {
+ $top = $matrix->getBlockValue($x, $y);
+ $bottom = ($y + 1 < $size) ? $matrix->getBlockValue($x, $y + 1) : 0;
+
+ if ($top && $bottom) {
+ $line .= '█';
+ } elseif ($top && ! $bottom) {
+ $line .= '▀';
+ } elseif (! $top && $bottom) {
+ $line .= '▄';
+ } else {
+ $line .= ' ';
+ }
+ }
+ $this->line($line);
+ }
- private function displayServerInfo($host, $httpPort, $laravelPort)
- {
- $this->components->twoColumnDetail('Server running', 'Press Ctrl+C to stop');
+ $this->newLine();
+ $this->line(" {$qrData}>");
+ $this->newLine();
+ $this->line(' iOS> Scan with Camera app or Jump app');
+ $this->line(' Android> Scan from within the Jump app');
+ $this->newLine();
+ } catch (\Throwable $e) {
+ // QR display is optional — don't break the server
+ }
}
private function getAllLocalIpAddresses(): array
@@ -823,6 +613,11 @@ private function killExistingServers()
}
}
} else {
+ // Unix: Kill WebSocket bridge servers and Workerman workers
+ exec("pkill -9 -f 'websocket-server.php' 2>/dev/null");
+ exec("pkill -9 -f 'WorkerMan:' 2>/dev/null");
+ usleep(300000);
+
// Unix: Kill PHP servers running the jump router
$output = shell_exec("pgrep -f 'router.php' 2>/dev/null");
diff --git a/src/JumpBridge.php b/src/JumpBridge.php
new file mode 100644
index 0000000..f87e247
--- /dev/null
+++ b/src/JumpBridge.php
@@ -0,0 +1,186 @@
+host = $host;
+ $this->port = $port;
+ $this->timeout = $timeout;
+ }
+
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ // Read dynamic bridge port from env (set by JumpCommand)
+ $port = (int) (getenv('JUMP_BRIDGE_PORT') ?: 3002);
+
+ self::$instance = new self('127.0.0.1', $port);
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Send a bridge call to the device and wait for the response.
+ */
+ public function call(string $method, string $paramsJson = '{}'): ?string
+ {
+ $this->ensureConnected();
+
+ if ($this->socket === null) {
+ return json_encode([
+ 'status' => 'error',
+ 'code' => 'NO_DEVICE',
+ 'message' => 'No device connected. Make sure Jump app is running and connected.',
+ ]);
+ }
+
+ $requestId = uniqid('bridge_', true);
+
+ $message = json_encode([
+ 'type' => 'bridge_call',
+ 'id' => $requestId,
+ 'method' => $method,
+ 'params' => json_decode($paramsJson, true) ?? [],
+ ]);
+
+ // Send length-prefixed message
+ $packed = pack('N', strlen($message)).$message;
+ $written = @fwrite($this->socket, $packed);
+
+ if ($written === false) {
+ $this->disconnect();
+
+ return json_encode([
+ 'status' => 'error',
+ 'code' => 'SEND_FAILED',
+ 'message' => 'Failed to send bridge call to device.',
+ ]);
+ }
+
+ // Wait for response
+ $response = $this->readResponse();
+
+ if ($response === null) {
+ return json_encode([
+ 'status' => 'error',
+ 'code' => 'TIMEOUT',
+ 'message' => "Bridge call '{$method}' timed out waiting for device response.",
+ ]);
+ }
+
+ $decoded = json_decode($response, true);
+
+ if (isset($decoded['error'])) {
+ return json_encode([
+ 'status' => 'error',
+ 'code' => 'DEVICE_ERROR',
+ 'message' => $decoded['error'],
+ ]);
+ }
+
+ return json_encode($decoded['result'] ?? []);
+ }
+
+ private function ensureConnected(): void
+ {
+ if ($this->socket !== null && ! feof($this->socket)) {
+ return;
+ }
+
+ $this->socket = @stream_socket_client(
+ "tcp://{$this->host}:{$this->port}",
+ $errno,
+ $errstr,
+ 5.0
+ );
+
+ if ($this->socket === false) {
+ $this->socket = null;
+
+ return;
+ }
+
+ stream_set_timeout($this->socket, (int) $this->timeout);
+ }
+
+ private function readResponse(): ?string
+ {
+ if ($this->socket === null) {
+ return null;
+ }
+
+ // Read 4-byte length prefix
+ $lengthData = $this->readExact(4);
+ if ($lengthData === null) {
+ return null;
+ }
+
+ $unpacked = unpack('N', $lengthData);
+ $length = $unpacked[1];
+
+ if ($length <= 0 || $length > 10 * 1024 * 1024) { // Max 10MB
+ return null;
+ }
+
+ return $this->readExact($length);
+ }
+
+ private function readExact(int $length): ?string
+ {
+ $data = '';
+ $remaining = $length;
+
+ while ($remaining > 0) {
+ $chunk = @fread($this->socket, $remaining);
+
+ if ($chunk === false || $chunk === '') {
+ $info = stream_get_meta_data($this->socket);
+ if ($info['timed_out']) {
+ return null;
+ }
+
+ return null;
+ }
+
+ $data .= $chunk;
+ $remaining -= strlen($chunk);
+ }
+
+ return $data;
+ }
+
+ private function disconnect(): void
+ {
+ if ($this->socket !== null) {
+ @fclose($this->socket);
+ $this->socket = null;
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->disconnect();
+ }
+}
diff --git a/src/NativeServiceProvider.php b/src/NativeServiceProvider.php
index d1a412e..e8d9a20 100644
--- a/src/NativeServiceProvider.php
+++ b/src/NativeServiceProvider.php
@@ -80,6 +80,7 @@ public function packageRegistered()
$this->publishPluginsServiceProvider();
$this->registerPluginServices();
$this->prepForIos();
+ $this->registerJumpBridgeFallback();
}
protected function publishPluginsServiceProvider(): void
@@ -334,6 +335,25 @@ private function setupComposerPostUpdateScript()
}
}
+ /**
+ * Register pure PHP fallback for nativephp_call() when running on dev machine.
+ *
+ * On device, nativephp_call() is a C extension that calls into Swift/Kotlin.
+ * On the dev machine (Jump hybrid mode), we define it as a PHP function that
+ * sends bridge calls over TCP to the WebSocket server, which relays to the device.
+ */
+ protected function registerJumpBridgeFallback(): void
+ {
+ // Only register if the C extension function doesn't exist
+ // (i.e., we're on the dev machine, not on device)
+ if (function_exists('nativephp_call')) {
+ return;
+ }
+
+ // Define the global nativephp_call function
+ require_once __DIR__.'/jump_bridge_functions.php';
+ }
+
protected function registerNativeComponents(): void
{
$componentPath = __DIR__.'/Edge/Components';
diff --git a/src/PushNotifications.php b/src/PushNotifications.php
index 90fafb3..a2636d2 100644
--- a/src/PushNotifications.php
+++ b/src/PushNotifications.php
@@ -64,16 +64,4 @@ public function getToken(): ?string
return null;
}
-
- /**
- * Clear the app icon badge number
- */
- public function clearBadge(): void
- {
- if (! function_exists('nativephp_call')) {
- return;
- }
-
- nativephp_call('PushNotification.ClearBadge', '{}');
- }
}
diff --git a/src/jump_bridge_functions.php b/src/jump_bridge_functions.php
new file mode 100644
index 0000000..85a6536
--- /dev/null
+++ b/src/jump_bridge_functions.php
@@ -0,0 +1,40 @@
+call($method, $params);
+ }
+}
+
+if (! function_exists('nativephp_can')) {
+ /**
+ * Check if a native bridge function is available.
+ *
+ * In Jump hybrid mode, we assume all functions are available
+ * on the connected device.
+ */
+ function nativephp_can(string $method): bool
+ {
+ return true;
+ }
+}