Files
patcher/main.py
2025-12-28 17:47:56 +03:00

475 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
__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()