This commit is contained in:
+228
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user