From 9da9e9854772476d068bf3d98325bc41af2a898d Mon Sep 17 00:00:00 2001 From: wowlikon Date: Sun, 14 Sep 2025 20:12:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0=20main.py=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.json | 122 +++++++------ main.py | 386 +++++++++++++++++++-------------------- patches/cleanup.py | 2 +- patches/color_theme.py | 10 +- patches/package_name.py | 2 - patches/settings_urls.py | 2 +- requirements.txt | 8 + 7 files changed, 270 insertions(+), 262 deletions(-) create mode 100644 requirements.txt diff --git a/config.json b/config.json index 0bff65d..1f8e094 100644 --- a/config.json +++ b/config.json @@ -1,63 +1,75 @@ { - "tools": { - "apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar", - "apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool" - }, - "new_package_name": "com.wowlikon.anixart2", - "server": "https://anixarty.wowlikon.tech/modding", - "theme": { - "colors": { - "primary": "#ccff00", - "secondary": "#ffffd700", - "background": "#ffffff", - "text": "#000000" + "base": { + "tools": { + "apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar", + "apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool" }, - "gradient": { - "angle": "135.0", - "from": "#ffff6060", - "to": "#ffccff00" + "xml_ns": { + "android": "http://schemas.android.com/apk/res/android", + "app": "http://schemas.android.com/apk/res-auto" } }, - "cleanup": { - "keep_dirs": ["META-INF", "kotlin"] - }, - "settings_urls": { - "Мы в социальных сетях": [ - { - "title": "wowlikon", - "description": "Разработчик", - "url": "https://t.me/wowlikon", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" + "patches": { + "package_name": { + "new_package_name": "com.wowlikon.anixart" + }, + "cleanup": { + "keep_dirs": ["META-INF", "kotlin"] + }, + "change_server": { + "server": "https://anixarty.wowlikon.tech/modding" + }, + "color_theme": { + "colors": { + "primary": "#ccff00", + "secondary": "#ffffd700", + "background": "#ffffff", + "text": "#000000" }, - { - "title": "Kentai Radiquum", - "description": "Разработчик", - "url": "https://t.me/radiquum", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" - }, - { - "title": "Мы в Telegram", - "description": "Подпишитесь на канал, чтобы быть в курсе последних новостей.", - "url": "https://t.me/http_teapod", - "icon": "@drawable/ic_custom_telegram", - "icon_space_reserved": "false" + "gradient": { + "angle": "135.0", + "from": "#ffff6060", + "to": "#ffccff00" } - ], - "Прочее": [ - { - "title": "Поддержать проект", - "description": "После пожертвования вы сможете выбрать в своём профиле любую роль, например «Кошка-девочка», которая будет видна всем пользователям мода.", - "url": "https://t.me/wowlikon", - "icon": "@drawable/ic_custom_crown", - "icon_space_reserved": "false" + }, + "custom_speed": { + "speeds": [9.0] + }, + "settings_urls": { + "menu": { + "Мы в социальных сетях": [ + { + "title": "wowlikon", + "description": "Разработчик", + "url": "https://t.me/wowlikon", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + }, + { + "title": "Kentai Radiquum", + "description": "Разработчик", + "url": "https://t.me/radiquum", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + }, + { + "title": "Мы в Telegram", + "description": "Подпишитесь на канал, чтобы быть в курсе последних новостей.", + "url": "https://t.me/http_teapod", + "icon": "@drawable/ic_custom_telegram", + "icon_space_reserved": "false" + } + ], + "Прочее": [ + { + "title": "Поддержать проект", + "description": "После пожертвования вы сможете выбрать в своём профиле любую роль, например «Кошка-девочка», которая будет видна всем пользователям мода.", + "url": "https://t.me/wowlikon", + "icon": "@drawable/ic_custom_crown", + "icon_space_reserved": "false" + } + ] } - ] - }, - "xml_ns": { - "android": "http://schemas.android.com/apk/res/android", - "app": "http://schemas.android.com/apk/res-auto" - }, - "speeds": [9.0] + } + } } diff --git a/main.py b/main.py index 4d26a34..a4ec073 100644 --- a/main.py +++ b/main.py @@ -1,230 +1,220 @@ -import os -import sys -import json -import yaml -import requests -import argparse -import colorama +from pathlib import Path +from typing import List + +import httpx +import typer import importlib import traceback -import subprocess -from tqdm import tqdm +import yaml -def init() -> dict: - for directory in ["original", "modified", "patches", "tools", "decompiled"]: - if not os.path.exists(directory): - os.makedirs(directory) +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 - with open("./config.json", "r") as config_file: - conf = json.load(config_file) +# --- Paths --- +TOOLS = Path("tools") +ORIGINAL = Path("original") +MODIFIED = Path("modified") +DECOMPILED = Path("decompiled") +PATCHES = Path("patches") - if not os.path.exists("./tools/apktool.jar"): - try: - print("Скачивание Apktool...") - jar_response = requests.get(conf["tools"]["apktool_jar_url"], stream=True) - jar_path = "tools/apktool.jar" - with open(jar_path, "wb") as f: - for chunk in jar_response.iter_content(chunk_size=8192): - f.write(chunk) +console = Console() +app = typer.Typer() - wrapper_response = requests.get(conf["tools"]["apktool_wrapper_url"]) - wrapper_path = "tools/apktool" - with open(wrapper_path, "w") as f: - f.write(wrapper_response.text) - os.chmod(wrapper_path, 0o755) - except Exception as e: - print(f"Ошибка при скачивании Apktool: {e}") - exit(1) +# ======================= 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.stream("GET", url, timeout=30) as r: + r.raise_for_status() + with open(dest, "wb") as f: + for chunk in r.iter_bytes(): + f.write(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") + wrapper = httpx.get(conf.base.tools.apktool_wrapper_url, timeout=30).text + (TOOLS / "apktool").write_text(wrapper, encoding="utf-8") + (TOOLS / "apktool").chmod(0o755) try: - result = subprocess.run( - ["java", "-version"], capture_output=True, text=True, check=True - ) - - version_line = result.stderr.splitlines()[0] - if "1.8" in version_line or any(f"{i}." in version_line for i in range(9, 100)): - print("Java 8 или более поздняя версия установлена.") - else: - print("Java 8 или более поздняя версия не установлена.") - sys.exit(1) - except subprocess.CalledProcessError: - print("Java не установлена. Установите Java 8 или более позднюю версию.") - exit(1) - - return conf - -def select_apk() -> str: - apks = [] - for file in os.listdir("original"): - if file.endswith(".apk") and os.path.isfile(os.path.join("original", file)): - apks.append(file) - - if not apks: - print("Нет файлов .apk в текущей директории") - sys.exit(1) - - if len(apks) == 1: - apk = apks[0] - print(f"Выбран файл {apk}") - return apk - - while True: - print("Выберете файл для модификации") - for index, apk in enumerate(apks): - print(f"{index + 1}. {apk}") - print("0. Exit") - - try: - selected_index = int(input("\nВведите номер файла: ")) - if selected_index == 0: - sys.exit(0) - elif selected_index > len(apks): - print("Неверный номер файла") - else: - apk = apks[selected_index - 1] - print(f"Выбран файл {apk}") - return apk - except ValueError: - print("Неверный формат ввода") - except KeyboardInterrupt: - print("Прервано пользователем") - sys.exit(0) - - -def decompile_apk(apk: str): - print("Декомпилируем apk...") - try: - result = subprocess.run( - "tools/apktool d -f -o decompiled " + os.path.join("original", apk), - shell=True, - check=True, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - except subprocess.CalledProcessError as e: - print("Ошибка при выполнении команды:") - print(e.stderr) - sys.exit(1) - - -def compile_apk(apk: str): - print("Компилируем apk...") - try: - subprocess.run( - "tools/apktool b decompiled -o " + os.path.join("modified", apk), - shell=True, - check=True, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - subprocess.run( - "zipalign -v 4 " + os.path.join("modified", apk) + " " + os.path.join("modified", apk.replace(".apk", "-aligned.apk")), - shell=True, - check=True, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - subprocess.run( - "apksigner sign " + - "--v1-signing-enabled false " + - "--v2-signing-enabled true " + - "--v3-signing-enabled true " + - "--ks keystore.jks " + - "--ks-pass file:keystore.pass " + - "--out " + os.path.join("modified", apk.replace(".apk", "-mod.apk")) + - " " + os.path.join("modified", apk.replace(".apk", "-aligned.apk")), - shell=True, - check=True, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - title = "anixart mod " - with open('./decompiled/apktool.yml') as f: - package = yaml.safe_load(f) - title += ' '.join([f'{k}: {v}' for k, v in package['versionInfo'].items()]) - with open("./modified/report.log", "w") as log_file: - log_file.write(title+'\n') - log_file.write("\n".join([f"{patch.name}: {'applied' if patch.applied else 'failed'}" for patch in patches])) - except subprocess.CalledProcessError as e: - print("Ошибка при выполнении команды:") - print(e.stderr) - sys.exit(1) + local["java"]["-version"]() + console.print("[green]Java найдена") + except ProcessExecutionError: + console.print("[red]Java не установлена") + raise typer.Exit(1) +# ======================= PATCHING ========================= class Patch: - def __init__(self, name, pkg): + def __init__(self, name: str, module): self.name = name - self.package = pkg + self.module = module self.applied = False - try: - self.priority = pkg.priority - except AttributeError: - self.priority = 0 + self.priority = getattr(module, "priority", 0) def apply(self, conf: dict) -> bool: try: - self.applied = self.package.apply(conf) - return True + self.applied = bool(self.module.apply(conf)) + return self.applied except Exception as e: - print(f"Ошибка при применении патча {self.name}: {e}") - print(type(e), e.args) + console.print(f"[red]Ошибка в патче {self.name}: {e}") traceback.print_exc() return False -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Автоматический патчер anixart" - ) - parser.add_argument("-v", "--verbose", - action="store_true", - help="Выводить подробные сообщения") +def select_apk() -> Path: + apks = [f for f in ORIGINAL.glob("*.apk")] + if not apks: + console.print("[red]Нет apk-файлов в папке original") + raise typer.Exit(1) - parser.add_argument("-f", "--force", - action="store_true", - help="Принудительно собрать APK") + if len(apks) == 1: + console.print(f"[green]Выбран {apks[0].name}") + return apks[0] - args = parser.parse_args() + options = {str(i): apk for i, apk in enumerate(apks, 1)} + for k, v in options.items(): + console.print(f"{k}. {v.name}") - conf = init() + 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() - patch = decompile_apk(apk) + decompile(apk) - if args.verbose: conf["verbose"] = True + patch_settings = conf.get("patches", {}) + patch_objs: List[Patch] = [] - patches = [] - for filename in os.listdir("patches/"): - if filename.endswith(".py") and filename != "__init__.py" and not filename.startswith("todo_"): - module_name = filename[:-3] - module = importlib.import_module(f"patches.{module_name}") - patches.append(Patch(module_name, module)) + 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)) - patches.sort(key=lambda x: x.package.priority, reverse=True) + patch_objs.sort(key=lambda p: p.priority, reverse=True) - for patch in tqdm(patches, colour="green", desc="Применение патчей"): - tqdm.write(f"Применение патча: {patch.name}") - patch.apply(conf) + 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) - statuses = {} - for patch in patches: - statuses[patch.name] = patch.applied - marker = colorama.Fore.GREEN + "✔" if patch.applied else colorama.Fore.RED + "✘" - print(f"{marker}{colorama.Style.RESET_ALL} {patch.name}") - - if all(statuses.values()): - print(f"{colorama.Fore.GREEN}Все патчи успешно применены{colorama.Style.RESET_ALL}") - compile_apk(apk) - elif any(statuses.values()): - print(f"{colorama.Fore.YELLOW}⚠{colorama.Style.RESET_ALL} Некоторые патчи не были успешно применены") - if args.force or input("Продолжить? (y/n): ").lower() == "y": - compile_apk(apk) - else: - print(colorama.Fore.RED + "Операция отменена" + colorama.Style.RESET_ALL) + 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: - print(f"{colorama.Fore.RED}Ни один патч не был успешно применен{colorama.Style.RESET_ALL}") - sys.exit(1) + console.print("[red]Сборка отменена") + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/patches/cleanup.py b/patches/cleanup.py index 7de37e5..c19ae4b 100644 --- a/patches/cleanup.py +++ b/patches/cleanup.py @@ -15,7 +15,7 @@ def apply(config: dict) -> bool: if config.get("verbose", False): tqdm.write(f'Удалён файл: {item_path}') elif os.path.isdir(item_path): - if item not in config["cleanup"]["keep_dirs"]: + if item not in config["keep_dirs"]: shutil.rmtree(item_path) if config.get("verbose", False): tqdm.write(f'Удалена папка: {item_path}') diff --git a/patches/color_theme.py b/patches/color_theme.py index 0eb01dc..9ef3a32 100644 --- a/patches/color_theme.py +++ b/patches/color_theme.py @@ -11,11 +11,11 @@ from utils.public import ( ) def apply(config: dict) -> bool: - main_color = config["theme"]["colors"]["primary"] - splash_color = config["theme"]["colors"]["secondary"] - gradient_angle = config["theme"]["gradient"]["angle"] - gradient_from = config["theme"]["gradient"]["from"] - gradient_to = config["theme"]["gradient"]["to"] + main_color = config["colors"]["primary"] + splash_color = config["colors"]["secondary"] + gradient_angle = config["gradient"]["angle"] + gradient_from = config["gradient"]["from"] + gradient_to = config["gradient"]["to"] # No connection alert coolor with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file: diff --git a/patches/package_name.py b/patches/package_name.py index 3167a6c..3e0fce9 100644 --- a/patches/package_name.py +++ b/patches/package_name.py @@ -11,8 +11,6 @@ def rename_dir(src, dst): def apply(config: dict) -> bool: - assert config["new_package_name"] is not None, "new_package_name is not configured" - for root, dirs, files in os.walk("./decompiled"): for filename in files: file_path = os.path.join(root, filename) diff --git a/patches/settings_urls.py b/patches/settings_urls.py index 5f85190..22a8b40 100644 --- a/patches/settings_urls.py +++ b/patches/settings_urls.py @@ -32,7 +32,7 @@ def apply(config: dict) -> bool: # Insert new PreferenceCategory before the last element last = root[-1] # last element pos = root.index(last) - for section, items in config["settings_urls"].items(): + for section, items in config["menu"].items(): root.insert(pos, make_category(config["xml_ns"], section, items)) pos += 1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1000822 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +typer[all]>=0.9.0 +rich>=13.0.0 +httpx>=1.2.0 +pydantic>=2.2.0 +plumbum>=1.8.0 +lxml>=4.9.3 +PyYAML>=6.0 +tqdm>=4.66.0