From 20c29d9cd9f87d52ee97cfe65971586913f007d7 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Sat, 11 Apr 2026 12:41:14 -0400 Subject: [PATCH 1/3] Add WebSocket bridge server for Jump with multi-device support Replace the single-device connection model with a connection pool so multiple iOS/Android devices can connect to the same Jump instance simultaneously and all receive real-time reloads, keepalive pings, and bridge calls. - Add websocket-server.php (Workerman-based WS + TCP bridge server) - Add JumpBridge.php client for PHP-to-device RPC via length-prefixed TCP - Add jump_bridge_functions.php (nativephp_call helper) - Refactor JumpCommand to start WS bridge, discover ports, proxy Vite assets - Update router.php to read Vite dev server config from hot file - Register bridge service provider bindings in NativeServiceProvider Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/jump/router.php | 186 +++++++-- resources/jump/websocket-server.php | 239 ++++++++++++ src/Commands/JumpCommand.php | 560 ++++++++-------------------- src/JumpBridge.php | 186 +++++++++ src/NativeServiceProvider.php | 20 + src/PushNotifications.php | 12 - src/jump_bridge_functions.php | 40 ++ 7 files changed, 780 insertions(+), 463 deletions(-) create mode 100644 resources/jump/websocket-server.php create mode 100644 src/JumpBridge.php create mode 100644 src/jump_bridge_functions.php diff --git a/resources/jump/router.php b/resources/jump/router.php index 77b59fb..9520790 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,26 @@ 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); - 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; } @@ -152,10 +133,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 +158,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 +889,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 +968,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 +984,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 +1030,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 +1043,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..b6becbf 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.'); - - return self::FAILURE; - } - - $bundleSize = file_exists($zipPath) ? $this->formatBytes(filesize($zipPath)) : 'unknown'; - $this->components->twoColumnDetail('Bundle created', $bundleSize); - } + // 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}"); - // 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'), ]; @@ -198,9 +181,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 +193,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 +226,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,394 +246,141 @@ 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; - } + $serverPath = __DIR__.'/../../resources/jump/websocket-server.php'; - // 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]; + if (! file_exists($serverPath)) { + $this->warn('WebSocket bridge server script not found, skipping hybrid mode support.'); - // 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 - } - } - - private function runNpmBuild(): void - { - $mode = $this->platform; - - // 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'); - } - - return true; - } catch (\Exception $e) { - $this->error('Failed to create bundle: '.$e->getMessage()); - if (is_dir($tempDir)) { - $this->cleanupTempDir($tempDir); - } - - return false; - } - } + $cmd = sprintf( + '%s %s serve --port=%d --host=127.0.0.1 --no-interaction', + escapeshellarg($phpBinary), + escapeshellarg($artisan), + $port + ); - private function copyFilesWithProgress($source, $destination, $excludedDirs = []): void - { - if (! is_dir($destination)) { - mkdir($destination, 0755, true); - } + $this->laravelProcess = proc_open($cmd, $descriptorSpec, $this->laravelPipes, base_path()); - if (PHP_OS_FAMILY === 'Windows') { - $this->copyWithRobocopy($source, $destination, $excludedDirs); - } else { - $this->copyWithRsync($source, $destination, $excludedDirs); - } - } + if (! is_resource($this->laravelProcess)) { + $this->error('Failed to start artisan serve'); - 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); - - return $exitCode < 8; - }); + // 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]); - 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); - } + if (is_resource($this->laravelPipes[2] ?? null)) { + fclose($this->laravelPipes[2]); } - - $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); - } - } - - $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; - } - } else { - exec('rm -rf '.escapeshellarg($tempDir)); - - return; - } - - $this->recursiveDelete($tempDir); - } - - private function recursiveDelete($dir) - { - 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()); + // 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 } - - rmdir($dir); - } - - private function formatBytes($bytes) - { - $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)); - - return round($bytes, 2).' '.$units[$pow]; } private function displayServerInfo($host, $httpPort, $laravelPort) @@ -823,6 +556,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; + } +} From 6d2697fc01da52956b045d0b04a8e75ad593a02d Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Mon, 13 Apr 2026 15:56:01 -0400 Subject: [PATCH 2/3] Add deep link QR code and terminal QR display for Jump - Change QR code data from JSON to jump:// deep link URL so scanning with the native camera app opens Jump and auto-connects - Render QR code directly in the terminal using Unicode block characters, eliminating the need to open a browser Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/jump/router.php | 8 ++--- src/Commands/JumpCommand.php | 64 +++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/resources/jump/router.php b/resources/jump/router.php index 9520790..c673cb0 100644 --- a/resources/jump/router.php +++ b/resources/jump/router.php @@ -118,11 +118,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( diff --git a/src/Commands/JumpCommand.php b/src/Commands/JumpCommand.php index b6becbf..4b7449f 100644 --- a/src/Commands/JumpCommand.php +++ b/src/Commands/JumpCommand.php @@ -140,11 +140,7 @@ private function startPhpServer(string $host, int $httpPort, bool $openQr, int $ $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; @@ -388,6 +384,64 @@ private function displayServerInfo($host, $httpPort, $laravelPort) $this->components->twoColumnDetail('Server running', 'Press Ctrl+C to stop'); } + /** + * 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 + { + try { + if (! class_exists(\Endroid\QrCode\Builder\Builder::class)) { + return; + } + + $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); + } + + $this->newLine(); + $this->line(" {$qrData}"); + $this->newLine(); + } catch (\Throwable $e) { + // QR display is optional — don't break the server + } + } + private function getAllLocalIpAddresses(): array { $ips = []; From 08e94a9c87d31b0cf955d6a9913b3eb325582db1 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Mon, 13 Apr 2026 16:47:14 -0400 Subject: [PATCH 3/3] Add /jump/open redirect endpoint and platform instructions - Add /jump/open endpoint that redirects browser to jump:// deep link (fallback for Android camera which only opens http:// URLs) - Update terminal QR instructions for iOS (Camera or Jump app) and Android (Jump app only) Co-Authored-By: Claude Opus 4.6 (1M context) --- resources/jump/router.php | 23 +++++++++++++++++++++++ src/Commands/JumpCommand.php | 3 +++ 2 files changed, 26 insertions(+) diff --git a/resources/jump/router.php b/resources/jump/router.php index c673cb0..6ab38ec 100644 --- a/resources/jump/router.php +++ b/resources/jump/router.php @@ -80,6 +80,29 @@ function jumpLog($message) exit; } +// 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... + + +
+

Opening in Jump...

+

If the app doesn't open, tap here.

+

Don't have Jump? Download for iOS

+
+ +HTML; + exit; +} + // Handle info endpoint if ($path === '/jump/info') { header('Content-Type: application/json'); diff --git a/src/Commands/JumpCommand.php b/src/Commands/JumpCommand.php index 4b7449f..0255830 100644 --- a/src/Commands/JumpCommand.php +++ b/src/Commands/JumpCommand.php @@ -437,6 +437,9 @@ private function displayTerminalQrCode(string $host, int $port): void $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 }