355 lines
12 KiB
Python
355 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from urllib.request import Request, urlopen
|
|
|
|
from .bsipa import BSIPA_PLUGIN_ID, bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap
|
|
from .config import is_windows
|
|
from .fsutil import sha256_file
|
|
from .installer import apply_plan
|
|
from .models import LockedPlugin, Lockfile, Registry
|
|
from .planner import create_plan
|
|
from .scanner import scan_bootstrap_files
|
|
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
|
|
|
|
|
|
DEFAULT_IPA_TIMEOUT_SECONDS = 30
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _default_proton() -> Path:
|
|
return Path.home() / ".local/share/Steam/steamapps/common/Proton - Experimental/proton"
|
|
|
|
|
|
def _proton_env(instance_path: Path) -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
env.update(
|
|
{
|
|
"SteamAppId": "620980",
|
|
"SteamOverlayGameId": "620980",
|
|
"SteamGameId": "620980",
|
|
"WINEDLLOVERRIDES": "winhttp=n,b",
|
|
"STEAM_COMPAT_DATA_PATH": str(Path.home() / ".local/share/BSManager/SharedContent/compatdata"),
|
|
"STEAM_COMPAT_INSTALL_PATH": str(instance_path),
|
|
"STEAM_COMPAT_CLIENT_INSTALL_PATH": str(Path.home() / ".local/share/Steam"),
|
|
"STEAM_COMPAT_APP_ID": "620980",
|
|
"SteamEnv": "1",
|
|
}
|
|
)
|
|
return env
|
|
|
|
|
|
def _files_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
before_by_path = {item["path"]: item for item in before}
|
|
after_by_path = {item["path"]: item for item in after}
|
|
created = [after_by_path[path] for path in sorted(after_by_path.keys() - before_by_path.keys())]
|
|
removed = [before_by_path[path] for path in sorted(before_by_path.keys() - after_by_path.keys())]
|
|
mutated = [
|
|
{
|
|
"path": path,
|
|
"before": before_by_path[path],
|
|
"after": after_by_path[path],
|
|
}
|
|
for path in sorted(before_by_path.keys() & after_by_path.keys())
|
|
if before_by_path[path].get("sha256") != after_by_path[path].get("sha256")
|
|
]
|
|
return {"created": created, "mutated": mutated, "removed": removed}
|
|
|
|
|
|
def _github_headers() -> dict[str, str]:
|
|
headers = {
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "plugin-helper",
|
|
}
|
|
token = os.environ.get("GITHUB_TOKEN")
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
return headers
|
|
|
|
|
|
def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict[str, Any]:
|
|
if not locked.repo or not locked.tag or not locked.asset:
|
|
raise ValueError("locked BSIPA entry needs repo, tag, and asset to fetch its archive")
|
|
release_url = f"https://api.github.com/repos/{locked.repo}/releases/tags/{locked.tag}"
|
|
request = Request(release_url, headers=_github_headers())
|
|
with urlopen(request, timeout=20) as response:
|
|
release = response.read().decode("utf-8")
|
|
|
|
import json
|
|
|
|
data = json.loads(release)
|
|
assets = data.get("assets", [])
|
|
selected = next((asset for asset in assets if asset.get("name") == locked.asset), None)
|
|
if not selected:
|
|
raise FileNotFoundError(f"{locked.id}: release {locked.tag} does not contain asset {locked.asset}")
|
|
download_url = selected.get("browser_download_url")
|
|
if not download_url:
|
|
raise ValueError(f"{locked.id}: GitHub asset has no browser_download_url")
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
asset_request = Request(download_url, headers={"User-Agent": "plugin-helper"})
|
|
with urlopen(asset_request, timeout=60) as response:
|
|
destination.write_bytes(response.read())
|
|
actual_sha = sha256_file(destination)
|
|
if locked.sha256 and actual_sha != locked.sha256:
|
|
destination.unlink(missing_ok=True)
|
|
raise ValueError(f"{locked.id}: fetched asset sha256 mismatch")
|
|
return {
|
|
"repo": locked.repo,
|
|
"tag": locked.tag,
|
|
"asset": locked.asset,
|
|
"url": download_url,
|
|
"path": str(destination),
|
|
"sha256": actual_sha,
|
|
}
|
|
|
|
|
|
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
|
|
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
|
|
if not locked:
|
|
raise ValueError("version lock does not include a bsipa entry")
|
|
if not locked.asset:
|
|
raise ValueError("locked BSIPA entry has no asset")
|
|
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
|
|
if destination.is_file():
|
|
actual_sha = sha256_file(destination)
|
|
if locked.sha256 and actual_sha != locked.sha256:
|
|
raise ValueError(f"{locked.id}: existing asset sha256 mismatch: {destination}")
|
|
return {
|
|
"asset": locked.asset,
|
|
"path": str(destination),
|
|
"sha256": actual_sha,
|
|
"cached": True,
|
|
}
|
|
result = _fetch_github_release_asset(locked, destination)
|
|
result["cached"] = False
|
|
return result
|
|
|
|
|
|
def build_bootstrap_command(
|
|
ipa: Path,
|
|
*,
|
|
proton: Path | None = None,
|
|
native: bool | None = None,
|
|
) -> list[str]:
|
|
beat_saber_exe = ipa.with_name("Beat Saber.exe")
|
|
use_native = is_windows() if native is None else native
|
|
if use_native:
|
|
return [str(ipa), str(beat_saber_exe), "-n"]
|
|
proton_path = proton or _default_proton()
|
|
return [str(proton_path), "run", str(ipa), str(beat_saber_exe), "-n"]
|
|
|
|
|
|
def _terminate_process(process: subprocess.Popen[str]) -> None:
|
|
if is_windows():
|
|
process.terminate()
|
|
else:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
|
|
|
|
def _kill_process(process: subprocess.Popen[str]) -> None:
|
|
if is_windows():
|
|
process.kill()
|
|
else:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
|
|
|
|
def _run_ipa(
|
|
*,
|
|
command: list[str],
|
|
instance_path: Path,
|
|
timeout_seconds: int,
|
|
native: bool = False,
|
|
) -> dict[str, Any]:
|
|
popen_kwargs: dict[str, Any] = {
|
|
"cwd": instance_path,
|
|
"text": True,
|
|
"stdout": subprocess.PIPE,
|
|
"stderr": subprocess.PIPE,
|
|
}
|
|
if native:
|
|
popen_kwargs["env"] = os.environ.copy()
|
|
else:
|
|
popen_kwargs["env"] = _proton_env(instance_path)
|
|
popen_kwargs["start_new_session"] = True
|
|
|
|
process = subprocess.Popen(command, **popen_kwargs)
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
timed_out = False
|
|
except subprocess.TimeoutExpired:
|
|
timed_out = True
|
|
_terminate_process(process)
|
|
try:
|
|
stdout, stderr = process.communicate(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
_kill_process(process)
|
|
stdout, stderr = process.communicate()
|
|
return {
|
|
"returncode": process.returncode,
|
|
"stdout": stdout,
|
|
"stderr": stderr,
|
|
"timedOut": timed_out,
|
|
"timeoutSeconds": timeout_seconds,
|
|
}
|
|
|
|
|
|
def run_bootstrap(
|
|
*,
|
|
instance: str,
|
|
instance_path: Path,
|
|
beat_saber_version: str,
|
|
registry: Registry,
|
|
lockfile: Lockfile,
|
|
state_root: Path,
|
|
repo_root: Path,
|
|
proton: Path | None = None,
|
|
native: bool | None = None,
|
|
progress: Callable[[str], None] | None = None,
|
|
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
|
|
install_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
tell = progress or (lambda _message: None)
|
|
use_native = is_windows() if native is None else native
|
|
bootstrap_mode = "native" if use_native else "proton"
|
|
|
|
tell("Fetching locked BSIPA archive")
|
|
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
|
|
if fetched.get("cached"):
|
|
tell(f"Using cached BSIPA archive: {fetched['path']}")
|
|
else:
|
|
tell(f"Downloaded BSIPA archive: {fetched['path']}")
|
|
|
|
tell("Scanning bootstrap files before install")
|
|
before = scan_bootstrap_files(instance_path)
|
|
tell("Creating BSIPA bootstrap plan")
|
|
plan, plan_path = create_plan(
|
|
instance=instance,
|
|
instance_path=instance_path,
|
|
beat_saber_version=beat_saber_version,
|
|
registry=registry,
|
|
lockfile=lockfile,
|
|
state_root=state_root,
|
|
repo_root=repo_root,
|
|
selected={BSIPA_PLUGIN_ID},
|
|
require_bootstrap=False,
|
|
install_id=install_id,
|
|
)
|
|
if not plan["changes"]:
|
|
raise ValueError("BSIPA bootstrap plan has no changes")
|
|
|
|
tell(f"Applying BSIPA bootstrap plan with {len(plan['changes'])} file changes")
|
|
apply_result = apply_plan(plan, state_root)
|
|
|
|
ipa = instance_path / "IPA.exe"
|
|
if not ipa.is_file():
|
|
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
|
|
beat_saber_exe = instance_path / "Beat Saber.exe"
|
|
if not beat_saber_exe.is_file():
|
|
raise FileNotFoundError(f"Beat Saber executable not found: {beat_saber_exe}")
|
|
|
|
command = build_bootstrap_command(ipa, proton=proton, native=use_native)
|
|
if use_native:
|
|
tell(f"Running IPA.exe \"Beat Saber.exe\" -n natively; timeout {ipa_timeout_seconds}s")
|
|
else:
|
|
proton_path = proton or _default_proton()
|
|
if not proton_path.is_file():
|
|
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
|
|
tell(f"Running IPA.exe \"Beat Saber.exe\" -n through Proton; timeout {ipa_timeout_seconds}s")
|
|
|
|
completed = _run_ipa(
|
|
command=command,
|
|
instance_path=instance_path,
|
|
timeout_seconds=ipa_timeout_seconds,
|
|
native=use_native,
|
|
)
|
|
tell("Scanning bootstrap files after IPA.exe \"Beat Saber.exe\" -n")
|
|
after = scan_bootstrap_files(instance_path)
|
|
delta = _files_delta(before, after)
|
|
state: dict[str, Any] = {
|
|
"schemaVersion": 1,
|
|
"instance": instance,
|
|
"beatSaberVersion": beat_saber_version,
|
|
"bootstrappedAt": _now_iso(),
|
|
"plugin": BSIPA_PLUGIN_ID,
|
|
"bootstrapMode": bootstrap_mode,
|
|
"archive": fetched,
|
|
"planPath": str(plan_path),
|
|
"applied": apply_result["applied"],
|
|
"command": command,
|
|
"ipaExitCode": completed["returncode"],
|
|
"ipaTimedOut": completed["timedOut"],
|
|
"ipaTimeoutSeconds": completed["timeoutSeconds"],
|
|
"ipaStdout": completed["stdout"][-20000:],
|
|
"ipaStderr": completed["stderr"][-20000:],
|
|
"files": after,
|
|
"delta": delta,
|
|
"health": {},
|
|
}
|
|
if install_id:
|
|
state["installId"] = install_id
|
|
if not use_native:
|
|
state["proton"] = str(proton or _default_proton())
|
|
save_bootstrap_state(state_root, instance, state, install_id=install_id)
|
|
state["health"] = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
|
|
save_bootstrap_state(state_root, instance, state, install_id=install_id)
|
|
state["statePath"] = str(bootstrap_state_path(state_root, instance, install_id=install_id))
|
|
if completed["timedOut"]:
|
|
raise TimeoutError(f"IPA.exe -n timed out after {ipa_timeout_seconds}s; state written to {state['statePath']}")
|
|
if completed["returncode"] != 0:
|
|
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
|
|
return state
|
|
|
|
|
|
def ensure_healthy_bootstrap(
|
|
*,
|
|
instance: str,
|
|
instance_path: Path,
|
|
beat_saber_version: str,
|
|
registry: Registry,
|
|
lockfile: Lockfile,
|
|
state_root: Path,
|
|
repo_root: Path,
|
|
selected_ids: set[str],
|
|
proton: Path | None = None,
|
|
native: bool | None = None,
|
|
progress: Callable[[str], None] | None = None,
|
|
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
|
|
install_id: str | None = None,
|
|
) -> None:
|
|
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
|
|
return
|
|
|
|
health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
|
|
if health["ok"]:
|
|
return
|
|
|
|
tell = progress or (lambda _message: None)
|
|
tell("BSIPA bootstrap is unhealthy; running bootstrap")
|
|
run_bootstrap(
|
|
instance=instance,
|
|
instance_path=instance_path,
|
|
beat_saber_version=beat_saber_version,
|
|
registry=registry,
|
|
lockfile=lockfile,
|
|
state_root=state_root,
|
|
repo_root=repo_root,
|
|
proton=proton,
|
|
native=native,
|
|
progress=progress,
|
|
ipa_timeout_seconds=ipa_timeout_seconds,
|
|
install_id=install_id,
|
|
)
|
|
|
|
health = check_bsipa_health(instance_path, state_root, instance, install_id=install_id)
|
|
if not health["ok"]:
|
|
raise ValueError(bootstrap_health_error(health))
|