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))