Add Beat Saber data backup command
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# plugin-helper
|
# 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:
|
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
|
- apply exactly that plan and record install state
|
||||||
- uninstall only files recorded in install state
|
- uninstall only files recorded in install state
|
||||||
|
|
||||||
Default BSManager instance root:
|
Default BSManager instance roots:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
|
/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
|
## 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
|
```sh
|
||||||
PYTHONPATH=src python -m plugin_helper instances
|
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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:
|
Install assets are currently expected to already exist locally, usually under:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.state/instances/<instance>/downloads/<plugin-id>/
|
.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 json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
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 .checker import check_lock
|
||||||
from .github import fetch_releases
|
from .github import fetch_releases
|
||||||
from .installer import apply_plan, uninstall_plugin
|
from .installer import apply_plan, uninstall_plugin
|
||||||
@@ -17,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 backup_userdata
|
from .userdata import sync_windows_data_repo
|
||||||
|
|
||||||
|
|
||||||
def _json(data: Any) -> None:
|
def _json(data: Any) -> None:
|
||||||
@@ -135,6 +137,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parents=[_common_parent()],
|
parents=[_common_parent()],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
subcommands.add_parser(
|
||||||
|
"menu",
|
||||||
|
help="Open an interactive instance/action menu",
|
||||||
|
parents=[_common_parent()],
|
||||||
|
)
|
||||||
|
|
||||||
scan = subcommands.add_parser(
|
scan = subcommands.add_parser(
|
||||||
"scan",
|
"scan",
|
||||||
help="Inspect installed Plugins, Libs, and IPA/Pending files",
|
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("--lockfile")
|
||||||
check.add_argument("--json", action="store_true", help="Print full JSON check output")
|
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 = subcommands.add_parser(
|
||||||
"updates",
|
"updates",
|
||||||
help="Check GitHub for newer matching releases for locked plugins",
|
help="Check GitHub for newer matching releases for locked plugins",
|
||||||
@@ -211,10 +238,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
backup = subcommands.add_parser(
|
backup = subcommands.add_parser(
|
||||||
"backup-userdata",
|
"backup-userdata",
|
||||||
help="Create a timestamped UserData backup archive",
|
help="Copy UserData and Windows AppData into this repo",
|
||||||
parents=[_common_parent()],
|
parents=[_common_parent()],
|
||||||
)
|
)
|
||||||
backup.add_argument("--instance", required=True)
|
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
|
return parser
|
||||||
|
|
||||||
@@ -225,17 +255,115 @@ def _common_parent() -> argparse.ArgumentParser:
|
|||||||
return parent
|
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:
|
def run(argv: list[str] | None = None) -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
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))
|
st_root = state_root(getattr(args, "state_dir", None))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.command == "instances":
|
if args.command == "instances":
|
||||||
found = list_instances(inst_root)
|
found = list_instances(inst_roots)
|
||||||
if not found:
|
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
|
return 1
|
||||||
for item in found:
|
for item in found:
|
||||||
flags = []
|
flags = []
|
||||||
@@ -249,8 +377,11 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
print(f"{item.name}\t{item.path}{suffix}")
|
print(f"{item.name}\t{item.path}{suffix}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.command == "menu":
|
||||||
|
return _run_menu(inst_roots, st_root)
|
||||||
|
|
||||||
if args.command == "scan":
|
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)
|
result = scan_instance(instance.path, include_hashes=args.hashes)
|
||||||
if args.json:
|
if args.json:
|
||||||
_json(result)
|
_json(result)
|
||||||
@@ -331,8 +462,57 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
print_updates(result)
|
print_updates(result)
|
||||||
return 2 if result["summary"]["errors"] else 0
|
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":
|
if args.command == "plan":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
root = repo_root()
|
root = repo_root()
|
||||||
registry_path = (root / args.registry).resolve() if not Path(args.registry).is_absolute() else Path(args.registry)
|
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"
|
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
|
return 0
|
||||||
|
|
||||||
if args.command == "uninstall":
|
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)
|
result = uninstall_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
|
||||||
print(f"Removed: {len(result['removed'])}")
|
print(f"Removed: {len(result['removed'])}")
|
||||||
if result["skipped"]:
|
if result["skipped"]:
|
||||||
@@ -376,12 +556,22 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
return 0 if result["stateUpdated"] else 2
|
return 0 if result["stateUpdated"] else 2
|
||||||
|
|
||||||
if args.command == "backup-userdata":
|
if args.command == "backup-userdata":
|
||||||
instance = get_instance(inst_root, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
result = backup_userdata(args.instance, instance.path, st_root)
|
root = repo_root()
|
||||||
manifest = result["manifest"]
|
backup_root = Path(args.backup_root).expanduser()
|
||||||
print(f"Archive: {result['archive']}")
|
if not backup_root.is_absolute():
|
||||||
print(f"Files: {manifest['fileCount']}")
|
backup_root = root / backup_root
|
||||||
print(f"Bytes: {manifest['totalSize']}")
|
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
|
return 0
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import fnmatch
|
||||||
|
import shutil
|
||||||
import tarfile
|
import tarfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .fsutil import sha256_file
|
from .fsutil import sha256_file
|
||||||
from .state import backups_dir
|
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]:
|
def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dict[str, Any]:
|
||||||
source = instance_path / "UserData"
|
source = instance_path / "UserData"
|
||||||
if not source.is_dir():
|
if not source.is_dir():
|
||||||
@@ -44,3 +70,94 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
|
|||||||
handle.flush()
|
handle.flush()
|
||||||
archive.add(handle.name, arcname="manifest.json")
|
archive.add(handle.name, arcname="manifest.json")
|
||||||
return {"archive": str(destination), "manifest": manifest}
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import os
|
||||||
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from plugin_helper.bootstrap import _run_ipa
|
||||||
from plugin_helper.checker import check_lock
|
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.fsutil import sha256_file
|
||||||
from plugin_helper.installer import apply_plan, uninstall_plugin
|
from plugin_helper.installer import apply_plan, uninstall_plugin
|
||||||
from plugin_helper.instances import get_instance, list_instances
|
from plugin_helper.instances import get_instance, list_instances
|
||||||
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
||||||
from plugin_helper.planner import create_plan
|
from plugin_helper.planner import create_plan
|
||||||
from plugin_helper.scanner import 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
|
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
|
from plugin_helper.userdata import backup_userdata, infer_windows_appdata_path, sync_windows_data_repo
|
||||||
|
|
||||||
|
|
||||||
class PluginHelperTests(unittest.TestCase):
|
class PluginHelperTests(unittest.TestCase):
|
||||||
@@ -39,6 +44,87 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
|
self.assertEqual(scan["files"][0]["path"], "Plugins/Example.dll")
|
||||||
self.assertIn("sha256", scan["files"][0])
|
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:
|
def test_plan_apply_and_uninstall_dll(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
work = Path(tmp)
|
work = Path(tmp)
|
||||||
@@ -235,6 +321,103 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
repo_root=work,
|
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:
|
def test_userdata_backup_contains_manifest(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -247,6 +430,56 @@ class PluginHelperTests(unittest.TestCase):
|
|||||||
self.assertTrue(Path(result["archive"]).exists())
|
self.assertTrue(Path(result["archive"]).exists())
|
||||||
self.assertEqual(result["manifest"]["fileCount"], 1)
|
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:
|
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