Files
plugin-helper/src/plugin_helper/bootstrap.py
T
2026-07-09 20:04:10 -07:00

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