Add BeatMods parsing and userdata restore

This commit is contained in:
pleb
2026-06-29 10:56:57 -07:00
parent 17bd736e59
commit 1f35f6b078
10 changed files with 448 additions and 22 deletions
+1
View File
@@ -1,4 +1,5 @@
/.state/ /.state/
/.state-*/
/.pytest_cache/ /.pytest_cache/
/build/ /build/
/dist/ /dist/
+12 -6
View File
@@ -13,24 +13,30 @@ Guidance for coding agents working in this repo.
investigating launch behavior, inherited Steam arguments, instance layout, or investigating launch behavior, inherited Steam arguments, instance layout, or
Proton environment details unless the user explicitly asks for BSManager code Proton environment details unless the user explicitly asks for BSManager code
changes. changes.
- Prefer repo-local state with `--state-dir .state` for planned installs unless - Prefer repo-local state for planned installs unless the task explicitly
the task explicitly targets the user's live default state. targets the user's live default state. Use `--state-dir .state` for the local
Linux install and `--state-dir .state-windows` for the mounted Windows install
when both roots contain the same instance name.
## Workflow Rules ## Workflow Rules
- Run commands from the repo root with `PYTHONPATH=src`. - Run commands from the repo root with `PYTHONPATH=src`.
- For human-style inspection, prefer the menu with repo-local state: - For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`. `PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install rather than the Windows - When targeting the local Linux BSManager install, pass
mirror, pass `--instances-root /home/pleb/.local/share/BSManager/BSInstances` `--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
or choose that path explicitly in the menu. `--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and
normally `--state-dir .state-windows`.
- Use the helper commands instead of manually copying plugin files into an - Use the helper commands instead of manually copying plugin files into an
instance. instance.
- Treat BSIPA as a bootstrap phase: - Treat BSIPA as a bootstrap phase:
- `bootstrap` installs the locked BSIPA archive and records generated files. - `bootstrap` installs the locked BSIPA archive and records generated files.
- ordinary plugin plans should depend on healthy bootstrap state. - ordinary plugin plans should depend on healthy bootstrap state.
- Be careful with duplicate instance names across Windows and local roots. Use - Be careful with duplicate instance names across Windows and local roots. Use
the menu or pass `--instances-root` explicitly when targeting one install. the menu or pass `--instances-root` explicitly when targeting one install, and
keep install/bootstrap state separate per target root.
## Validation ## Validation
+59 -1
View File
@@ -21,6 +21,40 @@ Default BSManager instance roots:
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
multiple explicit roots, separate them with `:`. multiple explicit roots, separate them with `:`.
## Managing Multiple Installs
The helper is intended to manage both the local Linux BSManager install and the
mounted Windows install. Lockfiles and registry entries are shared by Beat Saber
version, but install state is target-specific. When the same instance name
exists under both roots, such as `1.44.1`, use an explicit `--instances-root`
and a separate state directory for each target.
Suggested repo-local convention:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
```
Examples:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
--state-dir .state \
installed --instance 1.44.1
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
--state-dir .state-windows \
installed --instance 1.44.1
```
Do not reuse the same state directory for both targets when their instance names
match. The current state layout is keyed by instance name, so sharing one state
directory would mix bootstrap records, generated plans, backups, and installed
file records for different game trees.
## Commands ## Commands
For normal use, run the menu from the repo root. Use repo-local state so the For normal use, run the menu from the repo root. Use repo-local state so the
@@ -33,7 +67,8 @@ PYTHONPATH=src python -m plugin_helper --state-dir .state menu
The individual subcommands are mostly for automation and debugging. If you use The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir .state` unless you intentionally want the default live them, pass `--state-dir .state` unless you intentionally want the default live
state outside this repo. state outside this repo or are intentionally targeting the Windows install with
`.state-windows`.
Install assets are currently expected to already exist locally, usually under: Install assets are currently expected to already exist locally, usually under:
@@ -41,6 +76,13 @@ Install assets are currently expected to already exist locally, usually under:
.state/instances/<instance>/downloads/<plugin-id>/ .state/instances/<instance>/downloads/<plugin-id>/
``` ```
For a second target-specific state directory, copy or re-download the same
locked assets under that state root before planning. For example:
```text
.state-windows/instances/<instance>/downloads/<plugin-id>/
```
## Beat Saber Data Backups ## Beat Saber Data Backups
`backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber `backup-userdata` copies the mounted Windows `UserData` folder and Beat Saber
@@ -68,6 +110,22 @@ the latest backup run. Use
for a `UserData`-only sync, or `--backup-root <path>` to choose a different for a `UserData`-only sync, or `--backup-root <path>` to choose a different
destination. destination.
`restore-userdata` copies those backup trees back into a target instance. It
moves the current `UserData` and AppData trees aside first as
`<name>.pre-restore-<timestamp>` snapshots. On Linux BSManager installs,
AppData is restored into the BSManager SharedContent Proton prefix unless
`--appdata-path` is provided. If `backup-descriptor.json` is present, its
`instance` field must match `--instance`.
Example restore into the local Linux instance:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
restore-userdata \
--instance 1.44.1
```
The backup intentionally omits bulky/generated data: The backup intentionally omits bulky/generated data:
- `UserData/BeatLeader/Replays` - `UserData/BeatLeader/Replays`
+29 -5
View File
@@ -1,16 +1,23 @@
# plugin-helper Design # plugin-helper Design
`plugin-helper` is a Python CLI for managing Beat Saber plugins in a mounted Windows BSManager install. It installs from individual GitHub releases, keeps per-game-version plugin selections pinned, records exact filesystem changes, and leaves compatibility judgment visible enough for an agent to help when upstream packaging is inconsistent. `plugin-helper` is a Python CLI for managing Beat Saber plugins in BSManager
installs. It installs from pinned release artifacts, keeps per-game-version
plugin selections locked, records exact filesystem changes, and leaves
compatibility judgment visible enough for an agent to help when upstream
packaging is inconsistent.
The initial target is the Linux side of `incineroar`, after the Windows partition has been mounted manually. The current Beat Saber instances live under: The current targets are the local Linux BSManager install and the mounted
Windows BSManager install:
```text ```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/Windows/Users/pleb/BSManager/BSInstances
/home/pleb/.local/share/BSManager/BSInstances
``` ```
## Goals ## Goals
- Manage plugins for one BSManager Beat Saber instance at a time, such as `1.40.8`. - Manage plugins for one BSManager Beat Saber instance at a time, such as
`1.44.1`, while supporting the same instance name under multiple roots.
- Pull plugin releases directly from configured GitHub repositories. - Pull plugin releases directly from configured GitHub repositories.
- Determine candidate updates while respecting the pinned Beat Saber version. - Determine candidate updates while respecting the pinned Beat Saber version.
- Support selective updates and explicit pins. - Support selective updates and explicit pins.
@@ -25,8 +32,10 @@ The initial target is the Linux side of `incineroar`, after the Windows partitio
- Replacing BSManager as a GUI or Beat Saber instance manager. - Replacing BSManager as a GUI or Beat Saber instance manager.
- Downloading or downgrading Beat Saber versions. - Downloading or downgrading Beat Saber versions.
- Running `nixos-rebuild switch`. - Running `nixos-rebuild switch`.
- Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command. - Mutating the Windows partition unless the user has mounted it and explicitly
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage the mutable mounted game tree. runs an apply command targeting that root.
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the
CLI should manage mutable game trees.
## Core Model ## Core Model
@@ -82,6 +91,21 @@ Runtime state should not need to live inside the repository. By default, keep mu
For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles. For early development, a `--state-dir` option is useful so plans and manifests can be kept in the repo while the format settles.
When managing both local Linux and mounted Windows installs, install state must
be separated by target root as well as by instance name. The current state
layout is keyed by instance name, so two `1.44.1` installs should not share one
state directory. A practical repo-local convention is:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
```
The registry and lockfile remain shared for a Beat Saber version. Downloads may
be copied or re-fetched into each target-specific state directory, but generated
plans, bootstrap records, backups, and `installed.json` belong to one target
game tree.
## Registry ## Registry
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences. The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences.
+7 -1
View File
@@ -15,7 +15,13 @@ The initial tool should stay conservative:
- Mutating operations apply an explicit plan and record exact file hashes. - Mutating operations apply an explicit plan and record exact file hashes.
- Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree. - Nix packages `plugin-helper`, but does not directly manage the mutable Beat Saber tree.
This works well while Beat Saber is still launched from a Windows install or a mounted Windows filesystem. This works well while Beat Saber is launched from either the local Linux
BSManager install or a mounted Windows BSManager filesystem.
For the near term, the Python state model should treat target roots as distinct
installations even when they share an instance name. Lockfiles can stay keyed by
Beat Saber version, but bootstrap state, generated plans, backups, and
`installed.json` need to stay target-specific.
## Future: Nix-Orchestrated Plugin Sets ## Future: Nix-Orchestrated Plugin Sets
+5 -5
View File
@@ -3,10 +3,10 @@
Use this workflow after installing or removing a plugin batch. It is adapted Use this workflow after installing or removing a plugin batch. It is adapted
from the Setlist repo's working Proton/BSManager smoketest notes. from the Setlist repo's working Proton/BSManager smoketest notes.
The routine smoketest should be short: about 10 seconds wall time for launch and The routine smoketest should be short: about 20 seconds wall time for launch,
log check, followed by immediate teardown. If expected plugin log lines do not menu/UI initialization, and log check, followed by immediate teardown. If
appear inside that window, treat that as a failure to investigate instead of expected plugin log lines do not appear inside that window, treat that as a
stretching the timeout. failure to investigate instead of repeatedly stretching the timeout.
## Preconditions ## Preconditions
@@ -47,7 +47,7 @@ export SteamEnv=1
export OXR_PARALLEL_VIEWS=1 export OXR_PARALLEL_VIEWS=1
( (
sleep 10 sleep 20
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
sleep 2 sleep 2
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class BeatModsEntry:
name: str
mod_id: int | None
git_url: str | None
category: str | None
version_id: int | None
mod_version: str | None
zip_hash: str | None
dependencies: tuple[int, ...]
raw: dict[str, Any]
def extract_mods(payload: Any) -> list[dict[str, Any]]:
"""Return the list of BeatMods records from either current or legacy shapes."""
if isinstance(payload, dict) and isinstance(payload.get("mods"), list):
payload = payload["mods"]
if not isinstance(payload, list):
raise ValueError("BeatMods payload must be a list or an object with a mods list")
return [item for item in payload if isinstance(item, dict)]
def normalize_entry(entry: dict[str, Any]) -> BeatModsEntry:
"""Normalize BeatMods' nested {mod, latest} response and older flat records."""
mod = entry.get("mod") if isinstance(entry.get("mod"), dict) else entry
latest = entry.get("latest") if isinstance(entry.get("latest"), dict) else entry
dependencies = latest.get("dependencies", ())
return BeatModsEntry(
name=str(mod.get("name") or ""),
mod_id=_int_or_none(mod.get("id")),
git_url=_str_or_none(mod.get("gitUrl")),
category=_str_or_none(mod.get("category")),
version_id=_int_or_none(latest.get("id")),
mod_version=_str_or_none(latest.get("modVersion")),
zip_hash=_str_or_none(latest.get("zipHash")),
dependencies=tuple(_dependency_id(dep) for dep in dependencies if _dependency_id(dep) is not None),
raw=entry,
)
def normalize_mods(payload: Any) -> list[BeatModsEntry]:
return [normalize_entry(entry) for entry in extract_mods(payload)]
def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]:
return {entry.version_id: entry for entry in entries if entry.version_id is not None}
def _dependency_id(dependency: Any) -> int | None:
if isinstance(dependency, dict):
return _int_or_none(dependency.get("id"))
return _int_or_none(dependency)
def _int_or_none(value: Any) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _str_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value)
return text if text else None
+34 -2
View File
@@ -19,7 +19,7 @@ from .planner import create_plan
from .scanner import scan_instance from .scanner import scan_instance
from .state import load_installed_state from .state import load_installed_state
from .updates import check_updates from .updates import check_updates
from .userdata import sync_windows_data_repo from .userdata import restore_windows_data_repo, sync_windows_data_repo
def _json(data: Any) -> None: def _json(data: Any) -> None:
@@ -246,6 +246,16 @@ def build_parser() -> argparse.ArgumentParser:
backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path") backup.add_argument("--appdata-path", help="Override Beat Saber Windows AppData path")
backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData") backup.add_argument("--no-appdata", action="store_true", help="Only copy UserData")
restore = subcommands.add_parser(
"restore-userdata",
help="Restore UserData and AppData from the backups repo into an instance",
parents=[_common_parent()],
)
restore.add_argument("--instance", required=True)
restore.add_argument("--backup-root", default="../backups/beat-saber", help="Backup directory")
restore.add_argument("--appdata-path", help="Override Beat Saber AppData destination path")
restore.add_argument("--no-appdata", action="store_true", help="Only restore UserData")
return parser return parser
@@ -300,7 +310,8 @@ def _run_menu(inst_roots: list[Path], st_root: Path, input_func: Callable[[str],
("bootstrap-check", "Check BSIPA bootstrap", "Verifies recorded bootstrap state and the latest BSIPA log evidence."), ("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."), ("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."), ("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."), ("backup-userdata", "Back up UserData", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("change", "Choose another version", "Returns to the Beat Saber version picker."), ("change", "Choose another version", "Returns to the Beat Saber version picker."),
] ]
@@ -574,6 +585,27 @@ def run(argv: list[str] | None = None) -> int:
print(f" {item['source']} -> {item['destination']}") print(f" {item['source']} -> {item['destination']}")
return 0 return 0
if args.command == "restore-userdata":
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).resolve()
result = restore_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["restored"]:
print(f"{item['label']}: {item['fileCount']} files")
print(f" {item['source']} -> {item['destination']}")
if item["snapshot"]:
print(f" previous: {item['snapshot']}")
return 0
except Exception as exc: except Exception as exc:
print(f"error: {exc}", file=sys.stderr) print(f"error: {exc}", file=sys.stderr)
return 2 return 2
+71 -1
View File
@@ -84,6 +84,19 @@ def infer_windows_appdata_path(instance_path: Path) -> Path:
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber" return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
def infer_proton_appdata_path() -> Path:
return (
Path.home()
/ ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"
)
def infer_appdata_path(instance_path: Path) -> Path:
if "Users" in instance_path.resolve().parts:
return infer_windows_appdata_path(instance_path)
return infer_proton_appdata_path()
def sync_windows_data_repo( def sync_windows_data_repo(
*, *,
instance: str, instance: str,
@@ -96,7 +109,7 @@ def sync_windows_data_repo(
("UserData", instance_path / "UserData", backup_root / "UserData"), ("UserData", instance_path / "UserData", backup_root / "UserData"),
] ]
if include_appdata: if include_appdata:
appdata = appdata_path or infer_windows_appdata_path(instance_path) appdata = appdata_path or infer_appdata_path(instance_path)
sources.append(("AppData", appdata, backup_root / "AppData")) sources.append(("AppData", appdata, backup_root / "AppData"))
for label, source, _ in sources: for label, source, _ in sources:
@@ -145,6 +158,63 @@ def sync_windows_data_repo(
} }
def restore_windows_data_repo(
*,
instance: str,
instance_path: Path,
backup_root: Path,
appdata_path: Path | None = None,
include_appdata: bool = True,
) -> dict[str, Any]:
descriptor_path = backup_root / "backup-descriptor.json"
if descriptor_path.is_file():
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
descriptor_instance = descriptor.get("instance")
if descriptor_instance and descriptor_instance != instance:
raise ValueError(
f"backup descriptor instance {descriptor_instance!r} does not match requested instance {instance!r}"
)
created_at = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
restores: list[tuple[str, Path, Path]] = [
("UserData", backup_root / "UserData", instance_path / "UserData"),
]
if include_appdata:
appdata = appdata_path or infer_appdata_path(instance_path)
restores.append(("AppData", backup_root / "AppData", appdata))
for label, source, _ in restores:
if not source.is_dir():
raise FileNotFoundError(f"{label} backup not found: {source}")
restored: list[dict[str, Any]] = []
snapshots: list[dict[str, Any]] = []
for label, source, destination in restores:
snapshot: Path | None = None
if destination.exists():
snapshot = destination.parent / f"{destination.name}.pre-restore-{created_at}"
destination.rename(snapshot)
snapshots.append({"label": label, "path": str(snapshot)})
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(source, destination, symlinks=True)
restored.append(
{
"label": label,
"source": str(source),
"destination": str(destination),
"fileCount": sum(1 for item in destination.rglob("*") if item.is_file()),
"snapshot": str(snapshot) if snapshot else None,
}
)
return {
"backupRoot": str(backup_root),
"restored": restored,
"snapshots": snapshots,
}
def _ignore_backup_paths(source_root: Path, skipped: list[str]) -> Callable[[str, list[str]], set[str]]: 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]: def ignore(current_dir: str, names: list[str]) -> set[str]:
ignored: set[str] = set() ignored: set[str] = set()
+155 -1
View File
@@ -10,6 +10,7 @@ from unittest.mock import patch
from zipfile import ZipFile from zipfile import ZipFile
from plugin_helper.bootstrap import _run_ipa from plugin_helper.bootstrap import _run_ipa
from plugin_helper.beatmods import by_version_id, normalize_mods
from plugin_helper.checker import check_lock from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.fsutil import sha256_file from plugin_helper.fsutil import sha256_file
@@ -20,10 +21,67 @@ from plugin_helper.planner import create_plan
from plugin_helper.scanner import scan_bootstrap_files, scan_instance 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.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
from plugin_helper.updates import check_updates from plugin_helper.updates import check_updates
from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo from plugin_helper.userdata import (
backup_userdata,
infer_appdata_path,
infer_proton_appdata_path,
infer_windows_appdata_path,
restore_windows_data_repo,
sync_windows_data_repo,
)
class PluginHelperTests(unittest.TestCase): class PluginHelperTests(unittest.TestCase):
def test_normalize_beatmods_current_nested_response(self) -> None:
payload = {
"mods": [
{
"mod": {
"id": 10,
"name": "Example",
"gitUrl": "https://github.com/example/mod",
"category": "library",
},
"latest": {
"id": 1234,
"modVersion": "1.2.3",
"zipHash": "abc123",
"dependencies": [2561, {"id": "2567"}],
},
}
]
}
entries = normalize_mods(payload)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].name, "Example")
self.assertEqual(entries[0].mod_id, 10)
self.assertEqual(entries[0].version_id, 1234)
self.assertEqual(entries[0].dependencies, (2561, 2567))
self.assertEqual(by_version_id(entries)[1234].zip_hash, "abc123")
def test_normalize_beatmods_legacy_flat_response(self) -> None:
entries = normalize_mods(
[
{
"id": "12",
"name": "FlatExample",
"gitUrl": "",
"category": "mods",
"modVersion": "2.0.0",
"zipHash": "def456",
"dependencies": [{"id": 44}, "45", None],
}
]
)
self.assertEqual(entries[0].name, "FlatExample")
self.assertEqual(entries[0].mod_id, 12)
self.assertEqual(entries[0].version_id, 12)
self.assertIsNone(entries[0].git_url)
self.assertEqual(entries[0].dependencies, (44, 45))
def test_instances_and_scan(self) -> None: def test_instances_and_scan(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) root = Path(tmp)
@@ -480,6 +538,102 @@ class PluginHelperTests(unittest.TestCase):
self.assertIn("BeatLeader/Replays", descriptor["skipped"]) self.assertIn("BeatLeader/Replays", descriptor["skipped"])
self.assertIn("*.log", descriptor["excludePatterns"]) self.assertIn("*.log", descriptor["excludePatterns"])
def test_infer_proton_appdata_path(self) -> None:
self.assertEqual(
infer_proton_appdata_path(),
Path.home()
/ ".local/share/BSManager/SharedContent/compatdata/pfx/drive_c/users/steamuser/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber",
)
def test_infer_appdata_path_uses_windows_or_proton(self) -> None:
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")
self.assertEqual(infer_appdata_path(windows_instance), infer_windows_appdata_path(windows_instance))
self.assertEqual(infer_appdata_path(linux_instance), infer_proton_appdata_path())
def test_restore_windows_data_repo_roundtrip(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('{"saved": true}', encoding="utf-8")
appdata.mkdir(parents=True)
(appdata / "settings.cfg").write_text("settings", encoding="utf-8")
sync_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
(instance / "UserData" / "settings.json").write_text('{"saved": false}', encoding="utf-8")
(appdata / "settings.cfg").write_text("changed", encoding="utf-8")
result = restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"saved": true}')
self.assertEqual((appdata / "settings.cfg").read_text(), "settings")
self.assertEqual(len(result["restored"]), 2)
self.assertTrue(all(item["snapshot"] for item in result["restored"]))
def test_restore_windows_data_repo_rejects_instance_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text("{}", encoding="utf-8")
(backup_repo / "backup-descriptor.json").write_text(
json.dumps({"instance": "1.40.8"}) + "\n",
encoding="utf-8",
)
with self.assertRaisesRegex(ValueError, "does not match"):
restore_windows_data_repo(
instance="1.44.1",
instance_path=instance,
backup_root=backup_repo,
include_appdata=False,
)
def test_restore_userdata_cli(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instances_root = root / "instances"
instance = instances_root / "1.44.1"
backup_repo = root / "backup"
(instance / "UserData").mkdir(parents=True)
(instance / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
(backup_repo / "UserData").mkdir(parents=True)
(backup_repo / "UserData" / "settings.json").write_text('{"restored": true}', encoding="utf-8")
status = run(
[
"--instances-root",
str(instances_root),
"--state-dir",
str(root / "state"),
"restore-userdata",
"--instance",
"1.44.1",
"--backup-root",
str(backup_repo),
"--no-appdata",
]
)
self.assertEqual(status, 0)
self.assertEqual((instance / "UserData" / "settings.json").read_text(), '{"restored": true}')
def test_check_reports_missing_asset(self) -> None: def test_check_reports_missing_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp) work = Path(tmp)