__version__ = "1.0.0" import shutil from functools import wraps from pathlib import Path from typing import Any, Dict, List import typer from plumbum import ProcessExecutionError, local from rich.console import Console from rich.progress import Progress from rich.prompt import Prompt from rich.table import Table from utils.apk import APKMeta, APKProcessor from utils.config import Config, PatchTemplate, load_config from utils.info import print_model_fields, print_model_table from utils.patch_manager import (BuildError, ConfigError, PatcherError, PatchManager, handle_errors) from utils.tools import (CONFIGS, DECOMPILED, MODIFIED, ORIGINAL, PATCHES, TOOLS, download, ensure_dirs, run, select_apk) console = Console() app = typer.Typer( name="anixarty-patcher", help="Инструмент для модификации Anixarty APK", add_completion=False, ) from datetime import datetime def generate_report( apk_path: Path, meta: APKMeta, patches: List[PatchTemplate], manager: PatchManager, ) -> None: """Генерирует отчёт о сборке в формате Markdown""" report_path = MODIFIED / "report.md" applied_count = sum(1 for p in patches if p.applied) applied_patches = [p for p in patches if p.applied] failed_patches = [p for p in patches if not p.applied] applied_patches.sort(key=lambda p: p.priority, reverse=True) failed_patches.sort(key=lambda p: p.priority, reverse=True) def get_patch_info(patch: PatchTemplate) -> Dict[str, str]: """Получает описание и автора патча из модуля""" info = {"doc": "", "author": "-"} try: patch_module = manager.load_patch_module(patch.name) doc = patch_module.__doc__ if doc: info["doc"] = doc.strip().split("\n")[0] author = getattr(patch_module, "__author__", "") if author: info["author"] = f"`{author}`" except Exception: pass return info lines = [] lines.append(f"Anixarty {meta.version_name} (build {meta.version_code})") lines.append("") lines.append("## 📦 Информация о сборке") lines.append("") lines.append("| Параметр | Значение |") lines.append("|----------|----------|") lines.append(f"| Версия | `{meta.version_name}` |") lines.append(f"| Код версии | `{meta.version_code}` |") lines.append(f"| Пакет | `{meta.package}` |") lines.append(f"| Файл | `{apk_path.name}` |") lines.append(f"| Дата сборки | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |") lines.append("") lines.append("## 🔧 Применённые патчи") lines.append("") if applied_patches: lines.append( f"> ✅ Успешно применено: **{applied_count}** из **{len(patches)}**" ) lines.append("") lines.append("| Патч | Приоритет | Автор | Описание |") lines.append("|------|:---------:|-------|----------|") for p in applied_patches: info = get_patch_info(p) lines.append( f"| ✅ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |" ) else: lines.append("> ⚠️ Нет применённых патчей") lines.append("") if failed_patches: lines.append("## ❌ Ошибки") lines.append("") lines.append("| Патч | Приоритет | Автор | Описание |") lines.append("|------|:---------:|-------|----------|") for p in failed_patches: info = get_patch_info(p) lines.append( f"| ❌ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |" ) lines.append("") lines.append("---") lines.append("") lines.append( "*Собрано с помощью [anixarty-patcher](https://git.0x174.su/anixart-mod/patcher)*" ) report_path.write_text("\n".join(lines), encoding="utf-8") console.print(f"[dim]Отчёт сохранён: {report_path}[/dim]") # ========================= COMMANDS ========================= @app.command() @handle_errors def init(): """Инициализация: создание директорий и скачивание инструментов""" ensure_dirs() conf = load_config(console) # Проверка Java console.print("[cyan]Проверка Java...") try: local["java"]["-version"].run(retcode=None) console.print("[green]✔ Java найдена") except ProcessExecutionError: raise PatcherError("Java не установлена. Установите JDK 11+") # Скачивание apktool apktool_jar = TOOLS / "apktool.jar" if not apktool_jar.exists(): download(console, conf.tools.apktool_jar_url, apktool_jar) else: console.print(f"[dim]✔ {apktool_jar.name} уже существует[/dim]") # Скачивание apktool wrapper apktool_wrapper = TOOLS / "apktool" if not apktool_wrapper.exists(): download(console, conf.tools.apktool_wrapper_url, apktool_wrapper) apktool_wrapper.chmod(0o755) else: console.print(f"[dim]✔ {apktool_wrapper.name} уже существует[/dim]") # Проверка zipalign и apksigner for tool in ["zipalign", "apksigner"]: try: local[tool]["--version"].run(retcode=None) console.print(f"[green]✔ {tool} найден") except Exception: console.print(f"[yellow]⚠ {tool} не найден в PATH") # Проверка keystore if not Path("keystore.jks").exists(): console.print("[yellow]⚠ keystore.jks не найден. Создайте его командой:") console.print( "[dim] keytool -genkey -v -keystore keystore.jks -keyalg RSA " "-keysize 2048 -validity 10000 -alias key[/dim]" ) # Инициализация конфигов патчей console.print("\n[cyan]Инициализация конфигураций патчей...") manager = PatchManager(console) for name in manager.discover_patches(): patch = manager.load_patch(name) config_path = CONFIGS / f"{name}.json" if not config_path.exists(): patch.save_config() console.print(f" [green]✔ {name}.json создан") else: console.print(f" [dim]✔ {name}.json существует[/dim]") console.print("\n[green]✔ Инициализация завершена") @app.command("list") @handle_errors def list_patches(): """Показать список всех патчей""" manager = PatchManager(console) all_patches = manager.discover_all() table = Table(title="Доступные патчи") table.add_column("Приоритет", justify="center", style="cyan") table.add_column("Название", style="yellow") table.add_column("Статус", justify="center") table.add_column("Автор", style="magenta") table.add_column("Версия", style="yellow") table.add_column("Описание") patch_rows = [] for name in all_patches["ready"]: try: patch = manager.load_patch(name) status = "[green]✔ вкл[/green]" if patch.enabled else "[red]✘ выкл[/red]" patch_class = manager.load_patch_class(name) priority = getattr(patch_class, "priority", 0) patch_module = manager.load_patch_module(name) author = getattr(patch_module, "__author__", "") version = getattr(patch_module, "__version__", "") description = (patch_module.__doc__ or "").strip().split("\n")[0] patch_rows.append( (patch.priority, name, status, author, version, description) ) except Exception as e: raise e patch_rows.append((0, name, "[red]⚠ ошибка[/red]", "", "", str(e)[:40])) for name in all_patches["todo"]: try: patch_class = manager.load_patch_class(name) priority = getattr(patch_class, "priority", 0) patch_module = manager.load_patch_module(name) author = getattr(patch_module, "__author__", "") version = getattr(patch_module, "__version__", "") description = (patch_module.__doc__ or "").strip().split("\n")[0] patch_rows.append( ( priority, name, "[yellow]⚠ todo[/yellow]", author, version, description, ) ) except Exception: patch_rows.append((0, name, "[yellow]⚠ todo[/yellow]", "", "", "")) patch_rows.sort(key=lambda x: x[0], reverse=True) for priority, name, status, author, version, desc in patch_rows: table.add_row(str(priority), name, status, author, version, desc[:50]) console.print(table) @app.command() @handle_errors def info( patch_name: str = typer.Argument(..., help="Имя патча"), tree: bool = typer.Option(False, "--tree", "-t", help="Древовидный вывод полей"), ): """Показать подробную информацию о патче""" manager = PatchManager(console) all_patches = manager.discover_all() all_names = all_patches["ready"] + all_patches["todo"] if patch_name not in all_names: raise PatcherError(f"Патч '{patch_name}' не найден") patch_class = manager.load_patch_class(patch_name) console.print(f"\n[bold cyan]Патч: {patch_name}[/bold cyan]") console.print("-" * 50) if patch_class.__doc__: console.print(f"[white]{patch_class.__doc__.strip()}[/white]\n") is_todo = patch_name in all_patches["todo"] if is_todo: console.print("[yellow]Статус: в разработке[/yellow]\n") else: patch = manager.load_patch(patch_name) status = "[green]включён[/green]" if patch.enabled else "[red]выключен[/red]" console.print(f"Статус: {status}") console.print(f"Приоритет: [cyan]{patch.priority}[/cyan]\n") console.print("[bold]Поля конфигурации:[/bold]") if tree: print_model_fields(console, patch_class) else: table = print_model_table(console, patch_class) console.print(table) table = Table(show_header=True) table.add_column("Поле", style="yellow") table.add_column("Тип", style="cyan") table.add_column("По умолчанию") table.add_column("Описание") for field_name, field_info in patch_class.model_fields.items(): field_type = getattr( field_info.annotation, "__name__", str(field_info.annotation) ) default = str(field_info.default) if field_info.default is not None else "-" description = field_info.description or "" table.add_row(field_name, field_type, default, description) console.print(table) if not is_todo: config_path = CONFIGS / f"{patch_name}.json" if config_path.exists(): console.print(f"\n[bold]Текущая конфигурация[/bold] ({config_path}):") console.print(config_path.read_text()) @app.command() @handle_errors def enable(patch_name: str = typer.Argument(..., help="Имя патча")): """Включить патч""" manager = PatchManager(console) if patch_name not in manager.discover_patches(): raise PatcherError(f"Патч '{patch_name}' не найден") patch = manager.load_patch(patch_name) patch.enabled = True patch.save_config() console.print(f"[green]✔ Патч {patch_name} включён") @app.command() @handle_errors def disable(patch_name: str = typer.Argument(..., help="Имя патча")): """Выключить патч""" manager = PatchManager(console) if patch_name not in manager.discover_patches(): raise PatcherError(f"Патч '{patch_name}' не найден") patch = manager.load_patch(patch_name) patch.enabled = False patch.save_config() console.print(f"[yellow]✔ Патч {patch_name} выключен") @app.command() @handle_errors def build( force: bool = typer.Option( False, "--force", "-f", help="Принудительная сборка при ошибках" ), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), skip_compile: bool = typer.Option( False, "--skip-compile", "-s", help="Пропустить компиляцию (только патчи)" ), ): """Декомпиляция, применение патчей и сборка APK""" conf = load_config(console) apk_processor = APKProcessor(console, TOOLS) apk = select_apk(console) apk_processor.decompile(apk, DECOMPILED) manager = PatchManager(console) patches = manager.load_enabled_patches() if not patches: console.print("[yellow]Нет включённых патчей") if not force: raise typer.Exit(0) base_config = conf.base.copy() base_config["verbose"] = verbose base_config["decompiled"] = str(DECOMPILED) console.print(f"\n[cyan]Применение патчей ({len(patches)})...[/cyan]") with Progress(console=console) as progress: task = progress.add_task("Патчи", total=len(patches)) for patch in patches: success = patch.safe_apply(base_config) status = "[green]✔[/green]" if success else "[red]✘[/red]" progress.console.print(f" {status} [{patch.priority:2d}] {patch.name}") progress.advance(task) applied = sum(1 for p in patches if p.applied) failed = len(patches) - applied console.print() if failed == 0: console.print(f"[green]✔ Все патчи применены ({applied}/{len(patches)})") else: console.print( f"[yellow]⚠ Применено: {applied}/{len(patches)}, ошибок: {failed}" ) if skip_compile: console.print("[yellow]Компиляция пропущена (--skip-compile)") return should_compile = ( failed == 0 or force or Prompt.ask( "\nПродолжить сборку несмотря на ошибки?", choices=["y", "n"], default="n" ) == "y" ) if should_compile: console.print() signed_apk, meta = apk_processor.build_and_sign( source=DECOMPILED, output_dir=MODIFIED, ) generate_report(signed_apk, meta, patches, manager) else: console.print("[red]Сборка отменена") raise typer.Exit(1) @app.command() @handle_errors def clean( all_dirs: bool = typer.Option( False, "--all", "-a", help="Очистить все директории включая modified и configs" ) ): """Очистка временных файлов""" dirs_to_clean = [DECOMPILED] if all_dirs: dirs_to_clean.extend([MODIFIED, CONFIGS]) for d in dirs_to_clean: if d.exists(): shutil.rmtree(d) d.mkdir() console.print(f"[yellow]✔ Очищено: {d}") else: console.print(f"[dim]≫ Пропущено (не существует): {d}[/dim]") console.print("[green]✔ Очистка завершена") @app.command() @handle_errors def config(): """Показать текущую конфигурацию""" conf = load_config(console) console.print("[bold cyan]Конфигурация (config.json):[/bold cyan]\n") console.print("[yellow]Tools:[/yellow]") console.print(f" apktool_jar_url: {conf.tools.apktool_jar_url}") console.print(f" apktool_wrapper_url: {conf.tools.apktool_wrapper_url}") if conf.base: console.print("\n[yellow]Base:[/yellow]") for key, value in conf.base.items(): console.print(f" {key}: {value}") @app.command() @handle_errors def version(): """Показать версию инструмента""" console.print(f"[cyan]anixarty-patcher[/cyan] v{__version__}") if __name__ == "__main__": app()