Add BeatMods parsing and userdata restore
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
/.state/
|
/.state/
|
||||||
|
/.state-*/
|
||||||
/.pytest_cache/
|
/.pytest_cache/
|
||||||
/build/
|
/build/
|
||||||
/dist/
|
/dist/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 .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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user