Add BeatMods parsing and userdata restore
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
/.state/
|
||||
/.state-*/
|
||||
/.pytest_cache/
|
||||
/build/
|
||||
/dist/
|
||||
|
||||
@@ -13,24 +13,30 @@ Guidance for coding agents working in this repo.
|
||||
investigating launch behavior, inherited Steam arguments, instance layout, or
|
||||
Proton environment details unless the user explicitly asks for BSManager code
|
||||
changes.
|
||||
- Prefer repo-local state with `--state-dir .state` for planned installs unless
|
||||
the task explicitly targets the user's live default state.
|
||||
- Prefer repo-local state for planned installs unless the task explicitly
|
||||
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
|
||||
|
||||
- Run commands from the repo root with `PYTHONPATH=src`.
|
||||
- For human-style inspection, prefer the menu with repo-local state:
|
||||
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
|
||||
- When targeting the local Linux BSManager install rather than the Windows
|
||||
mirror, pass `--instances-root /home/pleb/.local/share/BSManager/BSInstances`
|
||||
or choose that path explicitly in the menu.
|
||||
- When targeting the local Linux BSManager install, pass
|
||||
`--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
|
||||
`--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
|
||||
instance.
|
||||
- Treat BSIPA as a bootstrap phase:
|
||||
- `bootstrap` installs the locked BSIPA archive and records generated files.
|
||||
- ordinary plugin plans should depend on healthy bootstrap state.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -21,6 +21,40 @@ Default BSManager instance roots:
|
||||
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
|
||||
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
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -41,6 +76,13 @@ Install assets are currently expected to already exist locally, usually under:
|
||||
.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
|
||||
|
||||
`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
|
||||
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:
|
||||
|
||||
- `UserData/BeatLeader/Replays`
|
||||
|
||||
+29
-5
@@ -1,16 +1,23 @@
|
||||
# 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
|
||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
|
||||
/home/pleb/.local/share/BSManager/BSInstances
|
||||
```
|
||||
|
||||
## 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.
|
||||
- Determine candidate updates while respecting the pinned Beat Saber version.
|
||||
- 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.
|
||||
- Downloading or downgrading Beat Saber versions.
|
||||
- Running `nixos-rebuild switch`.
|
||||
- Mutating the Windows partition unless the user has mounted it and explicitly runs an apply command.
|
||||
- Treating Nix as the plugin installer. Nix should package `plugin-helper`; the CLI should manage the mutable mounted game tree.
|
||||
- Mutating the Windows partition unless the user has mounted it and explicitly
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
The registry describes plugin sources and install behavior. It should be human-editable because many Beat Saber plugins have small packaging differences.
|
||||
|
||||
+7
-1
@@ -15,7 +15,13 @@ The initial tool should stay conservative:
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+5
-5
@@ -3,10 +3,10 @@
|
||||
Use this workflow after installing or removing a plugin batch. It is adapted
|
||||
from the Setlist repo's working Proton/BSManager smoketest notes.
|
||||
|
||||
The routine smoketest should be short: about 10 seconds wall time for launch and
|
||||
log check, followed by immediate teardown. If expected plugin log lines do not
|
||||
appear inside that window, treat that as a failure to investigate instead of
|
||||
stretching the timeout.
|
||||
The routine smoketest should be short: about 20 seconds wall time for launch,
|
||||
menu/UI initialization, and log check, followed by immediate teardown. If
|
||||
expected plugin log lines do not appear inside that window, treat that as a
|
||||
failure to investigate instead of repeatedly stretching the timeout.
|
||||
|
||||
## Preconditions
|
||||
|
||||
@@ -47,7 +47,7 @@ export SteamEnv=1
|
||||
export OXR_PARALLEL_VIEWS=1
|
||||
|
||||
(
|
||||
sleep 10
|
||||
sleep 20
|
||||
pkill -TERM -f "[B]eat Saber.exe" || pkill -TERM -f "[B]eat Saber" || true
|
||||
sleep 2
|
||||
pkill -KILL -f "[B]eat Saber.exe" || pkill -KILL -f "[B]eat Saber" || true
|
||||
|
||||
@@ -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
|
||||
@@ -19,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 sync_windows_data_repo
|
||||
from .userdata import restore_windows_data_repo, sync_windows_data_repo
|
||||
|
||||
|
||||
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("--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
|
||||
|
||||
|
||||
@@ -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."),
|
||||
("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."),
|
||||
("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."),
|
||||
]
|
||||
|
||||
@@ -574,6 +585,27 @@ def run(argv: list[str] | None = None) -> int:
|
||||
print(f" {item['source']} -> {item['destination']}")
|
||||
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:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -84,6 +84,19 @@ def infer_windows_appdata_path(instance_path: Path) -> Path:
|
||||
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(
|
||||
*,
|
||||
instance: str,
|
||||
@@ -96,7 +109,7 @@ def sync_windows_data_repo(
|
||||
("UserData", instance_path / "UserData", backup_root / "UserData"),
|
||||
]
|
||||
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"))
|
||||
|
||||
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(current_dir: str, names: list[str]) -> set[str]:
|
||||
ignored: set[str] = set()
|
||||
|
||||
+155
-1
@@ -10,6 +10,7 @@ from unittest.mock import patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
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.cli import installed_plugins_report, run
|
||||
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.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, 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):
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -480,6 +538,102 @@ class PluginHelperTests(unittest.TestCase):
|
||||
self.assertIn("BeatLeader/Replays", descriptor["skipped"])
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
work = Path(tmp)
|
||||
|
||||
Reference in New Issue
Block a user