Улучшение cli и удобства создания патчей
Сборка мода / build (push) Successful in 2m16s

This commit is contained in:
2025-12-28 17:47:56 +03:00
parent ec047cd3a5
commit 70337ee3ec
35 changed files with 2200 additions and 1111 deletions
+435 -202
View File
@@ -1,241 +1,474 @@
from typing import List, Dict, Any
__version__ = "1.0.0"
import shutil
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List
import typer
import importlib
import traceback
import yaml
from plumbum import local, ProcessExecutionError
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.config import *
from utils.tools import *
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()
app = typer.Typer(
name="anixarty-patcher",
help="Инструмент для модификации Anixarty APK",
add_completion=False,
)
# ======================= PATCHING =========================
class Patch:
def __init__(self, name: str, module):
self.name = name
self.module = module
self.applied = False
self.priority = getattr(module, "priority", 0)
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:
self.config = module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text())
except Exception as e:
console.print(f"[red]Ошибка при загрузке конфигурации патча {name}: {e}")
console.print(f"[yellow]Используются значения по умолчанию")
self.config = module.Config()
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
def apply(self, conf: Dict[str, Any]) -> bool:
try:
self.applied = bool(self.module.apply(self.config, conf))
return self.applied
except Exception as e:
console.print(f"[red]Ошибка в патче {self.name}: {e}")
traceback.print_exc()
return False
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("")
# ========================= INIT =========================
@app.command()
def init():
"""Создание директорий и скачивание инструментов"""
ensure_dirs()
conf = load_config(console)
lines.append("## 🔧 Применённые патчи")
lines.append("")
for f in PATCHES.glob("*.py"):
if f.name.startswith("todo_") or f.name == "__init__.py":
continue
patch = Patch(f.stem, __import__(f"patches.{f.stem}", fromlist=[""]))
json_string = patch.config.model_dump_json()
(CONFIGS / f"{patch.name}.json").write_text(json_string)
if not (TOOLS / "apktool.jar").exists():
download(console, conf.tools.apktool_jar_url, TOOLS / "apktool.jar")
if not (TOOLS / "apktool").exists():
download(console, conf.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(console).model_dump()
if patch_name:
patch = Patch(patch_name, __import__(f"patches.{patch_name}", fromlist=[""]))
console.print(f"[green]Информация о патче {patch.name}:")
console.print(f" [yellow]Приоритет: {patch.priority}")
console.print(f" [yellow]Описание: {patch.module.__doc__}")
console.print(f"[blue]Поля конфигурации")
for field_name, field_info in type(patch.config).model_fields.items():
field_data = {
'type': field_info.annotation.__name__,
'description': field_info.description,
'default': field_info.default,
'json_schema_extra': field_info.json_schema_extra,
}
console.print(f'{field_name} {field_data}')
console.print("\n[blue]" + "="*50 + "\n")
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:
conf = load_config(console)
console.print("[cyan]Список патчей:")
patch_list = []
for f in PATCHES.glob("*.py"):
if f.name == "__init__.py": continue
if f.name.startswith("todo_"):
try: priority = __import__(f"patches.{f.stem}.priority", fromlist=[""])
except: priority = None
patch_list.append((priority, f" [{priority}] [yellow]{f.stem}: [yellow]⚠ в разработке"))
continue
patch = Patch(f.stem, __import__(f"patches.{f.stem}", fromlist=[""]))
if patch.config.enabled: patch_list.append((patch.priority, f" [{patch.priority}] [yellow]{f.stem}: [green]✔ включен"))
else: patch_list.append((patch.priority, f" [{patch.priority}] [yellow]{f.stem}: [red]✘ выключен"))
for _, patch in sorted(patch_list, key=lambda x: (x[0] is None, x[0]), reverse=True): console.print(patch)
lines.append("> ⚠️ Нет применённых патчей")
lines.append("")
# ========================= UTIL =========================
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 failed_patches:
lines.append("## ❌ Ошибки")
lines.append("")
lines.append("| Патч | Приоритет | Автор | Описание |")
lines.append("|------|:---------:|-------|----------|")
if len(apks) == 1:
console.print(f"[green]Выбран {apks[0].name}")
return apks[0]
for p in failed_patches:
info = get_patch_info(p)
lines.append(
f"| ❌ `{p.name}` | {p.priority} | {info['author']} | {info['doc']} |"
)
options = {str(i): apk for i, apk in enumerate(apks, 1)}
for k, v in options.items():
console.print(f"{k}. {v.name}")
lines.append("")
choice = Prompt.ask("Выберите номер", choices=list(options.keys()))
return options[choice]
def decompile(apk: Path):
console.print("[yellow]Декомпиляция apk...")
run(
console,
[
"java",
"-jar", str(TOOLS / "apktool.jar"),
"d", "-f",
"-o", str(DECOMPILED),
str(apk),
]
lines.append("---")
lines.append("")
lines.append(
"*Собрано с помощью [anixarty-patcher](https://git.0x174.su/anixart-mod/patcher)*"
)
def compile(apk: Path, patches: List[Patch]):
console.print("[yellow]Сборка apk...")
with open(DECOMPILED / "apktool.yml", encoding="utf-8") as f:
meta = yaml.safe_load(f)
version_info = meta.get("versionInfo", {})
version_code = version_info.get("versionCode", 0)
version_name = version_info.get("versionName", "unknown")
filename_version = version_name.lower().replace(" ", "-").replace(".", "-")
out_apk = MODIFIED / f"Anixarty-mod-v{filename_version}.apk"
aligned = out_apk.with_stem(out_apk.stem + "-aligned")
signed = out_apk.with_stem(out_apk.stem + "-mod")
run(
console,
[
"java",
"-jar", str(TOOLS / "apktool.jar"),
"b", str(DECOMPILED),
"-o", str(out_apk),
]
)
run(
console,
["zipalign", "-v", "4", str(out_apk), str(aligned)]
)
run(
console,
[
"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),
]
)
console.print("[green]✔ APK успешно собран и подписан")
with open(MODIFIED / "report.log", "w", encoding="utf-8") as f:
f.write(f"Anixarty mod v {version_name} ({version_code})\n")
for p in patches:
f.write(f"{'' if p.applied else ''} {p.name}\n")
report_path.write_text("\n".join(lines), encoding="utf-8")
console.print(f"[dim]Отчёт сохранён: {report_path}[/dim]")
# ========================= BUILD =========================
# ========================= COMMANDS =========================
@app.command()
def build(
force: bool = typer.Option(False, "--force", "-f", help="Принудительная сборка"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
):
"""Декомпиляция, патчи и сборка apk"""
@handle_errors
def init():
"""Инициализация: создание директорий и скачивание инструментов"""
ensure_dirs()
conf = load_config(console)
apk = select_apk()
decompile(apk)
patch_objs: List[Patch] = []
conf.base |= {"verbose": verbose}
# Проверка Java
console.print("[cyan]Проверка Java...")
try:
local["java"]["-version"].run(retcode=None)
console.print("[green]✔ Java найдена")
except ProcessExecutionError:
raise PatcherError("Java не установлена. Установите JDK 11+")
for f in PATCHES.glob("*.py"):
if f.name.startswith("todo_") or f.name == "__init__.py":
continue
name = f.stem
module = importlib.import_module(f"patches.{name}")
if not module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text()):
console.print(f"[yellow]≫ Пропускаем {name}")
continue
patch_objs.append(Patch(name, module))
# Скачивание 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]")
patch_objs.sort(key=lambda p: p.priority, reverse=True)
# Скачивание 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]")
console.print("[cyan]Применение патчей")
with Progress() as progress:
task = progress.add_task("Патчи", total=len(patch_objs))
for p in patch_objs:
ok = p.apply(conf.base)
progress.console.print(f"{'' if ok else ''} {p.name}")
# Проверка 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)
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)
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)
if __name__ == "__main__": app()
@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()