475 lines
16 KiB
Python
475 lines
16 KiB
Python
__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()
|