Обновление структуры проекта, использование pydantic для конфигураций, улучшение отчёта о патчах
Build mod / build (push) Successful in 6m39s

This commit is contained in:
2025-10-01 23:07:37 +03:00
parent 5ba590cc31
commit fbc8b3e017
25 changed files with 406 additions and 315 deletions
+2 -1
View File
@@ -60,6 +60,7 @@ jobs:
id: get_title id: get_title
run: | run: |
TITLE=$(head -n 1 modified/report.log) TITLE=$(head -n 1 modified/report.log)
tail -n +2 modified/report.log > modified/report.log.tmp
echo "title=${TITLE}" >> $GITHUB_OUTPUT echo "title=${TITLE}" >> $GITHUB_OUTPUT
- name: Setup go - name: Setup go
@@ -73,7 +74,7 @@ jobs:
uses: https://gitea.com/actions/release-action@main uses: https://gitea.com/actions/release-action@main
with: with:
title: ${{ steps.get_title.outputs.title }} title: ${{ steps.get_title.outputs.title }}
body_path: modified/report.log body_path: modified/report.log.tmp
draft: true draft: true
api_key: '${{secrets.RELEASE_TOKEN}}' api_key: '${{secrets.RELEASE_TOKEN}}'
files: |- files: |-
+1 -90
View File
@@ -1,101 +1,12 @@
{ {
"base": {
"tools": { "tools": {
"apktool_jar_url": "https://bitbucket.org/iBotPeaches/apktool/downloads/apktool_2.12.0.jar", "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" "apktool_wrapper_url": "https://raw.githubusercontent.com/iBotPeaches/Apktool/master/scripts/linux/apktool"
}, },
"base": {
"xml_ns": { "xml_ns": {
"android": "http://schemas.android.com/apk/res/android", "android": "http://schemas.android.com/apk/res/android",
"app": "http://schemas.android.com/apk/res-auto" "app": "http://schemas.android.com/apk/res-auto"
} }
},
"patches": {
"package_name": {
"enabled": true,
"new_package_name": "com.wowlikon.anixart"
},
"compress": {
"enabled": true,
"remove_language_files": true,
"remove_AI_voiceover": true,
"remove_debug_lines": true,
"remove_drawable_files": false,
"remove_unknown_files": true,
"remove_unknown_files_keep_dirs": ["META-INF", "kotlin"],
"compress_png_files": true
},
"change_server": {
"enabled": false,
"server": "https://anixarty.wowlikon.tech/modding"
},
"color_theme": {
"enabled": true,
"colors": {
"primary": "#ccff00",
"secondary": "#ffffd700",
"background": "#ffffff",
"text": "#000000"
},
"gradient": {
"angle": "135.0",
"from": "#ffff6060",
"to": "#ffccff00"
}
},
"replace_navbar": {
"enabled": true,
"items": ["home", "discover", "feed", "bookmarks", "profile"]
},
"custom_speed": {
"enabled": true,
"speeds": [0.0]
},
"disable_ad": {
"enabled": true
},
"disable_beta_banner": {
"enabled": true
},
"insert_new": {
"enabled": true
},
"settings_urls": {
"enabled": true,
"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://git.wowlikon.tech/anixart-mod",
"icon": "@drawable/ic_custom_crown",
"icon_space_reserved": "false"
}
]
},
"version": " by wowlikon"
}
} }
} }
+1
View File
@@ -0,0 +1 @@
{"enabled":true,"server":"https://anixarty.0x174.su/patch"}
+17
View File
@@ -0,0 +1,17 @@
{
"enabled": true,
"logo": {
"gradient": {
"angle": 0.0,
"start_color": "#ffccff00",
"end_color": "#ffcccc00"
},
"ears_color": "#ffd0d0d0"
},
"colors": {
"primary": "#ccff00",
"secondary": "#ffcccc00",
"background": "#ffffff",
"text": "#000000"
}
}
+1
View File
@@ -0,0 +1 @@
{"enabled":true,"remove_language_files":true,"remove_AI_voiceover":true,"remove_debug_lines":false,"remove_drawable_files":false,"remove_unknown_files":true,"remove_unknown_files_keep_dirs":["META-INF","kotlin"],"compress_png_files":true}
+1
View File
@@ -0,0 +1 @@
{"enabled":true}
+1
View File
@@ -0,0 +1 @@
{"enabled":true}
+1
View File
@@ -0,0 +1 @@
{"enabled":true}
+1
View File
@@ -0,0 +1 @@
{"enabled":true,"package_name":"com.wowlikon.anixart"}
+1
View File
@@ -0,0 +1 @@
{"enabled":true,"items":["home","discover","feed","bookmarks","profile"]}
+1
View File
@@ -0,0 +1 @@
{"enabled":true,"version":" by wowlikon","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://git.wowlikon.tech/anixart-mod","icon":"@drawable/ic_custom_crown","icon_space_reserved":"false"}]}}
+72 -132
View File
@@ -1,5 +1,4 @@
from pathlib import Path from typing import List, Dict, Any
from typing import List
import httpx import httpx
import typer import typer
@@ -14,80 +13,36 @@ from rich.progress import Progress
from rich.prompt import Prompt from rich.prompt import Prompt
from rich.table import Table from rich.table import Table
from utils.config import *
from utils.tools import *
# --- Paths --- # --- Paths ---
TOOLS = Path("tools")
ORIGINAL = Path("original")
MODIFIED = Path("modified")
DECOMPILED = Path("decompiled")
PATCHES = Path("patches")
console = Console() console = Console()
app = typer.Typer() app = typer.Typer()
# ======================= CONFIG ========================= # ======================= PATCHING =========================
class ToolsConfig(BaseModel): class Patch:
apktool_jar_url: str def __init__(self, name: str, module):
apktool_wrapper_url: str self.name = name
self.module = module
self.applied = False
class XmlNamespaces(BaseModel): self.priority = getattr(module, "priority", 0)
android: str
app: str
class BaseSection(BaseModel):
tools: ToolsConfig
xml_ns: XmlNamespaces
class Config(BaseModel):
base: BaseSection
patches: dict
def load_config() -> Config:
try: try:
return Config.model_validate_json(Path("config.json").read_text()) self.config = module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text())
except FileNotFoundError: except Exception as e:
console.print("[red]Файл config.json не найден") console.print(f"[red]Ошибка при загрузке конфигурации патча {name}: {e}")
raise typer.Exit(1) self.config = module.Config()
except ValidationError as e:
console.print("[red]Ошибка валидации config.json:", e)
raise typer.Exit(1)
def apply(self, conf: Dict[str, Any]) -> bool:
# ======================= 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: try:
prog() if hide_output else prog & FG self.applied = bool(self.module.apply(self.config, conf))
except ProcessExecutionError as e: return self.applied
console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}") except Exception as e:
console.print(e.stderr) console.print(f"[red]Ошибка в патче {self.name}: {e}")
raise typer.Exit(1) traceback.print_exc()
return False
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 ========================= # ======================= INIT =========================
@@ -95,13 +50,20 @@ def download(url: str, dest: Path):
def init(): def init():
"""Создание директорий и скачивание инструментов""" """Создание директорий и скачивание инструментов"""
ensure_dirs() ensure_dirs()
conf = load_config() conf = load_config(console)
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(): if not (TOOLS / "apktool.jar").exists():
download(conf.base.tools.apktool_jar_url, TOOLS / "apktool.jar") download(console, conf.tools.apktool_jar_url, TOOLS / "apktool.jar")
if not (TOOLS / "apktool").exists(): if not (TOOLS / "apktool").exists():
download(conf.base.tools.apktool_wrapper_url, TOOLS / "apktool") download(console, conf.tools.apktool_wrapper_url, TOOLS / "apktool")
(TOOLS / "apktool").chmod(0o755) (TOOLS / "apktool").chmod(0o755)
try: try:
@@ -116,9 +78,9 @@ def init():
@app.command() @app.command()
def info(patch_name: str = ""): def info(patch_name: str = ""):
"""Вывод информации о патче""" """Вывод информации о патче"""
conf = load_config().model_dump() conf = load_config(console).model_dump()
if patch_name: if patch_name:
patch = Patch(patch_name, __import__(f"patches.{patch_name}", fromlist=[''])) patch = Patch(patch_name, __import__(f"patches.{patch_name}", fromlist=[""]))
console.print(f"[green]Информация о патче {patch.name}:") console.print(f"[green]Информация о патче {patch.name}:")
console.print(f" [yellow]Приоритет: {patch.priority}") console.print(f" [yellow]Приоритет: {patch.priority}")
console.print(f" [yellow]Описание: {patch.module.__doc__}") console.print(f" [yellow]Описание: {patch.module.__doc__}")
@@ -128,30 +90,12 @@ def info(patch_name: str = ""):
if f.name.startswith("todo_") or f.name == "__init__.py": if f.name.startswith("todo_") or f.name == "__init__.py":
continue continue
name = f.stem name = f.stem
if conf['patches'].get(name,{}).get('enabled',True): if conf["patches"].get(name, {}).get("enabled", True):
console.print(f" [yellow]{name}: [green]✔ enabled") console.print(f" [yellow]{name}: [green]✔ enabled")
else: else:
console.print(f" [yellow]{name}: [red]✘ disabled") console.print(f" [yellow]{name}: [red]✘ disabled")
# ======================= 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: def select_apk() -> Path:
apks = [f for f in ORIGINAL.glob("*.apk")] apks = [f for f in ORIGINAL.glob("*.apk")]
if not apks: if not apks:
@@ -173,14 +117,12 @@ def select_apk() -> Path:
def decompile(apk: Path): def decompile(apk: Path):
console.print("[yellow]Декомпиляция apk...") console.print("[yellow]Декомпиляция apk...")
run( run(
console,
[ [
"java", "java",
"-jar", "-jar", str(TOOLS / "apktool.jar"),
str(TOOLS / "apktool.jar"), "d", "-f",
"d", "-o", str(DECOMPILED),
"-f",
"-o",
str(DECOMPILED),
str(apk), str(apk),
] ]
) )
@@ -188,52 +130,51 @@ def decompile(apk: Path):
def compile(apk: Path, patches: List[Patch]): def compile(apk: Path, patches: List[Patch]):
console.print("[yellow]Сборка apk...") console.print("[yellow]Сборка apk...")
out_apk = MODIFIED / apk.name
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") aligned = out_apk.with_stem(out_apk.stem + "-aligned")
signed = out_apk.with_stem(out_apk.stem + "-mod") signed = out_apk.with_stem(out_apk.stem + "-mod")
run( run(
console,
[ [
"java", "java",
"-jar", "-jar", str(TOOLS / "apktool.jar"),
str(TOOLS / "apktool.jar"), "b", str(DECOMPILED),
"b", "-o", str(out_apk),
str(DECOMPILED),
"-o",
str(out_apk),
] ]
) )
run(["zipalign", "-v", "4", str(out_apk), str(aligned)])
run( run(
console,
["zipalign", "-v", "4", str(out_apk), str(aligned)]
)
run(
console,
[ [
"apksigner", "apksigner", "sign",
"sign", "--v1-signing-enabled", "false",
"--v1-signing-enabled", "--v2-signing-enabled", "true",
"false", "--v3-signing-enabled", "true",
"--v2-signing-enabled", "--ks", "keystore.jks",
"true", "--ks-pass", "file:keystore.pass",
"--v3-signing-enabled", "--out", str(signed),
"true",
"--ks",
"keystore.jks",
"--ks-pass",
"file:keystore.pass",
"--out",
str(signed),
str(aligned), str(aligned),
] ]
) )
with open(DECOMPILED / "apktool.yml", encoding="utf-8") as f: console.print("[green]✔ APK успешно собран и подписан")
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: with open(MODIFIED / "report.log", "w", encoding="utf-8") as f:
f.write(f"anixart mod {version_str}\n") f.write(f"Anixarty mod v {version_name} ({version_code})\n")
for p in patches: for p in patches:
f.write(f"{p.name}: {'applied' if p.applied else 'failed'}\n") f.write(f"{'' if p.applied else ''} {p.name}\n")
@app.command() @app.command()
@@ -242,22 +183,21 @@ def build(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
): ):
"""Декомпиляция, патчи и сборка apk""" """Декомпиляция, патчи и сборка apk"""
conf = load_config().model_dump() conf = load_config(console)
apk = select_apk() apk = select_apk()
decompile(apk) decompile(apk)
patch_settings = conf.get("patches", {})
patch_objs: List[Patch] = [] patch_objs: List[Patch] = []
conf.base |= {"verbose": verbose}
for f in PATCHES.glob("*.py"): for f in PATCHES.glob("*.py"):
if f.name.startswith("todo_") or f.name == "__init__.py": if f.name.startswith("todo_") or f.name == "__init__.py":
continue continue
name = f.stem name = f.stem
settings = patch_settings.get(name, {}) module = importlib.import_module(f"patches.{name}")
if not settings.get("enabled", True): if not module.Config.model_validate_json((CONFIGS / f"{name}.json").read_text()):
console.print(f"[yellow]≫ Пропускаем {name}") console.print(f"[yellow]≫ Пропускаем {name}")
continue continue
module = importlib.import_module(f"patches.{name}")
patch_objs.append(Patch(name, module)) patch_objs.append(Patch(name, module))
patch_objs.sort(key=lambda p: p.priority, reverse=True) patch_objs.sort(key=lambda p: p.priority, reverse=True)
@@ -266,7 +206,7 @@ def build(
with Progress() as progress: with Progress() as progress:
task = progress.add_task("Патчи", total=len(patch_objs)) task = progress.add_task("Патчи", total=len(patch_objs))
for p in patch_objs: for p in patch_objs:
ok = p.apply(patch_settings.get(p.name, {}) | conf.get("base", {})) ok = p.apply(conf.base)
progress.console.print(f"{'' if ok else ''} {p.name}") progress.console.print(f"{'' if ok else ''} {p.name}")
progress.advance(task) progress.advance(task)
+10 -3
View File
@@ -3,7 +3,7 @@
"change_server": { "change_server": {
"enabled": true, "enabled": true,
"server": "https://anixarty.wowlikon.tech/modding" "server": "https://anixarty.0x174.su/patch"
} }
""" """
@@ -13,11 +13,18 @@ priority = 0
import json import json
import requests import requests
from tqdm import tqdm from tqdm import tqdm
from typing import Dict, Any
from pydantic import Field
from utils.config import PatchConfig
#Config
class Config(PatchConfig):
server: str = Field("https://anixarty.0x174.su/patch", description="URL сервера")
# Patch # Patch
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
response = requests.get(config['server']) response = requests.get(config.server)
assert response.status_code == 200, f"Failed to fetch data {response.status_code} {response.text}" assert response.status_code == 200, f"Failed to fetch data {response.status_code} {response.text}"
new_api = json.loads(response.text) new_api = json.loads(response.text)
+47 -22
View File
@@ -3,16 +3,19 @@
"color_theme": { "color_theme": {
"enabled": true, "enabled": true,
"logo": {
"gradient": {
"angle": 0.0,
"start_color": "#ffccff00",
"end_color": "#ffcccc00"
},
"ears_color": "#ffffd0d0"
},
"colors": { "colors": {
"primary": "#ccff00", "primary": "#ccff00",
"secondary": "#ffffd700", "secondary": "#ffcccc00",
"background": "#ffffff", "background": "#ffffff",
"text": "#000000" "text": "#000000"
},
"gradient": {
"angle": "135.0",
"from": "#ffff6060",
"to": "#ffccff00"
} }
} }
""" """
@@ -21,20 +24,40 @@ priority = 0
# imports # imports
from lxml import etree from lxml import etree
from typing import Dict, Any
from pydantic import Field, BaseModel
from utils.config import PatchConfig
from utils.public import ( from utils.public import (
insert_after_public, insert_after_public,
insert_after_color, insert_after_color,
change_color, change_color,
) )
#Config
class Gradient(BaseModel):
angle: float = Field(0.0, description="Угол градиента")
start_color: str = Field("#ffccff00", description="Начальный цвет градиента")
end_color: str = Field("#ffcccc00", description="Конечный цвет градиента")
class Logo(BaseModel):
gradient: Gradient = Field(Gradient(), description="Настройки градиента") # type: ignore [reportCallIssue]
ears_color: str = Field("#ffd0d0d0", description="Цвет ушей логотипа")
class Colors(BaseModel):
primary: str = Field("#ccff00", description="Основной цвет")
secondary: str = Field("#ffcccc00", description="Вторичный цвет")
background: str = Field("#ffffff", description="Фоновый цвет")
text: str = Field("#000000", description="Цвет текста")
class Config(PatchConfig):
logo: Logo = Field(Logo(), description="Настройки цветов логотипа") # type: ignore [reportCallIssue]
colors: Colors = Field(Colors(), description="Настройки цветов") # type: ignore [reportCallIssue]
# Patch # Patch
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
main_color = config["colors"]["primary"] main_color = config.colors.primary
splash_color = config["colors"]["secondary"] splash_color = config.colors.secondary
gradient_angle = config["gradient"]["angle"]
gradient_from = config["gradient"]["from"]
gradient_to = config["gradient"]["to"]
# No connection alert coolor # No connection alert coolor
with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file: with open("./decompiled/assets/no_connection.html", "r", encoding="utf-8") as file:
@@ -57,9 +80,9 @@ def apply(config: dict) -> bool:
root = tree.getroot() root = tree.getroot()
# Change attributes with namespace # Change attributes with namespace
root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle) root.set(f"{{{base['xml_ns']['android']}}}angle", str(config.logo.gradient.angle))
root.set(f"{{{config['xml_ns']['android']}}}startColor", gradient_from) root.set(f"{{{base['xml_ns']['android']}}}startColor", config.logo.gradient.start_color)
root.set(f"{{{config['xml_ns']['android']}}}endColor", gradient_to) root.set(f"{{{base['xml_ns']['android']}}}endColor", config.logo.gradient.end_color)
# Save back # Save back
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
@@ -72,10 +95,12 @@ def apply(config: dict) -> bool:
root = tree.getroot() root = tree.getroot()
# Finding "path" # Finding "path"
for el in root.findall("path", namespaces=config["xml_ns"]): for el in root.findall("path", namespaces=base["xml_ns"]):
name = el.get(f"{{{config['xml_ns']['android']}}}name") name = el.get(f"{{{base['xml_ns']['android']}}}name")
if name == "path": if name == "path":
el.set(f"{{{config['xml_ns']['android']}}}fillColor", splash_color) el.set(f"{{{base['xml_ns']['android']}}}fillColor", config.colors.secondary)
elif name in ["path_1", "path_2"]:
el.set(f"{{{base['xml_ns']['android']}}}fillColor", config.logo.ears_color)
# Save back # Save back
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
@@ -88,11 +113,11 @@ def apply(config: dict) -> bool:
root = tree.getroot() root = tree.getroot()
# Change attributes with namespace # Change attributes with namespace
root.set(f"{{{config['xml_ns']['android']}}}angle", gradient_angle) root.set(f"{{{base['xml_ns']['android']}}}angle", str(config.logo.gradient.angle))
items = root.findall("item", namespaces=config['xml_ns']) items = root.findall("item", namespaces=base['xml_ns'])
assert len(items) == 2 assert len(items) == 2
items[0].set(f"{{{config['xml_ns']['android']}}}color", gradient_from) items[0].set(f"{{{base['xml_ns']['android']}}}color", config.logo.gradient.start_color)
items[1].set(f"{{{config['xml_ns']['android']}}}color", gradient_to) items[1].set(f"{{{base['xml_ns']['android']}}}color", config.logo.gradient.end_color)
# Save back # Save back
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
+37 -25
View File
@@ -34,28 +34,41 @@ import os
import shutil import shutil
import subprocess import subprocess
from tqdm import tqdm from tqdm import tqdm
from typing import Dict, List, Any
from pydantic import Field
from utils.config import PatchConfig
from utils.smali_parser import get_smali_lines, save_smali_lines from utils.smali_parser import get_smali_lines, save_smali_lines
#Config
class Config(PatchConfig):
remove_language_files: bool = Field(True, description="Удаляет все языки кроме русского и английского")
remove_AI_voiceover: bool = Field(True, description="Заменяет ИИ озвучку маскота аниксы пустыми mp3 файлами")
remove_debug_lines: bool = Field(False, description="Удаляет строки `.line n` из smali файлов использованные для дебага")
remove_drawable_files: bool = Field(False, description="Удаляет неиспользованные drawable-* из директории decompiled/res")
remove_unknown_files: bool = Field(True, description="Удаляет файлы из директории decompiled/unknown")
remove_unknown_files_keep_dirs: List[str] = Field(["META-INF", "kotlin"], description="Оставляет указанные директории в decompiled/unknown")
compress_png_files: bool = Field(True, description="Сжимает PNG в директории decompiled/res")
# Patch # Patch
def remove_unknown_files(config): def remove_unknown_files(config: Config, base: Dict[str, Any]):
path = "./decompiled/unknown" path = "./decompiled/unknown"
items = os.listdir(path) items = os.listdir(path)
for item in items: for item in items:
item_path = f"{path}/{item}" item_path = f"{path}/{item}"
if os.path.isfile(item_path): if os.path.isfile(item_path):
os.remove(item_path) os.remove(item_path)
if config.get("verbose", False): if base.get("verbose", False):
tqdm.write(f"Удалён файл: {item_path}") tqdm.write(f"Удалён файл: {item_path}")
elif os.path.isdir(item_path): elif os.path.isdir(item_path):
if item not in config["remove_unknown_files_keep_dirs"]: if item not in config.remove_unknown_files_keep_dirs:
shutil.rmtree(item_path) shutil.rmtree(item_path)
if config.get("verbose", False): if base.get("verbose", False):
tqdm.write(f"Удалёна директория: {item_path}") tqdm.write(f"Удалёна директория: {item_path}")
return True return True
def remove_debug_lines(config): def remove_debug_lines(config: Dict[str, Any]):
for root, _, files in os.walk("./decompiled"): for root, _, files in os.walk("./decompiled"):
for filename in files: for filename in files:
file_path = os.path.join(root, filename) file_path = os.path.join(root, filename)
@@ -72,14 +85,13 @@ def remove_debug_lines(config):
return True return True
def compress_png(config, png_path: str): def compress_png(config: Dict[str, Any], png_path: str):
try: try:
assert subprocess.run( assert subprocess.run(
[ [
"pngquant", "pngquant",
"--force", "--force",
"--ext", "--ext", ".png",
".png",
"--quality=65-90", "--quality=65-90",
png_path, png_path,
], ],
@@ -93,7 +105,7 @@ def compress_png(config, png_path: str):
return False return False
def compress_png_files(config): def compress_png_files(config: Dict[str, Any]):
compressed = [] compressed = []
for root, _, files in os.walk("./decompiled"): for root, _, files in os.walk("./decompiled"):
for file in files: for file in files:
@@ -103,7 +115,7 @@ def compress_png_files(config):
return len(compressed) > 0 and any(compressed) return len(compressed) > 0 and any(compressed)
def remove_AI_voiceover(config): def remove_AI_voiceover(config: Dict[str, Any]):
blank = "./resources/blank.mp3" blank = "./resources/blank.mp3"
path = "./decompiled/res/raw" path = "./decompiled/res/raw"
files = [ files = [
@@ -135,7 +147,7 @@ def remove_AI_voiceover(config):
return True return True
def remove_language_files(config): def remove_language_files(config: Dict[str, Any]):
path = "./decompiled/res" path = "./decompiled/res"
folders = [ folders = [
"values-af", "values-af",
@@ -236,7 +248,7 @@ def remove_language_files(config):
return True return True
def remove_drawable_files(config): def remove_drawable_files(config: Dict[str, Any]):
path = "./decompiled/res" path = "./decompiled/res"
folders = [ folders = [
"drawable-en-hdpi", "drawable-en-hdpi",
@@ -269,29 +281,29 @@ def remove_drawable_files(config):
return True return True
def apply(config) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
if config["remove_unknown_files"]: if config.remove_unknown_files:
tqdm.write(f"Удаление неизвестных файлов...") tqdm.write(f"Удаление неизвестных файлов...")
remove_unknown_files(config) remove_unknown_files(config, base)
if config["remove_drawable_files"]: if config.remove_drawable_files:
tqdm.write(f"Удаление директорий drawable-xx...") tqdm.write(f"Удаление директорий drawable-xx...")
remove_drawable_files(config) remove_drawable_files(base)
if config["compress_png_files"]: if config.compress_png_files:
tqdm.write(f"Сжатие PNG файлов...") tqdm.write(f"Сжатие PNG файлов...")
compress_png_files(config) compress_png_files(base)
if config["remove_language_files"]: if config.remove_language_files:
tqdm.write(f"Удаление языков...") tqdm.write(f"Удаление языков...")
remove_language_files(config) remove_language_files(base)
if config["remove_AI_voiceover"]: if config.remove_AI_voiceover:
tqdm.write(f"Удаление ИИ озвучки...") tqdm.write(f"Удаление ИИ озвучки...")
remove_AI_voiceover(config) remove_AI_voiceover(base)
if config["remove_debug_lines"]: if config.remove_debug_lines:
tqdm.write(f"Удаление дебаг линий...") tqdm.write(f"Удаление дебаг линий...")
remove_debug_lines(config) remove_debug_lines(base)
return True return True
+11 -3
View File
@@ -10,6 +10,10 @@ priority = 0
# imports # imports
import textwrap import textwrap
from tqdm import tqdm
from typing import Dict, Any
from utils.config import PatchConfig
from utils.smali_parser import ( from utils.smali_parser import (
find_smali_method_end, find_smali_method_end,
find_smali_method_start, find_smali_method_start,
@@ -18,13 +22,17 @@ from utils.smali_parser import (
) )
#Config
class Config(PatchConfig): ...
# Patch # Patch
def apply(config) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
replacement = textwrap.dedent("""\ replacement = [f'\t{line}\n' for line in textwrap.dedent("""\
.locals 0 .locals 0
const/4 p0, 0x1 const/4 p0, 0x1
return p0 return p0
""").splitlines() """).splitlines()]
path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/Prefs.smali" path = "./decompiled/smali_classes2/com/swiftsoft/anixartd/Prefs.smali"
lines = get_smali_lines(path) lines = get_smali_lines(path)
+9 -3
View File
@@ -12,10 +12,16 @@ priority = 0
import os import os
from tqdm import tqdm from tqdm import tqdm
from lxml import etree from lxml import etree
from typing import Dict, Any
from utils.config import PatchConfig
from utils.smali_parser import get_smali_lines, save_smali_lines
#Config
class Config(PatchConfig): ...
# Patch # Patch
def apply(config) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
attributes = [ attributes = [
"paddingTop", "paddingTop",
"paddingBottom", "paddingBottom",
@@ -36,9 +42,9 @@ def apply(config) -> bool:
root = tree.getroot() root = tree.getroot()
for attr in attributes: for attr in attributes:
if config["verbose"]: if base.get("verbose", False):
tqdm.write(f"set {attr} = 0.0dip") tqdm.write(f"set {attr} = 0.0dip")
root.set(f"{{{config["xml_ns"]['android']}}}{attr}", "0.0dip") root.set(f"{{{base['xml_ns']['android']}}}{attr}", "0.0dip")
tree.write( tree.write(
beta_banner_xml, pretty_print=True, xml_declaration=True, encoding="utf-8" beta_banner_xml, pretty_print=True, xml_declaration=True, encoding="utf-8"
+6 -1
View File
@@ -11,11 +11,16 @@ priority = 0
# imports # imports
import os import os
import shutil import shutil
from typing import Dict, Any
from utils.config import PatchConfig
from utils.public import insert_after_public from utils.public import insert_after_public
#Config
class Config(PatchConfig): ...
# Patch # Patch
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
# Mod first launch window # Mod first launch window
shutil.copy("./resources/avatar.png", "./decompiled/assets/avatar.png") shutil.copy("./resources/avatar.png", "./decompiled/assets/avatar.png")
shutil.copy( shutil.copy(
+16 -11
View File
@@ -12,7 +12,15 @@ priority = -1
# imports # imports
import os import os
from lxml import etree from lxml import etree
from tqdm import tqdm
from typing import Dict, Any
from pydantic import Field
from utils.config import PatchConfig
#Config
class Config(PatchConfig):
package_name: str = Field("com.wowlikon.anixart", description="Название пакета")
# Patch # Patch
def rename_dir(src, dst): def rename_dir(src, dst):
@@ -20,7 +28,7 @@ def rename_dir(src, dst):
os.rename(src, dst) os.rename(src, dst)
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
for root, dirs, files in os.walk("./decompiled"): for root, dirs, files in os.walk("./decompiled"):
for filename in files: for filename in files:
file_path = os.path.join(root, filename) file_path = os.path.join(root, filename)
@@ -31,11 +39,11 @@ def apply(config: dict) -> bool:
file_contents = file.read() file_contents = file.read()
new_contents = file_contents.replace( new_contents = file_contents.replace(
"com.swiftsoft.anixartd", config["new_package_name"] "com.swiftsoft.anixartd", config.package_name
) )
new_contents = new_contents.replace( new_contents = new_contents.replace(
"com/swiftsoft/anixartd", "com/swiftsoft/anixartd",
config["new_package_name"].replace(".", "/"), config.package_name.replace(".", "/"),
) )
with open(file_path, "w", encoding="utf-8") as file: with open(file_path, "w", encoding="utf-8") as file:
file.write(new_contents) file.write(new_contents)
@@ -46,7 +54,7 @@ def apply(config: dict) -> bool:
rename_dir( rename_dir(
"./decompiled/smali/com/swiftsoft/anixartd", "./decompiled/smali/com/swiftsoft/anixartd",
os.path.join( os.path.join(
"./decompiled", "smali", config["new_package_name"].replace(".", "/") "./decompiled", "smali", config.package_name.replace(".", "/")
), ),
) )
if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"): if os.path.exists("./decompiled/smali_classes2/com/swiftsoft/anixartd"):
@@ -55,7 +63,7 @@ def apply(config: dict) -> bool:
os.path.join( os.path.join(
"./decompiled", "./decompiled",
"smali_classes2", "smali_classes2",
config["new_package_name"].replace(".", "/"), config.package_name.replace(".", "/"),
), ),
) )
if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"): if os.path.exists("./decompiled/smali_classes4/com/swiftsoft"):
@@ -64,7 +72,7 @@ def apply(config: dict) -> bool:
os.path.join( os.path.join(
"./decompiled", "./decompiled",
"smali_classes4", "smali_classes4",
"/".join(config["new_package_name"].split(".")[:-1]), "/".join(config.package_name.split(".")[:-1]),
), ),
) )
@@ -88,7 +96,7 @@ def apply(config: dict) -> bool:
new_contents = file_contents.replace( new_contents = file_contents.replace(
"com/swiftsoft", "com/swiftsoft",
"/".join(config["new_package_name"].split(".")[:-1]), "/".join(config.package_name.split(".")[:-1]),
) )
with open(file_path, "w", encoding="utf-8") as file: with open(file_path, "w", encoding="utf-8") as file:
file.write(new_contents) file.write(new_contents)
@@ -101,11 +109,8 @@ def apply(config: dict) -> bool:
root = tree.getroot() root = tree.getroot()
last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0] last_linear = root.xpath("//LinearLayout/LinearLayout[4]")[0]
last_linear.set(f"{{{config['xml_ns']['android']}}}visibility", "gone") last_linear.set(f"{{{base['xml_ns']['android']}}}visibility", "gone")
tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8") tree.write(file_path, pretty_print=True, xml_declaration=True, encoding="utf-8")
return True return True
# smali_classes2/com/wowlikon/anixart/utils/DeviceInfoUtil.smali: const-string v3, "\u0411\u0430\u0433-\u0440\u0435\u043f\u043e\u0440\u0442 9.0 BETA 5 (25062213)"
+13 -6
View File
@@ -12,36 +12,43 @@ priority = 0
# imports # imports
from lxml import etree from lxml import etree
from tqdm import tqdm from tqdm import tqdm
from typing import Dict, List, Any
from pydantic import Field
from utils.config import PatchConfig
#Config
class Config(PatchConfig):
items: List[str] = Field(["home", "discover", "feed", "bookmarks", "profile"], description="Список элементов в панели навигации")
# Patch # Patch
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
file_path = "./decompiled/res/menu/bottom.xml" file_path = "./decompiled/res/menu/bottom.xml"
parser = etree.XMLParser(remove_blank_text=True) parser = etree.XMLParser(remove_blank_text=True)
tree = etree.parse(file_path, parser) tree = etree.parse(file_path, parser)
root = tree.getroot() root = tree.getroot()
items = root.findall("item", namespaces=config['xml_ns']) items = root.findall("item", namespaces=base['xml_ns'])
def get_id_suffix(item): def get_id_suffix(item):
full_id = item.get(f"{{{config['xml_ns']['android']}}}id") full_id = item.get(f"{{{base['xml_ns']['android']}}}id")
return full_id.split("tab_")[-1] if full_id else None return full_id.split("tab_")[-1] if full_id else None
items_by_id = {get_id_suffix(item): item for item in items} items_by_id = {get_id_suffix(item): item for item in items}
existing_order = [get_id_suffix(item) for item in items] existing_order = [get_id_suffix(item) for item in items]
ordered_items = [] ordered_items = []
for key in config['items']: for key in config.items:
if key in items_by_id: if key in items_by_id:
ordered_items.append(items_by_id[key]) ordered_items.append(items_by_id[key])
extra = [i for i in items if get_id_suffix(i) not in config['items']] extra = [i for i in items if get_id_suffix(i) not in config.items]
if extra: if extra:
tqdm.write("⚠Найдены лишние элементы: " + str([get_id_suffix(i) for i in extra])) tqdm.write("⚠Найдены лишние элементы: " + str([get_id_suffix(i) for i in extra]))
ordered_items.extend(extra) ordered_items.extend(extra)
for i in root.findall("item", namespaces=config['xml_ns']): for i in root.findall("item", namespaces=base['xml_ns']):
root.remove(i) root.remove(i)
for item in ordered_items: for item in ordered_items:
+48 -4
View File
@@ -26,8 +26,52 @@ priority = 0
# imports # imports
import shutil import shutil
from lxml import etree from lxml import etree
from tqdm import tqdm
from typing import Dict, List, Any
from pydantic import Field
from utils.config import PatchConfig
from utils.public import insert_after_public from utils.public import insert_after_public
#Config
DEFAULT_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://git.wowlikon.tech/anixart-mod",
"icon": "@drawable/ic_custom_crown",
"icon_space_reserved": "false"
}
]
}
class Config(PatchConfig):
version: str = Field(" by wowlikon", description="Суффикс версии")
menu: Dict[str, List[Dict[str, str]]] = Field(DEFAULT_MENU, description="Меню")
# Patch # Patch
def make_category(ns, name, items): def make_category(ns, name, items):
@@ -49,7 +93,7 @@ def make_category(ns, name, items):
return cat return cat
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
shutil.copy( shutil.copy(
"./resources/ic_custom_crown.xml", "./resources/ic_custom_crown.xml",
"./decompiled/res/drawable/ic_custom_crown.xml", "./decompiled/res/drawable/ic_custom_crown.xml",
@@ -70,8 +114,8 @@ def apply(config: dict) -> bool:
# Insert new PreferenceCategory before the last element # Insert new PreferenceCategory before the last element
last = root[-1] # last element last = root[-1] # last element
pos = root.index(last) pos = root.index(last)
for section, items in config["menu"].items(): for section, items in config.menu.items():
root.insert(pos, make_category(config["xml_ns"], section, items)) root.insert(pos, make_category(base["xml_ns"], section, items))
pos += 1 pos += 1
# Save back # Save back
@@ -86,7 +130,7 @@ def apply(config: dict) -> bool:
with open(filepath, "r", encoding="utf-8") as file: with open(filepath, "r", encoding="utf-8") as file:
for line in file.readlines(): for line in file.readlines():
if '"\u0412\u0435\u0440\u0441\u0438\u044f'.encode("unicode_escape").decode() in line: if '"\u0412\u0435\u0440\u0441\u0438\u044f'.encode("unicode_escape").decode() in line:
content += line[:line.rindex('"')] + config["version"] + line[line.rindex('"'):] content += line[:line.rindex('"')] + config.version + line[line.rindex('"'):]
else: else:
content += line content += line
with open(filepath, "w", encoding="utf-8") as file: with open(filepath, "w", encoding="utf-8") as file:
+11 -2
View File
@@ -10,19 +10,28 @@
priority = 0 priority = 0
# imports # imports
from tqdm import tqdm
from typing import Dict, List, Any
from pydantic import Field
from utils.config import PatchConfig
from utils.smali_parser import float_to_hex from utils.smali_parser import float_to_hex
from utils.public import ( from utils.public import (
insert_after_public, insert_after_public,
insert_after_id, insert_after_id,
) )
#Config
class Config(PatchConfig):
speeds: List[float] = Field([9.0], description="Список пользовательских скоростей воспроизведения")
# Patch # Patch
def apply(config: dict) -> bool: def apply(config: Config, base: Dict[str, Any]) -> bool:
assert float_to_hex(1.5) == "0x3fc00000" assert float_to_hex(1.5) == "0x3fc00000"
last = "speed75" last = "speed75"
for speed in config.get("speeds", []): for speed in config.speeds:
insert_after_public(last, f"speed{int(float(speed)*10)}") insert_after_public(last, f"speed{int(float(speed)*10)}")
insert_after_id(last, f"speed{int(float(speed)*10)}") insert_after_id(last, f"speed{int(float(speed)*10)}")
last = f"speed{int(float(speed)*10)}" last = f"speed{int(float(speed)*10)}"
+18 -7
View File
@@ -5,22 +5,24 @@
Каждый патч должен независеть от других патчей и проверять себя при применении. Он не должен вернуть True, если есть проблемы. Каждый патч должен независеть от других патчей и проверять себя при применении. Он не должен вернуть True, если есть проблемы.
На данный момент каждый патч должен иметь функцию `apply`, которая принимает на вход конфигурацию и возвращает True или False. На данный момент каждый патч должен иметь функцию `apply`, которая принимает на вход конфигурацию и возвращает True или False.
И модель `Config`, которая наследуется от `PatchConfig` (поле `enabled` добавлять не нужно).
Это позволяет упростить конфигурирование и проверять типы данных, и делать значения по умолчанию.
При успешном применении патча, функция apply должна вернуть True, иначе False. При успешном применении патча, функция apply должна вернуть True, иначе False.
Ошибка будет интерпретирована как False. С выводом ошибки в консоль. Ошибка будет интерпретирована как False. С выводом ошибки в консоль.
Ещё патч должен иметь переменную `priority`, которая указывает приоритет патча, чем выше, тем раньше он будет применен. Ещё патч должен иметь переменную `priority`, которая указывает приоритет патча, чем выше, тем раньше он будет применен.
Коротко о конфигурации. Она получается из `config.json` из config["patches"][patch_name]. Коротко о конфигурации. Она состоит из двух частей `config` (на основе модели `Config` и из файла `configs/название_патча.json`).
Дополнительно в основном методе `apply` кроме этого есть "verbose" и содержимое "base". И постоянной не типизированной переменной `base` из `config.json` и флага `verbose`.
Эти поля передаются всем патчам. `verbose` может быть указан так-же как флаг запуска:
``` ```
python ./main.py build --verbose python ./main.py build --verbose
``` ```
В конце файла должно быть описание конфигурации патча. В конце docstring может быть дополнительное описание конфигурации патча (основное описание получается из модели `Config`).
Это может быть как короткий фрагмент из названия патча и одной опции "enabled", которая обрабатывается в коде патчера. Это может быть как короткий фрагмент из названия патча и одной опции "enabled", которая обрабатывается в коде патчера.
"todo_template": { "todo_template": {
"enabled": true // Пример описания тк этот текст просто пример "enabled": true, // Пример описания тк этот текст просто пример
"example": true // Пример кастомного параметра
} }
""" """
@@ -28,11 +30,20 @@ priority = 0 # Приоритет патча, чем выше, тем раньш
# imports # imports
from tqdm import tqdm from tqdm import tqdm
from typing import Dict, List, Any
from pydantic import Field
from utils.config import PatchConfig
#Config
class Config(PatchConfig):
example: bool = Field(True, description="Пример кастомного параметра")
# Patch # Patch
def apply(config: dict) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE def apply(config: Config, base: Dict[str, Any]) -> bool: # Анотации типов для удобства, читаемости и поддержки IDE
tqdm.write("Вывод информации через tqdm, чтобы не мешать прогресс-бару") tqdm.write("Вывод информации через tqdm, чтобы не мешать прогресс-бару")
if config["verbose"]: tqdm.write("Пример включен" if config.example else "Пример отключен")
if base["verbose"]:
tqdm.write("Для вывода подробной и отладочной информации используйте флаг --verbose") tqdm.write("Для вывода подробной и отладочной информации используйте флаг --verbose")
return True return True
+30
View File
@@ -0,0 +1,30 @@
from pydantic import BaseModel, Field, ValidationError
from rich.console import Console
from typing import Dict, Any
from pathlib import Path
import typer
class ToolsConfig(BaseModel):
apktool_jar_url: str
apktool_wrapper_url: str
class Config(BaseModel):
tools: ToolsConfig
base: Dict[str, Any]
class PatchConfig(BaseModel):
enabled: bool = Field(True, description="Включить или отключить патч")
def load_config(console: Console) -> 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)
+45
View File
@@ -0,0 +1,45 @@
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
TOOLS = Path("tools")
ORIGINAL = Path("original")
MODIFIED = Path("modified")
DECOMPILED = Path("decompiled")
PATCHES = Path("patches")
CONFIGS = Path("configs")
def ensure_dirs():
for d in [TOOLS, ORIGINAL, MODIFIED, DECOMPILED, PATCHES, CONFIGS]:
d.mkdir(exist_ok=True)
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]
except ProcessExecutionError as e:
console.print(f"[red]Ошибка при выполнении команды: {' '.join(cmd)}")
console.print(e.stderr)
raise typer.Exit(1)
def download(console: Console, 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))