Add native Windows compatibility for config, bootstrap, and CLI.
Support profile-based config resolution with auto-selection of a sole profile, native IPA.exe -n bootstrap on Windows, platform-aware process cleanup, and Windows-friendly userdata backup path inference. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from typing import Any, Callable
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
|
||||
from .config import is_windows
|
||||
from .fsutil import sha256_file
|
||||
from .installer import apply_plan
|
||||
from .models import LockedPlugin, Lockfile, Registry
|
||||
@@ -130,31 +131,63 @@ def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str
|
||||
return result
|
||||
|
||||
|
||||
def build_bootstrap_command(
|
||||
ipa: Path,
|
||||
*,
|
||||
proton: Path | None = None,
|
||||
native: bool | None = None,
|
||||
) -> list[str]:
|
||||
use_native = is_windows() if native is None else native
|
||||
if use_native:
|
||||
return [str(ipa), "-n"]
|
||||
proton_path = proton or _default_proton()
|
||||
return [str(proton_path), "run", str(ipa), "-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]:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=instance_path,
|
||||
env=_proton_env(instance_path),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
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
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
_terminate_process(process)
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
_kill_process(process)
|
||||
stdout, stderr = process.communicate()
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
@@ -175,10 +208,14 @@ def run_bootstrap(
|
||||
state_root: Path,
|
||||
repo_root: Path,
|
||||
proton: Path | None = None,
|
||||
native: bool | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
ipa_timeout_seconds: int = 120,
|
||||
) -> 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"):
|
||||
@@ -210,30 +247,34 @@ def run_bootstrap(
|
||||
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 = build_bootstrap_command(ipa, proton=proton, native=use_native)
|
||||
if use_native:
|
||||
tell(f"Running IPA.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 -n through Proton; timeout {ipa_timeout_seconds}s")
|
||||
|
||||
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,
|
||||
native=use_native,
|
||||
)
|
||||
tell("Scanning bootstrap files after IPA.exe -n")
|
||||
after = scan_bootstrap_files(instance_path)
|
||||
delta = _files_delta(before, after)
|
||||
state = {
|
||||
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"],
|
||||
"proton": str(proton_path),
|
||||
"command": command,
|
||||
"ipaExitCode": completed["returncode"],
|
||||
"ipaTimedOut": completed["timedOut"],
|
||||
@@ -244,6 +285,8 @@ def run_bootstrap(
|
||||
"delta": delta,
|
||||
"health": {},
|
||||
}
|
||||
if not use_native:
|
||||
state["proton"] = str(proton or _default_proton())
|
||||
save_bootstrap_state(state_root, instance, state)
|
||||
state["health"] = check_bsipa_health(instance_path, state_root, instance)
|
||||
save_bootstrap_state(state_root, instance, state)
|
||||
|
||||
Reference in New Issue
Block a user