Compare commits

...

3 Commits

Author SHA1 Message Date
pleb 545d407116 Failed attempt to not open IPA.exe window 2026-07-05 15:44:04 -07:00
pleb ea1e2ad61e Document asset cache roadmap 2026-07-05 15:02:02 -07:00
pleb 53ad6e2c0d Show version-locked plugins in menu 2026-07-05 15:01:58 -07:00
13 changed files with 270 additions and 99 deletions
+18 -17
View File
@@ -6,10 +6,10 @@ The first implementation focuses on safe local workflows:
- discover BSManager instances
- scan existing `Plugins/` and `Libs/` files
- read checked-in registry and per-version lockfiles
- read the checked-in plugin catalog and per-version locks
- generate a machine-readable install plan from local release assets
- apply exactly that plan and record install state
- uninstall only files recorded in install state
- apply exactly that plan and record managed install state
- uninstall only files recorded in managed install state
Default BSManager instance root:
@@ -106,9 +106,10 @@ py -m plugin_helper
py -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
```
On native Windows, `bootstrap` runs `IPA.exe -n` directly instead of through
Proton. `bootstrap-check` accepts a recorded native bootstrap without requiring
`Logs/_latest.log` when `IPA.exe -n` completed successfully.
On native Windows, `bootstrap` runs `IPA.exe "Beat Saber.exe" -n` directly
instead of through Proton. `bootstrap-check` accepts a recorded native bootstrap
without requiring `Logs/_latest.log` when `IPA.exe "Beat Saber.exe" -n`
completed successfully.
When no config file is present on Windows, defaults are
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
@@ -125,12 +126,12 @@ That is equivalent to `PYTHONPATH=src python -m plugin_helper menu` when run
from an interactive terminal.
The menu reads `plugin-helper.local.toml` when present, shows each discovered
Beat Saber install with its resolved state directory, and lets you toggle
managed plugins with arrow keys and Space. In the plugin table, use `d` to
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all
currently disabled managed plugins. With a single Beat Saber installation, the
menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when
needed.
Beat Saber install with its resolved state directory, and lists plugins from
that instance's version lock. Managed install state decides whether each row is
currently checked or unchecked. In the plugin table, use `d` to disable all
currently enabled plugins (except `bsipa`) and `e` to enable all currently
unchecked plugins. With a single Beat Saber installation, the menu opens the
plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when needed.
The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir` directly only when you intentionally want to override
@@ -212,11 +213,11 @@ custom content or non-obvious user choices rather than pure cache data.
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` (natively on
Windows or through Proton on Linux), 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
applies the locked `bsipa` root archive, runs `IPA.exe "Beat Saber.exe" -n`
(natively on Windows or through Proton on Linux), and records every
bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and
`IPA/`, including backups created during patching.
- If an instance version lock 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
+33
View File
@@ -23,6 +23,39 @@ 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.
Asset downloads are currently colocated with target-specific state under the
same state root. That is convenient for the first CLI slice, but it makes
dual-boot use awkward: pointing Linux at the Windows state root reuses the asset
downloads, but also reuses the Windows `installed.json`. A later state layout
should separate a reusable asset cache from per-install managed state, for
example:
```text
cache/
downloads/<beat-saber-version>/<plugin-id>/<asset>
instances/
<install-id>/
installed.json
bootstrap.json
plans/
backups/
```
There is no urgent need to migrate the layout before the rest of the helper
settles, but new code should avoid assuming that downloads and per-install
state must always live together.
The version lock should eventually include structured source URLs for every
asset so the helper can fetch missing downloads itself. The lock already pins
the selected repo, tag, asset name, and checksum; adding source fields would
make the fetch path explicit for both GitHub release assets and BeatMods CDN
fallbacks. Hashes should remain useful audit metadata and a warning signal, but
the UX needs a recovery path for replaced upstream assets: report the mismatch,
show the expected and actual hashes, and let the user intentionally refresh or
re-lock after inspection instead of treating every mismatch as an unrecoverable
dead end.
## Future: Nix-Orchestrated Plugin Sets
Once Beat Saber is running on Linux through Steam Proton, it may make sense to let Nix orchestrate the plugin payload itself.
+2 -2
View File
@@ -48,13 +48,13 @@ Windows instances.
### Done
- Add native Windows bootstrap support.
- `bootstrap` auto-detects Windows and runs `IPA.exe -n` directly.
- `bootstrap` auto-detects Windows and runs `IPA.exe "Beat Saber.exe" -n` directly.
- `--native` forces native mode; `--proton` remains for Linux/Proton installs.
- Timeout cleanup uses `process.terminate()` / `process.kill()` on Windows
instead of POSIX process groups.
- Bootstrap state records `bootstrapMode: "native"` or `"proton"`.
- `bootstrap-check` accepts recorded native Windows bootstrap state without
`Logs/_latest.log` when `IPA.exe -n` completed successfully
`Logs/_latest.log` when `IPA.exe "Beat Saber.exe" -n` completed successfully
(`ipaExitCode == 0`, not timed out).
- Add Windows-aware default paths when no config is present:
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
+1
View File
@@ -10,5 +10,6 @@ dependencies = [
{ id = "songcore", constraint = ">=3.16.0" },
{ id = "sirautil", constraint = ">=3.3.1" },
{ id = "system-io-compression", constraint = ">=4.6.0" },
{ id = "system-io-compression-filesystem", constraint = ">=4.7.3056" },
{ id = "bsipa", constraint = ">=4.3.7" },
]
+15 -8
View File
@@ -18,6 +18,9 @@ from .scanner import scan_bootstrap_files
from .state import bootstrap_state_path, plugin_downloads_dir, save_bootstrap_state
DEFAULT_IPA_TIMEOUT_SECONDS = 30
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -112,7 +115,7 @@ def _fetch_github_release_asset(locked: LockedPlugin, destination: Path) -> dict
def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str, Any]:
locked = next((plugin for plugin in lockfile.plugins if plugin.id == BSIPA_PLUGIN_ID), None)
if not locked:
raise ValueError("lockfile does not include a bsipa entry")
raise ValueError("version lock does not include a bsipa entry")
if not locked.asset:
raise ValueError("locked BSIPA entry has no asset")
destination = plugin_downloads_dir(state_root, lockfile.instance, BSIPA_PLUGIN_ID) / locked.asset
@@ -137,11 +140,12 @@ def build_bootstrap_command(
proton: Path | None = None,
native: bool | None = None,
) -> list[str]:
beat_saber_exe = ipa.with_name("Beat Saber.exe")
use_native = is_windows() if native is None else native
if use_native:
return [str(ipa), "-n"]
return [str(ipa), str(beat_saber_exe), "-n"]
proton_path = proton or _default_proton()
return [str(proton_path), "run", str(ipa), "-n"]
return [str(proton_path), "run", str(ipa), str(beat_saber_exe), "-n"]
def _terminate_process(process: subprocess.Popen[str]) -> None:
@@ -210,7 +214,7 @@ def run_bootstrap(
proton: Path | None = None,
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native
@@ -246,15 +250,18 @@ def run_bootstrap(
ipa = instance_path / "IPA.exe"
if not ipa.is_file():
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
beat_saber_exe = instance_path / "Beat Saber.exe"
if not beat_saber_exe.is_file():
raise FileNotFoundError(f"Beat Saber executable not found: {beat_saber_exe}")
command = build_bootstrap_command(ipa, proton=proton, native=use_native)
if use_native:
tell(f"Running IPA.exe -n natively; timeout {ipa_timeout_seconds}s")
tell(f"Running IPA.exe \"Beat Saber.exe\" -n natively; timeout {ipa_timeout_seconds}s")
else:
proton_path = proton or _default_proton()
if not proton_path.is_file():
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
tell(f"Running IPA.exe \"Beat Saber.exe\" -n through Proton; timeout {ipa_timeout_seconds}s")
completed = _run_ipa(
command=command,
@@ -262,7 +269,7 @@ def run_bootstrap(
timeout_seconds=ipa_timeout_seconds,
native=use_native,
)
tell("Scanning bootstrap files after IPA.exe -n")
tell("Scanning bootstrap files after IPA.exe \"Beat Saber.exe\" -n")
after = scan_bootstrap_files(instance_path)
delta = _files_delta(before, after)
state: dict[str, Any] = {
@@ -311,7 +318,7 @@ def ensure_healthy_bootstrap(
proton: Path | None = None,
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
ipa_timeout_seconds: int = DEFAULT_IPA_TIMEOUT_SECONDS,
) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
return
+1 -1
View File
@@ -24,7 +24,7 @@ def check_lock(
messages: list[dict[str, str]] = []
if not registry_plugin:
messages.append({"level": "warning", "message": "missing registry entry"})
messages.append({"level": "warning", "message": "missing catalog entry"})
if strategy == "manual":
messages.append({"level": "error", "message": "manual install strategy cannot be applied"})
if not locked.asset:
+5 -5
View File
@@ -95,14 +95,14 @@ def build_parser() -> argparse.ArgumentParser:
state = subcommands.add_parser(
"state",
help="Show recorded plugin-helper install state",
help="Show recorded plugin-helper managed install state",
parents=[_common_parent()],
)
state.add_argument("--instance", required=True)
installed = subcommands.add_parser(
"installed",
help="List plugins installed by plugin-helper with locked release versions",
help="List plugins from the version lock with managed install state",
parents=[_common_parent()],
)
installed.add_argument("--instance", required=True)
@@ -112,7 +112,7 @@ def build_parser() -> argparse.ArgumentParser:
check = subcommands.add_parser(
"check",
help="Validate local registry, lockfile, and release asset readiness",
help="Validate local catalog, version lock, and release asset readiness",
parents=[_common_parent()],
)
check.add_argument("--instance", required=True)
@@ -154,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser:
plan = subcommands.add_parser(
"plan",
help="Create a dry-run install plan from registry and lockfile",
help="Create a dry-run install plan from the catalog and version lock",
parents=[_common_parent()],
)
plan.add_argument("--instance", required=True)
@@ -189,7 +189,7 @@ def build_parser() -> argparse.ArgumentParser:
enable = subcommands.add_parser(
"enable",
help="Reinstall a disabled locked plugin from local assets",
help="Enable a version-locked plugin from local assets",
parents=[_common_parent()],
)
enable.add_argument("--instance", required=True)
+2 -2
View File
@@ -88,7 +88,7 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
installed_state = load_installed_state(state_root, instance)
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
if not plugin_state:
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
removed: list[str] = []
skipped: list[dict[str, str]] = []
@@ -119,7 +119,7 @@ def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_
if not plugin_state:
if plugin_id in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is already disabled: {plugin_id}")
raise KeyError(f"plugin is not recorded in install state: {plugin_id}")
raise KeyError(f"plugin is not recorded in managed install state: {plugin_id}")
removed: list[str] = []
skipped: list[dict[str, str]] = []
+59 -41
View File
@@ -41,8 +41,8 @@ def enable_disabled_plugin(
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
if plugin_id not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
if plugin_id in installed_state.get("plugins", {}):
raise KeyError(f"plugin is already enabled: {plugin_id}")
root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
instance=instance,
@@ -51,7 +51,21 @@ def enable_disabled_plugin(
repo=repo,
)
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
raise KeyError(f"plugin is not locked for this instance: {plugin_id}")
tell = progress or (lambda _message: None)
tell(f"Planning {plugin_id} from local assets")
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
require_bootstrap=False,
)
ensure_healthy_bootstrap(
instance=instance,
@@ -65,16 +79,7 @@ def enable_disabled_plugin(
progress=progress,
)
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
tell(f"Applying {plugin_id}")
result = apply_plan(plan, state_root)
return {"planPath": str(path), **result}
@@ -100,8 +105,38 @@ def enable_disabled_plugins(
repo=repo,
)
installed_state = load_installed_state(state_root, instance)
disabled_plugins = installed_state.get("disabledPlugins", {})
selected_ids = set(plugin_ids)
enabled_plugins = installed_state.get("plugins", {})
locked_ids = {plugin.id for plugin in loaded_lockfile.plugins}
enabled: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
plannable_ids: list[str] = []
for plugin_id in plugin_ids:
if plugin_id in enabled_plugins:
errors.append({"plugin": plugin_id, "error": f"plugin is already enabled: {plugin_id}"})
continue
if plugin_id not in locked_ids:
errors.append({"plugin": plugin_id, "error": f"plugin is not locked for this instance: {plugin_id}"})
continue
plannable_ids.append(plugin_id)
if not plannable_ids:
return {"enabled": enabled, "errors": errors}
selected_ids = set(plannable_ids)
tell = progress or (lambda _message: None)
tell(f"Planning {len(plannable_ids)} plugins from local assets")
batch_plan, batch_path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected=selected_ids,
require_bootstrap=False,
)
ensure_healthy_bootstrap(
instance=instance,
@@ -115,31 +150,14 @@ def enable_disabled_plugins(
progress=progress,
)
enabled: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for plugin_id in plugin_ids:
if plugin_id not in disabled_plugins:
errors.append({"plugin": plugin_id, "error": f"plugin is not recorded as disabled: {plugin_id}"})
continue
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
errors.append(
{"plugin": plugin_id, "error": f"plugin is disabled but not locked for this instance: {plugin_id}"}
)
continue
try:
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
enabled.append({"plugin": plugin_id, "planPath": str(path), "applied": len(result["applied"])})
except Exception as exc:
errors.append({"plugin": plugin_id, "error": str(exc)})
tell(f"Applying {len(plannable_ids)} plugins")
result = apply_plan(batch_plan, state_root)
applied_by_plugin: dict[str, int] = {}
for item in result["applied"]:
plugin_id = item["plugin"]
applied_by_plugin[plugin_id] = applied_by_plugin.get(plugin_id, 0) + 1
for plugin_id in plannable_ids:
enabled.append({"plugin": plugin_id, "planPath": str(batch_path), "applied": applied_by_plugin.get(plugin_id, 0)})
return {"enabled": enabled, "errors": errors}
+2 -2
View File
@@ -118,11 +118,11 @@ def create_plan(
if strategy not in VALID_STRATEGIES:
raise ValueError(f"{locked.id}: invalid install strategy: {strategy}")
if strategy == "manual":
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete registry rule first")
raise ValueError(f"{locked.id}: install_strategy is manual; add a concrete catalog rule first")
if not locked.asset:
raise ValueError(f"{locked.id}: lock entry has no asset")
if registry_plugin and not _asset_matches_patterns(Path(locked.asset).name, registry_plugin.asset_patterns):
warnings.append(f"{locked.id}: asset does not match registry patterns")
warnings.append(f"{locked.id}: asset does not match catalog patterns")
asset_path = _find_asset(locked.asset, state_root, instance, repo_root, locked.id)
if not asset_path:
+19 -10
View File
@@ -13,15 +13,24 @@ def installed_plugins_report(
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
state_plugins = [
(plugin_id, plugin_state, "enabled")
for plugin_id, plugin_state in installed_state.get("plugins", {}).items()
]
state_plugins.extend(
(plugin_id, plugin_state, "disabled")
for plugin_id, plugin_state in installed_state.get("disabledPlugins", {}).items()
enabled_plugins = installed_state.get("plugins", {})
disabled_plugins = installed_state.get("disabledPlugins", {})
plugin_ids = list(locked_by_id)
plugin_ids.extend(
sorted(
plugin_id
for plugin_id in set(enabled_plugins) | set(disabled_plugins)
if plugin_id not in locked_by_id
)
)
for plugin_id, plugin_state, status in sorted(state_plugins):
for plugin_id in plugin_ids:
if plugin_id in enabled_plugins:
plugin_state = enabled_plugins[plugin_id]
status = "enabled"
else:
plugin_state = disabled_plugins.get(plugin_id, {})
status = "disabled"
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
@@ -50,9 +59,9 @@ def installed_plugins_report(
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
print(f"{report['instance']} version-locked plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
print("No plugins are listed in this instance's version lock.")
return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
+5 -5
View File
@@ -140,7 +140,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
progress=self._bootstrap_progress,
progress=self._operation_progress,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
@@ -217,7 +217,7 @@ class PluginHelperTui(App[int]):
state_root=target.state_root,
plugin_ids=plugin_ids,
repo=self.repo_root,
progress=self._bootstrap_progress,
progress=self._operation_progress,
)
except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}")
@@ -229,8 +229,8 @@ class PluginHelperTui(App[int]):
self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True)
def _bootstrap_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, f"Bootstrapping: {message}")
def _operation_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, message)
def _show_installations(self) -> None:
self.mode = "installations"
@@ -297,7 +297,7 @@ class PluginHelperTui(App[int]):
f"Space toggles selected. d disables all. e enables all.{back_hint}"
)
else:
self._set_status("No managed plugins recorded for this installation.")
self._set_status("No version-locked plugins for this installation.")
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
+108 -6
View File
@@ -319,14 +319,17 @@ state_dir = "config-state"
def test_build_bootstrap_command_native(self) -> None:
ipa = Path("C:/Games/Beat Saber/IPA.exe")
self.assertEqual(build_bootstrap_command(ipa, native=True), [str(ipa), "-n"])
self.assertEqual(
build_bootstrap_command(ipa, native=True),
[str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
)
def test_build_bootstrap_command_proton(self) -> None:
ipa = Path("/tmp/1.44.1/IPA.exe")
proton = Path("/tmp/proton")
self.assertEqual(
build_bootstrap_command(ipa, proton=proton, native=False),
[str(proton), "run", str(ipa), "-n"],
[str(proton), "run", str(ipa), str(ipa.with_name("Beat Saber.exe")), "-n"],
)
def test_profile_config_resolves_windows_paths(self) -> None:
@@ -547,7 +550,7 @@ state_dir = ".state"
check_health.assert_not_called()
run_bootstrap.assert_not_called()
def test_enable_disabled_plugin_bootstraps_before_plan(self) -> None:
def test_enable_disabled_plugin_plans_before_bootstrap(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
@@ -628,7 +631,55 @@ install_strategy = "dll-to-plugins"
lockfile=str(lock_path),
repo=work,
)
self.assertEqual(call_order, ["ensure", "plan", "apply"])
self.assertEqual(call_order, ["plan", "ensure", "apply"])
def test_enable_locked_plugin_missing_asset_does_not_bootstrap(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()
(instance / "Plugins").mkdir()
(work / "locks").mkdir()
(work / "registry").mkdir()
lock_path = work / "locks" / "1.44.1.lock.toml"
lock_path.write_text(
"""
beat_saber_version = "1.44.1"
instance = "1.44.1"
[[plugins]]
id = "example"
tag = "v1.0.0"
asset = "Missing.dll"
""".lstrip(),
encoding="utf-8",
)
registry_path = work / "registry" / "plugins.toml"
registry_path.write_text(
"""
[[plugins]]
id = "example"
name = "Example"
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
with patch("plugin_helper.operations.ensure_healthy_bootstrap") as ensure:
with self.assertRaisesRegex(FileNotFoundError, "asset not found"):
enable_disabled_plugin(
instance="1.44.1",
instance_path=instance,
state_root=state,
plugin_id="example",
registry=str(registry_path),
lockfile=str(lock_path),
repo=work,
)
ensure.assert_not_called()
def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -1399,6 +1450,33 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
self.assertEqual(report["plugins"][0]["fileCount"], 1)
def test_installed_plugins_report_starts_from_version_lock(self) -> None:
registry = Registry(
{
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
}
)
lockfile = Lockfile(
beat_saber_version="1.40.8",
instance="1.40.8",
plugins=(
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"),
LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"),
),
)
report = installed_plugins_report(
installed_state={"instance": "1.40.8", "plugins": {}},
registry=registry,
lockfile=lockfile,
)
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta"])
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"])
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0])
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
def test_update_check_reports_current_matching_asset(self) -> None:
registry = Registry(
{
@@ -1535,7 +1613,13 @@ sha256 = "{sha256_file(asset)}"
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]:
def _make_tui_fixture(
root: Path,
*,
disabled: bool = False,
hash_mismatch: bool = False,
recorded: bool = True,
) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
@@ -1574,7 +1658,10 @@ sha256 = "{sha256_file(asset)}"
)
target = instance / "Plugins" / "Example.dll"
if disabled:
if not recorded:
plugins = {}
disabled_plugins = {}
elif disabled:
plugins = {}
disabled_plugins = {
"example": {
@@ -1762,6 +1849,21 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated["disabledPlugins"])
async def test_space_enables_locked_unrecorded_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), recorded=False)
async with app.run_test() as pilot:
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
async def test_disable_all_disables_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))