Add BSIPA bootstrap support
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user