Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper

This commit is contained in:
pleb
2026-07-01 15:17:41 -07:00
20 changed files with 1368 additions and 346 deletions
@@ -1,9 +1,9 @@
---
name: beatsaber-plugin-builder
description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for net472 BSIPA projects.
description: Build, test-compile, or package Beat Saber PC BSIPA plugin source on Linux from local checkouts, GitHub branches, or pull requests. Use when asked to build a Beat Saber plugin, compile a plugin PR, produce a DLL/zip artifact, configure BeatSaberDir/local refs for dotnet builds, or diagnose Linux build failures for .NET Framework BSIPA projects.
---
# Build Beat Saber Plugin
# Beat Saber Plugin Builder
Use this skill to compile PC BSIPA plugin projects on this Linux host, especially from the `plugin-helper` repo. The workflow is adapted from the Setlist repo's Linux/Cursor build notes.
@@ -16,18 +16,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
```bash
pwd
git status --short
sed -n '1,220p' plugin-helper.local.toml
```
In `plugin-helper`, run commands from repo root and keep temporary source checkouts under `.state/build/<name>` unless the user asks for another location. Do not disturb unrelated dirty files.
In `plugin-helper`, run commands from repo root. Treat
`plugin-helper.local.toml` as the source of truth for profile
`instances_root` and `state_dir` values, and keep temporary source checkouts
under the chosen profile's `state_dir` such as
`<state_dir>/build/<name>`. Do not disturb unrelated dirty files.
2. Resolve source.
For a GitHub PR, clone or reuse a checkout under `.state/build`, add/fetch the upstream remote if needed, and check out the PR head:
For a GitHub PR, clone or reuse a checkout under the selected profile's
`<state_dir>/build`, add/fetch the upstream remote if needed, and check out
the PR head:
```bash
git clone https://github.com/<owner>/<repo>.git .state/build/<name>
git -C .state/build/<name> fetch origin pull/<pr>/head:pr-<pr>
git -C .state/build/<name> checkout pr-<pr>
git clone https://github.com/<owner>/<repo>.git <state_dir>/build/<name>
git -C <state_dir>/build/<name> fetch origin pull/<pr>/head:pr-<pr>
git -C <state_dir>/build/<name> checkout pr-<pr>
```
If the PR is from a fork and the repo already has a fork remote, preserve it. Never overwrite local source changes without explicit approval.
@@ -43,14 +50,19 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
4. Choose Beat Saber references.
Prefer a BSManager instance matching the plugin or manifest `gameVersion`. Common local roots:
Prefer a BSManager instance matching the plugin or manifest `gameVersion`.
Read `plugin-helper.local.toml` and select the intended profile, then use
that profile's `instances_root` and matching `state_dir` instead of
searching default BSManager paths manually:
```text
/home/pleb/.local/share/BSManager/BSInstances/<version>
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
```bash
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
```
Use a local `.csproj.user` or MSBuild properties rather than committing machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation does not mutate the game install.
Use a local `.csproj.user` or MSBuild properties rather than committing
machine paths. For test builds, pass `-p:DisableCopyToPlugins=True` and, for
BSMT projects that expose it, `-p:DisableCopyToGame=True` so compilation
does not mutate the game install.
5. Restore and build.
@@ -61,9 +73,25 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
dotnet build <solution-or-project> -c Release -p:DisableCopyToPlugins=True -p:DisableCopyToGame=True
```
If the project lacks .NET Framework reference assemblies on Linux, add or pass `Microsoft.NETFramework.ReferenceAssemblies.net472` as described in the reference file.
If the project lacks .NET Framework reference assemblies on Linux, add or
pass the package matching the project's target framework, usually
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current BSIPA
projects, as described in the reference file.
6. Collect artifacts.
6. Verify Beat Saber API changes before patching gameplay code.
When a Beat Saber upgrade breaks a type/member reference, first determine
whether the API moved assemblies before substituting another type. Inspect
the target game DLLs and nearby mod sources:
```bash
strings "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | rg 'TypeOrMemberName'
strings "<BeatSaberDir>/Beat Saber_Data/Managed/HMLib.dll" | rg 'TypeOrMemberName'
ilspycmd -t TypeName "<BeatSaberDir>/Beat Saber_Data/Managed/Main.dll" | sed -n '1,220p'
rg -n 'TypeOrMemberName|NearbyConcept' ~/src/<owner>/<repo> ~/src/Auros/SiraUtil ~/src/nike4613/BeatSaber-IPA-Reloaded
```
7. Collect artifacts.
Find produced DLLs and release zips:
@@ -71,23 +99,27 @@ For detailed Linux/BSMT behavior, read [linux-bsipa-build.md](references/linux-b
find <checkout> -path '*/bin/*' \( -name '*.dll' -o -name '*.zip' \) -print
```
Verify the DLL name, version, and manifest. If the result is meant for `plugin-helper`, place a copy under `.state/instances/<instance>/downloads/<plugin-id>/` and use the helper plan/apply workflow rather than hand-copying into a BSManager instance.
Verify the DLL name, version, and manifest. If the result is meant for
`plugin-helper`, place a copy under
`<state_dir>/instances/<instance>/downloads/<plugin-id>/` for the selected
profile and use the helper plan/apply workflow rather than hand-copying into
a BSManager instance.
7. Validate.
8. Validate.
For skill edits inside this repo, run:
```bash
python /home/pleb/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/beatsaber-plugin-builder
PYTHONPATH=src python -m compileall -q src tests
PYTHONPATH=src python -m unittest discover -s tests
PYTHONPATH=src .venv/bin/python -m compileall -q src tests
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests
```
For a plugin build, at minimum report the exact `dotnet build` result and artifact paths. For live game validation, use `docs/SMOKETEST.md` and tear down Beat Saber processes afterward.
## Failure Triage
- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the net472 reference-assemblies package or pass an equivalent MSBuild/package restore fix.
- Missing `Microsoft.NETFramework.ReferenceAssemblies`: add the package matching the target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects, or pass an equivalent MSBuild/package restore fix.
- Missing `Main.dll`, `HMUI.dll`, `IPA.Loader.dll`, `BSML.dll`, `SongCore.dll`, or similar: `BeatSaberDir` points at the wrong/unmodded instance, or dependencies are absent from `Plugins/`/`Libs/`.
- BSMT copies to `IPA/Pending` or `Plugins` during build: rebuild with `-p:DisableCopyToPlugins=True -p:DisableCopyToGame=True` unless the user explicitly wants deployment.
- NuGet package restore fails because a source is missing: inspect `NuGet.config` and installed package sources; use repo-local configuration where possible.
@@ -6,7 +6,8 @@ This workflow comes from `/home/pleb/ops/beatsaber/setlist/docs/pc-modding.md` a
## Toolchain
- PC BSIPA plugins are usually .NET Framework `net472` class libraries.
- PC BSIPA plugins are .NET Framework class libraries; current projects
commonly target `net48`, while older projects may still target `net472`.
- Linux `dotnet` SDK 6+ can build them because output DLLs are platform-agnostic CIL loaded by Beat Saber under Proton.
- `BeatSaberModdingTools.Tasks` supplies the MSBuild targets normally driven by Visual Studio/Rider BSMT extensions.
- On this host, `dotnet --list-sdks` should show a usable SDK. NuGet is available for package inspection.
@@ -24,16 +25,13 @@ Plugins/
winhttp.dll
```
Preferred local managed instance root:
In `plugin-helper`, prefer the profile selected from
`plugin-helper.local.toml` as the source of truth for the managed instance root
and state directory:
```text
/home/pleb/.local/share/BSManager/BSInstances/<version>
```
Windows mirror root:
```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances/<version>
```bash
sed -n '1,220p' plugin-helper.local.toml
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> instances
```
Use `BeatSaberVersion.txt` for the exact game version. The manifest `gameVersion` normally uses the `major.minor.patch` prefix, not the build suffix.
@@ -45,7 +43,7 @@ Prefer machine-local configuration in `<Project>.csproj.user`:
```xml
<Project>
<PropertyGroup>
<BeatSaberDir>/home/pleb/.local/share/BSManager/BSInstances/1.40.8</BeatSaberDir>
<BeatSaberDir>/path/from/selected/profile/instances_root/1.44.1</BeatSaberDir>
</PropertyGroup>
</Project>
```
@@ -60,7 +58,10 @@ For those, pass `-p:LocalRefsDir=/path/to/instance` or add a local `.csproj.user
When changing a project file for Linux portability, prefer the smallest explicit fix:
- Add `Microsoft.NETFramework.ReferenceAssemblies.net472` when MSBuild reports missing .NET Framework reference assemblies.
- Add the `Microsoft.NETFramework.ReferenceAssemblies.*` package matching the
project target framework, usually `Microsoft.NETFramework.ReferenceAssemblies.net48`
for current projects, when MSBuild reports missing .NET Framework reference
assemblies.
- Set or pass `DisableCopyToPlugins=True` for artifact-only builds.
- Keep hint paths rooted at `$(BeatSaberDir)` where possible.
- Do not add broad multi-version compatibility logic unless requested.
@@ -105,7 +106,9 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st
## Common Reference Failures
- `MSB3644` or missing `.NETFramework,Version=v4.7.2` reference assemblies: add `Microsoft.NETFramework.ReferenceAssemblies.net472`.
- `MSB3644` or missing `.NETFramework,Version=v4.x` reference assemblies: add
the matching `Microsoft.NETFramework.ReferenceAssemblies.*` package, usually
`Microsoft.NETFramework.ReferenceAssemblies.net48` for current projects.
- Missing game assemblies such as `Main.dll`, `HMUI.dll`, `UnityEngine.CoreModule.dll`: `BeatSaberDir` is wrong or incomplete.
- Missing mod dependencies such as `BSML.dll`, `SongCore.dll`, `SiraUtil.dll`, `BeatSaberPlaylistsLib.dll`: install or point at an instance containing those plugins, or fetch the dependency DLL from its verified release only when appropriate.
- `IPA.Loader.dll` missing: BSIPA is not bootstrapped in that instance.
@@ -115,17 +118,17 @@ If the purpose is to install the built artifact, copy it into plugin-helper's st
For a built plugin DLL intended for a managed instance:
```bash
mkdir -p .state/instances/<instance>/downloads/<plugin-id>
cp <checkout>/<path>/bin/Release/<Plugin>.dll .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
sha256sum .state/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
mkdir -p <state_dir>/instances/<instance>/downloads/<plugin-id>
cp <checkout>/<path>/bin/Release/<Plugin>.dll <state_dir>/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
sha256sum <state_dir>/instances/<instance>/downloads/<plugin-id>/<Plugin>.dll
```
Then update registry/lock data only if the user asked to manage/install the artifact, and use:
```bash
PYTHONPATH=src python -m plugin_helper --state-dir .state check --instance <instance>
PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src python -m plugin_helper --state-dir .state apply <plan-path>
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> check --instance <instance>
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> plan --instance <instance> --plugin <plugin-id>
PYTHONPATH=src .venv/bin/python -m plugin_helper --profile <profile-id> apply <plan-path>
```
Inspect the generated plan before applying.
@@ -3,12 +3,9 @@ name: beatsaber-plugin-manager
description: Install or update a Beat Saber plugin in the plugin-helper repo by using the local helper workflow. Use when the user asks to add, install, update, bump, lock, bootstrap BSIPA, or manage a Beat Saber plugin release for a BSManager instance. Prefer upstream GitHub release artifacts for normal plugins; use BeatMods primarily as compatibility/dependency metadata, with CDN artifacts only for inaccessible upstream assets, BeatMods-only packages, or framework/library dependencies.
---
# Install Beat Saber Plugin
# Beat Saber Plugin Installer
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation. Do not manually copy release files into the game instance except:
- to bootstrap BSIPA/core packages before the helper has a first-class bootstrap command
- to undo your own mistaken install before rerunning the helper
Use the repository's own `plugin-helper` commands to manage plugins for BSManager instances whenever the helper supports the operation.
## Hard Guardrail
@@ -37,14 +34,6 @@ such as .NET assemblies. Record the artifact source plus BeatMods `modVersion`,
version id, `zipHash`, dependencies, and supported game version in the repo
notes/lock data.
Accepted URL shapes include:
```text
https://github.com/<owner>/<repo>/releases
https://github.com/<owner>/<repo>/releases/tag/<tag>
https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>
```
## Workflow
1. Confirm the workspace is the `plugin-helper` repo.
+3
View File
@@ -1,5 +1,7 @@
/.state/
/.state-*/
/plugin-helper.local.toml
/plugin-helper.windows.toml
/.pytest_cache/
/build/
/dist/
@@ -7,3 +9,4 @@
/src/*.egg-info/
/__pycache__/
*.pyc
.venv
+38 -9
View File
@@ -6,13 +6,18 @@ Guidance for coding agents working in this repo.
- This repo manages Beat Saber plugins for BSManager instances.
- Default instance roots are:
- `/home/pleb/Windows/Users/pleb/BSManager/BSInstances`
- `/home/pleb/.local/share/BSManager/BSInstances`
- `~/Windows/Users/pleb/BSManager/BSInstances`
- `~/.local/share/BSManager/BSInstances`
- A local BSManager source checkout may be available at
`/home/pleb/src/Zagrios/bs-manager`. Use it as a read-only reference when
`~/src/Zagrios/bs-manager`. Use it as a read-only reference when
investigating launch behavior, inherited Steam arguments, instance layout, or
Proton environment details unless the user explicitly asks for BSManager code
changes.
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a
locked or registry plugin has a GitHub source repo. Prefer checking out the
upstream author/repo first, with `origin` pointing at upstream. If the user has
an existing personal fork checkout, preserve it as a remote named `github`
and set/add `origin` to the upstream repo instead of replacing local work.
- Prefer repo-local state for planned installs unless the task explicitly
targets the user's live default state. Use `--state-dir .state` for the local
Linux install and `--state-dir .state-windows` for the mounted Windows install
@@ -21,31 +26,55 @@ Guidance for coding agents working in this repo.
## Workflow Rules
- Run commands from the repo root with `PYTHONPATH=src`.
- A repo-local Python virtualenv is normally available at `.venv`; prefer
`.venv/bin/python` for helper commands and tests when it exists.
- For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src python -m plugin_helper --state-dir .state menu`.
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install, pass
`--instances-root /home/pleb/.local/share/BSManager/BSInstances` and normally
`--instances-root ~/.local/share/BSManager/BSInstances` and normally
`--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances` and
`--instances-root ~/Windows/Users/pleb/BSManager/BSInstances` and
normally `--state-dir .state-windows`.
- Use the helper commands instead of manually copying plugin files into an
instance.
- When adding, updating, building, or investigating a GitHub-hosted plugin,
check for `~/src/<owner>/<repo>` and clone the upstream repo there if
it is missing. Do not substitute forks or similar repos without explicit user
direction.
- Treat BSIPA as a bootstrap phase:
- `bootstrap` installs the locked BSIPA archive and records generated files.
- ordinary plugin plans should depend on healthy bootstrap state.
- Be careful with duplicate instance names across Windows and local roots. Use
the menu or pass `--instances-root` explicitly when targeting one install, and
keep install/bootstrap state separate per target root.
- After completing repo changes, suggest a concise commit message in the final
response unless the user already asked you to commit.
## Validation
- Run `PYTHONPATH=src python -m unittest discover -s tests` after code changes.
- Run `PYTHONPATH=src python -m compileall -q src tests` for syntax/import
checks.
- Run `PYTHONPATH=src .venv/bin/python -m unittest discover -s tests` after
code changes when `.venv` exists; otherwise use `python`.
- Run `PYTHONPATH=src .venv/bin/python -m compileall -q src tests` for
syntax/import checks when `.venv` exists; otherwise use `python`.
- For live game validation, follow `docs/SMOKETEST.md` and tear down Beat Saber
processes afterward.
## Pull Requests
- When the user asks for a PR against an upstream plugin, compose and submit a
polite pull request using the user's existing `gh` session.
- Keep upstream PRs small, focused, and easy for the maintainer to verify. Favor
narrow compatibility fixes, clear commit messages, and minimal formatting or
project-file churn.
- Work from the upstream checkout under `~/src/<owner>/<repo>` when available.
If the upstream remote is not writable, create or reuse the user's fork with
`gh repo fork`, push a topic branch there, and open the PR against upstream.
- Include concise verification notes in the PR body, especially the exact build
or smoke-test command and any generated artifact name. Mention local-only
build configuration separately if it was needed, and do not commit machine
paths or helper state unless the user explicitly asks for it.
## Launch Notes
- BSManager may inherit Beat Saber launch arguments configured in Steam.
+62 -45
View File
@@ -11,76 +11,93 @@ 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 roots:
Default BSManager instance root:
```text
/home/pleb/Windows/Users/pleb/BSManager/BSInstances
/home/pleb/.local/share/BSManager/BSInstances
```
Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search
multiple explicit roots, separate them with `:`.
## Managing Multiple Installs
The helper is intended to manage both the local Linux BSManager install and the
mounted Windows install. Lockfiles and registry entries are shared by Beat Saber
version, but install state is target-specific. When the same instance name
exists under both roots, such as `1.44.1`, use an explicit `--instances-root`
and a separate state directory for each target.
Suggested repo-local convention:
Default plugin-helper state directory:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
$XDG_STATE_HOME/plugin-helper
```
Examples:
If `XDG_STATE_HOME` is not set, the state directory defaults to:
```text
~/.local/state/plugin-helper
```
Override the instance root with `--instances-root`,
`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search
multiple explicit roots, separate them with `:`.
Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or
`plugin-helper.local.toml`.
## Local Configuration
This checkout is intended to manage the local Linux BSManager install. If you
also manage a Windows install, use a separate clone on that partition and point
both clones at the same state directory only when you intentionally want one
shared source of truth.
Copy the example config and adjust paths if needed:
```sh
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/.local/share/BSManager/BSInstances \
--state-dir .state \
installed --instance 1.44.1
PYTHONPATH=src python -m plugin_helper \
--instances-root /home/pleb/Windows/Users/pleb/BSManager/BSInstances \
--state-dir .state-windows \
installed --instance 1.44.1
cp plugin-helper.toml.example plugin-helper.local.toml
```
Do not reuse the same state directory for both targets when their instance names
match. The current state layout is keyed by instance name, so sharing one state
directory would mix bootstrap records, generated plans, backups, and installed
file records for different game trees.
`plugin-helper.local.toml` is ignored by git and uses top-level fields:
```toml
instances_root = "~/.local/share/BSManager/BSInstances"
state_dir = "~/.local/state/plugin-helper"
```
For repo-local state, set:
```toml
state_dir = ".state"
```
For a shared Windows-partition state directory, set the same `state_dir` in both
clones, for example:
```toml
state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
```
CLI flags override environment variables, environment variables override local
config, and local config overrides built-in defaults.
## Commands
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:
For normal use, run the Textual menu from the repo root:
```sh
PYTHONPATH=src python -m plugin_helper --state-dir .state menu
PYTHONPATH=src python -m plugin_helper
```
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 and `e` to enable all currently
disabled managed plugins.
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 or are intentionally targeting the Windows install with
`.state-windows`.
them, pass `--state-dir` directly only when you intentionally want to override
the configured state directory for one command.
Install assets are currently expected to already exist locally, usually under:
```text
.state/instances/<instance>/downloads/<plugin-id>/
```
For a second target-specific state directory, copy or re-download the same
locked assets under that state root before planning. For example:
```text
.state-windows/instances/<instance>/downloads/<plugin-id>/
<state-dir>/instances/<instance>/downloads/<plugin-id>/
```
## Beat Saber Data Backups
@@ -151,11 +151,11 @@ Purpose: restore in-game song discovery and playlist management.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| BeatSaverDownloader | [beatmods zip](https://beatmods.com/cdn/mod/a740c6e68a9b5d1dfda3cc8e81f7cf06.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 6.0.7, version id 2217, zipHash `a740c6e68a9b5d1dfda3cc8e81f7cf06`; BeatMods preferred repo `Top-Cat/BeatSaverDownloader` exposes no release assets through the GitHub releases API | IPA loaded BeatSaver Downloader 6.0.7 and started its internal webserver. Warning: it probed for missing `BetterSongList.dll` with IPA library-loader `CRITICAL` lines, then continued. |
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#3fb950; font-weight:600">verified</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; replaces failed BeatMods 1.7.3 compatibility trial | IPA loaded PlaylistManager 1.7.4, installed `PlaylistManagerAppInstaller`, and reached `MainSystemInit` during smoketest. The old `IPlatformUserModel` / `PlatformUserModel` failure did not recur. |
| PlaylistManager | [github](https://github.com/rithik-b/PlaylistManager) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | Local PR82 build from `.state/build/playlistmanager-pr82-skilltest`, artifact `PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip`; removed from the 1.44.1 lock after DiTails smoketest failure | IPA loaded PlaylistManager 1.7.4, but later smoketest coverage showed it was still incompatible with this stack. Removed from both live BS installs and do not reinstall until a compatible build is available. |
| BeatSaverUpdater | [github](https://github.com/ibillingsley/BeatSaverUpdater) | <span style="color:#3fb950; font-weight:600">verified</span> | GitHub `ibillingsley/BeatSaverUpdater` tag `1.2.11`, asset `BeatSaverUpdater-1.2.11-bs1.39.1-3698f98.zip`; BeatMods version id 2352, zipHash `d9ea8dd0cbaac66cbb02fa59a548e42b` | GitHub asset is byte-identical to the BeatMods CDN zip. IPA loaded BeatSaverUpdater 1.2.11. |
| BeatSaverVoting | [beatmods zip](https://beatmods.com/cdn/mod/bc002ed1a43e2c6d3a10d0750e5d94b4.zip) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | BeatMods 2.4.6, version id 2159, zipHash `bc002ed1a43e2c6d3a10d0750e5d94b4`; most recent blessed entry found was for Beat Saber 1.40.8, with no BeatMods verified entry for 1.44.1, 1.44.0, 1.43.0, 1.42.0, or 1.41.1 | IPA discovered and loaded BeatSaverVoting 2.4.6, but BS Utils caught a menu event failure from `BeatSaverVoting` caused by `TypeLoadException` resolving `IPlatformUserModel` from `PlatformUserModel`. Removed from the live instance after the failed smoketest. |
| BeatSaberPlaylistsLib | [beatmods zip](https://beatmods.com/cdn/mod/a3418b75ed7294a3856f3eca12bbd672.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.7.2, version id 2175, zipHash `a3418b75ed7294a3856f3eca12bbd672`; BeatMods preferred repo `Meivyn/BeatSaberPlaylistsLib` exposes no release assets through the GitHub releases API | IPA loaded BeatSaberPlaylistsLib 1.7.2. |
| BeatSaverSharp | [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; BeatMods preferred repo `lolPants/BeatSaverSharp` was inaccessible through the GitHub releases API | IPA loaded BeatSaverSharp 3.4.5. |
| BeatSaverSharp | [github](https://github.com/Auros/BeatSaverSharper), [beatmods zip](https://beatmods.com/cdn/mod/be37e13e93d9ac7da4efbdc3f514fa8f.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 3.4.5, version id 1831, zipHash `be37e13e93d9ac7da4efbdc3f514fa8f`; source repo updated to `Auros/BeatSaverSharper`; locked package remains the BeatMods CDN artifact | IPA loaded BeatSaverSharp 3.4.5. |
| ScoreSaberSharp | [beatmods zip](https://beatmods.com/cdn/mod/8713168c598577ee7c73fa3cf0e26f5c.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 0.1.0, version id 445, zipHash `8713168c598577ee7c73fa3cf0e26f5c`; BeatMods lists `scoresaber.com` rather than a GitHub release source | IPA loaded ScoreSaberSharp 0.1.0. Warning: bare manifest does not declare files. |
| BS Utils | [beatmods zip](https://beatmods.com/cdn/mod/918d13ac2821a3a17b2819f8861453e9.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 1.14.3, version id 2563, zipHash `918d13ac2821a3a17b2819f8861453e9`; BeatMods preferred repo `Kylemc1413/Beat-Saber-Utils` exposes no matching 1.14.3 GitHub release asset | IPA loaded BS Utils 1.14.3. |
| Ini Parser | [beatmods zip](https://beatmods.com/cdn/mod/5df74ad1c6b120fecdc615dd55f15b88.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatMods 2.5.9, version id 1352, zipHash `5df74ad1c6b120fecdc615dd55f15b88` | IPA loaded INI Parser 2.5.9. |
@@ -188,8 +188,8 @@ Purpose: add small gameplay helpers two or three at a time.
| HitsoundTweaks | [github](https://github.com/GalaxyMaster2/HitsoundTweaks) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `GalaxyMaster2/HitsoundTweaks` tag `v1.1.9`, asset `HitsoundTweaks-1.1.9-bs1.40.3-4ad8461.zip`; GitHub release digest matched downloaded asset | IPA loaded HitsoundTweaks 1.1.9, but SiraUtil failed to apply the `AudioTimeSyncController_dspTimeOffset_Patch` affinity patch with `InvalidProgramException`. Removed from the live instance after the failed smoke. BeatMods verified search on 2026-06-29 found no `HitsoundTweaks` entry for Beat Saber 1.42.0, 1.42.1, 1.43.0, 1.44.0, or 1.44.1. |
| KeepMyOverridesPls | [github](https://github.com/qqrz997/KeepMyOverridesPls) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `qqrz997/KeepMyOverridesPls` tag `v1.1.3-b`, asset `KeepMyOverridesPls-1.1.3-bs1.40.6-487d417.zip`; GitHub release digest matched downloaded asset | IPA loaded KeepMyOverridesPls 1.1.3, installed its app installer, wrote config, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.40.6 and plugin has no start/exit methods. |
| SoundReplacer | [github](https://github.com/Meivyn/SoundReplacer), [beatmods zip](https://beatmods.com/cdn/mod/7d7a869996e10249d1f85f95e060319b.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 2.0.1, version id 2213, zipHash `7d7a869996e10249d1f85f95e060319b`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SoundReplacer 2.0.1, installed its app and menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.29.4. |
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `lyyQwQ/KeyRemapper` tag `0.3.0`, asset `KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip`; GitHub release digest matched downloaded asset | IPA loaded KeyRemapper 0.3.0, initialized config, installed menu bindings, registered the Key Remapper button, and the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.39.1 and FPFC smoke logged dummy input manager because runtime was null. |
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_SquatToBegin` tag `v0.0.7`, asset `SquatToBegin.dll`; GitHub release digest matched downloaded asset | IPA loaded SquatToBegin 0.0.7 and the game reached `MainSystemInit`. Warnings: manifest reports Beat Saber 1.20.0 despite the GitHub release being labeled for 1.39.1+, and plugin has no start/exit methods. Verify squat gate behavior outside FPFC when practical. |
| KeyRemapper | [github](https://github.com/lyyQwQ/KeyRemapper), [pr](https://github.com/lyyQwQ/KeyRemapper/pull/6) | <span style="color:#f85149; font-weight:600">currently failed</span> | Local build from `/home/pleb/src/lyyQwQ/KeyRemapper` commit `23bb523`, asset `KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip`, SHA-256 `b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c`; PR 6 retargets the pause/menu Affinity patch from removed `UnityXRHelper` to `DevicelessVRHelper` and sets manifest gameVersion to 1.44.0. PR 6 was converted to draft after the failed manual smoke. | Replaces the prior GitHub `0.3.0` Beat Saber 1.39.1 asset. Helper installed `Plugins/KeyRemapper.dll` SHA-256 `07839dd28fa4bfbd88a3b72a8f77ebd22e70ac5acdc1239c47b9deabf775d8cd` and `Plugins/KeyRemapper.pdb` SHA-256 `e118c71519b632027f958d902d5ebcd9efb4851530091639cdbfaa96d43d4cb8`. User manual smoke on 2026-07-01 found the configured `UserData/KeyRemapper.json` pause bindings (`L_X`, `L_Y`, `R_A`, `R_B`) were not remapped. Windows logs show KeyRemapper loaded and read config without a KeyRemapper exception; `2026.07.01.09.53.35.log.gz` found both controllers and installed `KeyRemapper.Installers.GameplayInstaller`, but gameplay then flooded `MissingMethodException: AudioTimeSyncController/InitData .AudioTimeSyncController.get_state()`. TODO: add explicit logging around `GameplayInstaller`'s `IVRPlatformHelper.vrPlatformSDK == VRPlatformSDK.OpenXR` gate and verify whether that gate prevents `RemapBaseGameMenuButton`/`RestartHandler` from binding despite `UnityXRInputManager` being selected from `XRSettings.loadedDeviceName`. |
| SquatToBegin | [github](https://github.com/kinsi55/BeatSaber_SquatToBegin) | <span style="color:#d29922; font-weight:600">installed; manual resmoke pending</span> | Local build from `/home/pleb/src/kinsi55/BeatSaber_SquatToBegin` at commit `8703b84` plus local 1.44 compatibility fixes; asset `SquatToBegin.dll`, SHA-256 `0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd`. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0. | Replaces the prior GitHub `v0.0.7` DLL and the first local build. IPA loaded SquatToBegin 0.0.7, but the automated smoke did not reach the menu because Steam platform initialization failed. User manual smoke on 2026-07-01 found the squat gate did not arm and songs started immediately. Follow-up build keeps the 1.44 `IAudioTimeSource.State.Playing` fix and arms the gate from gameplay scene setup instead of relying on the stale level-selection prefix; verify VR behavior manually. |
| JDFixer | [github](https://github.com/zeph-yr/JDFixer), [pr](https://github.com/zeph-yr/JDFixer/pull/26) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from PR 26 commit `3fce6ce465911bdd5e8e00411bc4672c54a317f7`, asset `JDFixer.dll`; SHA-256 `16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d` | IPA loaded JDFixer 7.4.0 and the prior `OnEnable` Harmony failure did not recur; JDFixer logged config/donate activity. The smoke did not reach the main menu because Steam platform initialization failed (`SteamAPI Init failed`, Steam likely not running). Replaces failed GitHub `zeph-yr/JDFixer` tag `v.7.4.0`, asset `JDFixer.dll`, SHA-256 `a83ae3f68921a9698616ecd89d08b7397a550c2464a7871b6c65506ce0c7d360`; that release loaded JDFixer 7.4.0, but `OnEnable` failed with a Harmony patching exception because `StandardLevelScenesTransitionSetupDataSOPatch::TargetMethod()` returned null. |
### Batch 6: UI and Song Browser
@@ -200,7 +200,7 @@ Purpose: restore song-list, menu, and visualization conveniences.
| --- | --- | --- | --- | --- |
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#f85149; font-weight:600">failed compatibility trial</span> | GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip`; GitHub release digest matched downloaded asset; BeatMods also verifies DiTails 1.1.3 for 1.44.1 as version id 2609, zipHash `437904f6db78a2ee928738d7d254a93f` | IPA loaded DiTails 1.1.3, but menu initialization failed in `DiTails.Managers.DetailContextManager.Initialize()` with `NullReferenceException`. Removed from the live instance after the failed smoke. |
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from `/home/pleb/src/Auros/DiTails` commit `601e3c4` on branch `fix-1.44-artwork-initialization`, asset `DiTails-1.1.3-bs1.44.1-601e3c4.zip`, SHA-256 `b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e`; replaces failed GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip` and BeatMods 1.1.3 trial | Helper installed `Plugins/DiTails.dll` SHA-256 `5c856ffdfeab54982b84cb2e3033c970622668c553396460499d2343363d1d9d` in the mounted Windows instance. IPA loaded DiTails 1.1.3 and the previous `DetailContextManager.Initialize()` `NullReferenceException` did not recur before startup was blocked by `SteamAPI Init failed` because Steam was not running. |
| HideTheLogo | [github](https://github.com/TheBlackParrot/HideTheLogo) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `TheBlackParrot/HideTheLogo` tag `1.0.3`, asset `HideTheLogo-1.0.3-bs1.40.3-c968d91.zip` | IPA loaded HideTheLogo 1.0.3, logged `yeet`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.3. |
| SongChartVisualizer | [github](https://github.com/NuggoDEV/SongChartVisualizer), [beatmods zip](https://beatmods.com/cdn/mod/5d3fc025fe098277667fc0846e1b8fe3.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatMods 1.1.11, version id 2249, zipHash `5d3fc025fe098277667fc0846e1b8fe3`; GitHub releases API returned no releases on 2026-06-29 | IPA loaded SongChartVisualizer 1.1.11, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. |
| Setlist | | <span style="color:#f85149; font-weight:600">skipped after local-build trial</span> | Local build from `/home/pleb/ops/beatsaber/setlist` commit `14d21ad` with working tree modifications; first SHA-256 `01ecba3cfa697488faddf6eb8bfcc1aedff5d95f4b5a9f673f70b0ff150f5ab9`, rebuilt SHA-256 `57f07f5d99505ee35d45b3914484434fed98113c84cb94e74c6b362abd2216a1` after manifest dependency fix | First smoke ignored Setlist because `BeatLeader@^0.9.0` did not accept installed BeatLeader 0.10.0; after widening to `>=0.9.0`, IPA loaded Setlist 0.1.0 but only logged `No playlists loaded` and did not produce the expected `platformUserId`/playlist ownership lines. Removed from the live instance per skip instruction. |
+1 -1
View File
@@ -39,7 +39,7 @@ bs-manager installs these mostly as dependency closure. BeatMods records depende
- Required by: most UI/config mods here, including SongCore, SiraUtil, PlaylistManager, BeatSaverDownloader, Chroma, Vivify, ScoreSaber, and BeatLeader.
- BeatSaberPlaylistsLib https://github.com/Meivyn/BeatSaberPlaylistsLib
- Required by: PlaylistManager.
- BeatSaverSharp https://github.com/lolPants/BeatSaverSharp
- BeatSaverSharp https://github.com/Auros/BeatSaverSharper
- Required by: BeatSaverDownloader, BeatSaverUpdater, DiTails, PlaylistManager.
- BS Utils https://github.com/Kylemc1413/Beat-Saber-Utils
- Required by: BeatSaverDownloader, BeatSaverVoting, BeatLeader.
+77
View File
@@ -0,0 +1,77 @@
# Windows Compatibility Tracker
Started: 2026-07-01
This note tracks the changes needed to run `plugin-helper` natively on Windows
11 with Python 3.13, separate from the Linux helper that manages mounted
Windows instances.
## Target Setup
- Install Python with:
```powershell
winget install --id Python.Python.3.13 --exact
```
- Use a separate checkout or working copy on the Windows partition.
- Use a Windows-specific config file:
```powershell
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
```
- Keep native Windows state local to that checkout, such as `.state/`, so it
does not mix with the Linux `.state` or mounted-Windows `.state-windows`
directories.
## Current Compatibility Notes
- Core scan, plan, apply, uninstall, disable, and enable flows are mostly
platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and
local file hashes.
- Native Windows defaults should not reuse Linux-mounted paths such as
`/home/pleb/Windows/...`. A Windows-specific TOML file handles this for normal
use.
- Multiple `--instances-root` values use `os.pathsep`; that means `;` on
Windows and `:` on Linux. Documentation should make this platform-specific.
- The Textual TUI dependency supports Windows and Python 3.13, but the best
terminal target is Windows Terminal or a modern PowerShell host.
## Work Items
- Add native Windows bootstrap support.
- Current bootstrap assumes Proton.
- Native Windows should run `IPA.exe -n` directly from the Beat Saber instance.
- Timeout cleanup needs Windows-compatible process handling instead of
POSIX process groups.
- Decide whether `bootstrap-check` should accept a recorded native Windows
bootstrap state without a Proton launch history.
- Add Windows-aware default paths or keep requiring `--config
plugin-helper.windows.toml` for native use.
- Update README examples for PowerShell:
- editable install
- `--config plugin-helper.windows.toml`
- Windows path-list separator `;`
- Add or adjust tests for Windows behavior.
- Skip or rewrite the POSIX-only `_run_ipa` timeout test on Windows.
- Add tests for native Windows config path resolution.
- Add tests for native bootstrap command construction.
- Review backup and restore helpers on native Windows.
- `sync_windows_data_repo` and `restore_windows_data_repo` should work with
ordinary Windows paths.
- The older tar-based `backup_userdata` helper uses `NamedTemporaryFile` in a
way that may not be Windows-friendly if it becomes part of the CLI later.
## First Manual Smoke
From the Windows checkout:
```powershell
py -3.13 -m pip install -e .
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows instances
py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
```
If instance discovery fails, verify the BSManager instance root in
`plugin-helper.windows.toml`.
+18 -18
View File
@@ -163,12 +163,12 @@ reason = "BeatMods verified BeatSaberPlaylistsLib 1.7.2 for Beat Saber 1.44.1 as
[[plugins]]
id = "beatsaversharp"
repo = "lolPants/BeatSaverSharp"
repo = "Auros/BeatSaverSharper"
tag = "beatmods-3.4.5"
asset = "BeatSaverSharp-3.4.5.zip"
sha256 = "f5f37b27438e9b2fa1d9fbdf51a4f015f44ae04979cbdd9b90f6ae18583a6911"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. BeatMods upstream URL returned inaccessible via the GitHub releases API, so this remains a BeatMods CDN fallback."
reason = "BeatMods verified BeatSaverSharp 3.4.5 for Beat Saber 1.44.1 as version id 1831, zipHash be37e13e93d9ac7da4efbdc3f514fa8f. Source repository is now Auros/BeatSaverSharper; this remains a BeatMods CDN fallback for the locked 3.4.5 package."
[[plugins]]
id = "scoresabersharp"
@@ -232,15 +232,6 @@ sha256 = "4108b11eae11d09f8c4e838dde1dc36108f1f3fd4fff31b951e7c551fa59f5f1"
install_strategy = "bsipa-zip"
reason = "BeatMods verified BeatSaverDownloader 6.0.7 for Beat Saber 1.44.1 as version id 2217, zipHash a740c6e68a9b5d1dfda3cc8e81f7cf06. Upstream GitHub exposes no release assets through the releases API, so this remains a BeatMods CDN fallback."
[[plugins]]
id = "playlistmanager"
repo = "rithik-b/PlaylistManager"
tag = "pr-82-localbuild-da1ad17"
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
sha256 = "57b449e614db1d5214cd3a88000d52f5a989c8521390a9f4819b1c62f20f16fa"
install_strategy = "bsipa-zip"
reason = "Local build from .state/build/playlistmanager-pr82-skilltest, artifact PlaylistManager/bin/Release/net48/zip/PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip. Use this PR82 build instead of the failed BeatMods 1.7.3 compatibility trial for Beat Saber 1.44.1."
[[plugins]]
id = "beatsaverupdater"
repo = "ibillingsley/BeatSaverUpdater"
@@ -289,20 +280,20 @@ reason = "User-provided GitHub repository URL https://github.com/qqrz997/KeepMyO
[[plugins]]
id = "keyremapper"
repo = "lyyQwQ/KeyRemapper"
tag = "0.3.0"
asset = "KeyRemapper-0.3.0-bs1.39.1-8e4c11a.zip"
sha256 = "962e3f6e18bebdf101e6575fa7b8b7e0d92179bab4f0825d122078f1842f7380"
tag = "pr-6-23bb523-1.44-fix"
asset = "KeyRemapper-0.3.0-bs1.44.0-8e4c11a.zip"
sha256 = "b8aaaa72712ae10cec98f51f79b3b87fe7a588b0d3393dd087b7d84975246e6c"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub releases URL https://github.com/lyyQwQ/KeyRemapper/releases. Latest non-draft, non-prerelease release 0.3.0 exposes this asset; GitHub release digest matched the downloaded asset. Release notes label compatibility as Beat Saber 1.39.1 with BSIPA/BSML/SiraUtil dependencies."
reason = "Local build from /home/pleb/src/lyyQwQ/KeyRemapper commit 23bb523, submitted upstream as PR https://github.com/lyyQwQ/KeyRemapper/pull/6. This updates the menu remap Affinity patch target from removed UnityXRHelper to DevicelessVRHelper and sets manifest gameVersion to 1.44.0 for Beat Saber 1.44 compatibility."
[[plugins]]
id = "squattobegin"
repo = "kinsi55/BeatSaber_SquatToBegin"
tag = "v0.0.7"
tag = "localbuild-8703b84-1.44-fix"
asset = "SquatToBegin.dll"
sha256 = "8426a64f6a3224b8cd79b9ee86347727a5a43c40a0fda116bf9c613f145fc18e"
sha256 = "0bd6b5ee48cae370f255707afb9e452e08a68802f81493dd9a442b6a9f7b98bd"
install_strategy = "dll-to-plugins"
reason = "User-provided GitHub releases URL https://github.com/kinsi55/BeatSaber_SquatToBegin/releases. Latest non-draft, non-prerelease release v0.0.7 is labeled for Beat Saber 1.39.1+ and exposes this direct DLL asset; GitHub release digest matched the downloaded asset."
reason = "Local build from /home/pleb/src/kinsi55/BeatSaber_SquatToBegin at commit 8703b84 plus local 1.44 compatibility fixes: change AudioTimeSyncController.State.Playing to IAudioTimeSource.State.Playing and arm the squat gate from gameplay scene setup instead of relying on the stale level-selection prefix. BeatMods verified search on 2026-07-01 found no SquatToBegin entry for Beat Saber 1.44.1, 1.44.0, 1.43.0, 1.42.1, 1.42.0, 1.40.0, 1.39.1, or 1.20.0."
[[plugins]]
id = "introskip"
@@ -340,6 +331,15 @@ sha256 = "b889355cd47cc3b09a352f081110b8130ac6e1d285fa58776466117d585814db"
install_strategy = "bsipa-zip"
reason = "User-provided GitHub repository URL https://github.com/ErisApps/HitScoreVisualizer. Latest non-draft, non-prerelease release 3.7.3 exposes this asset for Beat Saber 1.42.0; GitHub release digest matched the downloaded asset."
[[plugins]]
id = "ditails"
repo = "Auros/DiTails"
tag = "localbuild-601e3c4-1.44-fix"
asset = "DiTails-1.1.3-bs1.44.1-601e3c4.zip"
sha256 = "b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e"
install_strategy = "bsipa-zip"
reason = "Local build from /home/pleb/src/Auros/DiTails commit 601e3c4 on branch fix-1.44-artwork-initialization. Use this build instead of the failed upstream 1.1.3 / BeatMods DiTails trial, which loaded but failed menu initialization with a NullReferenceException on Beat Saber 1.44.1."
[[plugins]]
id = "hidethelogo"
repo = "TheBlackParrot/HideTheLogo"
+8
View File
@@ -0,0 +1,8 @@
instances_root = "~/.local/share/BSManager/BSInstances"
state_dir = "~/.local/state/plugin-helper"
# To keep state inside this checkout instead:
# state_dir = ".state"
#
# To share one state directory between Linux and a Windows-partition checkout:
# state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state"
+3 -1
View File
@@ -10,7 +10,9 @@ readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
authors = [{ name = "plugin-helper contributors" }]
dependencies = []
dependencies = [
"textual>=8.2,<9",
]
[project.scripts]
plugin-helper = "plugin_helper.cli:main"
+1 -1
View File
@@ -407,7 +407,7 @@ required = true
[[plugins]]
id = "beatsaversharp"
name = "BeatSaverSharp"
repo = "lolPants/BeatSaverSharp"
repo = "Auros/BeatSaverSharper"
asset_patterns = ["BeatSaverSharp-*.zip"]
install_strategy = "bsipa-zip"
category = "library"
+107 -161
View File
@@ -4,18 +4,19 @@ import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable
from typing import Any
from .config import instances_roots, repo_root, state_root
from .config import repo_root, resolve_runtime_config
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
from .installer import apply_plan, disable_plugin, uninstall_plugin
from .instances import get_instance, list_instances
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry
from .operations import enable_disabled_plugin
from .planner import create_plan
from .reports import installed_plugins_report, print_installed_plugins
from .scanner import scan_instance
from .state import load_installed_state
from .updates import check_updates
@@ -26,68 +27,6 @@ def _json(data: Any) -> None:
print(json.dumps(data, indent=2, sort_keys=True))
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> dict[str, Any]:
locked_by_id = {plugin.id: plugin for plugin in lockfile.plugins}
plugins: list[dict[str, Any]] = []
for plugin_id, plugin_state in sorted(installed_state.get("plugins", {}).items()):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
def print_updates(report: dict[str, Any]) -> None:
plugins = report["plugins"]
summary = report["summary"]
@@ -129,7 +68,7 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="plugin-helper")
_add_common(parser)
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands = parser.add_subparsers(dest="command")
subcommands.add_parser(
"instances",
@@ -236,6 +175,25 @@ def build_parser() -> argparse.ArgumentParser:
uninstall.add_argument("plugin")
uninstall.add_argument("--force", action="store_true", help="Delete even when current file hashes differ")
disable = subcommands.add_parser(
"disable",
help="Remove a managed plugin's files from the game but keep state/assets for re-enable",
parents=[_common_parent()],
)
disable.add_argument("--instance", required=True)
disable.add_argument("plugin")
disable.add_argument("--force", action="store_true", help="Disable even when current file hashes differ")
enable = subcommands.add_parser(
"enable",
help="Reinstall a disabled locked plugin from local assets",
parents=[_common_parent()],
)
enable.add_argument("--instance", required=True)
enable.add_argument("--registry", default="registry/plugins.toml")
enable.add_argument("--lockfile")
enable.add_argument("plugin")
backup = subcommands.add_parser(
"backup-userdata",
help="Copy UserData and Windows AppData into the adjacent backups repo",
@@ -265,112 +223,69 @@ def _common_parent() -> argparse.ArgumentParser:
return parent
def _ask_choice(
def _run_menu(
*,
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)}")
runtime: Any,
explicit_instances_root: bool,
explicit_state_dir: bool,
) -> int:
try:
from .tui import InstallationChoice, PluginHelperTui
except ImportError as exc:
print(f"Textual is required for the menu TUI: {exc}")
print("Install project dependencies, for example: python -m pip install -e .")
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", "Copies UserData and AppData into the adjacent backups repo."),
("restore-userdata", "Restore UserData", "Restores UserData and AppData from the backups repo into an instance."),
("change", "Choose another version", "Returns to the Beat Saber version picker."),
]
selected_instance_key = _ask_choice(
title="Choose Beat Saber version",
choices=instance_choices,
input_func=ask,
choices: list[InstallationChoice] = []
for index, root in enumerate(runtime.instances_roots, start=1):
install_label = str(root) if len(runtime.instances_roots) > 1 else "Default"
for instance in list_instances(root):
choices.append(
InstallationChoice(
install_id=f"root-{index}",
install_label=install_label,
instance_name=instance.name,
instance_path=instance.path,
state_root=runtime.state_root,
)
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 not choices:
searched = ", ".join(str(root) for root in runtime.instances_roots)
print(f"No Beat Saber instances found under {searched}")
return 1
setup_hint = None
if not runtime.config_loaded and not explicit_instances_root and not explicit_state_dir:
setup_hint = (
f"No {runtime.config_path.name} found; using default roots and default state. "
f"Copy plugin-helper.toml.example to {runtime.config_path.name} to set a custom state dir."
)
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}")
app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint)
result = app.run()
return int(result or 0)
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
inst_roots = instances_roots(getattr(args, "instances_root", None))
st_root = state_root(getattr(args, "state_dir", None))
try:
if args.command is None:
if sys.stdin.isatty() and sys.stdout.isatty():
args.command = "menu"
else:
parser.print_help()
return 2
explicit_instances_root = getattr(args, "instances_root", None) is not None
explicit_state_dir = getattr(args, "state_dir", None) is not None
runtime = resolve_runtime_config(
instances_root_value=getattr(args, "instances_root", None),
state_dir_value=getattr(args, "state_dir", None),
)
inst_roots = runtime.instances_roots
st_root = runtime.state_root
if args.command == "instances":
found = list_instances(inst_roots)
if not found:
@@ -389,7 +304,11 @@ def run(argv: list[str] | None = None) -> int:
return 0
if args.command == "menu":
return _run_menu(inst_roots, st_root)
return _run_menu(
runtime=runtime,
explicit_instances_root=explicit_instances_root,
explicit_state_dir=explicit_state_dir,
)
if args.command == "scan":
instance = get_instance(inst_roots, args.instance)
@@ -566,6 +485,33 @@ def run(argv: list[str] | None = None) -> int:
print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2
if args.command == "disable":
instance = get_instance(inst_roots, args.instance)
result = disable_plugin(args.instance, instance.path, st_root, args.plugin, force=args.force)
print(f"Disabled: {args.plugin}")
print(f"Removed: {len(result['removed'])}")
if result["skipped"]:
print("Skipped:")
for item in result["skipped"]:
print(f" {item['path']}: {item['reason']}")
return 0 if result["stateUpdated"] else 2
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
result = enable_disabled_plugin(
instance=args.instance,
instance_path=instance.path,
state_root=st_root,
plugin_id=args.plugin,
registry=args.registry,
lockfile=args.lockfile,
)
print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}")
print(f"Applied: {len(result['applied'])}")
print(f"State: {result['statePath']}")
return 0
if args.command == "backup-userdata":
instance = get_instance(inst_roots, args.instance)
root = repo_root()
+119 -9
View File
@@ -1,33 +1,143 @@
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances")
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT)
DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
@dataclass(frozen=True)
class LocalConfig:
instances_roots: list[Path] | None
state_root: Path | None
@dataclass(frozen=True)
class RuntimeConfig:
instances_roots: list[Path]
state_root: Path
config_path: Path
config_loaded: bool
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def instances_root(value: str | None = None) -> Path:
return instances_roots(value)[0]
def instances_roots(value: str | None = None) -> list[Path]:
def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]:
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
if raw:
return [Path(item).expanduser() for item in raw.split(os.pathsep) if item]
return list(DEFAULT_INSTANCES_ROOTS)
return _resolve_path_list(raw, base or repo_root())
return [DEFAULT_INSTANCES_ROOT]
def state_root(value: str | None = None) -> Path:
if value:
return Path(value).expanduser()
return _resolve_path(value, repo_root())
env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR")
if env_state:
return _resolve_path(env_state, repo_root())
xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper"
def repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def default_config_path(root: Path | None = None) -> Path:
return (root or repo_root()) / LOCAL_CONFIG_NAME
def _resolve_path(value: str | Path, base: Path) -> Path:
path = Path(value).expanduser()
if path.is_absolute():
return path
return (base / path).resolve()
def _resolve_path_list(value: str, base: Path) -> list[Path]:
return [_resolve_path(item, base) for item in value.split(os.pathsep) if item]
def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]:
repo = root or repo_root()
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
if not path.exists():
if config_path:
raise FileNotFoundError(f"plugin-helper config not found: {path}")
return LocalConfig(instances_roots=None, state_root=None), path, False
with path.open("rb") as handle:
data: dict[str, Any] = tomllib.load(handle)
base = path.parent
instances_value = data.get("instances_root")
state_value = data.get("state_dir")
return (
LocalConfig(
instances_roots=_resolve_path_list(instances_value, base) if instances_value else None,
state_root=_resolve_path(state_value, base) if state_value else None,
),
path,
True,
)
def _env_instances_roots(root: Path) -> list[Path] | None:
value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
return _resolve_path_list(value, root) if value else None
def _env_state_root(root: Path) -> Path | None:
value = os.environ.get("PLUGIN_HELPER_STATE_DIR")
return _resolve_path(value, root) if value else None
def _default_state_root() -> Path:
xdg_state = os.environ.get("XDG_STATE_HOME")
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
return base / "plugin-helper"
def _default_instances_roots() -> list[Path]:
return [DEFAULT_INSTANCES_ROOT]
def resolve_runtime_config(
*,
instances_root_value: str | None = None,
state_dir_value: str | None = None,
root: Path | None = None,
) -> RuntimeConfig:
repo = root or repo_root()
local_config, loaded_path, loaded = load_local_config(root=repo)
resolved_instances = (
_resolve_path_list(instances_root_value, repo)
if instances_root_value
else _env_instances_roots(repo)
or local_config.instances_roots
or _default_instances_roots()
)
resolved_state = (
_resolve_path(state_dir_value, repo)
if state_dir_value
else _env_state_root(repo)
or local_config.state_root
or _default_state_root()
)
return RuntimeConfig(
instances_roots=resolved_instances,
state_root=resolved_state,
config_path=loaded_path,
config_loaded=loaded,
)
+35
View File
@@ -77,6 +77,7 @@ def apply_plan(plan: dict[str, Any], state_root: Path) -> dict[str, Any]:
"size": target.stat().st_size,
}
)
installed_state.setdefault("disabledPlugins", {}).pop(change["plugin"], None)
applied.append({"path": rel_target, "plugin": change["plugin"], "backup": backup})
save_installed_state(state_root, instance, installed_state)
@@ -110,3 +111,37 @@ def uninstall_plugin(instance: str, instance_path: Path, state_root: Path, plugi
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
def disable_plugin(instance: str, instance_path: Path, state_root: Path, plugin_id: str, force: bool = False) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
plugin_state = installed_state.get("plugins", {}).get(plugin_id)
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}")
removed: list[str] = []
skipped: list[dict[str, str]] = []
for item in plugin_state.get("files", []):
rel_path = ensure_relative(item["path"]).as_posix()
target = ensure_inside(instance_path, instance_path / rel_path)
if not target.exists():
removed.append(rel_path)
continue
current_sha = sha256_file(target)
if current_sha != item.get("sha256") and not force:
skipped.append({"path": rel_path, "reason": "hash mismatch"})
continue
target.unlink()
removed.append(rel_path)
if skipped and not force:
return {"removed": removed, "skipped": skipped, "stateUpdated": False}
disabled_state = dict(plugin_state)
disabled_state["disabledAt"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
installed_state.setdefault("disabledPlugins", {})[plugin_id] = disabled_state
installed_state.get("plugins", {}).pop(plugin_id, None)
save_installed_state(state_root, instance, installed_state)
return {"removed": removed, "skipped": skipped, "stateUpdated": True}
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import repo_root
from .installer import apply_plan
from .models import load_lockfile, load_registry
from .planner import create_plan
from .state import load_installed_state
def enable_disabled_plugin(
*,
instance: str,
instance_path: Path,
state_root: Path,
plugin_id: str,
registry: str = "registry/plugins.toml",
lockfile: str | None = None,
repo: Path | 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}")
root = repo or repo_root()
registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry)
lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml"
if not lock_path.is_absolute():
lock_path = (root / lock_path).resolve()
loaded_lockfile = load_lockfile(lock_path)
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}")
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=load_registry(registry_path),
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
return {"planPath": str(path), **result}
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from .models import Lockfile, Registry
def installed_plugins_report(
*,
installed_state: dict[str, Any],
registry: Registry,
lockfile: Lockfile,
) -> 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()
)
for plugin_id, plugin_state, status in sorted(state_plugins):
registry_plugin = registry.get(plugin_id)
locked = locked_by_id.get(plugin_id)
files = plugin_state.get("files", [])
plugins.append(
{
"id": plugin_id,
"name": registry_plugin.name if registry_plugin else plugin_id,
"version": locked.tag if locked and locked.tag else "(not locked)",
"asset": locked.asset if locked and locked.asset else "(unknown)",
"repo": (locked.repo if locked and locked.repo else None)
or (registry_plugin.repo if registry_plugin else None)
or "(unknown)",
"installedAt": plugin_state.get("installedAt", "(unknown)"),
"disabledAt": plugin_state.get("disabledAt"),
"status": status,
"fileCount": len(files),
"files": files,
}
)
return {
"instance": installed_state.get("instance", lockfile.instance),
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
"plugins": plugins,
}
def print_installed_plugins(report: dict[str, Any]) -> None:
plugins = report["plugins"]
print(f"{report['instance']} managed plugins ({len(plugins)})")
if not plugins:
print("No plugins have been installed by plugin-helper yet.")
return
headers = ("Plugin", "Status", "Version", "Asset", "Files", "Installed")
rows = [
(
f"{plugin['name']} ({plugin['id']})",
plugin["status"],
plugin["version"],
plugin["asset"],
str(plugin["fileCount"]),
plugin["disabledAt"] or plugin["installedAt"],
)
for plugin in plugins
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
header = " ".join(label.ljust(widths[index]) for index, label in enumerate(headers))
print(header)
print(" ".join("-" * width for width in widths))
for row in rows:
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
+275
View File
@@ -0,0 +1,275 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, Static
from .installer import disable_plugin
from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin
from .reports import installed_plugins_report
from .state import load_installed_state
@dataclass(frozen=True)
class InstallationChoice:
install_id: str
install_label: str
instance_name: str
instance_path: Path
state_root: Path
class PluginHelperTui(App[int]):
CSS = """
#title {
padding: 0 1;
text-style: bold;
}
#status {
padding: 0 1;
color: $text-muted;
}
"""
BINDINGS = [
Binding("enter", "select", "Select", priority=True),
Binding("space", "toggle_plugin", "Toggle", priority=True),
Binding("d", "disable_all_plugins", "Disable all", priority=True),
Binding("e", "enable_all_plugins", "Enable all", priority=True),
Binding("r", "refresh", "Refresh"),
Binding("b", "back", "Back"),
Binding("q", "quit", "Quit"),
Binding("ctrl+q", "quit", "Quit", show=False, priority=True),
]
def __init__(
self,
*,
choices: list[InstallationChoice],
repo_root: Path,
setup_hint: str | None = None,
) -> None:
super().__init__()
self.choices = choices
self.repo_root = repo_root
self.setup_hint = setup_hint
self.mode = "installations"
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.status_message = ""
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
yield Static("", id="title")
yield DataTable(id="table")
yield Static("", id="status")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
self._show_installations()
def action_select(self) -> None:
if self.mode != "installations":
return
index = self._cursor_index(len(self.choices))
if index is None:
return
self.selected_installation = self.choices[index]
self._show_plugins()
def action_back(self) -> None:
if self.mode == "plugins":
self._show_installations()
def action_refresh(self) -> None:
if self.mode == "plugins":
self._show_plugins()
else:
self._show_installations()
def action_toggle_plugin(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
if index is None:
return
plugin = self.plugin_rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
try:
if plugin["status"] == "enabled":
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
return
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
elif plugin["status"] == "disabled":
result = enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
self._set_status(f"Cannot toggle {plugin_id}: unknown status {plugin['status']}.")
return
except Exception as exc:
self._set_status(f"Could not toggle {plugin_id}: {exc}")
return
self._show_plugins(preserve_status=True)
def action_disable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
enabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "enabled"]
changed = 0
errors: list[str] = []
for plugin in enabled:
plugin_id = plugin["id"]
try:
result = disable_plugin(
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
)
if result["stateUpdated"]:
changed += 1
else:
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Disabled", changed, errors)
self._show_plugins(preserve_status=True)
def action_enable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
return
target = self.selected_installation
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
changed = 0
errors: list[str] = []
for plugin in disabled:
plugin_id = plugin["id"]
try:
enable_disabled_plugin(
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_id=plugin_id,
repo=self.repo_root,
)
changed += 1
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True)
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
self._set_title("Choose Beat Saber Installation")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Install", "Version", "Instance Path", "State Dir")
for choice in self.choices:
table.add_row(
choice.install_label,
choice.instance_name,
str(choice.instance_path),
str(choice.state_root),
)
if self.setup_hint:
self._set_status(self.setup_hint)
else:
self._set_status("Enter selects an installation. q quits.")
def _show_plugins(self, *, preserve_status: bool = False) -> None:
if self.selected_installation is None:
self._show_installations()
return
target = self.selected_installation
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
table.clear(columns=True)
table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset")
try:
lockfile = load_lockfile(self.repo_root / "locks" / f"{target.instance_name}.lock.toml")
report = installed_plugins_report(
installed_state=load_installed_state(target.state_root, target.instance_name),
registry=load_registry(self.repo_root / "registry" / "plugins.toml"),
lockfile=lockfile,
)
self.plugin_rows = report["plugins"]
except Exception as exc:
self.plugin_rows = []
if not preserve_status:
self._set_status(f"Could not load plugins: {exc}")
return
for plugin in self.plugin_rows:
table.add_row(
self._status_marker(plugin["status"]),
plugin["name"],
plugin["id"],
plugin["version"],
str(plugin["fileCount"]),
plugin["asset"],
)
if not preserve_status:
if self.plugin_rows:
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
else:
self._set_status("No managed plugins recorded for this installation.")
def _cursor_index(self, row_count: int) -> int | None:
table = self.query_one(DataTable)
row = table.cursor_coordinate.row
if 0 <= row < row_count:
return row
return None
def _set_title(self, message: str) -> None:
self.query_one("#title", Static).update(message)
def _set_status(self, message: str) -> None:
self.status_message = message
self.query_one("#status", Static).update(message)
def _set_bulk_status(self, verb: str, changed: int, errors: list[str]) -> None:
if errors:
preview = "; ".join(errors[:3])
suffix = f"; {len(errors) - 3} more" if len(errors) > 3 else ""
self._set_status(f"{verb} {changed} plugins; {len(errors)} failed: {preview}{suffix}")
else:
self._set_status(f"{verb} {changed} plugins.")
@staticmethod
def _status_marker(status: str) -> Text:
return Text("[x]" if status == "enabled" else "[ ]", no_wrap=True)
@staticmethod
def _format_skipped(skipped: list[dict[str, str]]) -> str:
if not skipped:
return "no files changed"
return "; ".join(f"{item['path']} {item['reason']}" for item in skipped)
+415 -44
View File
@@ -9,17 +9,23 @@ from pathlib import Path
from unittest.mock import patch
from zipfile import ZipFile
from rich.text import Text
from textual.coordinate import Coordinate
from textual.widgets import DataTable
from plugin_helper.bootstrap import _run_ipa
from plugin_helper.beatmods import by_version_id, normalize_mods
from plugin_helper.checker import check_lock
from plugin_helper.cli import installed_plugins_report, run
from plugin_helper.config import load_local_config, resolve_runtime_config
from plugin_helper.fsutil import sha256_file
from plugin_helper.installer import apply_plan, uninstall_plugin
from plugin_helper.installer import apply_plan, disable_plugin, 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_bootstrap_files, scan_instance
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state
from plugin_helper.state import downloads_dir, load_installed_state, plugin_downloads_dir, save_bootstrap_state, save_installed_state
from plugin_helper.tui import InstallationChoice, PluginHelperTui
from plugin_helper.updates import check_updates
from plugin_helper.userdata import (
backup_userdata,
@@ -119,58 +125,132 @@ class PluginHelperTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "ambiguous"):
get_instance([windows, local], "1.44.1")
def test_menu_selects_instance_and_action(self) -> None:
def test_local_config_loads_top_level_paths(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",
]
config = root / "plugin-helper.local.toml"
config.write_text(
"""
instances_root = "~/BSInstances"
state_dir = ".state"
""".lstrip(),
encoding="utf-8",
)
self.assertEqual(status, 0)
self.assertIn("Counts files currently present", output.getvalue())
local_config, loaded_path, loaded = load_local_config(config, root=root)
def test_menu_routes_duplicate_instance_names_by_selected_root(self) -> None:
self.assertTrue(loaded)
self.assertEqual(loaded_path, config)
self.assertEqual(local_config.instances_roots, [Path("~/BSInstances").expanduser()])
self.assertEqual(local_config.state_root, root / ".state")
def test_runtime_explicit_overrides_env_and_config(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",
]
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(
os.environ,
{
"PLUGIN_HELPER_INSTANCES_ROOT": str(root / "env-root"),
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
},
clear=True,
):
runtime = resolve_runtime_config(
instances_root_value=str(root / "explicit-root"),
state_dir_value="explicit-state",
root=root,
)
self.assertEqual(runtime.instances_roots, [root / "explicit-root"])
self.assertEqual(runtime.state_root, root / "explicit-state")
def test_runtime_env_overrides_config(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(
os.environ,
{
"PLUGIN_HELPER_INSTANCES_ROOT": f"{root / 'env-root-a'}{os.pathsep}{root / 'env-root-b'}",
"PLUGIN_HELPER_STATE_DIR": str(root / "env-state"),
},
clear=True,
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.instances_roots, [root / "env-root-a", root / "env-root-b"])
self.assertEqual(runtime.state_root, root / "env-state")
def test_runtime_uses_local_config_before_defaults(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "plugin-helper.local.toml").write_text(
"""
instances_root = "config-root"
state_dir = "config-state"
""".lstrip(),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.instances_roots, [root / "config-root"])
self.assertEqual(runtime.state_root, root / "config-state")
def test_runtime_default_state_uses_xdg_state_home(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
xdg = root / "xdg-state"
with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, xdg / "plugin-helper")
def test_runtime_default_state_uses_home_local_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
with patch.dict(os.environ, {}, clear=True):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper")
def test_no_args_prints_help_when_not_interactive(self) -> None:
output = StringIO()
with patch("sys.stdin.isatty", return_value=False), patch("sys.stdout", output):
status = run([])
self.assertEqual(status, 2)
self.assertIn("usage: plugin-helper", output.getvalue())
self.assertIn("menu", output.getvalue())
def test_no_command_defaults_to_menu_when_interactive(self) -> None:
with (
patch("sys.stdin.isatty", return_value=True),
patch("sys.stdout.isatty", return_value=True),
patch("plugin_helper.cli._run_menu", return_value=0) as run_menu,
):
status = run([])
self.assertEqual(status, 0)
self.assertIn("1.44.1: 1 files", output.getvalue())
run_menu.assert_called_once()
def test_run_ipa_timeout_returns_control(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -242,6 +322,127 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(removed["removed"], ["Plugins/Example.dll"])
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
def test_disable_plugin_removes_files_but_keeps_disabled_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.40.8"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
target = instance / "Plugins" / "Example.dll"
target.write_bytes(b"managed dll")
installed = {
"instance": "1.40.8",
"plugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(target),
"size": target.stat().st_size,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", installed)
result = disable_plugin("1.40.8", instance, state, "example")
self.assertEqual(result["removed"], ["Plugins/Example.dll"])
self.assertFalse(target.exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertEqual(updated["disabledPlugins"]["example"]["files"][0]["path"], "Plugins/Example.dll")
def test_enable_command_reinstalls_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance_root = work / "instances"
instance = instance_root / "1.40.8"
state = work / "state"
registry_dir = work / "registry"
locks_dir = work / "locks"
registry_dir.mkdir()
locks_dir.mkdir()
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
(registry_dir / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(locks_dir / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
disabled = {
"instance": "1.40.8",
"plugins": {},
"disabledPlugins": {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [
{
"path": "Plugins/Example.dll",
"sha256": sha256_file(asset),
"size": asset.stat().st_size,
}
],
}
},
}
from plugin_helper.state import save_installed_state
save_installed_state(state, "1.40.8", disabled)
with patch("plugin_helper.operations.repo_root", return_value=work):
status = run(
[
"--instances-root",
str(instance_root),
"--state-dir",
str(state),
"enable",
"--instance",
"1.40.8",
"example",
]
)
self.assertEqual(status, 0)
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["disabledPlugins"])
def test_zip_to_pending_targets_ipa_pending(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -850,5 +1051,175 @@ class PluginHelperTests(unittest.TestCase):
self.assertEqual(result["plugins"][0]["latestAssetSha256"], "new")
def _make_tui_fixture(root: Path, *, disabled: bool = False, hash_mismatch: bool = False) -> tuple[PluginHelperTui, Path, Path]:
repo = root / "repo"
instance_root = root / "instances"
instance = instance_root / "1.40.8"
state = root / "state"
(repo / "registry").mkdir(parents=True)
(repo / "locks").mkdir()
(instance / "Beat Saber_Data").mkdir(parents=True)
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.40.8", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
(repo / "registry" / "plugins.toml").write_text(
"""
[[plugins]]
id = "example"
name = "Example"
repo = "owner/example"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip(),
encoding="utf-8",
)
(repo / "locks" / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
[[plugins]]
id = "example"
repo = "owner/example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "{sha256_file(asset)}"
""".lstrip(),
encoding="utf-8",
)
target = instance / "Plugins" / "Example.dll"
if disabled:
plugins = {}
disabled_plugins = {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"disabledAt": "2026-06-14T17:20:00Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
}
else:
target.write_bytes(b"changed dll" if hash_mismatch else b"managed dll")
plugins = {
"example": {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
}
disabled_plugins = {}
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins},
)
choice = InstallationChoice(
install_id="test",
install_label="Test Install",
instance_name="1.40.8",
instance_path=instance,
state_root=state,
)
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
choices = [
InstallationChoice(
install_id="linux",
install_label="Linux",
instance_name="1.44.1",
instance_path=Path("/tmp/linux/1.44.1"),
state_root=Path("/tmp/state-linux"),
),
InstallationChoice(
install_id="windows",
install_label="Windows",
instance_name="1.44.1",
instance_path=Path("/tmp/windows/1.44.1"),
state_root=Path("/tmp/state-windows"),
),
]
app = PluginHelperTui(choices=choices, repo_root=Path("/tmp/repo"))
async with app.run_test():
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("enter")
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
table = app.query_one(DataTable)
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
await pilot.press("space")
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
async def test_space_enables_disabled_plugin_from_asset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
await pilot.press("enter")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
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["disabledPlugins"])
async def test_disable_all_disables_enabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("d")
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
self.assertNotIn("example", updated["plugins"])
self.assertIn("example", updated["disabledPlugins"])
self.assertIn("Disabled 1 plugins", app.status_message)
async def test_enable_all_enables_disabled_plugins(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("e")
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["disabledPlugins"])
self.assertIn("Enabled 1 plugins", app.status_message)
async def test_space_reports_hash_mismatch_without_state_update(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("space")
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8")
self.assertIn("example", updated["plugins"])
self.assertNotIn("example", updated.get("disabledPlugins", {}))
self.assertIn("hash mismatch", app.status_message)
if __name__ == "__main__":
unittest.main()