|
| 1 | +<?php |
| 2 | + |
| 3 | +namespace Native\Electron\Commands; |
| 4 | + |
| 5 | +use Carbon\CarbonInterface; |
| 6 | +use Illuminate\Console\Command; |
| 7 | +use Illuminate\Http\Client\Response; |
| 8 | +use Illuminate\Support\Facades\Http; |
| 9 | +use Illuminate\Support\Facades\Process; |
| 10 | +use Illuminate\Support\Number; |
| 11 | +use Illuminate\Support\Str; |
| 12 | +use Native\Electron\Traits\CleansEnvFile; |
| 13 | +use Native\Electron\Traits\CopiesToBuildDirectory; |
| 14 | +use Native\Electron\Traits\HandleApiRequests; |
| 15 | +use Native\Electron\Traits\HasPreAndPostProcessing; |
| 16 | +use Native\Electron\Traits\InstallsAppIcon; |
| 17 | +use Native\Electron\Traits\PrunesVendorDirectory; |
| 18 | +use Native\Electron\Traits\SetsAppName; |
| 19 | +use Symfony\Component\Finder\Finder; |
| 20 | +use ZipArchive; |
| 21 | + |
| 22 | +use function Laravel\Prompts\intro; |
| 23 | + |
| 24 | +class BundleCommand extends Command |
| 25 | +{ |
| 26 | + use CleansEnvFile; |
| 27 | + use CopiesToBuildDirectory; |
| 28 | + use HandleApiRequests; |
| 29 | + use HasPreAndPostProcessing; |
| 30 | + use InstallsAppIcon; |
| 31 | + use PrunesVendorDirectory; |
| 32 | + use SetsAppName; |
| 33 | + |
| 34 | + protected $signature = 'native:bundle {--fetch} {--without-cleanup}'; |
| 35 | + |
| 36 | + protected $description = 'Bundle your application for distribution.'; |
| 37 | + |
| 38 | + private ?string $key; |
| 39 | + |
| 40 | + private string $zipPath; |
| 41 | + |
| 42 | + private string $zipName; |
| 43 | + |
| 44 | + public function handle(): int |
| 45 | + { |
| 46 | + // Check for ZEPHPYR_KEY |
| 47 | + if (! $this->checkForZephpyrKey()) { |
| 48 | + return static::FAILURE; |
| 49 | + } |
| 50 | + |
| 51 | + // Check for ZEPHPYR_TOKEN |
| 52 | + if (! $this->checkForZephpyrToken()) { |
| 53 | + return static::FAILURE; |
| 54 | + } |
| 55 | + |
| 56 | + // Check if the token is valid |
| 57 | + if (! $this->checkAuthenticated()) { |
| 58 | + $this->error('Invalid API token: check your ZEPHPYR_TOKEN on '.$this->baseUrl().'user/api-tokens'); |
| 59 | + |
| 60 | + return static::FAILURE; |
| 61 | + } |
| 62 | + |
| 63 | + // Download the latest bundle if requested |
| 64 | + if ($this->option('fetch')) { |
| 65 | + if (! $this->fetchLatestBundle()) { |
| 66 | + |
| 67 | + return static::FAILURE; |
| 68 | + } |
| 69 | + |
| 70 | + $this->info('Latest bundle downloaded.'); |
| 71 | + |
| 72 | + return static::SUCCESS; |
| 73 | + } |
| 74 | + |
| 75 | + $this->preProcess(); |
| 76 | + |
| 77 | + $this->setAppName(slugify: true); |
| 78 | + intro('Copying App to build directory...'); |
| 79 | + |
| 80 | + // We update composer.json later, |
| 81 | + $this->copyToBuildDirectory(); |
| 82 | + |
| 83 | + $this->newLine(); |
| 84 | + intro('Cleaning .env file...'); |
| 85 | + $this->cleanEnvFile(); |
| 86 | + |
| 87 | + $this->newLine(); |
| 88 | + intro('Copying app icons...'); |
| 89 | + $this->installIcon(); |
| 90 | + |
| 91 | + $this->newLine(); |
| 92 | + intro('Pruning vendor directory'); |
| 93 | + $this->pruneVendorDirectory(); |
| 94 | + |
| 95 | + // Check composer.json for symlinked or private packages |
| 96 | + if (! $this->checkComposerJson()) { |
| 97 | + return static::FAILURE; |
| 98 | + } |
| 99 | + |
| 100 | + // Package the app up into a zip |
| 101 | + if (! $this->zipApplication()) { |
| 102 | + $this->error("Failed to create zip archive at {$this->zipPath}."); |
| 103 | + |
| 104 | + return static::FAILURE; |
| 105 | + } |
| 106 | + |
| 107 | + // Send the zip file |
| 108 | + $result = $this->sendToZephpyr(); |
| 109 | + $this->handleApiErrors($result); |
| 110 | + |
| 111 | + // Success |
| 112 | + $this->info('Successfully uploaded to Zephpyr.'); |
| 113 | + $this->line('Use native:bundle --fetch to retrieve the latest bundle.'); |
| 114 | + |
| 115 | + // Clean up temp files |
| 116 | + $this->cleanUp(); |
| 117 | + |
| 118 | + return static::SUCCESS; |
| 119 | + } |
| 120 | + |
| 121 | + private function zipApplication(): bool |
| 122 | + { |
| 123 | + $this->zipName = 'app_'.str()->random(8).'.zip'; |
| 124 | + $this->zipPath = $this->zipPath($this->zipName); |
| 125 | + |
| 126 | + // Create zip path |
| 127 | + if (! @mkdir(dirname($this->zipPath), recursive: true) && ! is_dir(dirname($this->zipPath))) { |
| 128 | + return false; |
| 129 | + } |
| 130 | + |
| 131 | + $zip = new ZipArchive; |
| 132 | + |
| 133 | + if ($zip->open($this->zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { |
| 134 | + return false; |
| 135 | + } |
| 136 | + |
| 137 | + $this->cleanEnvFile(); |
| 138 | + |
| 139 | + $this->addFilesToZip($zip); |
| 140 | + |
| 141 | + $zip->close(); |
| 142 | + |
| 143 | + return true; |
| 144 | + } |
| 145 | + |
| 146 | + private function checkComposerJson(): bool |
| 147 | + { |
| 148 | + $composerJson = json_decode(file_get_contents($this->buildPath('composer.json')), true); |
| 149 | + |
| 150 | + // // Fail if there is symlinked packages |
| 151 | + // foreach ($composerJson['repositories'] ?? [] as $repository) { |
| 152 | + // |
| 153 | + // $symlinked = $repository['options']['symlink'] ?? true; |
| 154 | + // if ($repository['type'] === 'path' && $symlinked) { |
| 155 | + // $this->error('Symlinked packages are not supported. Please remove them from your composer.json.'); |
| 156 | + // |
| 157 | + // return false; |
| 158 | + // } |
| 159 | + // // Work with private packages but will not in the future |
| 160 | + // // elseif ($repository['type'] === 'composer') { |
| 161 | + // // if (! $this->checkComposerPackageAuth($repository['url'])) { |
| 162 | + // // $this->error('Cannot authenticate with '.$repository['url'].'.'); |
| 163 | + // // $this->error('Go to '.$this->baseUrl().' and add your composer package credentials.'); |
| 164 | + // // |
| 165 | + // // return false; |
| 166 | + // // } |
| 167 | + // // } |
| 168 | + // } |
| 169 | + |
| 170 | + // Remove repositories with type path |
| 171 | + if (! empty($composerJson['repositories'])) { |
| 172 | + |
| 173 | + $this->newLine(); |
| 174 | + intro('Patching composer.json in development mode…'); |
| 175 | + |
| 176 | + $filteredRepo = array_filter($composerJson['repositories'], fn ($repository) => $repository['type'] !== 'path'); |
| 177 | + |
| 178 | + if (count($filteredRepo) !== count($composerJson['repositories'])) { |
| 179 | + $composerJson['repositories'] = $filteredRepo; |
| 180 | + file_put_contents($this->buildPath('composer.json'), json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); |
| 181 | + |
| 182 | + Process::path($this->buildPath()) |
| 183 | + ->run('composer update --no-dev', function (string $type, string $output) { |
| 184 | + echo $output; |
| 185 | + }); |
| 186 | + } |
| 187 | + |
| 188 | + } |
| 189 | + |
| 190 | + return true; |
| 191 | + } |
| 192 | + |
| 193 | + // private function checkComposerPackageAuth(string $repositoryUrl): bool |
| 194 | + // { |
| 195 | + // // Check if the user has authenticated the package on Zephpyr |
| 196 | + // $host = parse_url($repositoryUrl, PHP_URL_HOST); |
| 197 | + // $this->line('Checking '.$host.' authentication…'); |
| 198 | + // |
| 199 | + // return Http::acceptJson() |
| 200 | + // ->withToken(config('nativephp-internal.zephpyr.token')) |
| 201 | + // ->get($this->baseUrl().'api/v1/project/'.$this->key.'/composer/auth/'.$host) |
| 202 | + // ->successful(); |
| 203 | + // } |
| 204 | + |
| 205 | + private function addFilesToZip(ZipArchive $zip): void |
| 206 | + { |
| 207 | + $this->newLine(); |
| 208 | + intro('Creating zip archive…'); |
| 209 | + |
| 210 | + $app = (new Finder)->files() |
| 211 | + ->followLinks() |
| 212 | + ->ignoreVCSIgnored(true) |
| 213 | + ->in($this->buildPath()) |
| 214 | + ->exclude([ |
| 215 | + // We add those a few lines below |
| 216 | + 'vendor', |
| 217 | + 'node_modules', |
| 218 | + |
| 219 | + // Exclude the following directories |
| 220 | + 'dist', // Compiled nativephp assets |
| 221 | + 'build', // Compiled box assets |
| 222 | + 'temp', // Temp files |
| 223 | + 'tests', // Tests |
| 224 | + |
| 225 | + // TODO: include everything in the .gitignore file |
| 226 | + |
| 227 | + ...config('nativephp.cleanup_exclude_files', []), // User defined |
| 228 | + ]); |
| 229 | + |
| 230 | + $this->finderToZip($app, $zip); |
| 231 | + |
| 232 | + // Add .env file manually because Finder ignores hidden files |
| 233 | + $zip->addFile($this->buildPath('.env'), '.env'); |
| 234 | + |
| 235 | + // Add auth.json file to support private packages |
| 236 | + // WARNING: Only for testing purposes, don't uncomment this |
| 237 | + // $zip->addFile($this->buildPath('auth.json'), 'auth.json'); |
| 238 | + |
| 239 | + // Custom binaries |
| 240 | + $binaryPath = Str::replaceStart($this->buildPath('vendor'), '', config('nativephp.binary_path')); |
| 241 | + |
| 242 | + // Add composer dependencies without unnecessary files |
| 243 | + $vendor = (new Finder)->files() |
| 244 | + ->exclude(array_filter([ |
| 245 | + 'nativephp/php-bin', |
| 246 | + 'nativephp/electron/resources/js', |
| 247 | + '*/*/vendor', // Exclude sub-vendor directories |
| 248 | + $binaryPath, |
| 249 | + ])) |
| 250 | + ->in($this->buildPath('vendor')); |
| 251 | + |
| 252 | + $this->finderToZip($vendor, $zip, 'vendor'); |
| 253 | + |
| 254 | + // Add javascript dependencies |
| 255 | + if (file_exists($this->buildPath('node_modules'))) { |
| 256 | + $nodeModules = (new Finder)->files() |
| 257 | + ->in($this->buildPath('node_modules')); |
| 258 | + |
| 259 | + $this->finderToZip($nodeModules, $zip, 'node_modules'); |
| 260 | + } |
| 261 | + } |
| 262 | + |
| 263 | + private function finderToZip(Finder $finder, ZipArchive $zip, ?string $path = null): void |
| 264 | + { |
| 265 | + foreach ($finder as $file) { |
| 266 | + if ($file->getRealPath() === false) { |
| 267 | + continue; |
| 268 | + } |
| 269 | + |
| 270 | + $zip->addFile($file->getRealPath(), str($path)->finish(DIRECTORY_SEPARATOR).$file->getRelativePathname()); |
| 271 | + } |
| 272 | + } |
| 273 | + |
| 274 | + private function sendToZephpyr() |
| 275 | + { |
| 276 | + intro('Uploading zip to Zephpyr…'); |
| 277 | + |
| 278 | + return Http::acceptJson() |
| 279 | + ->timeout(300) // 5 minutes |
| 280 | + ->withoutRedirecting() // Upload won't work if we follow redirects (it transform POST to GET) |
| 281 | + ->withToken(config('nativephp-internal.zephpyr.token')) |
| 282 | + ->attach('archive', fopen($this->zipPath, 'r'), $this->zipName) |
| 283 | + ->post($this->baseUrl().'api/v1/project/'.$this->key.'/build/'); |
| 284 | + } |
| 285 | + |
| 286 | + private function fetchLatestBundle(): bool |
| 287 | + { |
| 288 | + intro('Fetching latest bundle…'); |
| 289 | + |
| 290 | + $response = Http::acceptJson() |
| 291 | + ->withToken(config('nativephp-internal.zephpyr.token')) |
| 292 | + ->get($this->baseUrl().'api/v1/project/'.$this->key.'/build/download'); |
| 293 | + |
| 294 | + if ($response->failed()) { |
| 295 | + |
| 296 | + if ($response->status() === 404) { |
| 297 | + $this->error('Project or bundle not found.'); |
| 298 | + } elseif ($response->status() === 500) { |
| 299 | + $this->error('Build failed. Please try again later.'); |
| 300 | + } elseif ($response->status() === 503) { |
| 301 | + $this->warn('Bundle not ready. Please try again in '.now()->addSeconds(intval($response->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.'); |
| 302 | + } else { |
| 303 | + $this->handleApiErrors($response); |
| 304 | + } |
| 305 | + |
| 306 | + return false; |
| 307 | + } |
| 308 | + |
| 309 | + // Save the bundle |
| 310 | + @mkdir(base_path('build'), recursive: true); |
| 311 | + file_put_contents(base_path('build/__nativephp_app_bundle'), $response->body()); |
| 312 | + |
| 313 | + return true; |
| 314 | + } |
| 315 | + |
| 316 | + protected function exitWithMessage(string $message): void |
| 317 | + { |
| 318 | + $this->error($message); |
| 319 | + $this->cleanUp(); |
| 320 | + |
| 321 | + exit(static::FAILURE); |
| 322 | + } |
| 323 | + |
| 324 | + private function handleApiErrors(Response $result): void |
| 325 | + { |
| 326 | + if ($result->status() === 413) { |
| 327 | + $fileSize = Number::fileSize(filesize($this->zipPath)); |
| 328 | + $this->exitWithMessage('File is too large to upload ('.$fileSize.'). Please contact support.'); |
| 329 | + } elseif ($result->status() === 422) { |
| 330 | + $this->error('Request refused:'.$result->json('message')); |
| 331 | + } elseif ($result->status() === 429) { |
| 332 | + $this->exitWithMessage('Too many requests. Please try again in '.now()->addSeconds(intval($result->header('Retry-After')))->diffForHumans(syntax: CarbonInterface::DIFF_ABSOLUTE).'.'); |
| 333 | + } elseif ($result->failed()) { |
| 334 | + $this->exitWithMessage("Request failed. Error code: {$result->status()}"); |
| 335 | + } |
| 336 | + } |
| 337 | + |
| 338 | + protected function cleanUp(): void |
| 339 | + { |
| 340 | + $this->postProcess(); |
| 341 | + |
| 342 | + if ($this->option('without-cleanup')) { |
| 343 | + return; |
| 344 | + } |
| 345 | + |
| 346 | + $previousBuilds = glob($this->zipPath().'/app_*.zip'); |
| 347 | + $failedZips = glob($this->zipPath().'/app_*.part'); |
| 348 | + |
| 349 | + $deleteFiles = array_merge($previousBuilds, $failedZips); |
| 350 | + |
| 351 | + if (empty($deleteFiles)) { |
| 352 | + return; |
| 353 | + } |
| 354 | + |
| 355 | + $this->line('Cleaning up…'); |
| 356 | + |
| 357 | + foreach ($deleteFiles as $file) { |
| 358 | + @unlink($file); |
| 359 | + } |
| 360 | + } |
| 361 | + |
| 362 | + protected function buildPath(string $path = ''): string |
| 363 | + { |
| 364 | + return base_path('temp/build/'.$path); |
| 365 | + } |
| 366 | + |
| 367 | + protected function zipPath(string $path = ''): string |
| 368 | + { |
| 369 | + return base_path('temp/zip/'.$path); |
| 370 | + } |
| 371 | + |
| 372 | + protected function sourcePath(string $path = ''): string |
| 373 | + { |
| 374 | + return base_path($path); |
| 375 | + } |
| 376 | +} |
0 commit comments