Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
99 lines
3.8 KiB
Python
99 lines
3.8 KiB
Python
"""Archive extraction and locating the game directory inside it."""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
|
|
class ArchiveError(Exception):
|
|
pass
|
|
|
|
|
|
def _safe_target(root: Path, name: str) -> Path | None:
|
|
target = (root / name).resolve()
|
|
return target if target.is_relative_to(root.resolve()) else None
|
|
|
|
|
|
def extract(archive: Path, filename: str, dest: Path, *, only_scripts: bool = False) -> None:
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
if filename.lower().endswith(".zip") and zipfile.is_zipfile(archive):
|
|
_extract_zip(archive, dest, only_scripts=only_scripts)
|
|
return
|
|
seven = shutil.which("7zz") or shutil.which("7z")
|
|
if not seven:
|
|
raise ArchiveError("Для этого формата нужен 7-Zip (7zz), он не установлен")
|
|
filters = [f"-ir!*{suffix}" for suffix in SCRIPT_EXTS | {".tar"}] if only_scripts else []
|
|
proc = subprocess.run([seven, "x", "-y", "-snl-", *filters, f"-o{dest}", str(archive)],
|
|
capture_output=True, text=True)
|
|
if proc.returncode not in (0, 1): # 1 = warnings
|
|
raise ArchiveError(f"7-Zip: {proc.stderr.strip() or proc.stdout.strip()[-500:]}")
|
|
_drop_escaping_links(dest)
|
|
# .tar.gz etc. extract to a single inner .tar first
|
|
inner = list(dest.iterdir())
|
|
if len(inner) == 1 and inner[0].suffix == ".tar":
|
|
tar = inner[0]
|
|
extract(tar, tar.name, dest / "_tar", only_scripts=only_scripts)
|
|
tar.unlink()
|
|
|
|
|
|
SCRIPT_EXTS = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
|
|
|
|
|
|
def _extract_zip(archive: Path, dest: Path, *, only_scripts: bool = False) -> None:
|
|
with zipfile.ZipFile(archive) as zf:
|
|
for info in zf.infolist():
|
|
name = info.filename
|
|
if only_scripts and Path(name).suffix.lower() not in SCRIPT_EXTS:
|
|
continue
|
|
if info.flag_bits & 0x800 == 0:
|
|
# legacy zips store names in cp437; many were really written as cp866/utf-8
|
|
try:
|
|
name = name.encode("cp437").decode("utf-8")
|
|
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
pass
|
|
target = _safe_target(dest, name)
|
|
if target is None:
|
|
continue
|
|
if info.is_dir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
with zf.open(info) as src, target.open("wb") as out:
|
|
shutil.copyfileobj(src, out, 1 << 20)
|
|
|
|
|
|
def _drop_escaping_links(root: Path) -> None:
|
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
for name in dirnames + filenames:
|
|
p = Path(dirpath, name)
|
|
if p.is_symlink():
|
|
p.unlink()
|
|
|
|
|
|
def find_game_dir(root: Path) -> Path:
|
|
"""Find the Ren'Py `game` directory (the one holding scripts or archives)."""
|
|
best: tuple[int, Path] | None = None
|
|
for dirpath, _, filenames in os.walk(root):
|
|
exts = {Path(f).suffix.lower() for f in filenames}
|
|
if not exts & {".rpy", ".rpyc", ".rpa", ".rpymc"}:
|
|
continue
|
|
p = Path(dirpath)
|
|
depth = len(p.relative_to(root).parts)
|
|
score = (0 if p.name.lower() == "game" else 100) + depth
|
|
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.parts[-3:]]:
|
|
continue
|
|
if best is None or score < best[0]:
|
|
best = (score, p)
|
|
if best is None:
|
|
raise ArchiveError("В архиве не найдена игра Ren'Py (нет .rpy/.rpyc/.rpa)")
|
|
game = best[1]
|
|
# scripts in a subfolder of game/ — walk up to game/ itself
|
|
for parent in [game, *game.parents]:
|
|
if parent.name.lower() == "game":
|
|
return parent
|
|
if parent == root:
|
|
break
|
|
return game
|