Улучшение 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
+228
View File
@@ -0,0 +1,228 @@
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
import yaml
from pydantic import BaseModel, Field, computed_field
from rich.console import Console
from utils.tools import DECOMPILED, MODIFIED, TOOLS, run
class APKMeta(BaseModel):
"""Метаданные APK файла"""
version_code: int = Field(default=0)
version_name: str = Field(default="unknown")
package: str = Field(default="unknown")
path: Path
@computed_field
@property
def safe_version(self) -> str:
"""Версия, безопасная для использования в именах файлов"""
return self.version_name.lower().replace(" ", "-").replace(".", "-")
@computed_field
@property
def output_name(self) -> str:
"""Имя выходного файла"""
return f"Anixarty-v{self.safe_version}.apk"
@computed_field
@property
def aligned_name(self) -> str:
"""Имя выровненного файла"""
return f"Anixarty-v{self.safe_version}-aligned.apk"
@computed_field
@property
def signed_name(self) -> str:
"""Имя подписанного файла"""
return f"Anixarty-v{self.safe_version}-mod.apk"
class SigningConfig(BaseModel):
"""Конфигурация подписи APK"""
keystore: Path = Field(default=Path("keystore.jks"))
keystore_pass_file: Path = Field(default=Path("keystore.pass"))
v1_signing: bool = Field(default=False)
v2_signing: bool = Field(default=True)
v3_signing: bool = Field(default=True)
class APKProcessor:
"""Класс для работы с APK файлами"""
def __init__(self, console: Console, tools_dir: Path = TOOLS):
self.console = console
self.tools_dir = tools_dir
self.apktool_jar = tools_dir / "apktool.jar"
def decompile(self, apk: Path, output: Path = DECOMPILED) -> None:
"""Декомпилирует APK файл"""
self.console.print("[yellow]Декомпиляция APK...")
run(
self.console,
[
"java",
"-jar",
str(self.apktool_jar),
"d",
"-f",
"-o",
str(output),
str(apk),
],
)
self.console.print("[green]✔ Декомпиляция завершена")
def compile(self, source: Path, output: Path) -> None:
"""Компилирует APK из исходников"""
self.console.print("[yellow]Сборка APK...")
run(
self.console,
[
"java",
"-jar",
str(self.apktool_jar),
"b",
str(source),
"-o",
str(output),
],
)
self.console.print("[green]✔ Сборка завершена")
def align(self, input_apk: Path, output_apk: Path) -> None:
"""Выравнивает APK с помощью zipalign"""
self.console.print("[yellow]Выравнивание APK...")
run(
self.console, ["zipalign", "-f", "-v", "4", str(input_apk), str(output_apk)]
)
self.console.print("[green]✔ Выравнивание завершено")
def sign(
self,
input_apk: Path,
output_apk: Path,
config: Optional[SigningConfig] = None,
) -> None:
"""Подписывает APK"""
if config is None:
config = SigningConfig()
self.console.print("[yellow]Подпись APK...")
run(
self.console,
[
"apksigner",
"sign",
"--v1-signing-enabled",
str(config.v1_signing).lower(),
"--v2-signing-enabled",
str(config.v2_signing).lower(),
"--v3-signing-enabled",
str(config.v3_signing).lower(),
"--ks",
str(config.keystore),
"--ks-pass",
f"file:{config.keystore_pass_file}",
"--out",
str(output_apk),
str(input_apk),
],
)
self.console.print("[green]✔ Подпись завершена")
def _get_package_name_from_manifest(self, decompiled_path: Path) -> str:
"""Читает имя пакета напрямую из AndroidManifest.xml"""
manifest_path = decompiled_path / "AndroidManifest.xml"
if not manifest_path.exists():
return "unknown"
try:
tree = ET.parse(manifest_path)
root = tree.getroot()
return root.get("package", "unknown")
except Exception:
return "unknown"
def get_meta(self, decompiled: Path = DECOMPILED) -> APKMeta:
"""Извлекает метаданные из декомпилированного APK"""
apktool_yml = decompiled / "apktool.yml"
if not apktool_yml.exists():
raise FileNotFoundError(f"Файл {apktool_yml} не найден")
with open(apktool_yml, encoding="utf-8") as f:
meta = yaml.safe_load(f)
version_info = meta.get("versionInfo", {})
package_name = self._get_package_name_from_manifest(decompiled)
if package_name == "unknown":
package_info = meta_yaml.get("packageInfo", {})
package_name = package_info.get("renameManifestPackage") or "unknown"
return APKMeta(
version_code=version_info.get("versionCode", 0),
version_name=version_info.get("versionName", "unknown"),
package=package_name,
path=decompiled,
)
def _extract_package_from_manifest(self, decompiled: Path) -> str | None:
"""Извлекает имя пакета из AndroidManifest.xml"""
manifest = decompiled / "AndroidManifest.xml"
if not manifest.exists():
return None
try:
import re
content = manifest.read_text(encoding="utf-8")
match = re.search(r'package="([^"]+)"', content)
if match:
return match.group(1)
except Exception:
pass
return None
def build_and_sign(
self,
source: Path = DECOMPILED,
output_dir: Path = MODIFIED,
signing_config: Optional[SigningConfig] = None,
cleanup: bool = True,
) -> tuple[Path, APKMeta]:
"""
Полный цикл сборки: компиляция, выравнивание, подпись.
Возвращает путь к подписанному APK и метаданные.
"""
meta = self.get_meta(source)
out_apk = output_dir / meta.output_name
aligned_apk = output_dir / meta.aligned_name
signed_apk = output_dir / meta.signed_name
for f in [out_apk, aligned_apk, signed_apk]:
f.unlink(missing_ok=True)
self.compile(source, out_apk)
self.align(out_apk, aligned_apk)
self.sign(aligned_apk, signed_apk, signing_config)
if cleanup:
out_apk.unlink(missing_ok=True)
aligned_apk.unlink(missing_ok=True)
idsig = signed_apk.with_suffix(".apk.idsig")
idsig.unlink(missing_ok=True)
self.console.print(f"[green]✔ APK готов: {signed_apk.name}")
return signed_apk, meta
+120 -13
View File
@@ -1,30 +1,137 @@
from pydantic import BaseModel, Field, ValidationError
from rich.console import Console
from typing import Dict, Any
import json
import traceback
from abc import ABC, abstractmethod
from pathlib import Path
import typer
from typing import Any, Dict, Literal
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
from rich.console import Console
from utils.tools import CONFIGS
class ToolsConfig(BaseModel):
apktool_jar_url: str
apktool_wrapper_url: str
@field_validator("apktool_jar_url", "apktool_wrapper_url")
@classmethod
def validate_url(cls, v: str) -> str:
if not v.startswith(("http://", "https://")):
raise ValueError("URL должен начинаться с http:// или https://")
return v
class SigningConfig(BaseModel):
keystore: Path = Field(default=Path("keystore.jks"))
keystore_pass_file: Path = Field(default=Path("keystore.pass"))
v1_signing: bool = False
v2_signing: bool = True
v3_signing: bool = True
class BuildConfig(BaseModel):
verbose: bool = False
force: bool = False
clean_after_build: bool = True
class Config(BaseModel):
tools: ToolsConfig
base: Dict[str, Any]
class PatchConfig(BaseModel):
enabled: bool = Field(True, description="Включить или отключить патч")
signing: SigningConfig = Field(default_factory=SigningConfig)
build: BuildConfig = Field(default_factory=BuildConfig)
base: Dict[str, Any] = Field(default_factory=dict)
def load_config(console: Console) -> Config:
try:
return Config.model_validate_json(Path("config.json").read_text())
except FileNotFoundError:
"""Загружает и валидирует конфигурацию"""
config_path = Path("config.json")
if not config_path.exists():
console.print("[red]Файл config.json не найден")
raise typer.Exit(1)
try:
return Config.model_validate_json(config_path.read_text())
except ValidationError as e:
console.print("[red]Ошибка валидации config.json:", e)
console.print(f"[red]Ошибка валидации config.json:\n{e}")
raise typer.Exit(1)
class PatchTemplate(BaseModel, ABC):
model_config = ConfigDict(arbitrary_types_allowed=True, validate_default=True)
enabled: bool = Field(default=True, description="Включить или отключить патч")
priority: int = Field(default=0, description="Приоритет применения патча")
_name: str = PrivateAttr()
_applied: bool = PrivateAttr(default=False)
_console: Console | None = PrivateAttr(default=None)
def __init__(self, name: str, console: Console, **data):
loaded_data = self._load_config_static(name, console)
merged_data = {**loaded_data, **data}
valid_fields = set(self.model_fields.keys())
filtered_data = {k: v for k, v in merged_data.items() if k in valid_fields}
super().__init__(**filtered_data)
self._name = name
self._console = console
self._applied = False
@staticmethod
def _load_config_static(name: str, console: Console | None) -> Dict[str, Any]:
"""Загружает конфигурацию из файла (статический метод)"""
config_path = CONFIGS / f"{name}.json"
try:
if config_path.exists():
return json.loads(config_path.read_text())
except Exception as e:
if console:
console.print(
f"[red]Ошибка при загрузке конфигурации патча {name}: {e}"
)
console.print(f"[yellow]Используются значения по умолчанию")
return {}
def save_config(self) -> None:
"""Сохраняет конфигурацию в файл"""
config_path = CONFIGS / f"{self._name}.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(self.model_dump_json(indent=2))
@property
def name(self) -> str:
return self._name
@property
def applied(self) -> bool:
return self._applied
@applied.setter
def applied(self, value: bool) -> None:
self._applied = value
@property
def console(self) -> Console | None:
return self._console
@abstractmethod
def apply(self, base: Dict[str, Any]) -> Any:
raise NotImplementedError(
"Попытка применения шаблона патча, а не его реализации"
)
def safe_apply(self, base: Dict[str, Any]) -> bool:
"""Безопасно применяет патч с обработкой ошибок"""
try:
self._applied = self.apply(base)
return self._applied
except Exception as e:
if self._console:
self._console.print(f"[red]Ошибка в патче {self._name}: {e}")
if base.get("verbose"):
self._console.print_exception()
return False
+159
View File
@@ -0,0 +1,159 @@
from typing import Any, get_args, get_origin
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from rich.console import Console
from rich.table import Table
def format_field_type(annotation: Any) -> str:
"""Форматирует тип поля для отображения"""
if annotation is None:
return "None"
origin = get_origin(annotation)
if origin is not None:
args = get_args(annotation)
origin_name = getattr(origin, "__name__", str(origin))
if origin_name == "UnionType" or str(origin) == "typing.Union":
args_str = " | ".join(format_field_type(a) for a in args)
return args_str
if args:
args_str = ", ".join(format_field_type(a) for a in args)
return f"{origin_name}[{args_str}]"
return origin_name
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return f"[magenta]{annotation.__name__}[/magenta]"
return getattr(annotation, "__name__", str(annotation))
def print_model_fields(
console: Console,
model_class: type[BaseModel],
indent: int = 0,
visited: set | None = None,
) -> None:
"""Рекурсивно выводит поля модели с поддержкой вложенных моделей"""
if visited is None:
visited = set()
if model_class in visited:
console.print(
f"{' ' * indent}[dim](циклическая ссылка на {model_class.__name__})[/dim]"
)
return
visited.add(model_class)
prefix = " " * indent
for field_name, field_info in model_class.model_fields.items():
annotation = field_info.annotation
field_type = format_field_type(annotation)
default = field_info.default
description = field_info.description or ""
if default is None:
default_str = "[dim]None[/dim]"
elif default is ...:
default_str = "[red]required[/red]"
elif isinstance(default, bool):
default_str = "[green]true[/green]" if default else "[red]false[/red]"
else:
default_str = str(default)
console.print(
f"{prefix}[yellow]{field_name}[/yellow]: {field_type} = {default_str}"
+ (f" [dim]# {description}[/dim]" if description else "")
)
nested_model = None
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
nested_model = annotation
else:
origin = get_origin(annotation)
if origin is not None:
for arg in get_args(annotation):
if isinstance(arg, type) and issubclass(arg, BaseModel):
nested_model = arg
break
if nested_model is not None:
console.print(f"{prefix} [dim]└─ {nested_model.__name__}:[/dim]")
print_model_fields(console, nested_model, indent + 2, visited.copy())
def print_model_table(
console: Console,
model_class: type[BaseModel],
prefix: str = "",
visited: set | None = None,
) -> Table:
"""Выводит поля модели в виде таблицы с вложенными моделями"""
if visited is None:
visited = set()
table = Table(show_header=True, box=None if prefix else None)
table.add_column("Поле", style="yellow")
table.add_column("Тип", style="cyan")
table.add_column("По умолчанию")
table.add_column("Описание", style="dim")
_add_model_rows(table, model_class, prefix, visited)
return table
def _add_model_rows(
table: Table,
model_class: type[BaseModel],
prefix: str = "",
visited: set | None = None,
) -> None:
"""Добавляет строки модели в таблицу рекурсивно"""
if visited is None:
visited = set()
if model_class in visited:
return
visited.add(model_class)
for field_name, field_info in model_class.model_fields.items():
annotation = field_info.annotation
field_type = format_field_type(annotation)
default = field_info.default
description = field_info.description or ""
if default is None:
default_str = "-"
elif default is ...:
default_str = "[red]required[/red]"
elif isinstance(default, bool):
default_str = "true" if default else "false"
elif isinstance(default, BaseModel):
default_str = "{...}"
else:
default_str = str(default)[:20]
full_name = f"{prefix}{field_name}" if prefix else field_name
table.add_row(full_name, field_type, default_str, description[:40])
nested_model = None
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
nested_model = annotation
else:
origin = get_origin(annotation)
if origin is not None:
for arg in get_args(annotation):
if isinstance(arg, type) and issubclass(arg, BaseModel):
nested_model = arg
break
if nested_model is not None and nested_model not in visited:
_add_model_rows(table, nested_model, f" {full_name}.", visited.copy())
+107
View File
@@ -0,0 +1,107 @@
import importlib
from contextlib import contextmanager
from functools import wraps
from pathlib import Path
from typing import Dict, List, Type
import typer
from rich.console import Console
from utils.config import PatchTemplate
from utils.tools import PATCHES
class PatcherError(Exception):
"""Базовое исключение патчера"""
pass
class ConfigError(PatcherError):
"""Ошибка конфигурации"""
pass
class BuildError(PatcherError):
"""Ошибка сборки"""
pass
def handle_errors(func):
"""Декоратор для обработки ошибок CLI"""
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except PatcherError as e:
Console().print(f"[red]Ошибка: {e}")
raise typer.Exit(1)
except KeyboardInterrupt:
Console().print("\n[yellow]Прервано пользователем")
raise typer.Exit(130)
return wrapper
class PatchManager:
"""Менеджер для работы с патчами"""
def __init__(self, console: Console, patches_dir: Path = PATCHES):
self.console = console
self.patches_dir = patches_dir
def discover_patches(self, include_todo: bool = False) -> List[str]:
"""Находит все доступные патчи"""
patches = []
for f in self.patches_dir.glob("*.py"):
if f.name == "__init__.py":
continue
if f.name.startswith("todo_") and not include_todo:
continue
patches.append(f.stem)
return patches
def discover_all(self) -> Dict[str, List[str]]:
"""Находит все патчи, разделяя на готовые и в разработке"""
ready = []
todo = []
for f in self.patches_dir.glob("*.py"):
if f.name == "__init__.py":
continue
if f.name.startswith("todo_"):
todo.append(f.stem)
else:
ready.append(f.stem)
return {"ready": ready, "todo": todo}
def load_patch_module(self, name: str) -> type:
"""Загружает модуль патча"""
module = importlib.import_module(f"patches.{name}")
return module
def load_patch_class(self, name: str) -> type:
"""Загружает класс патча"""
module = importlib.import_module(f"patches.{name}")
return module.Patch
def load_patch(self, name: str) -> PatchTemplate:
"""Загружает экземпляр патча"""
module = importlib.import_module(f"patches.{name}")
return module.Patch(name=name, console=self.console)
def load_enabled_patches(self) -> List[PatchTemplate]:
"""Загружает все включённые патчи, отсортированные по приоритету"""
patches = []
for name in self.discover_patches():
patch = self.load_patch(name)
if patch.enabled:
patches.append(patch)
else:
self.console.print(f"[dim]≫ Пропускаем {name}[/dim]")
return sorted(patches, key=lambda p: p.priority, reverse=True)
+2 -1
View File
@@ -1,6 +1,7 @@
from typing_extensions import Optional
from copy import deepcopy
from lxml import etree
from typing_extensions import Optional
def insert_after_public(anchor_name: str, elem_name: str) -> Optional[int]:
+26 -5
View File
@@ -1,11 +1,11 @@
from plumbum import local, ProcessExecutionError
from rich.progress import Progress
from rich.console import Console
from pathlib import Path
from typing import List
import httpx
import typer
from plumbum import FG, ProcessExecutionError, local
from rich.console import Console
from rich.progress import Progress
TOOLS = Path("tools")
ORIGINAL = Path("original")
@@ -23,7 +23,7 @@ def ensure_dirs():
def run(console: Console, cmd: List[str], hide_output=True):
prog = local[cmd[0]][cmd[1:]]
try:
prog() if hide_output else prog & FG # type: ignore [reportUndefinedVariable]
prog() if hide_output else prog & FG
except ProcessExecutionError as e:
console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}")
console.print(e.stderr)
@@ -31,6 +31,7 @@ def run(console: Console, cmd: List[str], hide_output=True):
def download(console: Console, url: str, dest: Path):
"""Скачивание файла по URL"""
console.print(f"[cyan]Скачивание {url}{dest.name}")
with httpx.Client(follow_redirects=True, timeout=60.0) as client:
@@ -45,3 +46,23 @@ def download(console: Console, url: str, dest: Path):
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
progress.update(task, advance=len(chunk))
def select_apk(console) -> Path:
"""Выбор APK файла из папки original"""
apks = list(ORIGINAL.glob("*.apk"))
if not apks:
raise BuildError("Нет apk-файлов в папке original")
if len(apks) == 1:
console.print(f"[green]Выбран {apks[0].name}")
return apks[0]
console.print("[cyan]Доступные APK файлы:")
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]