Compare commits

..

11 Commits

Author SHA1 Message Date
pleb 1b4087c5b5 Update mounted Windows state guidance 2026-07-05 14:06:13 -07:00
pleb 74ac0a1c1d Keep TUI cursor stable after plugin toggle 2026-07-05 14:05:35 -07:00
pleb 400e69289c Add BeatSaberPlus and Naluluna private payloads to 1.44.1 lock
Record Discord BeatSaberPlus 6.4.5 and a local Naluluna instance bundle so both suites can be disabled and re-enabled through plugin-helper state.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 23:17:56 -07:00
pleb b75d8ccc57 Improve TUI startup and keep plugin toggles responsive
Auto-open the plugin table when only one installation exists, run enable and
disable work off the UI thread with immediate status feedback, and skip the
back action for single-instance setups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:56:52 -07:00
pleb c86e51b85a Auto-bootstrap BSIPA when re-enabling plugins
Run bootstrap automatically on enable paths when health is bad, skip bsipa
in disable-all, and add tests for the shared bootstrap gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:55:47 -07:00
pleb f160e4b349 Refine agents instructions 2026-07-01 22:41:54 -07:00
pleb dbc4c7e0b4 Document native Windows setup and update compatibility tracker.
Add PowerShell examples, profile usage notes, and record completed Windows
compatibility work in the tracker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:33:28 -07:00
pleb c67878cac0 Add native Windows compatibility for config, bootstrap, and CLI.
Support profile-based config resolution with auto-selection of a sole profile,
native IPA.exe -n bootstrap on Windows, platform-aware process cleanup, and
Windows-friendly userdata backup path inference.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:33:24 -07:00
pleb 6af1b03c35 Add line-ending policy and renormalize to LF 2026-07-01 20:47:58 -07:00
pleb 7f0fcc8d20 Merge branch 'main' of gitea.satstack.dev:pleb/beatsaber-plugin-helper 2026-07-01 15:17:41 -07:00
pleb 13d9481168 Add Naluluna mods and BSplus mods 2026-06-29 15:34:46 -07:00
20 changed files with 1324 additions and 190 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{md,markdown}]
trim_trailing_whitespace = false
[*.{nix,yml,yaml}]
indent_size = 2
[Makefile]
indent_style = tab
+17
View File
@@ -0,0 +1,17 @@
# Normalize text files to LF in the repository.
* text=auto eol=lf
# Windows scripts should stay CRLF when present.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Binary assets
*.dll binary
*.exe binary
*.gif binary
*.ico binary
*.jpeg binary
*.jpg binary
*.png binary
*.zip binary
+3 -20
View File
@@ -5,23 +5,15 @@ Guidance for coding agents working in this repo.
## Project Shape
- This repo manages Beat Saber plugins for BSManager instances.
- Default instance roots are:
- `~/Windows/Users/pleb/BSManager/BSInstances`
- `~/.local/share/BSManager/BSInstances`
- A local BSManager source checkout may be available at
`~/src/Zagrios/bs-manager`. Use it as a read-only reference when
investigating launch behavior, inherited Steam arguments, instance layout, or
Proton environment details unless the user explicitly asks for BSManager code
changes.
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a
locked or registry plugin has a GitHub source repo. Prefer checking out the
upstream author/repo first, with `origin` pointing at upstream. If the user has
an existing personal fork checkout, preserve it as a remote named `github`
and set/add `origin` to the upstream repo instead of replacing local work.
- Prefer repo-local state for planned installs unless the task explicitly
targets the user's live default state. Use `--state-dir .state` for the local
Linux install and `--state-dir .state-windows` for the mounted Windows install
when both roots contain the same instance name.
targets the user's live default state. For mounted Windows installs, prefer
the shared Windows-partition state directory configured in
`plugin-helper.local.toml` when both roots contain the same instance name.
## Workflow Rules
@@ -30,12 +22,6 @@ Guidance for coding agents working in this repo.
`.venv/bin/python` for helper commands and tests when it exists.
- For human-style inspection, prefer the menu with repo-local state:
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
- When targeting the local Linux BSManager install, pass
`--instances-root ~/.local/share/BSManager/BSInstances` and normally
`--state-dir .state`.
- When targeting the mounted Windows BSManager install, pass
`--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,
@@ -45,9 +31,6 @@ Guidance for coding agents working in this repo.
- 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.
+49 -6
View File
@@ -31,7 +31,8 @@ If `XDG_STATE_HOME` is not set, the state directory defaults to:
Override the instance root with `--instances-root`,
`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search
multiple explicit roots, separate them with `:`.
multiple explicit roots, separate them with `:` on Linux/macOS or `;` on
Windows.
Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or
`plugin-helper.local.toml`.
@@ -73,6 +74,45 @@ 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.
## Native Windows
For a separate checkout on a Windows partition, copy the Windows example config
and adjust paths if needed:
```powershell
Copy-Item plugin-helper.windows.toml.example plugin-helper.windows.toml
```
`plugin-helper.windows.toml` is ignored by git. It uses profile entries:
```toml
[[profiles]]
id = "windows"
label = "Native Windows BSManager"
instances_root = "~/BSManager/BSInstances"
state_dir = ".state"
```
Run commands with `--config` and `--profile` so native Windows state stays in
that checkout's `.state/` directory. If `plugin-helper.windows.toml` has only
one `[[profiles]]` entry, that profile is selected automatically when
`--profile` is omitted:
```powershell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
py -m pip install -e .
py -m plugin_helper
py -m plugin_helper --config plugin-helper.windows.toml --profile windows installed --instance 1.44.1
```
On native Windows, `bootstrap` runs `IPA.exe -n` directly instead of through
Proton. `bootstrap-check` accepts a recorded native bootstrap without requiring
`Logs/_latest.log` when `IPA.exe -n` completed successfully.
When no config file is present on Windows, defaults are
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
## Commands
For normal use, run the Textual menu from the repo root:
@@ -87,8 +127,10 @@ 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.
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all
currently disabled managed plugins. With a single Beat Saber installation, the
menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when
needed.
The individual subcommands are mostly for automation and debugging. If you use
them, pass `--state-dir` directly only when you intentionally want to override
@@ -170,9 +212,10 @@ custom content or non-obvious user choices rather than pure cache data.
arguments such as `--no-yeet fpfc` can make the game fail command-line
parsing after BSIPA and plugins have already loaded.
- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command
applies the locked `bsipa` root archive, runs `IPA.exe -n` through Proton, and
records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`,
`Libs/`, and `IPA/`, including backups created during patching.
applies the locked `bsipa` root archive, runs `IPA.exe -n` (natively on
Windows or through Proton on Linux), and records every bootstrap-relevant
file under root `IPA.exe*`, `winhttp.dll`, `Libs/`, and `IPA/`, including
backups created during patching.
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
Use `bootstrap-check` before planning a batch when you want a quick gate.
+3 -3
View File
@@ -94,11 +94,11 @@ For early development, a `--state-dir` option is useful so plans and manifests c
When managing both local Linux and mounted Windows installs, install state must
be separated by target root as well as by instance name. The current state
layout is keyed by instance name, so two `1.44.1` installs should not share one
state directory. A practical repo-local convention is:
state directory. A practical convention is:
```text
.state/ local Linux BSManager state
.state-windows/ mounted Windows BSManager state
.state/ local Linux BSManager state
~/Windows/Users/pleb/ops/plugin-helper/.state mounted Windows BSManager state
```
The registry and lockfile remain shared for a Beat Saber version. Downloads may
+31 -26
View File
@@ -103,7 +103,7 @@ Track the tool work discovered while using it for 1.44.1.
| Model BSIPA/bootstrap installation separately | <span style="color:#8b949e; font-weight:600">todo</span> | BSManager creates root `IPA/`, `IPA.exe`, `winhttp.dll`, `Libs/`, `Logs`, `UserData`, and root `Plugins/`. |
| Resolve BeatMods dependency closure | <span style="color:#8b949e; font-weight:600">todo</span> | Use as metadata/advisory input even when downloading plugin artifacts from upstream GitHub. |
| Install BeatMods library payloads into `Libs/` | <span style="color:#8b949e; font-weight:600">todo</span> | Include framework-library cases when required; these are likely exceptions to GitHub-preferred sourcing. |
| Support local/private plugin payloads | <span style="color:#8b949e; font-weight:600">todo</span> | Needed for paid closed-source and manual plugins. |
| Support local/private plugin payloads | <span style="color:#8b949e; font-weight:600">todo</span> | Needed for paid closed-source and manual plugins. BeatSaberPlus 6.4.5 was installed manually from a Discord-only release zip on 2026-06-29. |
| Record install state for every copied file | <span style="color:#8b949e; font-weight:600">todo</span> | Required for rollback and clean omission testing. |
| Add a batch install workflow or documented command sequence | <span style="color:#8b949e; font-weight:600">todo</span> | Useful for two-or-three-at-a-time validation. |
| Add IPA log inspection helper | <span style="color:#8b949e; font-weight:600">todo</span> | Nice-to-have; manual log watching via `docs/SMOKETEST.md` is acceptable today. |
@@ -222,33 +222,37 @@ Purpose: add visual and stream-facing mods after functional mods are stable.
Purpose: restore private plugin set only after public/dependency-heavy mods are
known good.
See `docs/notes/naluluna-mod-assistant.md` for how NalulunaModAssistant sources
Patreon-only Naluluna packages, where it installs them by default, and how to
treat its output as local/private payload input for `plugin-helper`.
| Plugin | Upstream | Status | Source/version | Verification notes |
| --- | --- | --- | --- | --- |
| ReeSabers | | <span style="color:#8b949e; font-weight:600">todo</span> | paid/private | Verify saber visuals in VR when practical. |
| BeatSaberPlus_Chat | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and module UI. |
| BeatSaberPlus_ChatEmoteRain | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_ChatIntegrations | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_ChatRequest | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_GameTweaker | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_MenuMusic | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_Multiplayer | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_NoteTweaker | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_SongChartVisualizer | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| BeatSaberPlus_SongOverlay | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaMenu | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and menu. |
| NalulunaCounters | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song counters. |
| NalulunaLevelDetail | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song detail panel. |
| NalulunaSliceVisualizer | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
| NalulunaSongPreview | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song preview. |
| NalulunaMissIndicator | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song visuals. |
| NalulunaEnergy | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song HUD. |
| NalulunaFps | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify overlay. |
| NalulunaPPCoin | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaRewinder | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
| NalulunaAvatars | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify avatar load. |
| NalulunaShaders | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency for Naluluna visuals. |
| NalulunaSkybox | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify skybox sample manifest and skybox load. |
| NalulunaUtils | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency load. |
| BeatSaberPlus_Chat | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Discord release zip `BeatSaberPlus_v6.4.5-bs-1.44.0.zip`, SHA-256 `21ad51edea5703485779b851e7e86aac11c3d6e3b1de2460b2c3c3b4b5704657`; manual install into Windows BSManager `1.44.1` root `Plugins/` | IPA loaded BeatSaberPlus_Chat 6.4.5, ChatPlexSDK initialized all modules and added a menu button, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.44.0. Chat/Twitch module UI not exercised in FPFC smoke. |
| BeatSaberPlus_ChatEmoteRain | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_ChatEmoteRain 6.4.5 and ChatPlexSDK initialized the Chat Emote Rain module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_ChatIntegrations | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_ChatIntegrations 6.4.5 and ChatPlexSDK initialized the Chat Integrations module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_ChatRequest | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_ChatRequest 6.4.5 and ChatPlexSDK initialized the Chat Request module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_GameTweaker | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_GameTweaker 6.4.5 and applied Harmony patches; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_MenuMusic | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_MenuMusic 6.4.5 and applied Harmony patches; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_Multiplayer | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_Multiplayer 6.4.5, applied Harmony patches, and added a menu button; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_NoteTweaker | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_NoteTweaker 6.4.5 and applied Harmony patches; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_SongChartVisualizer | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_SongChartVisualizer 6.4.5 and ChatPlexSDK initialized the Song Chart Visualizer module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| BeatSaberPlus_SongOverlay | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Same Discord bundle as BeatSaberPlus_Chat | IPA loaded BeatSaberPlus_SongOverlay 6.4.5 and ChatPlexSDK initialized the Song Overlay module; same FPFC smoketest as BeatSaberPlus_Chat on 2026-06-29. |
| NalulunaMenu | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaMenu` 1.2.0 archive `NalulunaMenu-1.2.0-bs1.44.1.zip`; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaMenu 1.2.0, logged `Init`, `OnMenu`, `PlayDefaultMenuMusic`, and registered its menu button; the game reached `MainSystemInit`. Warning: IPA logged missing `NalulunaMenu.resources`. |
| NalulunaCounters | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaCounters` 1.3.2; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaCounters 1.3.2, logged `BeatLeader: available`, initialized its menu button, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.43.0. In-song counters not exercised in FPFC smoke. |
| NalulunaLevelDetail | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaLevelDetail` 2.0.0; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaLevelDetail 2.0.0, controller `Awake`, menu button registration, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. Song detail panel not exercised in FPFC smoke. |
| NalulunaSliceVisualizer | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify in-song visuals after install. |
| NalulunaSongPreview | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaSongPreview` 1.0.1; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaSongPreview 1.0.1, controller `Awake`, menu button registration, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.4. Song preview not exercised in FPFC smoke. |
| NalulunaMissIndicator | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify in-song visuals after install. |
| NalulunaEnergy | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaEnergy` 1.1.0; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaEnergy 1.1.0, controller `Awake`, menu button registration, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. In-song HUD not exercised in FPFC smoke. |
| NalulunaFps | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify overlay after install. |
| NalulunaPPCoin | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Not present in Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29. Verify startup after install. |
| NalulunaRewinder | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaRewinder` 1.2.0; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaRewinder 1.2.0, config loaded, menu button registered, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.2. Rewind behavior not exercised in FPFC smoke. |
| NalulunaAvatars | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaAvatars` 1.10.0 plus bundled `AliciaSolid` 1.10.0 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaAvatars 1.10.0, logged `Init`, started OSC face tracking, and registered its menu button; the game reached `MainSystemInit`. Warnings: configured avatar `2299336039803240.vrm` missing; IPA logged missing `NalulunaAvatars.resources`; one `NullReferenceException` in `HMUI.CurvedCanvasSettings.Start` during menu setup. VR avatar load not exercised. |
| NalulunaShaders | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaShaders` 2.1.3 archive `NalulunaShaders-2.1.3-bs1.39.1.zip`; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaShaders 2.1.3, logged `Assets loaded`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.39.1. |
| NalulunaSkybox | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaSkybox` 2.0.0 plus `NalulunaSkyboxSamples` 1.1.0 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaSkybox 2.0.0 and NalulunaSkyboxSamples 1.1.0, controller `Awake`, menu button registration, and the game reached `MainSystemInit`. Warning: Skybox manifest targets Beat Saber 1.39.1. Skybox swap not exercised in FPFC smoke. |
| NalulunaUtils | [patreon](https://www.patreon.com/c/naluluna/posts) | <span style="color:#d29922; font-weight:600">verified with warning</span> | Patreon via NalulunaModAssistant; `NalulunaUtils` 5.7.0 archive `NalulunaUtils-5.7.0-bs1.44.0.zip` plus bundled `OpenVRHelper` 0.2.3 manifest; manual install into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded NalulunaUtils 5.7.0 and OpenVRHelper 0.2.3, logged `Awake`, registered dependent Naluluna menu buttons, and `OnMenuSceneActive: Succeeded`; the game reached `MainSystemInit`. Warnings: manifest targets Beat Saber 1.44.0; IPA logged missing `NalulunaUtils.resources` and optional libs (SteamVR, DynamicBone, FinalIK, CameraPlus, BGNet). |
## Shared Libraries and Dependency Packages
@@ -268,6 +272,7 @@ are user-facing features.
| SongDetailsCache | [github](https://github.com/kinsi55/BeatSaber_SongDetails) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BetterSongList, SongRankedBadge | GitHub `kinsi55/BeatSaber_SongDetails` tag `v1.4.0`, asset `SongDetailsCache.BS.Lib.zip`; BeatMods version id 2226, zipHash `e1167b64cd3eff7e3651ec2dbbe50d81` | IPA loaded SongDetailsCache 1.4.0 and SongRankedBadge used it to load song details. Warning: manifest targets Beat Saber 1.13.2. |
| System.IO.Compression | [beatmods zip](https://beatmods.com/cdn/mod/a4e9e26f61967e56168e08eecb01ab88.zip) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaberPlaylistsLib, System.IO.Compression.FileSystem | BeatMods 4.6.57, version id 1763, zipHash `a4e9e26f61967e56168e08eecb01ab88` | IPA loaded System.IO.Compression 4.6.57; logged a duplicate-library notice because the game also ships this assembly. |
| System.IO.Compression.FileSystem | [beatmods zip](https://beatmods.com/cdn/mod/e19f6fd395d54de7bfcbbbe3084dea28.zip) | <span style="color:#3fb950; font-weight:600">verified</span> | BeatSaverDownloader | BeatMods 4.7.3056, version id 1762, zipHash `e19f6fd395d54de7bfcbbbe3084dea28` | IPA loaded System.IO.Compression.FileSystem 4.7.3056. |
| ChatPlexSDK_BS | [discord](https://discord.com/channels/723117082111246416/768683248804167690) | <span style="color:#d29922; font-weight:600">verified with warning</span> | BeatSaberPlus | Bundled in Discord release zip `BeatSaberPlus_v6.4.5-bs-1.44.0.zip`; manually installed into Windows BSManager `1.44.1` root `Plugins/` on 2026-06-29 | IPA loaded ChatPlexSDK_BS 6.4.5, initialized all BeatSaberPlus modules, added a menu button, and cached Twitch/BTTV/FFZ/7TV emotes during FPFC smoke on 2026-06-29. Warning: manifest targets Beat Saber 1.44.0. |
| Dynamic Bone | | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
| Final IK | | <span style="color:#dbab79; font-weight:600">defer</span> | TBD | TBD | Include only if a selected 1.44.1 mod requires it. |
@@ -296,4 +301,4 @@ Record plugins skipped for this 1.44.1 pass. This is not a fix list for today.
| 5 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 6 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 7 | 2026-06-29 | <span style="color:#d29922; font-weight:600">verified with warning</span> | Installed and smoke-tested AdBlocker 1.0.5, HighlightBombs 1.0.3, PitchBlack 0.0.3, and ReeCamera 0.0.5. All four loaded and the game reached `MainSystemInit`. HighlightBombs installed its app installer and loaded QuickOutline material. ReeCamera logged Spout load success and installed app/menu installers. | Warnings: all four manifests target older Beat Saber versions (1.34.2, 1.32.0, 1.39.1, 1.42.0). AdBlocker and PitchBlack used BeatMods CDN or byte-identical GitHub assets because JonnyVR1/AdBlocker exposes no GitHub releases and HighlightBombs BeatMods 1.0.3 is newer than GitHub v1.0.1. ReeCamera first launch logged missing `UserData/ReeCamera.json` until mod creates it on exit; archive replaced bundled `CameraUtils.dll`. In-song bomb/lighting/camera visuals were not exercised in FPFC smoke. Pre-existing LeaderboardCore and PlaylistManager errors still appear in the log. |
| 8 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
| 8 | 2026-06-29 | <span style="color:#d29922; font-weight:600">verified with warning</span> | FPFC smoketest `Logs/2026.06.29.15.32.05.log`: BeatSaberPlus 6.4.5 bundle and ChatPlexSDK_BS loaded and initialized all modules; ten Patreon Naluluna packages loaded (`NalulunaUtils`, `NalulunaMenu`, `NalulunaAvatars`, `NalulunaSkybox`, `NalulunaShaders`, `NalulunaCounters`, `NalulunaEnergy`, `NalulunaLevelDetail`, `NalulunaSongPreview`, `NalulunaRewinder`); NalulunaUtils registered menu buttons and `OnMenuSceneActive: Succeeded`; NalulunaMenu played default menu music; the game reached `MainSystemInit`. | Warnings: BeatSaberPlus/ChatPlex and several Naluluna manifests target older Beat Saber versions; NalulunaUtils logged missing satellite resources and optional libs; NalulunaAvatars referenced a missing configured VRM and hit one menu `NullReferenceException`; pre-existing LeaderboardCore Harmony patch error still appears; slice/miss/fps/ppcoin Naluluna mods remain uninstalled; in-song Naluluna features and VR avatar load were not exercised. |
+203
View File
@@ -0,0 +1,203 @@
# NalulunaModAssistant Source and Install Notes
This note records how NalulunaModAssistant behaves when used to install
Patreon-only Naluluna mods. It is intended to guide future `plugin-helper`
support for private/local payloads without checking private artifacts,
session-specific URLs, or account data into the repo.
Observed run:
- Date: 2026-06-29
- Assistant path: `C:\Users\pleb\Apps\Naluluna\NalulunaModAssistant.exe`
- Assistant version: `1.5.1`
- Runtime UI: WebView2 `149.0.4022.80`
- Log path: `C:\Users\pleb\Apps\Naluluna\Logs\NalulunaModAssistant\_latest.log`
## High-Level Flow
NalulunaModAssistant is a WebView2 desktop app. It authenticates the user through
an embedded browser, fetches a versioned mod catalog, then downloads selected
plugin archives into a temporary working directory before extracting them into
the configured Beat Saber directory.
The observed Patreon flow was:
1. Initialize WebView2 and a temporary working directory.
2. Load the initial catalog JSON named `start`.
3. Navigate to Patreon at `https://www.patreon.com/c/naluluna/posts`.
4. Resolve the NalulunaModAssistant files post:
`https://www.patreon.com/naluluna/posts/nalulunamodassis-74489236`.
5. Confirm the logged-in Patreon account has access.
6. Detect Beat Saber version `1.44.1`.
7. Select mod list version `1.42.0`.
8. Fetch the catalog JSON named `1-42-0`.
9. Download selected archives and extract their payloads into the configured
Beat Saber tree.
The assistant binary also contains a `GetJsonFromGoogleSiteAsync` code path and
Fanbox login/status paths. Those appear to be alternate or older source flows.
The 2026-06-29 run used Patreon, not Google Sites, GitHub, BeatMods, or Fanbox.
## Source Model
Naluluna plugin artifacts are not normal public GitHub or BeatMods releases.
For Patreon installs, the authoritative source is the private
NalulunaModAssistant Patreon post and its attachment metadata.
The assistant downloads files through two forms:
- Relative catalog paths such as `nma/files/NalulunaUtils-5.7.0-bs1.44.0.zip`
and `nma/imgs/NalulunaCounters.jpg`.
- Patreon file endpoints such as `https://www.patreon.com/file?...`, which
resolve to tokenized `patreonusercontent.com` CDN URLs after authentication.
Do not record tokenized CDN URLs, cookies, WebView profile data, or private
account identifiers in repo files. They are session-specific and may grant access
to paid artifacts. For lockfile-style notes, record the Patreon post, the
assistant-visible archive name, the mod version, the Beat Saber target string,
the installed paths, and local file hashes when available.
## Catalog Versioning
In the observed run, the game was `1.44.1` but the assistant selected
`modListVersion: 1.42.0` and fetched catalog node `1-42-0`.
That means the assistant's catalog version should not be assumed to exactly
match the game version. Treat it as an upstream compatibility bucket. For
documentation and locks, record both values:
- actual game version reported by the assistant
- mod list version selected by the assistant
## Install Target
The assistant reads and writes a configured Beat Saber directory, not a
BSManager instance root chosen by `plugin-helper`.
Observed config:
- Beat Saber directory:
`C:\Program Files (x86)\Steam\steamapps\common\Beat Saber`
- Working directory:
`C:\Users\pleb\AppData\Local\Temp\tmp97E.tmp`
This matters because the observed run installed into the Steam Beat Saber tree,
not the BSManager `1.44.1` instance at
`C:\Users\pleb\BSManager\BSInstances\1.44.1`.
Before using NalulunaModAssistant as a source for a BSManager instance, either:
- point the assistant at the intended BSManager instance root, if the UI allows
it, or
- treat the Steam install as a staging source and copy exact installed files into
the target instance through a `plugin-helper` local/private payload workflow.
Do not manually copy files into the live instance as a substitute for
`plugin-helper` state tracking unless this is an explicit one-off recovery step.
## Extraction Behavior
Archives are extracted relative to the Beat Saber directory. The observed
payloads wrote to these top-level areas:
- `Plugins/`
- `Libs/Native/`
- `UserData/NalulunaUtils/`
- `UserData/NalulunaSkybox/`
- `NalulunaAvatars/`
Examples from the observed run:
- `Plugins/NalulunaUtils.dll`
- `Plugins/NalulunaAvatars.dll`
- `Plugins/NalulunaSkybox.dll`
- `Plugins/NalulunaSkyboxSamples.manifest`
- `Libs/Native/OVRLipSync.dll`
- `UserData/NalulunaUtils/OpenVRHelper/OpenVRHelper.exe`
- `UserData/NalulunaSkybox/*.jpgx`
- `NalulunaAvatars/AliciaSolid.vrm`
Some packages install manifests or data assets instead of a plugin DLL. A
`plugin-helper` implementation should record every extracted file, not just the
primary DLL.
## Observed Install Batch
The 2026-06-29 run installed the following selected packages:
- `NalulunaShaders` `2.1.3`, archive
`NalulunaShaders-2.1.3-bs1.39.1.zip`
- `NalulunaUtils` `5.7.0`, archive
`NalulunaUtils-5.7.0-bs1.44.0.zip`
- `AliciaSolid` `1.10.0`, archive
`AliciaSolid-1.10.0-bs1.26.0.zip`
- `NalulunaAvatars` `1.10.0`
- `NalulunaSkybox` `2.0.0`
- `OpenVRHelper` `0.2.3`
- `NalulunaRewinder` `1.2.0`
- `NalulunaCounters` `1.3.2`
- `NalulunaEnergy` `1.1.0`
- `NalulunaLevelDetail` `2.0.0`
- `NalulunaSongPreview` `1.0.1`
- `NalulunaSkyboxSamples` `1.1.0`
The first `NalulunaUtils` attempt downloaded successfully but failed extraction
with a Windows file-lock error:
```text
The process cannot access the file
'C:\Users\pleb\AppData\Local\Temp\tmp97E.tmp\NalulunaUtils-5.7.0-bs1.44.0.zip'
because it is being used by another process.
```
A later retry succeeded and extracted `Plugins/NalulunaUtils.dll`.
Several downloads log `download interrupted: FileSecurityCheckFailed` after a
successful install. In the observed run, those messages did not necessarily mean
the mod failed; the assistant logged `Installed: ...` first. Treat this message
as a warning to cross-check against extracted files and the assistant's final
success/failure count.
## Implications for plugin-helper
Private Naluluna payloads should be modeled as local/private artifacts rather
than first-class remote fetches until there is an explicit supported way to
authenticate and download them safely.
Recommended near-term workflow:
1. Use NalulunaModAssistant to download/install into a staging Beat Saber tree.
2. Inspect the assistant log for selected package names, versions, archive names,
and extracted paths.
3. Hash the installed files or preserved archives.
4. Add a `plugin-helper` local/private payload entry for each package.
5. Apply that payload to the intended BSManager instance with normal install
state recording.
6. Smoketest the BSManager instance and record results in
`docs/notes/install-and-verify-plugins-1.44.1.md`.
Longer term, `plugin-helper` could add a "local archive import" command that
accepts a zip from the assistant's working directory or a saved local cache,
normalizes the extracted paths, computes hashes, and creates an auditable
install plan without needing to understand Patreon authentication.
## What Not To Commit
Do not commit:
- downloaded private plugin archives
- tokenized `patreonusercontent.com` URLs
- WebView2 profile data
- Patreon or Fanbox cookies
- full `_patreon.log` / `_fanbox.log` captures
- account identifiers from logs
Safe to commit:
- assistant version
- Patreon post URL
- package names and versions
- non-tokenized archive names shown by the assistant
- extracted relative paths
- hashes of local artifacts or installed files
- compatibility and smoketest notes
+50 -24
View File
@@ -15,6 +15,12 @@ Windows instances.
```
- Use a separate checkout or working copy on the Windows partition.
- Copy and adjust the Windows config template:
```powershell
Copy-Item plugin-helper.windows.toml.example plugin-helper.windows.toml
```
- Use a Windows-specific config file:
```powershell
@@ -22,46 +28,55 @@ 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.
does not mix with Linux-managed state 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
- Native Windows defaults do 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.
Windows and `:` on Linux. README documents this platform-specific separator.
- 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
### Done
- 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.
- `bootstrap` auto-detects Windows and runs `IPA.exe -n` directly.
- `--native` forces native mode; `--proton` remains for Linux/Proton installs.
- Timeout cleanup uses `process.terminate()` / `process.kill()` on Windows
instead of POSIX process groups.
- Bootstrap state records `bootstrapMode: "native"` or `"proton"`.
- `bootstrap-check` accepts recorded native Windows bootstrap state without
`Logs/_latest.log` when `IPA.exe -n` completed successfully
(`ipaExitCode == 0`, not timed out).
- Add Windows-aware default paths when no config is present:
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
Normal native use still prefers `--config plugin-helper.windows.toml`.
- Add `--config` and `--profile` CLI flags with `[[profiles]]` TOML support.
- Add `plugin-helper.windows.toml.example` as the committed template.
- Update README examples for PowerShell, profiles, and the `;` path separator.
- Add tests for profile config resolution, native bootstrap command
construction, native bootstrap health checks, and platform-gated IPA timeout
behavior.
- 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.
- `sync_windows_data_repo` and `restore_windows_data_repo` work with ordinary
Windows paths; `infer_windows_appdata_path` now keys off `BSManager` rather
than the first `Users` path segment.
- `backup_userdata` no longer uses `NamedTemporaryFile` while the tar archive
is open, which fixes Windows permission errors.
### Remaining
- Run the first manual smoke on a native Windows checkout (see below).
- Consider whether the menu TUI needs Windows-specific setup hints when
`plugin-helper.windows.toml` is present but `--profile` was omitted.
## First Manual Smoke
@@ -75,3 +90,14 @@ py -3.13 -m plugin_helper --config plugin-helper.windows.toml --profile windows
If instance discovery fails, verify the BSManager instance root in
`plugin-helper.windows.toml`.
## Implementation Map
| Area | Files |
| --- | --- |
| Config profiles and Windows defaults | `src/plugin_helper/config.py` |
| Native bootstrap and process handling | `src/plugin_helper/bootstrap.py` |
| Native bootstrap health gate | `src/plugin_helper/bsipa.py` |
| CLI flags | `src/plugin_helper/cli.py` |
| Windows config template | `plugin-helper.windows.toml.example` |
| Tests | `tests/test_plugin_helper.py` |
+18
View File
@@ -402,3 +402,21 @@ asset = "JDFixer.dll"
sha256 = "16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d"
install_strategy = "dll-to-plugins"
reason = "Local build from GitHub PR https://github.com/zeph-yr/JDFixer/pull/26 at commit 3fce6ce465911bdd5e8e00411bc4672c54a317f7. Use this PR build instead of the failed upstream v.7.4.0 release asset, which loaded but failed OnEnable on Beat Saber 1.44.1."
[[plugins]]
id = "beatsaberplus"
repo = "discord/BeatSaberPlus"
tag = "v6.4.5-bs-1.44.0"
asset = "BeatSaberPlus_v6.4.5-bs-1.44.0.zip"
sha256 = "21ad51edea5703485779b851e7e86aac11c3d6e3b1de2460b2c3c3b4b5704657"
install_strategy = "bsipa-zip"
reason = "User-provided Discord release zip C:\\Users\\pleb\\Downloads\\BeatSaberPlus_v6.4.5-bs-1.44.0.zip. Bundles BeatSaberPlus modules, ChatPlexSDK_BS, and license files for Beat Saber 1.44.0/1.44.1."
[[plugins]]
id = "naluluna"
repo = "patreon/NalulunaModAssistant"
tag = "installed-1.44.1"
asset = "Naluluna-1.44.1-installed-bundle.zip"
sha256 = "40c8998257aa95ab58aab3e40802eabbf1c7ef83d6c79114a93631e508d9d38b"
install_strategy = "root-zip"
reason = "Local bundle captured from the Windows BSManager 1.44.1 instance on 2026-07-01 after NalulunaModAssistant Patreon install. Includes Plugins payloads plus UserData/NalulunaUtils, UserData/NalulunaSkybox, NalulunaAvatars, and Libs/Native/OVRLipSync.dll."
+5
View File
@@ -0,0 +1,5 @@
[[profiles]]
id = "windows"
label = "Native Windows BSManager"
instances_root = "~/BSManager/BSInstances"
state_dir = ".state"
+26
View File
@@ -937,3 +937,29 @@ required = true
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "beatsaberplus"
name = "BeatSaberPlus"
repo = "discord/BeatSaberPlus"
asset_patterns = ["BeatSaberPlus_*.zip"]
install_strategy = "bsipa-zip"
category = "streaming"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
[[plugins]]
id = "naluluna"
name = "Naluluna"
repo = "patreon/NalulunaModAssistant"
asset_patterns = ["Naluluna-*-installed-bundle.zip", "NalulunaUtils-*.zip"]
install_strategy = "root-zip"
category = "cosmetic"
[[plugins.dependencies]]
id = "bsipa"
constraint = ">=4.3.7"
required = true
+105 -19
View File
@@ -8,7 +8,8 @@ from pathlib import Path
from typing import Any, Callable
from urllib.request import Request, urlopen
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
from .bsipa import BSIPA_PLUGIN_ID, bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap
from .config import is_windows
from .fsutil import sha256_file
from .installer import apply_plan
from .models import LockedPlugin, Lockfile, Registry
@@ -130,31 +131,63 @@ def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str
return result
def build_bootstrap_command(
ipa: Path,
*,
proton: Path | None = None,
native: bool | None = None,
) -> list[str]:
use_native = is_windows() if native is None else native
if use_native:
return [str(ipa), "-n"]
proton_path = proton or _default_proton()
return [str(proton_path), "run", str(ipa), "-n"]
def _terminate_process(process: subprocess.Popen[str]) -> None:
if is_windows():
process.terminate()
else:
os.killpg(process.pid, signal.SIGTERM)
def _kill_process(process: subprocess.Popen[str]) -> None:
if is_windows():
process.kill()
else:
os.killpg(process.pid, signal.SIGKILL)
def _run_ipa(
*,
command: list[str],
instance_path: Path,
timeout_seconds: int,
native: bool = False,
) -> dict[str, Any]:
process = subprocess.Popen(
command,
cwd=instance_path,
env=_proton_env(instance_path),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
popen_kwargs: dict[str, Any] = {
"cwd": instance_path,
"text": True,
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
}
if native:
popen_kwargs["env"] = os.environ.copy()
else:
popen_kwargs["env"] = _proton_env(instance_path)
popen_kwargs["start_new_session"] = True
process = subprocess.Popen(command, **popen_kwargs)
try:
stdout, stderr = process.communicate(timeout=timeout_seconds)
timed_out = False
except subprocess.TimeoutExpired:
timed_out = True
os.killpg(process.pid, signal.SIGTERM)
_terminate_process(process)
try:
stdout, stderr = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
_kill_process(process)
stdout, stderr = process.communicate()
return {
"returncode": process.returncode,
@@ -175,10 +208,14 @@ def run_bootstrap(
state_root: Path,
repo_root: Path,
proton: Path | None = None,
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
) -> dict[str, Any]:
tell = progress or (lambda _message: None)
use_native = is_windows() if native is None else native
bootstrap_mode = "native" if use_native else "proton"
tell("Fetching locked BSIPA archive")
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
if fetched.get("cached"):
@@ -210,30 +247,34 @@ def run_bootstrap(
if not ipa.is_file():
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
proton_path = proton or _default_proton()
if not proton_path.is_file():
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
command = build_bootstrap_command(ipa, proton=proton, native=use_native)
if use_native:
tell(f"Running IPA.exe -n natively; timeout {ipa_timeout_seconds}s")
else:
proton_path = proton or _default_proton()
if not proton_path.is_file():
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
command = [str(proton_path), "run", str(ipa), "-n"]
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
completed = _run_ipa(
command=command,
instance_path=instance_path,
timeout_seconds=ipa_timeout_seconds,
native=use_native,
)
tell("Scanning bootstrap files after IPA.exe -n")
after = scan_bootstrap_files(instance_path)
delta = _files_delta(before, after)
state = {
state: dict[str, Any] = {
"schemaVersion": 1,
"instance": instance,
"beatSaberVersion": beat_saber_version,
"bootstrappedAt": _now_iso(),
"plugin": BSIPA_PLUGIN_ID,
"bootstrapMode": bootstrap_mode,
"archive": fetched,
"planPath": str(plan_path),
"applied": apply_result["applied"],
"proton": str(proton_path),
"command": command,
"ipaExitCode": completed["returncode"],
"ipaTimedOut": completed["timedOut"],
@@ -244,6 +285,8 @@ def run_bootstrap(
"delta": delta,
"health": {},
}
if not use_native:
state["proton"] = str(proton or _default_proton())
save_bootstrap_state(state_root, instance, state)
state["health"] = check_bsipa_health(instance_path, state_root, instance)
save_bootstrap_state(state_root, instance, state)
@@ -253,3 +296,46 @@ def run_bootstrap(
if completed["returncode"] != 0:
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
return state
def ensure_healthy_bootstrap(
*,
instance: str,
instance_path: Path,
beat_saber_version: str,
registry: Registry,
lockfile: Lockfile,
state_root: Path,
repo_root: Path,
selected_ids: set[str],
proton: Path | None = None,
native: bool | None = None,
progress: Callable[[str], None] | None = None,
ipa_timeout_seconds: int = 120,
) -> None:
if not planning_requires_bootstrap(lockfile.plugins, selected_ids):
return
health = check_bsipa_health(instance_path, state_root, instance)
if health["ok"]:
return
tell = progress or (lambda _message: None)
tell("BSIPA bootstrap is unhealthy; running bootstrap")
run_bootstrap(
instance=instance,
instance_path=instance_path,
beat_saber_version=beat_saber_version,
registry=registry,
lockfile=lockfile,
state_root=state_root,
repo_root=repo_root,
proton=proton,
native=native,
progress=progress,
ipa_timeout_seconds=ipa_timeout_seconds,
)
health = check_bsipa_health(instance_path, state_root, instance)
if not health["ok"]:
raise ValueError(bootstrap_health_error(health))
+25 -2
View File
@@ -10,10 +10,29 @@ from .state import bootstrap_state_path, load_bootstrap_state
BSIPA_PLUGIN_ID = "bsipa"
def planning_requires_bootstrap(lockfile_plugins: tuple[Any, ...] | list[Any], selected_ids: set[str]) -> bool:
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile_plugins)
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
return has_locked_bsipa and planning_ordinary_plugins
def bootstrap_health_error(health: dict[str, Any]) -> str:
joined = "; ".join(health["messages"])
return f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}"
def latest_log_path(instance_path: Path) -> Path:
return instance_path / "Logs" / "_latest.log"
def _native_bootstrap_satisfied(state: dict[str, Any]) -> bool:
return (
state.get("bootstrapMode") == "native"
and state.get("ipaExitCode") == 0
and not state.get("ipaTimedOut")
)
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
state = load_bootstrap_state(state_root, instance)
messages: list[str] = []
@@ -27,12 +46,15 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
messages.append(f"missing {rel}/")
log_path = latest_log_path(instance_path)
native_bootstrap = _native_bootstrap_satisfied(state)
if not log_path.is_file():
messages.append("missing Logs/_latest.log")
if not native_bootstrap:
messages.append("missing Logs/_latest.log")
else:
text = log_path.read_text(encoding="utf-8", errors="replace")
if "Beat Saber IPA (BSIPA):" not in text and "Beat Saber IPA" not in text:
messages.append("Logs/_latest.log does not show BSIPA startup")
if not native_bootstrap:
messages.append("Logs/_latest.log does not show BSIPA startup")
if not state:
messages.append(f"missing bootstrap state: {bootstrap_state_path(state_root, instance)}")
@@ -44,4 +66,5 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
"logPath": str(log_path),
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
"bootstrapRecordedAt": state.get("updatedAt"),
"bootstrapMode": state.get("bootstrapMode"),
}
+10 -2
View File
@@ -61,6 +61,8 @@ def print_updates(report: dict[str, Any]) -> None:
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
default = argparse.SUPPRESS if suppress_default else None
parser.add_argument("--config", default=default, help="plugin-helper config TOML path")
parser.add_argument("--profile", default=default, help="Config profile id from [[profiles]]")
parser.add_argument("--instances-root", default=default, help="BSManager instances root")
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
@@ -120,13 +122,14 @@ def build_parser() -> argparse.ArgumentParser:
bootstrap = subcommands.add_parser(
"bootstrap",
help="Install locked BSIPA, run IPA.exe -n through Proton, and record bootstrap files",
help="Install locked BSIPA, run IPA.exe -n, and record bootstrap files",
parents=[_common_parent()],
)
bootstrap.add_argument("--instance", required=True)
bootstrap.add_argument("--registry", default="registry/plugins.toml")
bootstrap.add_argument("--lockfile")
bootstrap.add_argument("--proton", help="Path to Proton executable")
bootstrap.add_argument("--proton", help="Path to Proton executable (Linux/Proton installs)")
bootstrap.add_argument("--native", action="store_true", help="Run IPA.exe -n natively instead of through Proton")
bootstrap.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
bootstrap_check = subcommands.add_parser(
@@ -282,6 +285,8 @@ def run(argv: list[str] | None = None) -> int:
runtime = resolve_runtime_config(
instances_root_value=getattr(args, "instances_root", None),
state_dir_value=getattr(args, "state_dir", None),
config_path_value=getattr(args, "config", None),
profile_id=getattr(args, "profile", None),
)
inst_roots = runtime.instances_roots
st_root = runtime.state_root
@@ -409,6 +414,7 @@ def run(argv: list[str] | None = None) -> int:
state_root=st_root,
repo_root=root,
proton=Path(args.proton).expanduser() if args.proton else None,
native=True if args.native else None,
progress=lambda message: print(f" {message}", flush=True),
)
if args.json:
@@ -498,6 +504,7 @@ def run(argv: list[str] | None = None) -> int:
if args.command == "enable":
instance = get_instance(inst_roots, args.instance)
progress = (lambda message: print(f" {message}", flush=True))
result = enable_disabled_plugin(
instance=args.instance,
instance_path=instance.path,
@@ -505,6 +512,7 @@ def run(argv: list[str] | None = None) -> int:
plugin_id=args.plugin,
registry=args.registry,
lockfile=args.lockfile,
progress=progress,
)
print(f"Enabled: {args.plugin}")
print(f"Plan: {result['planPath']}")
+82 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
@@ -10,12 +11,22 @@ from typing import Any
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
WINDOWS_CONFIG_NAME = "plugin-helper.windows.toml"
@dataclass(frozen=True)
class Profile:
id: str
label: str | None
instances_roots: list[Path]
state_root: Path
@dataclass(frozen=True)
class LocalConfig:
instances_roots: list[Path] | None
state_root: Path | None
profiles: tuple[Profile, ...]
@dataclass(frozen=True)
@@ -24,6 +35,12 @@ class RuntimeConfig:
state_root: Path
config_path: Path
config_loaded: bool
profile_id: str | None = None
profile_label: str | None = None
def is_windows() -> bool:
return sys.platform == "win32"
def repo_root() -> Path:
@@ -38,7 +55,7 @@ def instances_roots(value: str | None = None, *, base: Path | None = None) -> li
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
if raw:
return _resolve_path_list(raw, base or repo_root())
return [DEFAULT_INSTANCES_ROOT]
return _default_instances_roots()
def state_root(value: str | None = None) -> Path:
@@ -47,13 +64,16 @@ def state_root(value: str | None = None) -> Path:
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"
return _default_state_root()
def default_config_path(root: Path | None = None) -> Path:
return (root or repo_root()) / LOCAL_CONFIG_NAME
repo = root or repo_root()
if is_windows():
windows_config = repo / WINDOWS_CONFIG_NAME
if windows_config.is_file():
return windows_config
return repo / LOCAL_CONFIG_NAME
def _resolve_path(value: str | Path, base: Path) -> Path:
@@ -67,13 +87,34 @@ 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 _parse_profiles(data: dict[str, Any], base: Path) -> tuple[Profile, ...]:
profiles: list[Profile] = []
for entry in data.get("profiles", []):
if not isinstance(entry, dict):
continue
profile_id = entry.get("id")
instances_value = entry.get("instances_root")
state_value = entry.get("state_dir")
if not profile_id or not instances_value or not state_value:
raise ValueError(f"profile {profile_id!r} needs id, instances_root, and state_dir")
profiles.append(
Profile(
id=str(profile_id),
label=entry.get("label"),
instances_roots=_resolve_path_list(str(instances_value), base),
state_root=_resolve_path(str(state_value), base),
)
)
return tuple(profiles)
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
return LocalConfig(instances_roots=None, state_root=None, profiles=()), path, False
with path.open("rb") as handle:
data: dict[str, Any] = tomllib.load(handle)
@@ -81,10 +122,12 @@ def load_local_config(config_path: str | Path | None = None, *, root: Path | Non
base = path.parent
instances_value = data.get("instances_root")
state_value = data.get("state_dir")
profiles = _parse_profiles(data, base)
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,
profiles=profiles,
),
path,
True,
@@ -102,28 +145,57 @@ def _env_state_root(root: Path) -> Path | None:
def _default_state_root() -> Path:
if is_windows():
localappdata = os.environ.get("LOCALAPPDATA")
if localappdata:
return Path(localappdata) / "plugin-helper"
return Path.home() / "AppData" / "Local" / "plugin-helper"
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]:
if is_windows():
return [Path.home() / "BSManager" / "BSInstances"]
return [DEFAULT_INSTANCES_ROOT]
def _select_profile(local_config: LocalConfig, profile_id: str | None) -> Profile | None:
if profile_id:
for profile in local_config.profiles:
if profile.id == profile_id:
return profile
available = ", ".join(profile.id for profile in local_config.profiles) or "(none)"
raise ValueError(f"unknown profile {profile_id!r}; available profiles: {available}")
if (
local_config.profiles
and local_config.instances_roots is None
and local_config.state_root is None
and len(local_config.profiles) == 1
):
return local_config.profiles[0]
return None
def resolve_runtime_config(
*,
instances_root_value: str | None = None,
state_dir_value: str | None = None,
config_path_value: str | None = None,
profile_id: str | None = None,
root: Path | None = None,
) -> RuntimeConfig:
repo = root or repo_root()
local_config, loaded_path, loaded = load_local_config(root=repo)
local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo)
profile = _select_profile(local_config, profile_id)
resolved_instances = (
_resolve_path_list(instances_root_value, repo)
if instances_root_value
else _env_instances_roots(repo)
or (profile.instances_roots if profile else None)
or local_config.instances_roots
or _default_instances_roots()
)
@@ -131,6 +203,7 @@ def resolve_runtime_config(
_resolve_path(state_dir_value, repo)
if state_dir_value
else _env_state_root(repo)
or (profile.state_root if profile else None)
or local_config.state_root
or _default_state_root()
)
@@ -140,4 +213,6 @@ def resolve_runtime_config(
state_root=resolved_state,
config_path=loaded_path,
config_loaded=loaded,
profile_id=profile.id if profile else None,
profile_label=profile.label if profile else None,
)
+106 -8
View File
@@ -1,15 +1,34 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
from .bootstrap import ensure_healthy_bootstrap
from .config import repo_root
from .installer import apply_plan
from .models import load_lockfile, load_registry
from .models import Lockfile, Registry, load_lockfile, load_registry
from .planner import create_plan
from .state import load_installed_state
def _resolve_paths(
*,
instance: str,
registry: str,
lockfile: str | None,
repo: Path | None,
) -> tuple[Path, Path, Path, Lockfile, Registry]:
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)
loaded_registry = load_registry(registry_path)
return root, registry_path, lock_path, loaded_lockfile, loaded_registry
def enable_disabled_plugin(
*,
instance: str,
@@ -19,25 +38,38 @@ def enable_disabled_plugin(
registry: str = "registry/plugins.toml",
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
installed_state = load_installed_state(state_root, instance)
if plugin_id not in installed_state.get("disabledPlugins", {}):
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
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)
root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
instance=instance,
registry=registry,
lockfile=lockfile,
repo=repo,
)
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
ensure_healthy_bootstrap(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected_ids={plugin_id},
progress=progress,
)
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=load_registry(registry_path),
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
@@ -45,3 +77,69 @@ def enable_disabled_plugin(
)
result = apply_plan(plan, state_root)
return {"planPath": str(path), **result}
def enable_disabled_plugins(
*,
instance: str,
instance_path: Path,
state_root: Path,
plugin_ids: list[str],
registry: str = "registry/plugins.toml",
lockfile: str | None = None,
repo: Path | None = None,
progress: Callable[[str], None] | None = None,
) -> dict[str, Any]:
if not plugin_ids:
return {"enabled": [], "errors": []}
root, _registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
instance=instance,
registry=registry,
lockfile=lockfile,
repo=repo,
)
installed_state = load_installed_state(state_root, instance)
disabled_plugins = installed_state.get("disabledPlugins", {})
selected_ids = set(plugin_ids)
ensure_healthy_bootstrap(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected_ids=selected_ids,
progress=progress,
)
enabled: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for plugin_id in plugin_ids:
if plugin_id not in disabled_plugins:
errors.append({"plugin": plugin_id, "error": f"plugin is not recorded as disabled: {plugin_id}"})
continue
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
errors.append(
{"plugin": plugin_id, "error": f"plugin is disabled but not locked for this instance: {plugin_id}"}
)
continue
try:
plan, path = create_plan(
instance=instance,
instance_path=instance_path,
beat_saber_version=loaded_lockfile.beat_saber_version,
registry=loaded_registry,
lockfile=loaded_lockfile,
state_root=state_root,
repo_root=root,
selected={plugin_id},
)
result = apply_plan(plan, state_root)
enabled.append({"plugin": plugin_id, "planPath": str(path), "applied": len(result["applied"])})
except Exception as exc:
errors.append({"plugin": plugin_id, "error": str(exc)})
return {"enabled": enabled, "errors": errors}
+3 -6
View File
@@ -9,7 +9,7 @@ from zipfile import ZipFile
from .fsutil import ensure_relative, sha256_bytes, sha256_file
from .models import Lockfile, Registry, VALID_STRATEGIES
from .bsipa import BSIPA_PLUGIN_ID, check_bsipa_health
from .bsipa import bootstrap_health_error, check_bsipa_health, planning_requires_bootstrap
from .state import downloads_dir, plans_dir, plugin_downloads_dir
@@ -81,13 +81,10 @@ def create_plan(
changes: list[dict[str, Any]] = []
warnings: list[str] = []
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
planning_ordinary_plugins = any(plugin_id != BSIPA_PLUGIN_ID for plugin_id in selected_ids)
if require_bootstrap and has_locked_bsipa and planning_ordinary_plugins:
if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids):
health = check_bsipa_health(instance_path, state_root, instance)
if not health["ok"]:
joined = "; ".join(health["messages"])
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
raise ValueError(bootstrap_health_error(health))
for locked in lockfile.plugins:
if locked.id not in selected_ids:
+101 -44
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -9,9 +10,10 @@ from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, Static
from .bsipa import BSIPA_PLUGIN_ID
from .installer import disable_plugin
from .models import load_lockfile, load_registry
from .operations import enable_disabled_plugin
from .operations import enable_disabled_plugin, enable_disabled_plugins
from .reports import installed_plugins_report
from .state import load_installed_state
@@ -63,6 +65,7 @@ class PluginHelperTui(App[int]):
self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = []
self.status_message = ""
self._busy = False
def compose(self) -> ComposeResult:
yield Header(show_clock=False)
@@ -74,6 +77,10 @@ class PluginHelperTui(App[int]):
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.cursor_type = "row"
if len(self.choices) == 1:
self.selected_installation = self.choices[0]
self._show_plugins()
return
self._show_installations()
def action_select(self) -> None:
@@ -87,16 +94,20 @@ class PluginHelperTui(App[int]):
def action_back(self) -> None:
if self.mode == "plugins":
if len(self.choices) == 1:
return
self._show_installations()
def action_refresh(self) -> None:
if self._busy:
return
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:
async def action_toggle_plugin(self) -> None:
if self._busy or self.mode != "plugins" or self.selected_installation is None:
return
index = self._cursor_index(len(self.plugin_rows))
if index is None:
@@ -104,26 +115,32 @@ class PluginHelperTui(App[int]):
plugin = self.plugin_rows[index]
plugin_id = plugin["id"]
target = self.selected_installation
self._busy = True
try:
if plugin["status"] == "enabled":
result = disable_plugin(
self._set_status(f"Disabling {plugin_id}...")
result = await asyncio.to_thread(
disable_plugin,
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
force=False,
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(
self._set_status(f"Enabling {plugin_id}...")
result = await asyncio.to_thread(
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,
progress=self._bootstrap_progress,
)
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else:
@@ -132,57 +149,89 @@ class PluginHelperTui(App[int]):
except Exception as exc:
self._set_status(f"Could not toggle {plugin_id}: {exc}")
return
finally:
self._busy = False
self._show_plugins(preserve_status=True)
def action_disable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None:
async def action_disable_all_plugins(self) -> None:
if self._busy or 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"]
enabled = [
plugin
for plugin in self.plugin_rows
if plugin["status"] == "enabled" and plugin["id"] != BSIPA_PLUGIN_ID
]
skipped_bsipa = any(
plugin["status"] == "enabled" and plugin["id"] == BSIPA_PLUGIN_ID for plugin in self.plugin_rows
)
if not enabled:
self._set_status("No enabled plugins to disable.")
return
self._busy = True
self._set_status(f"Disabling {len(enabled)} plugins...")
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)
try:
for plugin in enabled:
plugin_id = plugin["id"]
try:
result = await asyncio.to_thread(
disable_plugin,
target.instance_name,
target.instance_path,
target.state_root,
plugin_id,
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}")
finally:
self._busy = False
if skipped_bsipa and not errors:
self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).")
else:
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:
async def action_enable_all_plugins(self) -> None:
if self._busy or 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}")
if not disabled:
self._set_status("No disabled plugins to enable.")
return
plugin_ids = [plugin["id"] for plugin in disabled]
self._busy = True
self._set_status(f"Enabling {len(plugin_ids)} plugins...")
try:
result = await asyncio.to_thread(
enable_disabled_plugins,
instance=target.instance_name,
instance_path=target.instance_path,
state_root=target.state_root,
plugin_ids=plugin_ids,
repo=self.repo_root,
progress=self._bootstrap_progress,
)
except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}")
return
finally:
self._busy = False
changed = len(result["enabled"])
errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]]
self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True)
def _bootstrap_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, f"Bootstrapping: {message}")
def _show_installations(self) -> None:
self.mode = "installations"
self.plugin_rows = []
@@ -207,6 +256,9 @@ class PluginHelperTui(App[int]):
self._show_installations()
return
target = self.selected_installation
selected_row: int | None = None
if self.mode == "plugins" and self.plugin_rows:
selected_row = self._cursor_index(len(self.plugin_rows))
self.mode = "plugins"
self._set_title(f"{target.install_label} / {target.instance_name}")
table = self.query_one(DataTable)
@@ -236,9 +288,14 @@ class PluginHelperTui(App[int]):
str(plugin["fileCount"]),
plugin["asset"],
)
if selected_row is not None and self.plugin_rows:
table.move_cursor(row=min(selected_row, len(self.plugin_rows) - 1))
if not preserve_status:
if self.plugin_rows:
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.")
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
self._set_status(
f"Space toggles selected. d disables all. e enables all.{back_hint}"
)
else:
self._set_status("No managed plugins recorded for this installation.")
+7 -9
View File
@@ -6,7 +6,6 @@ import shutil
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any, Callable
from .fsutil import sha256_file
@@ -62,25 +61,24 @@ def backup_userdata(instance: str, instance_path: Path, state_root: Path) -> dic
}
destination.parent.mkdir(parents=True, exist_ok=True)
manifest_path = destination.parent / f".{destination.name}.manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
with tarfile.open(destination, "w:gz") as archive:
archive.add(source, arcname="UserData")
with NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as handle:
json.dump(manifest, handle, indent=2, sort_keys=True)
handle.write("\n")
handle.flush()
archive.add(handle.name, arcname="manifest.json")
archive.add(manifest_path, arcname="manifest.json")
manifest_path.unlink(missing_ok=True)
return {"archive": str(destination), "manifest": manifest}
def infer_windows_appdata_path(instance_path: Path) -> Path:
parts = instance_path.resolve().parts
try:
users_index = parts.index("Users")
bsmanager_index = parts.index("BSManager")
except ValueError as exc:
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from exc
if users_index + 1 >= len(parts):
if bsmanager_index < 2 or parts[bsmanager_index - 2] != "Users":
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}")
profile = Path(*parts[: users_index + 2])
profile = Path(*parts[:bsmanager_index])
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
+462 -14
View File
@@ -13,15 +13,17 @@ 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.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
from plugin_helper.bsipa import check_bsipa_health, planning_requires_bootstrap
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.config import is_windows, load_local_config, resolve_runtime_config
from plugin_helper.fsutil import sha256_file
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.operations import enable_disabled_plugin
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, save_installed_state
@@ -218,7 +220,10 @@ state_dir = "config-state"
root = Path(tmp)
xdg = root / "xdg-state"
with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True):
with (
patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg), "HOME": str(root)}, clear=True),
patch("plugin_helper.config.is_windows", return_value=False),
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, xdg / "plugin-helper")
@@ -226,11 +231,15 @@ state_dir = "config-state"
def test_runtime_default_state_uses_home_local_state(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
env = {"HOME": str(root), "USERPROFILE": str(root)}
with patch.dict(os.environ, {}, clear=True):
with (
patch.dict(os.environ, env, clear=True),
patch("plugin_helper.config.is_windows", return_value=False),
):
runtime = resolve_runtime_config(root=root)
self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper")
self.assertEqual(runtime.state_root, Path(root) / ".local" / "state" / "plugin-helper")
def test_no_args_prints_help_when_not_interactive(self) -> None:
output = StringIO()
@@ -253,6 +262,8 @@ state_dir = "config-state"
run_menu.assert_called_once()
def test_run_ipa_timeout_returns_control(self) -> None:
if is_windows():
self.skipTest("POSIX process-group timeout behavior is Linux-specific")
with tempfile.TemporaryDirectory() as tmp:
result = _run_ipa(
command=["python", "-c", "import time; time.sleep(30)"],
@@ -263,6 +274,333 @@ state_dir = "config-state"
self.assertTrue(result["timedOut"])
self.assertNotEqual(result["returncode"], 0)
def test_run_ipa_timeout_returns_control_on_windows(self) -> None:
if not is_windows():
self.skipTest("Windows subprocess timeout behavior is Windows-specific")
with tempfile.TemporaryDirectory() as tmp:
result = _run_ipa(
command=["python", "-c", "import time; time.sleep(30)"],
instance_path=Path(tmp),
timeout_seconds=1,
native=True,
)
self.assertTrue(result["timedOut"])
self.assertNotEqual(result["returncode"], 0)
def test_build_bootstrap_command_native(self) -> None:
ipa = Path("C:/Games/Beat Saber/IPA.exe")
self.assertEqual(build_bootstrap_command(ipa, native=True), [str(ipa), "-n"])
def test_build_bootstrap_command_proton(self) -> None:
ipa = Path("/tmp/1.44.1/IPA.exe")
proton = Path("/tmp/proton")
self.assertEqual(
build_bootstrap_command(ipa, proton=proton, native=False),
[str(proton), "run", str(ipa), "-n"],
)
def test_profile_config_resolves_windows_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
config = root / "plugin-helper.windows.toml"
config.write_text(
"""
[[profiles]]
id = "windows"
label = "Native Windows BSManager"
instances_root = "~/BSInstances"
state_dir = ".state"
""".lstrip(),
encoding="utf-8",
)
local_config, loaded_path, loaded = load_local_config(config, root=root)
runtime = resolve_runtime_config(config_path_value=str(config), profile_id="windows", root=root)
auto_runtime = resolve_runtime_config(config_path_value=str(config), root=root)
self.assertTrue(loaded)
self.assertEqual(loaded_path, config)
self.assertEqual(len(local_config.profiles), 1)
self.assertEqual(local_config.profiles[0].id, "windows")
self.assertEqual(runtime.instances_roots, [Path("~/BSInstances").expanduser()])
self.assertEqual(runtime.state_root, root / ".state")
self.assertEqual(runtime.profile_id, "windows")
self.assertEqual(auto_runtime.state_root, root / ".state")
self.assertEqual(auto_runtime.profile_id, "windows")
def test_native_bootstrap_health_accepts_state_without_log(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "native",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
result = check_bsipa_health(instance, state, "1.44.1")
self.assertTrue(result["ok"])
self.assertEqual(result["bootstrapMode"], "native")
def test_proton_bootstrap_health_still_requires_log(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "proton",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
result = check_bsipa_health(instance, state, "1.44.1")
self.assertFalse(result["ok"])
self.assertIn("missing Logs/_latest.log", result["messages"])
def test_planning_requires_bootstrap(self) -> None:
lockfile_plugins = (
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
)
self.assertTrue(planning_requires_bootstrap(lockfile_plugins, {"example"}))
self.assertFalse(planning_requires_bootstrap(lockfile_plugins, {"bsipa"}))
self.assertFalse(planning_requires_bootstrap((), {"example"}))
def test_ensure_healthy_bootstrap_noop_when_healthy(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "IPA").mkdir()
(instance / "Libs").mkdir()
(instance / "IPA.exe").write_bytes(b"ipa")
(instance / "winhttp.dll").write_bytes(b"proxy")
save_bootstrap_state(
state,
"1.44.1",
{
"bootstrapMode": "native",
"ipaExitCode": 0,
"ipaTimedOut": False,
"files": scan_bootstrap_files(instance),
},
)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
),
)
registry = Registry(
{
"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip"),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"example"},
)
run_bootstrap.assert_not_called()
def test_ensure_healthy_bootstrap_runs_when_unhealthy(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(
LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),
LockedPlugin(id="example", repo=None, tag="v1.0.0", asset="Example.dll", sha256=None),
),
)
registry = Registry(
{
"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip"),
"example": RegistryPlugin(
id="example",
name="Example",
repo=None,
install_strategy="dll-to-plugins",
),
}
)
healthy = {
"ok": True,
"messages": [],
"statePath": str(state / "instances" / "1.44.1" / "bootstrap.json"),
"logPath": str(instance / "Logs" / "_latest.log"),
}
unhealthy = {**healthy, "ok": False, "messages": ["missing IPA.exe"]}
with patch("plugin_helper.bootstrap.check_bsipa_health", side_effect=[unhealthy, healthy]) as check_health:
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"example"},
)
self.assertEqual(check_health.call_count, 2)
run_bootstrap.assert_called_once()
def test_ensure_healthy_bootstrap_skips_for_bsipa_only(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
lockfile = Lockfile(
beat_saber_version="1.44.1",
instance="1.44.1",
plugins=(LockedPlugin(id="bsipa", repo=None, tag="4.3.7", asset="BSIPA.zip", sha256=None),),
)
registry = Registry(
{"bsipa": RegistryPlugin(id="bsipa", name="BSIPA", repo=None, install_strategy="root-zip")}
)
with patch("plugin_helper.bootstrap.check_bsipa_health") as check_health:
with patch("plugin_helper.bootstrap.run_bootstrap") as run_bootstrap:
ensure_healthy_bootstrap(
instance="1.44.1",
instance_path=instance,
beat_saber_version="1.44.1",
registry=registry,
lockfile=lockfile,
state_root=state,
repo_root=work,
selected_ids={"bsipa"},
)
check_health.assert_not_called()
run_bootstrap.assert_not_called()
def test_enable_disabled_plugin_bootstraps_before_plan(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
instance = work / "instances" / "1.44.1"
state = work / "state"
instance.mkdir(parents=True)
(instance / "Beat Saber_Data").mkdir()
(instance / "Plugins").mkdir()
asset = plugin_downloads_dir(state, "1.44.1", "example") / "Example.dll"
asset.write_bytes(b"managed dll")
save_installed_state(
state,
"1.44.1",
{
"disabledPlugins": {
"example": {
"disabledAt": "2026-01-01T00:00:00Z",
"files": [{"path": "Plugins/Example.dll", "sha256": sha256_file(asset)}],
}
}
},
)
(work / "locks").mkdir()
(work / "registry").mkdir()
lock_path = work / "locks" / "1.44.1.lock.toml"
lock_path.write_text(
"""
beat_saber_version = "1.44.1"
instance = "1.44.1"
[[plugins]]
id = "bsipa"
tag = "4.3.7"
asset = "BSIPA.zip"
[[plugins]]
id = "example"
tag = "v1.0.0"
asset = "Example.dll"
sha256 = "%s"
""" % sha256_file(asset),
encoding="utf-8",
)
registry_path = work / "registry" / "plugins.toml"
registry_path.write_text(
"""
[bsipa]
name = "BSIPA"
install_strategy = "root-zip"
[example]
name = "Example"
install_strategy = "dll-to-plugins"
""",
encoding="utf-8",
)
call_order: list[str] = []
def _ensure(**_kwargs: object) -> None:
call_order.append("ensure")
def _create_plan(**_kwargs: object) -> tuple[dict[str, object], Path]:
call_order.append("plan")
return {"changes": [], "instance": "1.44.1", "instancePath": str(instance)}, work / "plan.json"
def _apply_plan(_plan: dict[str, object], _state_root: Path) -> dict[str, object]:
call_order.append("apply")
return {"applied": [], "statePath": str(state / "instances" / "1.44.1" / "installed.json")}
with patch("plugin_helper.operations.ensure_healthy_bootstrap", side_effect=_ensure):
with patch("plugin_helper.operations.create_plan", side_effect=_create_plan):
with patch("plugin_helper.operations.apply_plan", side_effect=_apply_plan):
enable_disabled_plugin(
instance="1.44.1",
instance_path=instance,
state_root=state,
plugin_id="example",
registry=str(registry_path),
lockfile=str(lock_path),
repo=work,
)
self.assertEqual(call_order, ["ensure", "plan", "apply"])
def test_plan_apply_and_uninstall_dll(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
@@ -689,13 +1027,21 @@ sha256 = "{sha256_file(asset)}"
self.assertTrue(Path(result["archive"]).exists())
self.assertEqual(result["manifest"]["fileCount"], 1)
def test_infer_windows_appdata_path_from_native_instance(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
instance = root / "Users" / "pleb" / "BSManager" / "BSInstances" / "1.44.1"
instance.mkdir(parents=True)
expected = root / "Users" / "pleb" / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
self.assertEqual(infer_windows_appdata_path(instance), expected)
def test_infer_windows_appdata_path_from_mounted_instance(self) -> None:
instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
expected = Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber")
self.assertEqual(
infer_windows_appdata_path(instance),
Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"),
)
if is_windows():
self.skipTest("POSIX mount paths are Linux-specific")
self.assertEqual(infer_windows_appdata_path(instance), expected)
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -747,6 +1093,8 @@ sha256 = "{sha256_file(asset)}"
)
def test_infer_appdata_path_uses_windows_or_proton(self) -> None:
if is_windows():
self.skipTest("POSIX mount paths are Linux-specific")
windows_instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
linux_instance = Path("/home/pleb/.local/share/BSManager/BSInstances/1.44.1")
@@ -1123,6 +1471,75 @@ sha256 = "{sha256_file(asset)}"
return PluginHelperTui(choices=[choice], repo_root=repo), instance, state
def _make_two_plugin_tui_fixture(root: Path) -> 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()
plugins_state: dict[str, dict] = {}
lock_entries: list[str] = []
registry_entries: list[str] = []
for plugin_id, name, filename in (
("alpha", "Alpha", "Alpha.dll"),
("beta", "Beta", "Beta.dll"),
):
asset = plugin_downloads_dir(state, "1.40.8", plugin_id) / filename
asset.write_bytes(f"{plugin_id} dll".encode())
(instance / "Plugins" / filename).write_bytes(f"{plugin_id} dll".encode())
registry_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
name = "{name}"
repo = "owner/{plugin_id}"
asset_patterns = ["*.dll"]
install_strategy = "dll-to-plugins"
""".lstrip()
)
lock_entries.append(
f"""
[[plugins]]
id = "{plugin_id}"
repo = "owner/{plugin_id}"
tag = "v1.0.0"
asset = "{filename}"
sha256 = "{sha256_file(asset)}"
""".lstrip()
)
plugins_state[plugin_id] = {
"installedAt": "2026-06-14T17:18:40Z",
"files": [{"path": f"Plugins/{filename}", "sha256": sha256_file(asset), "size": asset.stat().st_size}],
}
(repo / "registry" / "plugins.toml").write_text("".join(registry_entries), encoding="utf-8")
(repo / "locks" / "1.40.8.lock.toml").write_text(
f"""
beat_saber_version = "1.40.8"
instance = "1.40.8"
{"".join(lock_entries)}
""".lstrip(),
encoding="utf-8",
)
save_installed_state(
state,
"1.40.8",
{"instance": "1.40.8", "plugins": plugins_state, "disabledPlugins": {}},
)
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 = [
@@ -1148,30 +1565,61 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations")
async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_tui_fixture(Path(tmp))
async with app.run_test():
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.selected_installation, app.choices[0])
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 1)
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.mode, "plugins")
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")
await pilot.pause()
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_toggle_keeps_cursor_on_selected_plugin(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, instance, _state = _make_two_plugin_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 2)
await pilot.press("down")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
await pilot.press("space")
await pilot.pause()
self.assertEqual(table.cursor_row, 1)
self.assertEqual(app.plugin_rows[table.cursor_row]["id"], "beta")
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
self.assertTrue((instance / "Plugins" / "Alpha.dll").exists())
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.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
@@ -1183,8 +1631,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("d")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8")
@@ -1197,8 +1645,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
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")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8")
@@ -1211,8 +1659,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
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")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8")