diff --git a/.agents/skills/beatsaber-plugin-builder/SKILL.md b/.agents/skills/beatsaber-plugin-builder/SKILL.md index 633e39e..1daf24d 100644 --- a/.agents/skills/beatsaber-plugin-builder/SKILL.md +++ b/.agents/skills/beatsaber-plugin-builder/SKILL.md @@ -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/` 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 + `/build/`. 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 + `/build`, add/fetch the upstream remote if needed, and check out + the PR head: ```bash - git clone https://github.com//.git .state/build/ - git -C .state/build/ fetch origin pull//head:pr- - git -C .state/build/ checkout pr- + git clone https://github.com//.git /build/ + git -C /build/ fetch origin pull//head:pr- + git -C /build/ checkout 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/ - /home/pleb/Windows/Users/pleb/BSManager/BSInstances/ + ```bash + PYTHONPATH=src .venv/bin/python -m plugin_helper --profile 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 -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 "/Beat Saber_Data/Managed/Main.dll" | rg 'TypeOrMemberName' + strings "/Beat Saber_Data/Managed/HMLib.dll" | rg 'TypeOrMemberName' + ilspycmd -t TypeName "/Beat Saber_Data/Managed/Main.dll" | sed -n '1,220p' + rg -n 'TypeOrMemberName|NearbyConcept' ~/src// ~/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 -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//downloads//` 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 + `/instances//downloads//` 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. diff --git a/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md b/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md index 58d377e..cb94817 100644 --- a/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md +++ b/.agents/skills/beatsaber-plugin-builder/references/linux-bsipa-build.md @@ -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/ -``` - -Windows mirror root: - -```text -/home/pleb/Windows/Users/pleb/BSManager/BSInstances/ +```bash +sed -n '1,220p' plugin-helper.local.toml +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile 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 `.csproj.user`: ```xml - /home/pleb/.local/share/BSManager/BSInstances/1.40.8 + /path/from/selected/profile/instances_root/1.44.1 ``` @@ -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//downloads/ -cp //bin/Release/.dll .state/instances//downloads//.dll -sha256sum .state/instances//downloads//.dll +mkdir -p /instances//downloads/ +cp //bin/Release/.dll /instances//downloads//.dll +sha256sum /instances//downloads//.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 -PYTHONPATH=src python -m plugin_helper --state-dir .state plan --instance --plugin -PYTHONPATH=src python -m plugin_helper --state-dir .state apply +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile check --instance +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile plan --instance --plugin +PYTHONPATH=src .venv/bin/python -m plugin_helper --profile apply ``` Inspect the generated plan before applying. diff --git a/.agents/skills/beatsaber-plugin-manager/SKILL.md b/.agents/skills/beatsaber-plugin-manager/SKILL.md index e0e9d77..89bc5b4 100644 --- a/.agents/skills/beatsaber-plugin-manager/SKILL.md +++ b/.agents/skills/beatsaber-plugin-manager/SKILL.md @@ -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///releases -https://github.com///releases/tag/ -https://github.com///releases/download// -``` - ## Workflow 1. Confirm the workspace is the `plugin-helper` repo. diff --git a/.gitignore b/.gitignore index 1c26636..a049623 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 148499d..efecf59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//` 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//` 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//` 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. diff --git a/README.md b/README.md index 2580b58..73e64b5 100644 --- a/README.md +++ b/README.md @@ -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//downloads// -``` - -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//downloads// +/instances//downloads// ``` ## Beat Saber Data Backups diff --git a/docs/notes/install-and-verify-plugins-1.44.1.md b/docs/notes/install-and-verify-plugins-1.44.1.md index 0d7dc78..8e67cb7 100644 --- a/docs/notes/install-and-verify-plugins-1.44.1.md +++ b/docs/notes/install-and-verify-plugins-1.44.1.md @@ -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) | verified with warning | 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) | verified | 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) | failed compatibility trial | 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) | verified | 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) | failed compatibility trial | 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) | verified | 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) | verified | 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) | verified | 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) | verified with warning | 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) | verified | 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) | verified | 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) | failed compatibility trial | 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) | verified with warning | 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) | verified with warning | 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) | verified with warning | 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) | verified with warning | 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) | currently failed | 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) | installed; manual resmoke pending | 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) | installed; smoke blocked | 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) | verified with warning | 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) | verified with warning | 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) | failed compatibility trial | 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) | installed; smoke blocked | 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) | verified with warning | 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) | verified with warning | 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 | | skipped after local-build trial | 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. | diff --git a/docs/notes/mods-used-in-1.40.8.md b/docs/notes/mods-used-in-1.40.8.md index 9146898..c87c99c 100755 --- a/docs/notes/mods-used-in-1.40.8.md +++ b/docs/notes/mods-used-in-1.40.8.md @@ -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. diff --git a/docs/notes/windows-compatibility.md b/docs/notes/windows-compatibility.md new file mode 100644 index 0000000..a655760 --- /dev/null +++ b/docs/notes/windows-compatibility.md @@ -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`. diff --git a/locks/1.44.1.lock.toml b/locks/1.44.1.lock.toml index e075394..9d1ffdb 100644 --- a/locks/1.44.1.lock.toml +++ b/locks/1.44.1.lock.toml @@ -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" diff --git a/plugin-helper.toml.example b/plugin-helper.toml.example new file mode 100644 index 0000000..caab0a8 --- /dev/null +++ b/plugin-helper.toml.example @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 93708df..f0f8a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/registry/plugins.toml b/registry/plugins.toml index 79036c6..3b70cad 100644 --- a/registry/plugins.toml +++ b/registry/plugins.toml @@ -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" diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index d711494..0178d55 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -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, - ) - 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, + 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] - continue + if not choices: + searched = ", ".join(str(root) for root in runtime.instances_roots) + print(f"No Beat Saber instances found under {searched}") + return 1 - 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}") + 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." + ) + 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() diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index 9b6f5e2..6832c89 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -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, + ) diff --git a/src/plugin_helper/installer.py b/src/plugin_helper/installer.py index d650194..bb51fec 100644 --- a/src/plugin_helper/installer.py +++ b/src/plugin_helper/installer.py @@ -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} diff --git a/src/plugin_helper/operations.py b/src/plugin_helper/operations.py new file mode 100644 index 0000000..501f140 --- /dev/null +++ b/src/plugin_helper/operations.py @@ -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} diff --git a/src/plugin_helper/reports.py b/src/plugin_helper/reports.py new file mode 100644 index 0000000..218e7fb --- /dev/null +++ b/src/plugin_helper/reports.py @@ -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))) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py new file mode 100644 index 0000000..0ceed4c --- /dev/null +++ b/src/plugin_helper/tui.py @@ -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) diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index ede277e..887730d 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -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() + config = root / "plugin-helper.local.toml" + config.write_text( + """ +instances_root = "~/BSInstances" +state_dir = ".state" +""".lstrip(), + encoding="utf-8", + ) - 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", - ] - ) + local_config, loaded_path, loaded = load_local_config(config, root=root) - self.assertEqual(status, 0) - self.assertIn("Counts files currently present", output.getvalue()) + 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_menu_routes_duplicate_instance_names_by_selected_root(self) -> None: + 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") + (root / "plugin-helper.local.toml").write_text( + """ +instances_root = "config-root" +state_dir = "config-state" +""".lstrip(), + encoding="utf-8", + ) - 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", - ] + 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(status, 0) - self.assertIn("1.44.1: 1 files", output.getvalue()) + 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) + 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()