from pathlib import Path from typing import List import httpx import typer import importlib import traceback import yaml from pydantic import BaseModel, ValidationError from plumbum import local, ProcessExecutionError from rich.console import Console from rich.progress import Progress from rich.prompt import Prompt from rich.table import Table # --- Paths --- TOOLS = Path("tools") ORIGINAL = Path("original") MODIFIED = Path("modified") DECOMPILED = Path("decompiled") PATCHES = Path("patches") console = Console() app = typer.Typer() # ======================= CONFIG ========================= class ToolsConfig(BaseModel): apktool_jar_url: str apktool_wrapper_url: str class XmlNamespaces(BaseModel): android: str app: str class BaseSection(BaseModel): tools: ToolsConfig xml_ns: XmlNamespaces class Config(BaseModel): base: BaseSection patches: dict def load_config() -> Config: try: return Config.model_validate_json(Path("config.json").read_text()) except FileNotFoundError: console.print("[red]Файл config.json не найден") raise typer.Exit(1) except ValidationError as e: console.print("[red]Ошибка валидации config.json:", e) raise typer.Exit(1) # ======================= UTILS ========================= def ensure_dirs(): for d in [TOOLS, ORIGINAL, MODIFIED, DECOMPILED, PATCHES]: d.mkdir(exist_ok=True) def run(cmd: List[str], hide_output=True): prog = local[cmd[0]][cmd[1:]] try: prog() if hide_output else prog & FG except ProcessExecutionError as e: console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}") console.print(e.stderr) raise typer.Exit(1) def download(url: str, dest: Path): console.print(f"[cyan]Скачивание {url} → {dest.name}") with httpx.Client(follow_redirects=True, timeout=60.0) as client: with client.stream("GET", url) as response: response.raise_for_status() total = int(response.headers.get("Content-Length", 0)) dest.parent.mkdir(parents=True, exist_ok=True) with open(dest, "wb") as f, Progress(console=console) as progress: task = progress.add_task("Загрузка", total=total if total else None) for chunk in response.iter_bytes(chunk_size=8192): f.write(chunk) progress.update(task, advance=len(chunk)) # ======================= INIT ========================= @app.command() def init(): """Создание директорий и скачивание инструментов""" ensure_dirs() conf = load_config() if not (TOOLS / "apktool.jar").exists(): download(conf.base.tools.apktool_jar_url, TOOLS / "apktool.jar") if not (TOOLS / "apktool").exists(): download(conf.base.tools.apktool_wrapper_url, TOOLS / "apktool") (TOOLS / "apktool").chmod(0o755) try: local["java"]["-version"]() console.print("[green]Java найдена") except ProcessExecutionError: console.print("[red]Java не установлена") raise typer.Exit(1) # ======================= INFO ========================= @app.command() def info(patch_name: str = ""): """Вывод информации о патче""" conf = load_config().model_dump() if patch_name: patch = Patch(patch_name, __import__(f"patcher.patches.{patch_name}")) console.print(f"[green]Информация о патче {patch.name}:") console.print(f" [yellow]Приоритет: {patch.priority}") console.print(f" [yellow]Описание: {patch.module.__doc__}") else: console.print("[cyan]Список патчей:") for f in PATCHES.glob("*.py"): if f.name.startswith("todo_") or f.name == "__init__.py": continue name = f.stem if conf['patches'].get(name,{}).get('enabled',True): console.print(f" [yellow]{name}: [green]✔ enabled") else: console.print(f" [yellow]{name}: [red]✘ disabled") # ======================= PATCHING ========================= class Patch: def __init__(self, name: str, module): self.name = name self.module = module self.applied = False self.priority = getattr(module, "priority", 0) def apply(self, conf: dict) -> bool: try: self.applied = bool(self.module.apply(conf)) return self.applied except Exception as e: console.print(f"[red]Ошибка в патче {self.name}: {e}") traceback.print_exc() return False def select_apk() -> Path: apks = [f for f in ORIGINAL.glob("*.apk")] if not apks: console.print("[red]Нет apk-файлов в папке original") raise typer.Exit(1) if len(apks) == 1: console.print(f"[green]Выбран {apks[0].name}") return apks[0] options = {str(i): apk for i, apk in enumerate(apks, 1)} for k, v in options.items(): console.print(f"{k}. {v.name}") choice = Prompt.ask("Выберите номер", choices=list(options.keys())) return options[choice] def decompile(apk: Path): console.print("[yellow]Декомпиляция apk...") run( [ "java", "-jar", str(TOOLS / "apktool.jar"), "d", "-f", "-o", str(DECOMPILED), str(apk), ] ) def compile(apk: Path, patches: List[Patch]): console.print("[yellow]Сборка apk...") out_apk = MODIFIED / apk.name aligned = out_apk.with_stem(out_apk.stem + "-aligned") signed = out_apk.with_stem(out_apk.stem + "-mod") run( [ "java", "-jar", str(TOOLS / "apktool.jar"), "b", str(DECOMPILED), "-o", str(out_apk), ] ) run(["zipalign", "-v", "4", str(out_apk), str(aligned)]) run( [ "apksigner", "sign", "--v1-signing-enabled", "false", "--v2-signing-enabled", "true", "--v3-signing-enabled", "true", "--ks", "keystore.jks", "--ks-pass", "file:keystore.pass", "--out", str(signed), str(aligned), ] ) with open(DECOMPILED / "apktool.yml", encoding="utf-8") as f: meta = yaml.safe_load(f) version_str = " ".join( f"{k}:{v}" for k, v in meta.get("versionInfo", {}).items() ) with open(MODIFIED / "report.log", "w", encoding="utf-8") as f: f.write(f"anixart mod {version_str}\n") for p in patches: f.write(f"{p.name}: {'applied' if p.applied else 'failed'}\n") @app.command() def build( force: bool = typer.Option(False, "--force", "-f", help="Принудительная сборка"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), ): """Декомпиляция, патчи и сборка apk""" conf = load_config().model_dump() apk = select_apk() decompile(apk) patch_settings = conf.get("patches", {}) patch_objs: List[Patch] = [] for f in PATCHES.glob("*.py"): if f.name.startswith("todo_") or f.name == "__init__.py": continue name = f.stem settings = patch_settings.get(name, {}) if not settings.get("enabled", True): console.print(f"[yellow]≫ Пропускаем {name}") continue module = importlib.import_module(f"patches.{name}") patch_objs.append(Patch(name, module)) patch_objs.sort(key=lambda p: p.priority, reverse=True) console.print("[cyan]Применение патчей") with Progress() as progress: task = progress.add_task("Патчи", total=len(patch_objs)) for p in patch_objs: ok = p.apply(patch_settings.get(p.name, {}) | conf.get("base", {})) progress.console.print(f"{'✔' if ok else '✘'} {p.name}") progress.advance(task) successes = sum(p.applied for p in patch_objs) if successes == len(patch_objs): compile(apk, patch_objs) elif successes > 0 and ( force or Prompt.ask("Продолжить сборку?", choices=["y", "n"]) == "y" ): compile(apk, patch_objs) else: console.print("[red]Сборка отменена") raise typer.Exit(1) if __name__ == "__main__": app()