From c67878cac077cc914e056370b9b3503e8e03c656 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 22:33:24 -0700 Subject: [PATCH] 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 --- plugin-helper.windows.toml.example | 5 + src/plugin_helper/bootstrap.py | 79 ++++++++++++---- src/plugin_helper/bsipa.py | 16 +++- src/plugin_helper/cli.py | 10 +- src/plugin_helper/config.py | 89 ++++++++++++++++-- src/plugin_helper/userdata.py | 16 ++-- tests/test_plugin_helper.py | 145 +++++++++++++++++++++++++++-- 7 files changed, 313 insertions(+), 47 deletions(-) create mode 100644 plugin-helper.windows.toml.example diff --git a/plugin-helper.windows.toml.example b/plugin-helper.windows.toml.example new file mode 100644 index 0000000..3082f46 --- /dev/null +++ b/plugin-helper.windows.toml.example @@ -0,0 +1,5 @@ +[[profiles]] +id = "windows" +label = "Native Windows BSManager" +instances_root = "~/BSManager/BSInstances" +state_dir = ".state" diff --git a/src/plugin_helper/bootstrap.py b/src/plugin_helper/bootstrap.py index 52a709b..94725a8 100644 --- a/src/plugin_helper/bootstrap.py +++ b/src/plugin_helper/bootstrap.py @@ -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) diff --git a/src/plugin_helper/bsipa.py b/src/plugin_helper/bsipa.py index 385c12e..b32f99f 100644 --- a/src/plugin_helper/bsipa.py +++ b/src/plugin_helper/bsipa.py @@ -14,6 +14,14 @@ def latest_log_path(instance_path: Path) -> Path: return instance_path / "Logs" / "_latest.log" +def _native_bootstrap_satisfied(state: dict[str, Any]) -> bool: + return ( + state.get("bootstrapMode") == "native" + and state.get("ipaExitCode") == 0 + and not state.get("ipaTimedOut") + ) + + 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] = [] @@ -27,12 +35,15 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> messages.append(f"missing {rel}/") log_path = latest_log_path(instance_path) + native_bootstrap = _native_bootstrap_satisfied(state) if not log_path.is_file(): - messages.append("missing Logs/_latest.log") + if not native_bootstrap: + 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 native_bootstrap: + 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)}") @@ -44,4 +55,5 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> "logPath": str(log_path), "logSha256": sha256_file(log_path) if log_path.is_file() else None, "bootstrapRecordedAt": state.get("updatedAt"), + "bootstrapMode": state.get("bootstrapMode"), } diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 0178d55..69d9ce0 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -61,6 +61,8 @@ def print_updates(report: dict[str, Any]) -> None: def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None: default = argparse.SUPPRESS if suppress_default else None + parser.add_argument("--config", default=default, help="plugin-helper config TOML path") + parser.add_argument("--profile", default=default, help="Config profile id from [[profiles]]") parser.add_argument("--instances-root", default=default, help="BSManager instances root") parser.add_argument("--state-dir", default=default, help="plugin-helper state directory") @@ -120,13 +122,14 @@ def build_parser() -> argparse.ArgumentParser: bootstrap = subcommands.add_parser( "bootstrap", - help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files", + help="Install locked BSIPA, run IPA.exe -n, and record bootstrap files", parents=[_common_parent()], ) bootstrap.add_argument("--instance", required=True) bootstrap.add_argument("--registry", default="registry/plugins.toml") bootstrap.add_argument("--lockfile") - bootstrap.add_argument("--proton", help="Path to Proton executable") + bootstrap.add_argument("--proton", help="Path to Proton executable (Linux/Proton installs)") + bootstrap.add_argument("--native", action="store_true", help="Run IPA.exe -n natively instead of through Proton") bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output") bootstrap_check = subcommands.add_parser( @@ -282,6 +285,8 @@ def run(argv: list[str] | None = None) -> int: runtime = resolve_runtime_config( instances_root_value=getattr(args, "instances_root", None), state_dir_value=getattr(args, "state_dir", None), + config_path_value=getattr(args, "config", None), + profile_id=getattr(args, "profile", None), ) inst_roots = runtime.instances_roots st_root = runtime.state_root @@ -409,6 +414,7 @@ def run(argv: list[str] | None = None) -> int: state_root=st_root, repo_root=root, proton=Path(args.proton).expanduser() if args.proton else None, + native=True if args.native else None, progress=lambda message: print(f" {message}", flush=True), ) if args.json: diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index 6832c89..cc720ef 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import sys import tomllib from dataclasses import dataclass from pathlib import Path @@ -10,12 +11,22 @@ from typing import Any LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances" DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT LOCAL_CONFIG_NAME = "plugin-helper.local.toml" +WINDOWS_CONFIG_NAME = "plugin-helper.windows.toml" + + +@dataclass(frozen=True) +class Profile: + id: str + label: str | None + instances_roots: list[Path] + state_root: Path @dataclass(frozen=True) class LocalConfig: instances_roots: list[Path] | None state_root: Path | None + profiles: tuple[Profile, ...] @dataclass(frozen=True) @@ -24,6 +35,12 @@ class RuntimeConfig: state_root: Path config_path: Path config_loaded: bool + profile_id: str | None = None + profile_label: str | None = None + + +def is_windows() -> bool: + return sys.platform == "win32" def repo_root() -> Path: @@ -38,7 +55,7 @@ def instances_roots(value: str | None = None, *, base: Path | None = None) -> li raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") if raw: return _resolve_path_list(raw, base or repo_root()) - return [DEFAULT_INSTANCES_ROOT] + return _default_instances_roots() def state_root(value: str | None = None) -> Path: @@ -47,13 +64,16 @@ def state_root(value: str | None = None) -> Path: env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR") if env_state: return _resolve_path(env_state, repo_root()) - xdg_state = os.environ.get("XDG_STATE_HOME") - base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" - return base / "plugin-helper" + return _default_state_root() def default_config_path(root: Path | None = None) -> Path: - return (root or repo_root()) / LOCAL_CONFIG_NAME + repo = root or repo_root() + if is_windows(): + windows_config = repo / WINDOWS_CONFIG_NAME + if windows_config.is_file(): + return windows_config + return repo / LOCAL_CONFIG_NAME def _resolve_path(value: str | Path, base: Path) -> Path: @@ -67,13 +87,34 @@ def _resolve_path_list(value: str, base: Path) -> list[Path]: return [_resolve_path(item, base) for item in value.split(os.pathsep) if item] +def _parse_profiles(data: dict[str, Any], base: Path) -> tuple[Profile, ...]: + profiles: list[Profile] = [] + for entry in data.get("profiles", []): + if not isinstance(entry, dict): + continue + profile_id = entry.get("id") + instances_value = entry.get("instances_root") + state_value = entry.get("state_dir") + if not profile_id or not instances_value or not state_value: + raise ValueError(f"profile {profile_id!r} needs id, instances_root, and state_dir") + profiles.append( + Profile( + id=str(profile_id), + label=entry.get("label"), + instances_roots=_resolve_path_list(str(instances_value), base), + state_root=_resolve_path(str(state_value), base), + ) + ) + return tuple(profiles) + + def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]: repo = root or repo_root() path = _resolve_path(config_path, repo) if config_path else default_config_path(repo) if not path.exists(): if config_path: raise FileNotFoundError(f"plugin-helper config not found: {path}") - return LocalConfig(instances_roots=None, state_root=None), path, False + return LocalConfig(instances_roots=None, state_root=None, profiles=()), path, False with path.open("rb") as handle: data: dict[str, Any] = tomllib.load(handle) @@ -81,10 +122,12 @@ def load_local_config(config_path: str | Path | None = None, *, root: Path | Non base = path.parent instances_value = data.get("instances_root") state_value = data.get("state_dir") + profiles = _parse_profiles(data, base) return ( LocalConfig( instances_roots=_resolve_path_list(instances_value, base) if instances_value else None, state_root=_resolve_path(state_value, base) if state_value else None, + profiles=profiles, ), path, True, @@ -102,28 +145,57 @@ def _env_state_root(root: Path) -> Path | None: def _default_state_root() -> Path: + if is_windows(): + localappdata = os.environ.get("LOCALAPPDATA") + if localappdata: + return Path(localappdata) / "plugin-helper" + return Path.home() / "AppData" / "Local" / "plugin-helper" xdg_state = os.environ.get("XDG_STATE_HOME") base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" return base / "plugin-helper" def _default_instances_roots() -> list[Path]: + if is_windows(): + return [Path.home() / "BSManager" / "BSInstances"] return [DEFAULT_INSTANCES_ROOT] +def _select_profile(local_config: LocalConfig, profile_id: str | None) -> Profile | None: + if profile_id: + for profile in local_config.profiles: + if profile.id == profile_id: + return profile + available = ", ".join(profile.id for profile in local_config.profiles) or "(none)" + raise ValueError(f"unknown profile {profile_id!r}; available profiles: {available}") + + if ( + local_config.profiles + and local_config.instances_roots is None + and local_config.state_root is None + and len(local_config.profiles) == 1 + ): + return local_config.profiles[0] + return None + + def resolve_runtime_config( *, instances_root_value: str | None = None, state_dir_value: str | None = None, + config_path_value: str | None = None, + profile_id: str | None = None, root: Path | None = None, ) -> RuntimeConfig: repo = root or repo_root() - local_config, loaded_path, loaded = load_local_config(root=repo) + local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo) + profile = _select_profile(local_config, profile_id) resolved_instances = ( _resolve_path_list(instances_root_value, repo) if instances_root_value else _env_instances_roots(repo) + or (profile.instances_roots if profile else None) or local_config.instances_roots or _default_instances_roots() ) @@ -131,6 +203,7 @@ def resolve_runtime_config( _resolve_path(state_dir_value, repo) if state_dir_value else _env_state_root(repo) + or (profile.state_root if profile else None) or local_config.state_root or _default_state_root() ) @@ -140,4 +213,6 @@ def resolve_runtime_config( state_root=resolved_state, config_path=loaded_path, config_loaded=loaded, + profile_id=profile.id if profile else None, + profile_label=profile.label if profile else None, ) diff --git a/src/plugin_helper/userdata.py b/src/plugin_helper/userdata.py index ab570b3..180930c 100644 --- a/src/plugin_helper/userdata.py +++ b/src/plugin_helper/userdata.py @@ -6,7 +6,6 @@ import shutil import tarfile from datetime import datetime, timezone from pathlib import Path -from tempfile import NamedTemporaryFile from typing import Any, Callable from .fsutil import sha256_file @@ -62,25 +61,24 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic } destination.parent.mkdir(parents=True, exist_ok=True) + manifest_path = destination.parent / f".{destination.name}.manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") with tarfile.open(destination, "w:gz") as archive: archive.add(source, arcname="UserData") - with NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as handle: - json.dump(manifest, handle, indent=2, sort_keys=True) - handle.write("\n") - handle.flush() - archive.add(handle.name, arcname="manifest.json") + archive.add(manifest_path, arcname="manifest.json") + manifest_path.unlink(missing_ok=True) return {"archive": str(destination), "manifest": manifest} def infer_windows_appdata_path(instance_path: Path) -> Path: parts = instance_path.resolve().parts try: - users_index = parts.index("Users") + bsmanager_index = parts.index("BSManager") except ValueError as exc: raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc - if users_index + 1 >= len(parts): + if bsmanager_index < 2 or parts[bsmanager_index - 2] != "Users": raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") - profile = Path(*parts[: users_index + 2]) + profile = Path(*parts[:bsmanager_index]) return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 887730d..19ab5b8 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -13,11 +13,12 @@ from rich.text import Text from textual.coordinate import Coordinate from textual.widgets import DataTable -from plugin_helper.bootstrap import _run_ipa +from plugin_helper.bootstrap import _run_ipa, build_bootstrap_command +from plugin_helper.bsipa import check_bsipa_health from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run -from plugin_helper.config import load_local_config, resolve_runtime_config +from plugin_helper.config import is_windows, load_local_config, resolve_runtime_config from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin from plugin_helper.instances import get_instance, list_instances @@ -218,7 +219,10 @@ state_dir = "config-state" root = Path(tmp) xdg = root / "xdg-state" - with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True): + with ( + patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg), "HOME": str(root)}, clear=True), + patch("plugin_helper.config.is_windows", return_value=False), + ): runtime = resolve_runtime_config(root=root) self.assertEqual(runtime.state_root, xdg / "plugin-helper") @@ -226,11 +230,15 @@ state_dir = "config-state" def test_runtime_default_state_uses_home_local_state(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) + env = {"HOME": str(root), "USERPROFILE": str(root)} - with patch.dict(os.environ, {}, clear=True): + with ( + patch.dict(os.environ, env, clear=True), + patch("plugin_helper.config.is_windows", return_value=False), + ): runtime = resolve_runtime_config(root=root) - self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper") + self.assertEqual(runtime.state_root, Path(root) / ".local" / "state" / "plugin-helper") def test_no_args_prints_help_when_not_interactive(self) -> None: output = StringIO() @@ -253,6 +261,8 @@ state_dir = "config-state" run_menu.assert_called_once() def test_run_ipa_timeout_returns_control(self) -> None: + if is_windows(): + self.skipTest("POSIX process-group timeout behavior is Linux-specific") with tempfile.TemporaryDirectory() as tmp: result = _run_ipa( command=["python", "-c", "import time; time.sleep(30)"], @@ -263,6 +273,113 @@ state_dir = "config-state" self.assertTrue(result["timedOut"]) self.assertNotEqual(result["returncode"], 0) + def test_run_ipa_timeout_returns_control_on_windows(self) -> None: + if not is_windows(): + self.skipTest("Windows subprocess timeout behavior is Windows-specific") + with tempfile.TemporaryDirectory() as tmp: + result = _run_ipa( + command=["python", "-c", "import time; time.sleep(30)"], + instance_path=Path(tmp), + timeout_seconds=1, + native=True, + ) + + self.assertTrue(result["timedOut"]) + self.assertNotEqual(result["returncode"], 0) + + def test_build_bootstrap_command_native(self) -> None: + ipa = Path("C:/Games/Beat Saber/IPA.exe") + self.assertEqual(build_bootstrap_command(ipa, native=True), [str(ipa), "-n"]) + + def test_build_bootstrap_command_proton(self) -> None: + ipa = Path("/tmp/1.44.1/IPA.exe") + proton = Path("/tmp/proton") + self.assertEqual( + build_bootstrap_command(ipa, proton=proton, native=False), + [str(proton), "run", str(ipa), "-n"], + ) + + def test_profile_config_resolves_windows_paths(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = root / "plugin-helper.windows.toml" + config.write_text( + """ +[[profiles]] +id = "windows" +label = "Native Windows BSManager" +instances_root = "~/BSInstances" +state_dir = ".state" +""".lstrip(), + encoding="utf-8", + ) + + local_config, loaded_path, loaded = load_local_config(config, root=root) + runtime = resolve_runtime_config(config_path_value=str(config), profile_id="windows", root=root) + auto_runtime = resolve_runtime_config(config_path_value=str(config), root=root) + + self.assertTrue(loaded) + self.assertEqual(loaded_path, config) + self.assertEqual(len(local_config.profiles), 1) + self.assertEqual(local_config.profiles[0].id, "windows") + self.assertEqual(runtime.instances_roots, [Path("~/BSInstances").expanduser()]) + self.assertEqual(runtime.state_root, root / ".state") + self.assertEqual(runtime.profile_id, "windows") + self.assertEqual(auto_runtime.state_root, root / ".state") + self.assertEqual(auto_runtime.profile_id, "windows") + + def test_native_bootstrap_health_accepts_state_without_log(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance = work / "1.44.1" + state = work / "state" + instance.mkdir(parents=True) + (instance / "IPA").mkdir() + (instance / "Libs").mkdir() + (instance / "IPA.exe").write_bytes(b"ipa") + (instance / "winhttp.dll").write_bytes(b"proxy") + save_bootstrap_state( + state, + "1.44.1", + { + "bootstrapMode": "native", + "ipaExitCode": 0, + "ipaTimedOut": False, + "files": scan_bootstrap_files(instance), + }, + ) + + result = check_bsipa_health(instance, state, "1.44.1") + + self.assertTrue(result["ok"]) + self.assertEqual(result["bootstrapMode"], "native") + + def test_proton_bootstrap_health_still_requires_log(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance = work / "1.44.1" + state = work / "state" + instance.mkdir(parents=True) + (instance / "IPA").mkdir() + (instance / "Libs").mkdir() + (instance / "IPA.exe").write_bytes(b"ipa") + (instance / "winhttp.dll").write_bytes(b"proxy") + save_bootstrap_state( + state, + "1.44.1", + { + "bootstrapMode": "proton", + "ipaExitCode": 0, + "ipaTimedOut": False, + "files": scan_bootstrap_files(instance), + }, + ) + + result = check_bsipa_health(instance, state, "1.44.1") + + self.assertFalse(result["ok"]) + self.assertIn("missing Logs/_latest.log", result["messages"]) + def test_plan_apply_and_uninstall_dll(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) @@ -689,13 +806,21 @@ sha256 = "{sha256_file(asset)}" self.assertTrue(Path(result["archive"]).exists()) self.assertEqual(result["manifest"]["fileCount"], 1) + def test_infer_windows_appdata_path_from_native_instance(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1" + instance.mkdir(parents=True) + expected = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" + self.assertEqual(infer_windows_appdata_path(instance), expected) + def test_infer_windows_appdata_path_from_mounted_instance(self) -> None: instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1") + expected = Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber") - self.assertEqual( - infer_windows_appdata_path(instance), - Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"), - ) + if is_windows(): + self.skipTest("POSIX mount paths are Linux-specific") + self.assertEqual(infer_windows_appdata_path(instance), expected) def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -747,6 +872,8 @@ sha256 = "{sha256_file(asset)}" ) def test_infer_appdata_path_uses_windows_or_proton(self) -> None: + if is_windows(): + self.skipTest("POSIX mount paths are Linux-specific") windows_instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1") linux_instance = Path("/home/pleb/.local/share/BSManager/BSInstances/1.44.1")