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