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:
pleb
2026-07-01 22:33:24 -07:00
parent 6af1b03c35
commit c67878cac0
7 changed files with 313 additions and 47 deletions
+136 -9
View File
@@ -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")