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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .fsutil import sha256_file
|
||||||
|
from .state import bootstrap_state_path, load_bootstrap_state
|
||||||
|
|
||||||
|
|
||||||
|
BSIPA_PLUGIN_ID = "bsipa"
|
||||||
|
|
||||||
|
|
||||||
|
def latest_log_path(instance_path: Path) -> Path:
|
||||||
|
return instance_path / "Logs" / "_latest.log"
|
||||||
|
|
||||||
|
|
||||||
|
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
|
state = load_bootstrap_state(state_root, instance)
|
||||||
|
messages: list[str] = []
|
||||||
|
|
||||||
|
required = ["IPA.exe", "winhttp.dll"]
|
||||||
|
for rel in required:
|
||||||
|
if not (instance_path / rel).is_file():
|
||||||
|
messages.append(f"missing {rel}")
|
||||||
|
for rel in ("IPA", "Libs"):
|
||||||
|
if not (instance_path / rel).is_dir():
|
||||||
|
messages.append(f"missing {rel}/")
|
||||||
|
|
||||||
|
log_path = latest_log_path(instance_path)
|
||||||
|
if not log_path.is_file():
|
||||||
|
messages.append("missing Logs/_latest.log")
|
||||||
|
else:
|
||||||
|
text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if "Beat Saber IPA (BSIPA):" not in text and "Beat Saber IPA" not in text:
|
||||||
|
messages.append("Logs/_latest.log does not show BSIPA startup")
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": not messages,
|
||||||
|
"messages": messages,
|
||||||
|
"statePath": str(bootstrap_state_path(state_root, instance)),
|
||||||
|
"logPath": str(log_path),
|
||||||
|
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
|
||||||
|
"bootstrapRecordedAt": state.get("updatedAt"),
|
||||||
|
}
|
||||||
@@ -4,12 +4,21 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
|
||||||
|
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||||
|
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
|
||||||
|
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
|
||||||
|
|
||||||
|
|
||||||
def instances_root(value: str | None = None) -> Path:
|
def instances_root(value: str | None = None) -> Path:
|
||||||
|
return instances_roots(value)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def instances_roots(value: str | None = None) -> list[Path]:
|
||||||
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||||
return Path(raw).expanduser() if raw else DEFAULT_INSTANCES_ROOT
|
if raw:
|
||||||
|
return [Path(item).expanduser() for item in raw.split(os.pathsep) if item]
|
||||||
|
return list(DEFAULT_INSTANCES_ROOTS)
|
||||||
|
|
||||||
|
|
||||||
def state_root(value: str | None = None) -> Path:
|
def state_root(value: str | None = None) -> Path:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -13,6 +14,9 @@ class Instance:
|
|||||||
has_userdata: bool
|
has_userdata: bool
|
||||||
|
|
||||||
|
|
||||||
|
RootInput = Path | Sequence[Path]
|
||||||
|
|
||||||
|
|
||||||
def looks_like_instance(path: Path) -> bool:
|
def looks_like_instance(path: Path) -> bool:
|
||||||
return (
|
return (
|
||||||
(path / "Beat Saber_Data").is_dir()
|
(path / "Beat Saber_Data").is_dir()
|
||||||
@@ -22,7 +26,13 @@ def looks_like_instance(path: Path) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_instances(root: Path) -> list[Instance]:
|
def _root_list(root: RootInput) -> list[Path]:
|
||||||
|
if isinstance(root, Path):
|
||||||
|
return [root]
|
||||||
|
return list(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_instances_one(root: Path) -> list[Instance]:
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return []
|
return []
|
||||||
instances: list[Instance] = []
|
instances: list[Instance] = []
|
||||||
@@ -41,7 +51,20 @@ def list_instances(root: Path) -> list[Instance]:
|
|||||||
return instances
|
return instances
|
||||||
|
|
||||||
|
|
||||||
def get_instance(root: Path, name: str) -> Instance:
|
def list_instances(root: RootInput) -> list[Instance]:
|
||||||
|
instances: list[Instance] = []
|
||||||
|
seen_paths: set[Path] = set()
|
||||||
|
for item in _root_list(root):
|
||||||
|
for instance in _list_instances_one(item):
|
||||||
|
resolved = instance.path.resolve(strict=False)
|
||||||
|
if resolved in seen_paths:
|
||||||
|
continue
|
||||||
|
seen_paths.add(resolved)
|
||||||
|
instances.append(instance)
|
||||||
|
return sorted(instances, key=lambda item: (item.name, str(item.path)))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_instance_one(root: Path, name: str) -> Instance:
|
||||||
path = root / name
|
path = root / name
|
||||||
if not path.is_dir() or not looks_like_instance(path):
|
if not path.is_dir() or not looks_like_instance(path):
|
||||||
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
|
raise FileNotFoundError(f"Beat Saber instance not found: {path}")
|
||||||
@@ -52,3 +75,22 @@ def get_instance(root: Path, name: str) -> Instance:
|
|||||||
has_libs=(path / "Libs").is_dir(),
|
has_libs=(path / "Libs").is_dir(),
|
||||||
has_userdata=(path / "UserData").is_dir(),
|
has_userdata=(path / "UserData").is_dir(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_instance(root: RootInput, name: str) -> Instance:
|
||||||
|
if isinstance(root, Path):
|
||||||
|
return _get_instance_one(root, name)
|
||||||
|
|
||||||
|
matches: list[Instance] = []
|
||||||
|
for item in _root_list(root):
|
||||||
|
try:
|
||||||
|
matches.append(_get_instance_one(item, name))
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if not matches:
|
||||||
|
searched = ", ".join(str(item) for item in _root_list(root))
|
||||||
|
raise FileNotFoundError(f"Beat Saber instance not found: {name} under {searched}")
|
||||||
|
if len(matches) > 1:
|
||||||
|
paths = ", ".join(str(item.path) for item in matches)
|
||||||
|
raise ValueError(f"Beat Saber instance name is ambiguous: {name}; matches: {paths}")
|
||||||
|
return matches[0]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
||||||
from .models import Lockfile, Registry, VALID_STRATEGIES
|
from .models import Lockfile, Registry, VALID_STRATEGIES
|
||||||
|
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
|
||||||
from .state import downloads_dir, plans_dir, plugin_downloads_dir
|
from .state import downloads_dir, plans_dir, plugin_downloads_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -74,11 +75,20 @@ def create_plan(
|
|||||||
state_root: Path,
|
state_root: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
selected: set[str] | None = None,
|
selected: set[str] | None = None,
|
||||||
|
require_bootstrap: bool = True,
|
||||||
) -> tuple[dict[str, Any], Path]:
|
) -> tuple[dict[str, Any], Path]:
|
||||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||||
changes: list[dict[str, Any]] = []
|
changes: list[dict[str, Any]] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
|
||||||
|
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
|
||||||
|
if require_bootstrap and has_locked_bsipa and planning_ordinary_plugins:
|
||||||
|
health = check_bsipa_health(instance_path, state_root, instance)
|
||||||
|
if not health["ok"]:
|
||||||
|
joined = "; ".join(health["messages"])
|
||||||
|
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
|
||||||
|
|
||||||
for locked in lockfile.plugins:
|
for locked in lockfile.plugins:
|
||||||
if locked.id not in selected_ids:
|
if locked.id not in selected_ids:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from .fsutil import sha256_file
|
|||||||
|
|
||||||
|
|
||||||
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
|
SCAN_DIRS = ("Plugins", "Libs", "IPA/Pending")
|
||||||
|
BOOTSTRAP_DIRS = ("Libs", "IPA")
|
||||||
|
BOOTSTRAP_ROOT_GLOBS = ("winhttp.dll", "IPA.exe*")
|
||||||
|
|
||||||
|
|
||||||
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
|
def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str, Any]:
|
||||||
@@ -31,3 +33,32 @@ def scan_instance(instance_path: Path, include_hashes: bool = False) -> dict[str
|
|||||||
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
|
"pending": sum(1 for item in files if item["path"].startswith("IPA/Pending/")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_bootstrap_files(instance_path: Path) -> list[dict[str, Any]]:
|
||||||
|
files_by_path: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for pattern in BOOTSTRAP_ROOT_GLOBS:
|
||||||
|
for path in sorted(instance_path.glob(pattern)):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(instance_path).as_posix()
|
||||||
|
files_by_path[rel] = {
|
||||||
|
"path": rel,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"sha256": sha256_file(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
for dirname in BOOTSTRAP_DIRS:
|
||||||
|
root = instance_path / dirname
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in sorted(item for item in root.rglob("*") if item.is_file()):
|
||||||
|
rel = path.relative_to(instance_path).as_posix()
|
||||||
|
files_by_path[rel] = {
|
||||||
|
"path": rel,
|
||||||
|
"size": path.stat().st_size,
|
||||||
|
"sha256": sha256_file(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
return [files_by_path[path] for path in sorted(files_by_path)]
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ def installed_state_path(state_root: Path, instance: str) -> Path:
|
|||||||
return instance_state_dir(state_root, instance) / "installed.json"
|
return instance_state_dir(state_root, instance) / "installed.json"
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_state_path(state_root: Path, instance: str) -> Path:
|
||||||
|
return instance_state_dir(state_root, instance) / "bootstrap.json"
|
||||||
|
|
||||||
|
|
||||||
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
return read_json(
|
return read_json(
|
||||||
installed_state_path(state_root, instance),
|
installed_state_path(state_root, instance),
|
||||||
@@ -22,6 +26,16 @@ def load_installed_state(state_root: Path, instance: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bootstrap_state(state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
|
return read_json(bootstrap_state_path(state_root, instance), {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_bootstrap_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||||
|
state.setdefault("instance", instance)
|
||||||
|
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
atomic_write_json(bootstrap_state_path(state_root, instance), state)
|
||||||
|
|
||||||
|
|
||||||
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
def save_installed_state(state_root: Path, instance: str, state: dict[str, Any]) -> None:
|
||||||
state.setdefault("instance", instance)
|
state.setdefault("instance", instance)
|
||||||
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
state["updatedAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|||||||
Reference in New Issue
Block a user