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:
+136
-9
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user