forked from anixart-mod/patcher
265 lines
7.6 KiB
Python
265 lines
7.6 KiB
Python
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)
|
|
|
|
|
|
# ======================= 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("enable", 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()
|