diff --git a/README.md b/README.md index 9e2d4bf..ee8c5fd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # plugin-helper -`plugin-helper` is an early Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. +`plugin-helper` is an early Python CLI for managing Beat Saber plugins in BSManager installs. The first implementation focuses on safe local workflows: @@ -11,28 +11,97 @@ The first implementation focuses on safe local workflows: - apply exactly that plan and record install state - uninstall only files recorded in install state -Default BSManager instance root: +Default BSManager instance roots: ```text /home/pleb/Windows/Users/pleb/BSManager/BSInstances +/home/pleb/.local/share/BSManager/BSInstances ``` -Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. +Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search +multiple explicit roots, separate them with `:`. ## Commands -Run from the repo root with `PYTHONPATH=src` unless installed. +For normal use, run the menu from the repo root. Use repo-local state so the +menu sees the same plans, downloads, and install records used by the helper +workflow: ```sh -PYTHONPATH=src python -m plugin_helper instances -PYTHONPATH=src python -m plugin_helper --state-dir .state installed --instance 1.40.8 -PYTHONPATH=src python -m plugin_helper updates --instance 1.40.8 -PYTHONPATH=src python -m plugin_helper scan --instance 1.40.8 -PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance 1.40.8 +PYTHONPATH=src python -m plugin_helper --state-dir .state menu ``` +The individual subcommands are mostly for automation and debugging. If you use +them, pass `--state-dir .state` unless you intentionally want the default live +state outside this repo. + Install assets are currently expected to already exist locally, usually under: ```text .state/instances//downloads// ``` + +## Beat Saber Data Backups + +`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber +Windows app data into this repo. With the Windows mount at `~/Windows`, the +helper infers Beat Saber's Windows app data as: + +```text +/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber +``` + +Example manual backup after mounting Windows: + +```sh +PYTHONPATH=src python -m plugin_helper \ + --instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \ + backup-userdata \ + --instance 1.44.1 +``` + +By default the repo receives plain copied files under `backups/beat-saber/UserData` +and `backups/beat-saber/AppData`, plus `backups/beat-saber/backup-descriptor.json` +describing the source paths from the latest backup run. Use +`--appdata-path ` if the Windows profile path ever differs, `--no-appdata` +for a `UserData`-only sync, or `--backup-root ` to choose a different +repo-local destination. + +The backup intentionally omits bulky/generated data: + +- `UserData/BeatLeader/Replays` +- `UserData/BeatLeader/ReplayerCache` +- `UserData/BeatLeader/LeaderboardsCache` +- `UserData/BeatLeader/ReplayHeadersCache` +- `UserData/ScoreSaber/Replays` +- `UserData/BeatSaberPlus/Cache` +- `UserData/BeatSaverNotifier.json` +- `UserData/Accsaber/PlayerScoreCache.json` +- `UserData/NalulunaAvatars/cache` +- `UserData/SongDetailsCache.proto` +- `AppData/com.unity.addressables` +- `*.log` + +Other large folders seen in the 1.44.1 `UserData` tree are +`Custom Campaigns`, `SongCore`, `AssetBundleLoadingTools`, `NalulunaMenu`, and +`NalulunaSkybox`. Those are not skipped by default because they can contain +custom content or non-obvious user choices rather than pure cache data. + +## Operational notes + +- BSManager can inherit launch arguments configured in Steam for Beat Saber. + Check both places before debugging black screens or startup hangs. Duplicating + arguments such as `--no-yeet fpfc` can make the game fail command-line + parsing after BSIPA and plugins have already loaded. +- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command + applies the locked `bsipa` root archive, runs `IPA.exe -n` through Proton, and + records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`, + `Libs/`, and `IPA/`, including backups created during patching. +- If an instance lockfile includes `bsipa`, ordinary plugin plans require a + recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup. + Use `bootstrap-check` before planning a batch when you want a quick gate. +- Use [`docs/SMOKETEST.md`](docs/SMOKETEST.md) after installing or removing a + plugin batch. It documents the short Proton/BSManager launch loop, IPA log + checks, and teardown commands. +- The 1.44.1 migration tracker lives in + [`docs/notes/install-and-verify-plugins-1.44.1.md`](docs/notes/install-and-verify-plugins-1.44.1.md). diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index ba7c818..944752e 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -4,9 +4,11 @@ import argparse import json import sys from pathlib import Path -from typing import Any +from typing import Any, Callable -from .config import instances_root, repo_root, state_root +from .config import instances_roots, repo_root, state_root +from .bootstrap import run_bootstrap +from .bsipa import check_bsipa_health from .checker import check_lock from .github import fetch_releases from .installer import apply_plan, uninstall_plugin @@ -17,7 +19,7 @@ from .planner import create_plan from .scanner import scan_instance from .state import load_installed_state from .updates import check_updates -from .userdata import backup_userdata +from .userdata import sync_windows_data_repo def _json(data: Any) -> None: @@ -135,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser: parents=[_common_parent()], ) + subcommands.add_parser( + "menu", + help="Open an interactive instance/action menu", + parents=[_common_parent()], + ) + scan = subcommands.add_parser( "scan", help="Inspect installed Plugins, Libs, and IPA/Pending files", @@ -171,6 +179,25 @@ def build_parser() -> argparse.ArgumentParser: check.add_argument("--lockfile") check.add_argument("--json", action="store_true", help="Print full JSON check output") + bootstrap = subcommands.add_parser( + "bootstrap", + help="Install locked BSIPA, run IPA.exe -n through Proton, 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("--json", action="store_true", help="Print full JSON bootstrap output") + + bootstrap_check = subcommands.add_parser( + "bootstrap-check", + help="Verify recorded BSIPA bootstrap state and latest IPA log", + parents=[_common_parent()], + ) + bootstrap_check.add_argument("--instance", required=True) + bootstrap_check.add_argument("--json", action="store_true", help="Print full JSON bootstrap health output") + updates = subcommands.add_parser( "updates", help="Check GitHub for newer matching releases for locked plugins", @@ -211,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser: backup = subcommands.add_parser( "backup-userdata", - help="Create a timestamped UserData backup archive", + help="Copy UserData and Windows AppData into this repo", parents=[_common_parent()], ) backup.add_argument("--instance", required=True) + backup.add_argument("--backup-root", default="backups/beat-saber", help="Repo-local backup directory") + backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path") + backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData") return parser @@ -225,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser: return parent +def _ask_choice( + *, + title: str, + choices: list[tuple[str, str] | tuple[str, str, str]], + input_func: Callable[[str], str] | None = None, +) -> str | None: + ask = input_func or input + print() + print(title) + for index, choice in enumerate(choices, start=1): + label = choice[1] + print(f" {index}. {label}") + if len(choice) > 2: + print(f" {choice[2]}") + print(" q. Quit") + + while True: + answer = ask("> ").strip().lower() + if answer in {"q", "quit", "exit"}: + return None + if answer.isdigit(): + index = int(answer) + if 1 <= index <= len(choices): + return choices[index - 1][0] + print("Choose a listed number, or q to quit.") + + +def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str], str] | None = None) -> int: + ask = input_func or input + instances = list_instances(inst_roots) + if not instances: + print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}") + return 1 + + instances_by_choice = {str(index): item for index, item in enumerate(instances, start=1)} + instance_choices = [(str(index), f"{item.name} {item.path}") for index, item in enumerate(instances, start=1)] + action_choices = [ + ("installed", "Show managed installs", "Lists plugins recorded in plugin-helper state with locked versions and files."), + ("updates", "Check locked plugin updates", "Looks at GitHub releases for newer assets matching locked plugins."), + ("scan", "Scan installed files", "Counts files currently present in Plugins/, Libs/, and IPA/Pending/."), + ("check", "Check lockfile and assets", "Validates registry entries, lockfile data, local assets, and SHA-256 values."), + ("bootstrap", "Bootstrap BSIPA", "Fetches the locked BSIPA archive, installs it, runs IPA.exe -n, and records bootstrap files."), + ("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."), + ("plan", "Create install plan", "Writes a dry-run JSON plan for locked plugin files before anything is applied."), + ("apply", "Apply a plan by path", "Installs exactly the file changes from a previously generated plan JSON."), + ("backup-userdata", "Back up UserData", "Creates a timestamped archive of UserData before risky changes."), + ("change", "Choose another version", "Returns to the Beat Saber version picker."), + ] + + selected_instance_key = _ask_choice( + title="Choose Beat Saber version", + choices=instance_choices, + input_func=ask, + ) + if selected_instance_key is None: + return 0 + selected_instance = instances_by_choice[selected_instance_key] + + while True: + selected_action = _ask_choice( + title=f"Choose action for {selected_instance.name}", + choices=action_choices, + input_func=ask, + ) + if selected_action is None: + return 0 + if selected_action == "change": + selected_instance_key = _ask_choice( + title="Choose Beat Saber version", + choices=instance_choices, + input_func=ask, + ) + if selected_instance_key is None: + return 0 + selected_instance = instances_by_choice[selected_instance_key] + continue + + command = [ + "--instances-root", + str(selected_instance.path.parent), + "--state-dir", + str(st_root), + selected_action, + ] + if selected_action == "apply": + plan_path = ask("Plan path> ").strip() + if not plan_path: + print("No plan path entered.") + continue + command.append(plan_path) + else: + command.extend(["--instance", selected_instance.name]) + + print() + status = run(command) + print(f"Command exited with status {status}") + + def run(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - inst_root = instances_root(getattr(args, "instances_root", None)) + inst_roots = instances_roots(getattr(args, "instances_root", None)) st_root = state_root(getattr(args, "state_dir", None)) try: if args.command == "instances": - found = list_instances(inst_root) + found = list_instances(inst_roots) if not found: - print(f"No Beat Saber instances found under {inst_root}") + print(f"No Beat Saber instances found under {', '.join(str(root) for root in inst_roots)}") return 1 for item in found: flags = [] @@ -249,8 +377,11 @@ def run(argv: list[str] | None = None) -> int: print(f"{item.name}\t{item.path}{suffix}") return 0 + if args.command == "menu": + return _run_menu(inst_roots, st_root) + if args.command == "scan": - instance = get_instance(inst_root, args.instance) + instance = get_instance(inst_roots, args.instance) result = scan_instance(instance.path, include_hashes=args.hashes) if args.json: _json(result) @@ -331,8 +462,57 @@ def run(argv: list[str] | None = None) -> int: print_updates(result) return 2 if result["summary"]["errors"] else 0 + if args.command == "bootstrap": + instance = get_instance(inst_roots, args.instance) + root = repo_root() + registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) + lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" + if not lock_path.is_absolute(): + lock_path = (root / lock_path).resolve() + lockfile = load_lockfile(lock_path) + result = run_bootstrap( + instance=args.instance, + instance_path=instance.path, + beat_saber_version=lockfile.beat_saber_version, + registry=load_registry(registry_path), + lockfile=lockfile, + state_root=st_root, + repo_root=root, + proton=Path(args.proton).expanduser() if args.proton else None, + progress=lambda message: print(f" {message}", flush=True), + ) + if args.json: + _json(result) + else: + delta = result["delta"] + print(f"Bootstrap state: {result['statePath']}") + print(f"Plan: {result['planPath']}") + print(f"IPA.exe -n exit: {result['ipaExitCode']}") + print( + "Bootstrap files: " + f"{len(delta['created'])} created, {len(delta['mutated'])} mutated, " + f"{len(delta['removed'])} removed" + ) + print(f"Health: {'ok' if result['health']['ok'] else 'error'}") + for message in result["health"]["messages"]: + print(f" {message}") + return 0 if result["health"]["ok"] else 2 + + if args.command == "bootstrap-check": + instance = get_instance(inst_roots, args.instance) + result = check_bsipa_health(instance.path, st_root, args.instance) + if args.json: + _json(result) + else: + print(f"BSIPA bootstrap: {'ok' if result['ok'] else 'error'}") + print(f"State: {result['statePath']}") + print(f"Log: {result['logPath']}") + for message in result["messages"]: + print(f" {message}") + return 0 if result["ok"] else 2 + if args.command == "plan": - instance = get_instance(inst_root, args.instance) + instance = get_instance(inst_roots, args.instance) root = repo_root() registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry) lock_path = Path(args.lockfile) if args.lockfile else root / "locks" / f"{args.instance}.lock.toml" @@ -366,7 +546,7 @@ def run(argv: list[str] | None = None) -> int: return 0 if args.command == "uninstall": - instance = get_instance(inst_root, args.instance) + instance = get_instance(inst_roots, args.instance) result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force) print(f"Removed: {len(result['removed'])}") if result["skipped"]: @@ -376,12 +556,22 @@ def run(argv: list[str] | None = None) -> int: return 0 if result["stateUpdated"] else 2 if args.command == "backup-userdata": - instance = get_instance(inst_root, args.instance) - result = backup_userdata(args.instance, instance.path, st_root) - manifest = result["manifest"] - print(f"Archive: {result['archive']}") - print(f"Files: {manifest['fileCount']}") - print(f"Bytes: {manifest['totalSize']}") + instance = get_instance(inst_roots, args.instance) + root = repo_root() + backup_root = Path(args.backup_root).expanduser() + if not backup_root.is_absolute(): + backup_root = root / backup_root + result = sync_windows_data_repo( + instance=args.instance, + instance_path=instance.path, + backup_root=backup_root, + appdata_path=Path(args.appdata_path).expanduser() if args.appdata_path else None, + include_appdata=not args.no_appdata, + ) + print(f"Backup root: {result['backupRoot']}") + for item in result["copied"]: + print(f"{item['label']}: {item['fileCount']} files") + print(f" {item['source']} -> {item['destination']}") return 0 except Exception as exc: diff --git a/src/plugin_helper/userdata.py b/src/plugin_helper/userdata.py index 20f1a5b..faeb7c8 100644 --- a/src/plugin_helper/userdata.py +++ b/src/plugin_helper/userdata.py @@ -1,16 +1,42 @@ from __future__ import annotations import json +import fnmatch +import shutil import tarfile from datetime import datetime, timezone from pathlib import Path from tempfile import NamedTemporaryFile -from typing import Any +from typing import Any, Callable from .fsutil import sha256_file from .state import backups_dir +DEFAULT_BACKUP_EXCLUDES = ( + "BeatLeader/Replays", + "BeatLeader/Replays/**", + "BeatLeader/ReplayerCache", + "BeatLeader/ReplayerCache/**", + "BeatLeader/LeaderboardsCache", + "BeatLeader/LeaderboardsCache/**", + "BeatLeader/ReplayHeadersCache", + "ScoreSaber/Replays", + "ScoreSaber/Replays/**", + "BeatSaberPlus/Cache", + "BeatSaberPlus/Cache/**", + "BeatSaverNotifier.json", + "Accsaber/PlayerScoreCache.json", + "NalulunaAvatars/cache", + "NalulunaAvatars/cache/**", + "SongDetailsCache.proto", + "com.unity.addressables", + "com.unity.addressables/**", + "*.log", + "*.log.*", +) + + def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]: source = instance_path / "UserData" if not source.is_dir(): @@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic handle.flush() archive.add(handle.name, arcname="manifest.json") 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") + 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): + raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") + profile = Path(*parts[: users_index + 2]) + return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" + + +def sync_windows_data_repo( + *, + instance: str, + instance_path: Path, + backup_root: Path, + appdata_path: Path | None = None, + include_appdata: bool = True, +) -> dict[str, Any]: + sources: list[tuple[str, Path, Path]] = [ + ("UserData", instance_path / "UserData", backup_root / "UserData"), + ] + if include_appdata: + appdata = appdata_path or infer_windows_appdata_path(instance_path) + sources.append(("AppData", appdata, backup_root / "AppData")) + + for label, source, _ in sources: + if not source.is_dir(): + raise FileNotFoundError(f"{label} directory not found: {source}") + + backup_root.mkdir(parents=True, exist_ok=True) + + copied: list[dict[str, Any]] = [] + skipped: list[str] = [] + for label, source, destination in sources: + if destination.exists(): + shutil.rmtree(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree( + source, + destination, + symlinks=True, + ignore=_ignore_backup_paths(source, skipped), + ) + copied.append( + { + "label": label, + "source": str(source), + "destination": str(destination), + "fileCount": sum(1 for item in destination.rglob("*") if item.is_file()), + } + ) + + manifest = { + "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "instance": instance, + "sources": copied, + "excludePatterns": list(DEFAULT_BACKUP_EXCLUDES), + "skipped": sorted(set(skipped)), + } + (backup_root / "backup-descriptor.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + return { + "backupRoot": str(backup_root), + "copied": copied, + "manifest": manifest, + } + + +def _ignore_backup_paths(source_root: Path, skipped: list[str]) -> Callable[[str, list[str]], set[str]]: + def ignore(current_dir: str, names: list[str]) -> set[str]: + ignored: set[str] = set() + current_path = Path(current_dir) + for name in names: + relative = (current_path / name).relative_to(source_root).as_posix() + if _is_excluded(relative): + ignored.add(name) + skipped.append(relative) + return ignored + + return ignore + + +def _is_excluded(relative_path: str) -> bool: + return any(fnmatch.fnmatchcase(relative_path, pattern) for pattern in DEFAULT_BACKUP_EXCLUDES) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index b309f88..1b8eeb8 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -1,21 +1,26 @@ from __future__ import annotations +import json import tempfile import unittest +import os +from io import StringIO from pathlib import Path +from unittest.mock import patch from zipfile import ZipFile +from plugin_helper.bootstrap import _run_ipa from plugin_helper.checker import check_lock -from plugin_helper.cli import installed_plugins_report +from plugin_helper.cli import installed_plugins_report, run from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, uninstall_plugin from plugin_helper.instances import get_instance, list_instances from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin from plugin_helper.planner import create_plan -from plugin_helper.scanner import scan_instance -from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir +from plugin_helper.scanner import scan_bootstrap_files, scan_instance +from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state from plugin_helper.updates import check_updates -from plugin_helper.userdata import backup_userdata +from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo class PluginHelperTests(unittest.TestCase): @@ -39,6 +44,87 @@ class PluginHelperTests(unittest.TestCase): self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll") self.assertIn("sha256", scan["files"][0]) + def test_multi_root_instances_and_ambiguous_lookup(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + windows = root / "windows" + local = root / "local" + win_inst = windows / "1.44.1" + local_inst = local / "1.44.1" + (win_inst / "Beat Saber_Data").mkdir(parents=True) + (local_inst / "Beat Saber_Data").mkdir(parents=True) + + instances = list_instances([windows, local]) + + self.assertEqual(len(instances), 2) + self.assertEqual({item.path for item in instances}, {win_inst, local_inst}) + with self.assertRaisesRegex(ValueError, "ambiguous"): + get_instance([windows, local], "1.44.1") + + def test_menu_selects_instance_and_action(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instance = root / "instances" / "1.40.8" + state = root / "state" + (instance / "Beat Saber_Data").mkdir(parents=True) + (instance / "Plugins").mkdir() + + answers = iter(["1", "3", "q"]) + output = StringIO() + with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output): + status = run( + [ + "--instances-root", + str(root / "instances"), + "--state-dir", + str(state), + "menu", + ] + ) + + self.assertEqual(status, 0) + self.assertIn("Counts files currently present", output.getvalue()) + + def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + first_root = root / "a-root" + second_root = root / "z-root" + first = first_root / "1.44.1" + second = second_root / "1.44.1" + state = root / "state" + (first / "Beat Saber_Data").mkdir(parents=True) + (second / "Beat Saber_Data").mkdir(parents=True) + (second / "Plugins").mkdir() + (second / "Plugins" / "Example.dll").write_bytes(b"dll") + + answers = iter(["2", "3", "q"]) + output = StringIO() + with patch("builtins.input", side_effect=lambda _: next(answers)), patch("sys.stdout", output): + status = run( + [ + "--instances-root", + os.pathsep.join([str(first_root), str(second_root)]), + "--state-dir", + str(state), + "menu", + ] + ) + + self.assertEqual(status, 0) + self.assertIn("1.44.1: 1 files", output.getvalue()) + + def test_run_ipa_timeout_returns_control(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + result = _run_ipa( + command=["python", "-c", "import time; time.sleep(30)"], + instance_path=Path(tmp), + timeout_seconds=1, + ) + + self.assertTrue(result["timedOut"]) + self.assertNotEqual(result["returncode"], 0) + def test_plan_apply_and_uninstall_dll(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) @@ -235,6 +321,103 @@ class PluginHelperTests(unittest.TestCase): repo_root=work, ) + def test_scan_bootstrap_files_includes_root_ipa_and_bsipa_dirs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + instance = Path(tmp) + (instance / "IPA" / "Backups").mkdir(parents=True) + (instance / "Libs").mkdir() + (instance / "winhttp.dll").write_bytes(b"proxy") + (instance / "IPA.exe").write_bytes(b"ipa") + (instance / "IPA.exe.config").write_bytes(b"config") + (instance / "IPA" / "Backups" / "Beat Saber.exe.bak").write_bytes(b"backup") + (instance / "Libs" / "0Harmony.dll").write_bytes(b"harmony") + + paths = [item["path"] for item in scan_bootstrap_files(instance)] + + self.assertEqual( + paths, + [ + "IPA.exe", + "IPA.exe.config", + "IPA/Backups/Beat Saber.exe.bak", + "Libs/0Harmony.dll", + "winhttp.dll", + ], + ) + + def test_plan_requires_healthy_bootstrap_for_locked_bsipa_dependencies(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + instance = work / "instances" / "1.44.1" + state = work / "state" + instance.mkdir(parents=True) + (instance / "Beat Saber_Data").mkdir() + asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll" + asset.write_bytes(b"managed dll") + + registry = Registry( + { + "bsipa": RegistryPlugin( + id="bsipa", + name="BSIPA", + repo=None, + install_strategy="root-zip", + ), + "example": RegistryPlugin( + id="example", + name="Example", + repo=None, + install_strategy="dll-to-plugins", + ), + } + ) + lockfile = Lockfile( + beat_saber_version="1.44.1", + instance="1.44.1", + plugins=( + LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None), + LockedPlugin( + id="example", + repo=None, + tag="v1.0.0", + asset="Example.dll", + sha256=sha256_file(asset), + ), + ), + ) + + with self.assertRaisesRegex(ValueError, "BSIPA bootstrap is not healthy"): + create_plan( + instance="1.44.1", + instance_path=instance, + beat_saber_version="1.44.1", + registry=registry, + lockfile=lockfile, + state_root=state, + repo_root=work, + selected={"example"}, + ) + + (instance / "IPA").mkdir() + (instance / "Libs").mkdir() + (instance / "Logs").mkdir() + (instance / "IPA.exe").write_bytes(b"ipa") + (instance / "winhttp.dll").write_bytes(b"proxy") + (instance / "Logs" / "_latest.log").write_text("Beat Saber IPA (BSIPA): 4.3.7\n", encoding="utf-8") + save_bootstrap_state(state, "1.44.1", {"files": scan_bootstrap_files(instance)}) + + plan, _ = create_plan( + instance="1.44.1", + instance_path=instance, + beat_saber_version="1.44.1", + registry=registry, + lockfile=lockfile, + state_root=state, + repo_root=work, + selected={"example"}, + ) + self.assertEqual(plan["changes"][0]["target"], "Plugins/Example.dll") + def test_userdata_backup_contains_manifest(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -247,6 +430,56 @@ class PluginHelperTests(unittest.TestCase): self.assertTrue(Path(result["archive"]).exists()) self.assertEqual(result["manifest"]["fileCount"], 1) + def test_infer_windows_appdata_path_from_mounted_instance(self) -> None: + instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1") + + self.assertEqual( + infer_windows_appdata_path(instance), + Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"), + ) + + def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1" + appdata = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" + backup_repo = root / "backup" + (instance / "UserData").mkdir(parents=True) + (instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8") + (instance / "UserData" / "BeatLeader" / "Replays").mkdir(parents=True) + (instance / "UserData" / "BeatLeader" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8") + (instance / "UserData" / "ScoreSaber" / "Replays").mkdir(parents=True) + (instance / "UserData" / "ScoreSaber" / "Replays" / "big.bsor").write_text("replay", encoding="utf-8") + (instance / "UserData" / "BeatSaberPlus" / "Cache").mkdir(parents=True) + (instance / "UserData" / "BeatSaberPlus" / "Cache" / "cached.dat").write_text("cache", encoding="utf-8") + (instance / "UserData" / "BeatSaverNotifier.json").write_text('{"refreshToken":"secret"}', encoding="utf-8") + (instance / "UserData" / "Accsaber").mkdir(parents=True) + (instance / "UserData" / "Accsaber" / "PlayerScoreCache.json").write_text("{}", encoding="utf-8") + appdata.mkdir(parents=True) + (appdata / "Player.log").write_text("log", encoding="utf-8") + (appdata / "settings.cfg").write_text("settings", encoding="utf-8") + + result = sync_windows_data_repo( + instance="1.44.1", + instance_path=instance, + backup_root=backup_repo, + ) + + self.assertEqual(result["backupRoot"], str(backup_repo)) + self.assertEqual((backup_repo / "UserData" / "settings.json").read_text(), "{}") + self.assertFalse((backup_repo / "UserData" / "BeatLeader" / "Replays").exists()) + self.assertFalse((backup_repo / "UserData" / "ScoreSaber" / "Replays").exists()) + self.assertFalse((backup_repo / "UserData" / "BeatSaberPlus" / "Cache").exists()) + self.assertFalse((backup_repo / "UserData" / "BeatSaverNotifier.json").exists()) + self.assertFalse((backup_repo / "UserData" / "Accsaber" / "PlayerScoreCache.json").exists()) + self.assertFalse((backup_repo / "AppData" / "Player.log").exists()) + self.assertEqual((backup_repo / "AppData" / "settings.cfg").read_text(), "settings") + descriptor = json.loads((backup_repo / "backup-descriptor.json").read_text(encoding="utf-8")) + self.assertEqual(descriptor["instance"], "1.44.1") + self.assertEqual(descriptor["sources"][0]["source"], str(instance / "UserData")) + self.assertIn("BeatLeader/Replays", descriptor["skipped"]) + self.assertIn("*.log", descriptor["excludePatterns"]) + def test_check_reports_missing_asset(self) -> None: with tempfile.TemporaryDirectory() as tmp: work = Path(tmp)