Add BSIPA bootstrap support

This commit is contained in:
pleb
2026-06-28 14:35:10 -07:00
parent 158bc23298
commit 8ad2a3dd35
7 changed files with 412 additions and 4 deletions
+255
View File
@@ -0,0 +1,255 @@
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, check_bsipa_health
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
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("lockfile 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 _run_ipa(
*,
command: list[str],
instance_path: Path,
timeout_seconds: int,
) -> dict[str, Any]:
process = subprocess.Popen(
command,
cwd=instance_path,
env=_proton_env(instance_path),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
stdout, stderr = process.communicate(timeout=timeout_seconds)
timed_out = False
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGTERM)
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
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,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
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,
)
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}")
proton_path = proton or _default_proton()
if not proton_path.is_file():
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
command = [str(proton_path), "run", str(ipa), "-n"]
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
completed = _run_ipa(
command=command,
instance_path=instance_path,
timeout_seconds=ipa_timeout_seconds,
)
tell("Scanning bootstrap files after IPA.exe -n")
after = scan_bootstrap_files(instance_path)
delta = _files_delta(before, after)
state = {
"schemaVersion": 1,
"instance": instance,
"beatSaberVersion": beat_saber_version,
"bootstrappedAt": _now_iso(),
"plugin": BSIPA_PLUGIN_ID,
"archive": fetched,
"planPath": str(plan_path),
"applied": apply_result["applied"],
"proton": str(proton_path),
"command": command,
"ipaExitCode": completed["returncode"],
"ipaTimedOut": completed["timedOut"],
"ipaTimeoutSeconds": completed["timeoutSeconds"],
"ipaStdout": completed["stdout"][-20000:],
"ipaStderr": completed["stderr"][-20000:],
"files": after,
"delta": delta,
"health": {},
}
save_bootstrap_state(state_root, instance, state)
state["health"] = check_bsipa_health(instance_path, state_root, instance)
save_bootstrap_state(state_root, instance, state)
state["statePath"] = str(bootstrap_state_path(state_root, instance))
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