Compare commits
10 Commits
538feae811
...
54803c5c5b
| Author | SHA1 | Date | |
|---|---|---|---|
| 54803c5c5b | |||
| f0d7955ea0 | |||
| 17d50e1239 | |||
| 4b544e0858 | |||
| c7d629644a | |||
| f40f3ddc56 | |||
| bcfada09f9 | |||
| 861ce587f3 | |||
| 77a15daeda | |||
| 8ff20bd4e4 |
@@ -98,6 +98,11 @@ that checkout's `.state/` directory. If `plugin-helper.windows.toml` has only
|
||||
one `[[profiles]]` entry, that profile is selected automatically when
|
||||
`--profile` is omitted:
|
||||
|
||||
From the repo root, double-click or run `menu.bat` to create `.venv` on first
|
||||
use, install the package, and open the Textual menu.
|
||||
|
||||
Or set up manually:
|
||||
|
||||
```powershell
|
||||
py -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
@@ -114,6 +119,20 @@ completed successfully.
|
||||
When no config file is present on Windows, defaults are
|
||||
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
|
||||
|
||||
## Building plugins
|
||||
|
||||
PC BSIPA plugins target .NET Framework (`net48`). On native Windows, install the
|
||||
reference assemblies before `dotnet build`:
|
||||
|
||||
```powershell
|
||||
winget install -e --id Microsoft.DotNet.Framework.DeveloperPack_4
|
||||
```
|
||||
|
||||
Point `BeatSaberDir` at your BSManager instance and pass
|
||||
`-p:DisableCopyToPlugins=True` so builds do not mutate the live install. See
|
||||
[`.agents/skills/beatsaber-plugin-builder/`](.agents/skills/beatsaber-plugin-builder/)
|
||||
for the full workflow.
|
||||
|
||||
## Commands
|
||||
|
||||
For normal use, run the Textual menu from the repo root:
|
||||
|
||||
@@ -111,16 +111,6 @@ stack loaded successfully.
|
||||
| SquatToBegin lifecycle notice | `[IPA/Loader] Plugin SquatToBegin has no methods marked [OnStart] or [OnEnable]` | Upstream plugin shape; gate still arms from gameplay scene setup. |
|
||||
| IPA feature deprecation | `[IPA/Loader/Features] Encountered old features used` / `No such feature SiraLocalizer.LocalizedPlugin` | Loader metadata drift; not a load failure. |
|
||||
|
||||
Still treat these as real problems when investigating a failed smoketest:
|
||||
|
||||
- `Mono.Cecil.AssemblyResolutionException`, `TypeLoadException`, or missing
|
||||
assembly errors for an installed plugin
|
||||
- Harmony `Patching exception` or repeated `NullReferenceException` stacks from
|
||||
a newly installed plugin (LeaderboardCore `PanelView_SetIsLoaded` is a known
|
||||
pre-existing warning on this stack; revisit only if in-game leaderboards break)
|
||||
- Plugin absent from the IPA load list after install
|
||||
- Startup never reaches `[MainSystemInit] Initialization was completed`
|
||||
|
||||
## Status Legend
|
||||
|
||||
- <span style="color:#8b949e; font-weight:600">todo</span>: not attempted
|
||||
@@ -236,7 +226,7 @@ Purpose: restore song-list, menu, and visualization conveniences.
|
||||
|
||||
| Plugin | Upstream | Status | Source/version | Verification notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
|
||||
| BetterSongList | [github](https://github.com/kinsi55/BeatSaber_BetterSongList) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `kinsi55/BeatSaber_BetterSongList` tag `v0.4.3`, asset `BetterSongList.dll`; GitHub release digest matched downloaded asset | IPA loaded BetterSongList 0.4.3 and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. SongDetailsCache is optional but recommended for cached song-detail lookups; the registry lists it as a non-required dependency. |
|
||||
| HitScoreVisualizer | [github](https://github.com/ErisApps/HitScoreVisualizer) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `ErisApps/HitScoreVisualizer` tag `3.7.3`, asset `HitScoreVisualizer-3.7.3-bs1.42.0-a565cbb.zip`; GitHub release digest matched downloaded asset | IPA loaded HitScoreVisualizer 3.7.3, installed app/menu installers, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.42.0. |
|
||||
| DiTails | [github](https://github.com/Auros/DiTails) | <span style="color:#d29922; font-weight:600">installed; smoke blocked</span> | Local build from `/home/pleb/src/Auros/DiTails` commit `601e3c4` on branch `fix-1.44-artwork-initialization`, asset `DiTails-1.1.3-bs1.44.1-601e3c4.zip`, SHA-256 `b8735f24545f1a50392865bf3013b930bee8e4ba7feac824a2482e2f1deeba1e`; replaces failed GitHub `Auros/DiTails` tag `1.1.3`, asset `DiTails-v1.1.3-g1.42.0-271d394.zip` and BeatMods 1.1.3 trial | Helper installed `Plugins/DiTails.dll` SHA-256 `5c856ffdfeab54982b84cb2e3033c970622668c553396460499d2343363d1d9d` in the mounted Windows instance. IPA loaded DiTails 1.1.3 and the previous `DetailContextManager.Initialize()` `NullReferenceException` did not recur before startup was blocked by `SteamAPI Init failed` because Steam was not running. |
|
||||
| HideTheLogo | [github](https://github.com/TheBlackParrot/HideTheLogo) | <span style="color:#d29922; font-weight:600">verified with warning</span> | GitHub `TheBlackParrot/HideTheLogo` tag `1.0.3`, asset `HideTheLogo-1.0.3-bs1.40.3-c968d91.zip` | IPA loaded HideTheLogo 1.0.3, logged `yeet`, and the game reached `MainSystemInit`. Warning: manifest targets Beat Saber 1.40.3. |
|
||||
@@ -267,7 +257,7 @@ 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. |
|
||||
| ReeSabers | [beatleader patreon](https://www.patreon.com/beatleader) | <span style="color:#d29922; font-weight:600">installed; smoke pending</span> | User-provided Patreon zip `ReeSabers_1.42.0.zip` (original SHA-256 `258b28b27e8a9274a15b22c91ed9a611a85367d9a9f31b575fd992ecd4bdc3ee`); staged helper payload SHA-256 `3f4b08fc450b1dc29f4fc3282c5099d3e09578e3fa2734dfa3c5d670dddfb219` omits bundled `CameraUtils.dll` to preserve locked CameraUtils 1.0.8 | Helper installed `Plugins/ReeSabers.dll` and `UserData/ReeSabers/` presets/textures on 2026-07-12. Live smoke not run yet; verify saber visuals in VR when practical. |
|
||||
| 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. |
|
||||
@@ -288,7 +278,7 @@ treat its output as local/private payload input for `plugin-helper`.
|
||||
| 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. |
|
||||
| NalulunaAvatars | [patreon](https://www.patreon.com/naluluna/posts/nalulunaavatars-52013165) | <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). |
|
||||
|
||||
+43
-15
@@ -44,12 +44,12 @@ reason = "BeatMods verified SongCore 3.16.0 for Beat Saber 1.44.1 as version id
|
||||
[[plugins]]
|
||||
id = "customjsondata"
|
||||
repo = "Aeroluna/CustomJSONData"
|
||||
tag = "v2.6.8"
|
||||
asset = "CustomJSONData-2.6.8+1.40.0-bs1.40.0-7c2c32c.zip"
|
||||
download_url = "https://github.com/Aeroluna/CustomJSONData/releases/download/v2.6.8/CustomJSONData-2.6.8%2B1.40.0-bs1.40.0-7c2c32c.zip"
|
||||
tag = "beatmods-2.6.8+1.40.0"
|
||||
asset = "CustomJSONData-2.6.8+1.40.0.zip"
|
||||
download_url = "https://beatmods.com/cdn/mod/fed31638bbb678580ef760ec83cfd486.zip"
|
||||
sha256 = "30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. GitHub asset is byte-identical to the BeatMods CDN zip."
|
||||
reason = "BeatMods verified CustomJSONData 2.6.8+1.40.0 for Beat Saber 1.44.1 as version id 2327, zipHash fed31638bbb678580ef760ec83cfd486. Downloaded from BeatMods CDN and verified SHA-256 30555c77485d2837bd294608fe4e23c34415b89413997be67a6150318090b216."
|
||||
|
||||
[[plugins]]
|
||||
id = "heck"
|
||||
@@ -201,12 +201,12 @@ reason = "BeatMods verified ScoreSaberSharp 0.1.0 for Beat Saber 1.44.1 as versi
|
||||
[[plugins]]
|
||||
id = "scoresaber"
|
||||
repo = "ScoreSaber/pc-mod"
|
||||
tag = "v3.3.27"
|
||||
asset = "ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
|
||||
download_url = "https://github.com/ScoreSaber/pc-mod/releases/download/v3.3.27/ScoreSaber-v3.3.27-bs1.42.0-to-1.44.0-9b4cfcf.zip"
|
||||
sha256 = "8ad509ab38353dfb4d92d127ba4e40917552f700580a7384509cafa70e62c096"
|
||||
tag = "v3.4.1"
|
||||
asset = "ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip"
|
||||
download_url = "https://github.com/ScoreSaber/pc-mod/releases/download/v3.4.1/ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip"
|
||||
sha256 = "6a74de62103b9b6afb1e02a3740d800cf696a88beee68e55d56de24751d5f8de"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "User-provided GitHub releases URL https://github.com/ScoreSaber/pc-mod/releases. Latest non-draft, non-prerelease release v3.3.27 includes this highest compatible asset, labeled for Beat Saber 1.42.0 through 1.44.0; GitHub release digest matched the downloaded asset."
|
||||
reason = "Updated from GitHub ScoreSaber/pc-mod tag v3.4.1 release https://github.com/ScoreSaber/pc-mod/releases/tag/v3.4.1, asset ScoreSaber-v3.4.1-bs1.42.0-to-1.44.1-4a3f9ff.zip. GitHub release digest matched 6a74de62103b9b6afb1e02a3740d800cf696a88beee68e55d56de24751d5f8de."
|
||||
|
||||
[[plugins]]
|
||||
id = "protobuf-net"
|
||||
@@ -248,6 +248,16 @@ sha256 = "0b7c2adcf4db9806cdc765164788a9394bca2ee12179fad1b8ed255da1b7104e"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "User-provided GitHub releases URL https://github.com/BeatLeader/beatleader-mod/releases. Latest non-draft, non-prerelease release v0.10.0 includes this highest compatible asset, labeled for Beat Saber 1.42+; GitHub release digest matched the downloaded asset. The archive bundles Plugins/LeaderboardCore.dll, so the standalone LeaderboardCore lock entry is intentionally omitted."
|
||||
|
||||
[[plugins]]
|
||||
id = "accsaber-reloaded"
|
||||
repo = "not-dexter/accsaber-reloaded-plugin"
|
||||
tag = "v1.1.3"
|
||||
asset = "1.40.8.zip"
|
||||
download_url = "https://github.com/not-dexter/accsaber-reloaded-plugin/releases/download/v1.1.3/1.40.8.zip"
|
||||
sha256 = "c1b1687e8378ee7f550edcbd0811fa71f8ddbaa9f167a5d1b48d4cbf9e808af7"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "User-provided GitHub releases URL https://github.com/not-dexter/accsaber-reloaded-plugin/releases. Latest release v1.1.3 has no 1.44.x asset; use highest available game-version asset 1.40.8.zip. GitHub release digest matched the downloaded asset. Depends on BSIPA, BeatSaberMarkupLanguage, SiraUtil, SongCore, and LeaderboardCore (satisfied by BeatLeader-bundled LeaderboardCore)."
|
||||
|
||||
[[plugins]]
|
||||
id = "beatsaverdownloader"
|
||||
repo = "Top-Cat/BeatSaverDownloader"
|
||||
@@ -268,6 +278,15 @@ sha256 = "6237da05cbc044e99211cb0e569c917fe61bb0da25e42c61a6d438371548ebba"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "BeatMods verified BeatSaverUpdater 1.2.11 for Beat Saber 1.44.1 as version id 2352, zipHash d9ea8dd0cbaac66cbb02fa59a548e42b. GitHub asset is byte-identical to the BeatMods CDN zip."
|
||||
|
||||
[[plugins]]
|
||||
id = "playlistmanager"
|
||||
repo = "rithik-b/PlaylistManager"
|
||||
tag = "localbuild-pr82-da1ad17-1.44"
|
||||
asset = "PlaylistManager-1.7.4-bs1.44.0-da1ad17.zip"
|
||||
sha256 = "cb86029a2defca846e2e1ebdf75bbe1368797fbf1b8ce509615155080b9ffbf9"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "Local build from GitHub PR https://github.com/rithik-b/PlaylistManager/pull/82 at commit da1ad17. Use this PR build instead of upstream releases, which still target older Beat Saber versions and fail on 1.44.x with IPlatformUserModel / PlatformUserModel resolution errors."
|
||||
|
||||
[[plugins]]
|
||||
id = "failbutton"
|
||||
repo = "qe201020335/FailButton"
|
||||
@@ -291,11 +310,11 @@ reason = "User-provided GitHub repository URL https://github.com/Reezonate/EasyO
|
||||
[[plugins]]
|
||||
id = "cinema"
|
||||
repo = "MisterKelley/BeatSaberCinema"
|
||||
tag = "localbuild-010ce6e-bs-1.42.3-bsl-filter"
|
||||
tag = "localbuild-010ce6e-bs-1.42.3"
|
||||
asset = "BeatSaberCinema-1.7.13-bs1.42.3-010ce6e.zip"
|
||||
sha256 = "2294f3a4e082626ae4508f821469e8f5156c827bf37efdf3327fa6e12039d5e0"
|
||||
sha256 = "f31d186444908df121b8b1e7491e557a6acfc501ba9a92f06989e7dd31444fad"
|
||||
install_strategy = "bsipa-zip"
|
||||
reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3. Built locally from commit 010ce6e01728af1e21774ccbb8d14405773f5b02 against the 1.44.1 instance after fixing the BGLib.AppFlow hint-path casing for Linux and compiling HasVideoFilter against BetterSongList 0.4.3's real ITransformerPlugin/IFilter interfaces; manifest still targets Beat Saber 1.42.3."
|
||||
reason = "User-provided fork/branch URL https://github.com/MisterKelley/BeatSaberCinema/tree/bs-1.42.3. Rebuilt locally on Windows from commit 010ce6e01728af1e21774ccbb8d14405773f5b02 against the 1.44.1 instance (Release DLL sha256 ef23c5ffff6d93aaf00cb1f5661873e945b3e59fc5d475c5616f6ce6a09148ae packaged as Plugins/BeatSaberCinema.dll). Manifest still targets Beat Saber 1.42.3; HasVideoFilter uses reflection against BetterSongList."
|
||||
|
||||
[[plugins]]
|
||||
id = "cinema-tools"
|
||||
@@ -473,8 +492,17 @@ reason = "User-provided Discord release zip C:\\Users\\pleb\\Downloads\\BeatSabe
|
||||
[[plugins]]
|
||||
id = "naluluna"
|
||||
repo = "patreon/NalulunaModAssistant"
|
||||
tag = "installed-1.44.1"
|
||||
tag = "installed-1.44.1-utils-5.8.0"
|
||||
asset = "Naluluna-1.44.1-installed-bundle.zip"
|
||||
sha256 = "40c8998257aa95ab58aab3e40802eabbf1c7ef83d6c79114a93631e508d9d38b"
|
||||
sha256 = "1d05575a1dd5c0d95aa001384c9f5a2b854361b937cefac13891b1a586c3f2ed"
|
||||
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."
|
||||
reason = "Local bundle recaptured from the Windows BSManager 1.44.1 instance on 2026-07-11 after NalulunaModAssistant installed NalulunaUtils 5.8.0 (archive NalulunaUtils-5.8.0-bs1.40.8.zip). Only Plugins/NalulunaUtils.dll changed versus the prior 2026-07-01 bundle; other Naluluna payloads, OpenVRHelper, skybox samples, AliciaSolid, and Libs/Native/OVRLipSync.dll are unchanged."
|
||||
|
||||
[[plugins]]
|
||||
id = "reesabers"
|
||||
repo = "patreon/ReeSabers"
|
||||
tag = "1.42.0"
|
||||
asset = "ReeSabers_1.42.0.zip"
|
||||
sha256 = "3f4b08fc450b1dc29f4fc3282c5099d3e09578e3fa2734dfa3c5d670dddfb219"
|
||||
install_strategy = "root-zip"
|
||||
reason = "User-provided BeatLeader Patreon zip C:\\Users\\pleb\\Downloads\\ReeSabers_1.42.0.zip (original SHA-256 258b28b27e8a9274a15b22c91ed9a611a85367d9a9f31b575fd992ecd4bdc3ee). Staged install payload omits bundled Plugins/CameraUtils.dll so it does not downgrade the locked CameraUtils 1.0.8 already present on 1.44.1; installs Plugins/ReeSabers.dll plus UserData/ReeSabers presets and textures."
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "VENV_PY=.venv\Scripts\python.exe"
|
||||
set "STAMP_FILE=.venv\.last-install"
|
||||
set "NEED_INSTALL=0"
|
||||
|
||||
if not exist "%VENV_PY%" (
|
||||
echo Creating virtual environment...
|
||||
py -m venv .venv
|
||||
if errorlevel 1 (
|
||||
echo Failed to create virtual environment.
|
||||
exit /b 1
|
||||
)
|
||||
set "NEED_INSTALL=1"
|
||||
) else if not exist "%STAMP_FILE%" (
|
||||
set "NEED_INSTALL=1"
|
||||
) else (
|
||||
"%VENV_PY%" -c "import pathlib, sys, time; p=pathlib.Path(r'%STAMP_FILE%'); sys.exit(0 if time.time()-p.stat().st_mtime >= 20*3600 else 1)"
|
||||
if not errorlevel 1 set "NEED_INSTALL=1"
|
||||
)
|
||||
|
||||
if "%NEED_INSTALL%"=="1" (
|
||||
echo Installing plugin-helper...
|
||||
"%VENV_PY%" -m pip install -e .
|
||||
if errorlevel 1 (
|
||||
echo Failed to install plugin-helper.
|
||||
exit /b 1
|
||||
)
|
||||
type nul > "%STAMP_FILE%"
|
||||
)
|
||||
|
||||
"%VENV_PY%" -m plugin_helper
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
|
||||
endlocal & exit /b %EXIT_CODE%
|
||||
@@ -1,6 +1,13 @@
|
||||
id = "accsaber-reloaded"
|
||||
name = "AccSaber Reloaded"
|
||||
repo = "not-dexter/accsaber-reloaded-plugin"
|
||||
asset_patterns = ["1.40.8.zip"]
|
||||
asset_patterns = ["1.*.zip"]
|
||||
install_strategy = "bsipa-zip"
|
||||
category = "leaderboard"
|
||||
dependencies = [
|
||||
{ id = "bsipa", constraint = ">=4.2.2" },
|
||||
{ id = "beatsabermarkuplanguage", constraint = ">=1.6.10" },
|
||||
{ id = "sirautil", constraint = ">=3.1.2" },
|
||||
{ id = "songcore", constraint = ">=3.10.2" },
|
||||
{ id = "beatleader", constraint = ">=0.10.0" },
|
||||
]
|
||||
|
||||
@@ -4,3 +4,6 @@ repo = "kinsi55/BeatSaber_BetterSongList"
|
||||
asset_patterns = ["BetterSongList.dll"]
|
||||
install_strategy = "dll-to-plugins"
|
||||
category = "ui"
|
||||
dependencies = [
|
||||
{ id = "songdetailscache", constraint = ">=1.4.0", required = false },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
id = "reesabers"
|
||||
name = "ReeSabers"
|
||||
repo = "patreon/ReeSabers"
|
||||
asset_patterns = ["ReeSabers_*.zip"]
|
||||
install_strategy = "root-zip"
|
||||
category = "cosmetic"
|
||||
dependencies = [
|
||||
{ id = "camerautils", constraint = ">=1.0.8" },
|
||||
{ id = "beatleader", constraint = ">=0.10.0" },
|
||||
{ id = "bsipa", constraint = ">=4.3.7" },
|
||||
]
|
||||
@@ -89,6 +89,7 @@ def _read_ffmpeg_exe(archive_path: Path) -> bytes:
|
||||
def _write_member(archive: ZipFile, name: str, data: bytes) -> None:
|
||||
info = ZipInfo(name, date_time=ZIP_TIMESTAMP)
|
||||
info.compress_type = ZIP_STORED
|
||||
info.create_system = 3 # Unix; keep the bundle hash stable across hosts
|
||||
info.external_attr = 0o644 << 16
|
||||
archive.writestr(info, data)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ def installed_plugins_report(
|
||||
"files": files,
|
||||
}
|
||||
)
|
||||
plugins.sort(key=lambda plugin: (plugin["name"].casefold(), plugin["id"]))
|
||||
return {
|
||||
"instance": installed_state.get("instance", lockfile.instance),
|
||||
"beatSaberVersion": installed_state.get("beatSaberVersion", lockfile.beat_saber_version),
|
||||
|
||||
+214
-18
@@ -6,9 +6,11 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual import events, on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable, Footer, Header, Static
|
||||
from textual.coordinate import Coordinate
|
||||
from textual.widgets import DataTable, Footer, Header, Input, Static
|
||||
|
||||
from .bsipa import BSIPA_PLUGIN_ID
|
||||
from .installer import disable_plugin
|
||||
@@ -32,6 +34,43 @@ class InstallationChoice:
|
||||
state_root: Path
|
||||
|
||||
|
||||
class ActivatableDataTable(DataTable):
|
||||
"""DataTable that activates a row on every click, not only a re-click."""
|
||||
|
||||
async def _on_click(self, event: events.Click) -> None:
|
||||
# Textual dispatches `_on_click` for every class in the MRO, so we fully
|
||||
# handle the click here and prevent DataTable's handler from also running.
|
||||
self._set_hover_cursor(True)
|
||||
meta = event.style.meta
|
||||
if "row" not in meta or "column" not in meta:
|
||||
event.prevent_default()
|
||||
return
|
||||
if self.cursor_type != "row" and meta.get("out_of_bounds", False):
|
||||
event.prevent_default()
|
||||
return
|
||||
|
||||
row_index = meta["row"]
|
||||
column_index = meta["column"]
|
||||
is_header_click = self.show_header and row_index == -1
|
||||
is_row_label_click = self.show_row_labels and column_index == -1
|
||||
if is_header_click:
|
||||
column = self.ordered_columns[column_index]
|
||||
self.post_message(
|
||||
DataTable.HeaderSelected(self, column.key, column_index, label=column.label)
|
||||
)
|
||||
elif is_row_label_click:
|
||||
row = self.ordered_rows[row_index]
|
||||
self.post_message(
|
||||
DataTable.RowLabelSelected(self, row.key, row_index, label=row.label)
|
||||
)
|
||||
elif self.show_cursor and self.cursor_type != "none":
|
||||
self.cursor_coordinate = Coordinate(row_index, column_index)
|
||||
self._post_selected_message()
|
||||
self._scroll_cursor_into_view(animate=True)
|
||||
event.stop()
|
||||
event.prevent_default()
|
||||
|
||||
|
||||
class PluginHelperTui(App[int]):
|
||||
CSS = """
|
||||
#title {
|
||||
@@ -39,6 +78,14 @@ class PluginHelperTui(App[int]):
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
#filter {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#filter.-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#status {
|
||||
padding: 0 1;
|
||||
color: $text-muted;
|
||||
@@ -47,6 +94,8 @@ class PluginHelperTui(App[int]):
|
||||
BINDINGS = [
|
||||
Binding("enter", "select", "Select", priority=True),
|
||||
Binding("space", "toggle_plugin", "Toggle", priority=True),
|
||||
Binding("/", "start_filter", "Filter", priority=True),
|
||||
Binding("escape", "clear_filter", "Clear filter", show=False, priority=True),
|
||||
Binding("d", "disable_all_plugins", "Disable all", priority=True),
|
||||
Binding("e", "enable_all_plugins", "Enable all", priority=True),
|
||||
Binding("s", "save_known_good", "Save known-good", priority=True),
|
||||
@@ -71,25 +120,55 @@ class PluginHelperTui(App[int]):
|
||||
self.mode = "installations"
|
||||
self.selected_installation: InstallationChoice | None = None
|
||||
self.plugin_rows: list[dict[str, Any]] = []
|
||||
self.filter_query = ""
|
||||
self.status_message = ""
|
||||
self._busy = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(show_clock=False)
|
||||
yield Static("", id="title")
|
||||
yield DataTable(id="table")
|
||||
filter_input = Input(placeholder="Filter plugins by name or id...", id="filter", classes="-hidden")
|
||||
filter_input.can_focus = False
|
||||
yield filter_input
|
||||
yield ActivatableDataTable(id="table")
|
||||
yield Static("", id="status")
|
||||
yield Footer()
|
||||
|
||||
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
||||
if self._filter_input_focused():
|
||||
if action in {
|
||||
"select",
|
||||
"toggle_plugin",
|
||||
"start_filter",
|
||||
"disable_all_plugins",
|
||||
"enable_all_plugins",
|
||||
"save_known_good",
|
||||
"restore_known_good",
|
||||
"back",
|
||||
}:
|
||||
return False
|
||||
elif action == "clear_filter" and not self.filter_query and not self._filter_visible():
|
||||
return False
|
||||
return True
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one(DataTable)
|
||||
table.cursor_type = "row"
|
||||
filter_input = self.query_one("#filter", Input)
|
||||
filter_input.can_focus = False
|
||||
if len(self.choices) == 1:
|
||||
self.selected_installation = self.choices[0]
|
||||
self._show_plugins()
|
||||
return
|
||||
self._show_installations()
|
||||
|
||||
async def on_data_table_row_selected(self, _event: DataTable.RowSelected) -> None:
|
||||
if self.mode == "installations":
|
||||
self.action_select()
|
||||
return
|
||||
if self.mode == "plugins":
|
||||
await self.action_toggle_plugin()
|
||||
|
||||
def action_select(self) -> None:
|
||||
if self.mode != "installations":
|
||||
return
|
||||
@@ -103,6 +182,7 @@ class PluginHelperTui(App[int]):
|
||||
if self.mode == "plugins":
|
||||
if len(self.choices) == 1:
|
||||
return
|
||||
self._clear_filter_state()
|
||||
self._show_installations()
|
||||
|
||||
def action_refresh(self) -> None:
|
||||
@@ -113,13 +193,63 @@ class PluginHelperTui(App[int]):
|
||||
else:
|
||||
self._show_installations()
|
||||
|
||||
def action_start_filter(self) -> None:
|
||||
if self.mode != "plugins" or self._busy:
|
||||
return
|
||||
filter_input = self.query_one("#filter", Input)
|
||||
self._set_filter_visible(True)
|
||||
filter_input.can_focus = True
|
||||
filter_input.focus()
|
||||
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
|
||||
|
||||
def action_clear_filter(self) -> None:
|
||||
if self.mode != "plugins":
|
||||
return
|
||||
had_query = bool(self.filter_query)
|
||||
was_focused = self._filter_input_focused()
|
||||
if not had_query and not was_focused and not self._filter_visible():
|
||||
return
|
||||
self._clear_filter_state()
|
||||
self.query_one(DataTable).focus()
|
||||
if had_query:
|
||||
self._render_plugin_rows()
|
||||
self._set_status("Filter cleared.")
|
||||
else:
|
||||
self._set_plugin_status_hint()
|
||||
|
||||
@on(Input.Changed, "#filter")
|
||||
def _on_filter_changed(self, event: Input.Changed) -> None:
|
||||
if self.mode != "plugins":
|
||||
return
|
||||
self.filter_query = event.value
|
||||
self._render_plugin_rows()
|
||||
filtered = self._filtered_plugins()
|
||||
if self.filter_query.strip():
|
||||
self._set_status(f"Filter: {len(filtered)}/{len(self.plugin_rows)} plugins.")
|
||||
else:
|
||||
self._set_status("Type to filter. Enter keeps filter. Esc clears.")
|
||||
|
||||
@on(Input.Submitted, "#filter")
|
||||
def _on_filter_submitted(self, _event: Input.Submitted) -> None:
|
||||
if self.mode != "plugins":
|
||||
return
|
||||
if not self.filter_query.strip():
|
||||
self._set_filter_visible(False)
|
||||
self.query_one(DataTable).focus()
|
||||
filtered = self._filtered_plugins()
|
||||
if self.filter_query.strip():
|
||||
self._set_status(f"Filtered to {len(filtered)}/{len(self.plugin_rows)} plugins. / edits filter.")
|
||||
else:
|
||||
self._set_plugin_status_hint()
|
||||
|
||||
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))
|
||||
rows = self._filtered_plugins()
|
||||
index = self._cursor_index(len(rows))
|
||||
if index is None:
|
||||
return
|
||||
plugin = self.plugin_rows[index]
|
||||
plugin = rows[index]
|
||||
plugin_id = plugin["id"]
|
||||
target = self.selected_installation
|
||||
self._busy = True
|
||||
@@ -298,6 +428,7 @@ class PluginHelperTui(App[int]):
|
||||
def _show_installations(self) -> None:
|
||||
self.mode = "installations"
|
||||
self.plugin_rows = []
|
||||
self._clear_filter_state()
|
||||
self._set_title("Choose Beat Saber Installation")
|
||||
table = self.query_one(DataTable)
|
||||
table.clear(columns=True)
|
||||
@@ -312,16 +443,19 @@ class PluginHelperTui(App[int]):
|
||||
if self.setup_hint:
|
||||
self._set_status(self.setup_hint)
|
||||
else:
|
||||
self._set_status("Enter selects an installation. q quits.")
|
||||
self._set_status("Click or Enter selects an installation. q quits.")
|
||||
|
||||
def _show_plugins(self, *, preserve_status: bool = False) -> None:
|
||||
if self.selected_installation is None:
|
||||
self._show_installations()
|
||||
return
|
||||
target = self.selected_installation
|
||||
selected_row: int | None = None
|
||||
selected_id: str | None = None
|
||||
if self.mode == "plugins" and self.plugin_rows:
|
||||
selected_row = self._cursor_index(len(self.plugin_rows))
|
||||
filtered = self._filtered_plugins()
|
||||
selected_row = self._cursor_index(len(filtered))
|
||||
if selected_row is not None:
|
||||
selected_id = filtered[selected_row]["id"]
|
||||
self.mode = "plugins"
|
||||
self._set_title(f"{target.install_label} / {target.instance_name}")
|
||||
table = self.query_one(DataTable)
|
||||
@@ -342,7 +476,22 @@ class PluginHelperTui(App[int]):
|
||||
self._set_status(f"Could not load plugins: {exc}")
|
||||
return
|
||||
|
||||
for plugin in self.plugin_rows:
|
||||
self._render_plugin_rows(selected_id=selected_id)
|
||||
if not preserve_status:
|
||||
if self.plugin_rows:
|
||||
self._set_plugin_status_hint()
|
||||
else:
|
||||
self._set_status("No version-locked plugins for this installation.")
|
||||
|
||||
def _render_plugin_rows(self, *, selected_id: str | None = None) -> None:
|
||||
table = self.query_one(DataTable)
|
||||
if selected_id is None and self.mode == "plugins" and table.row_count:
|
||||
selected_row = self._cursor_index(table.row_count)
|
||||
if selected_row is not None:
|
||||
selected_id = str(table.get_row_at(selected_row)[2])
|
||||
table.clear()
|
||||
filtered = self._filtered_plugins()
|
||||
for plugin in filtered:
|
||||
table.add_row(
|
||||
self._status_marker(plugin["status"]),
|
||||
plugin["name"],
|
||||
@@ -351,17 +500,64 @@ 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:
|
||||
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
|
||||
self._set_status(
|
||||
"Space toggles selected. d disables all. e enables all. "
|
||||
f"s saves known-good. g restores known-good.{back_hint}"
|
||||
)
|
||||
if selected_id is not None and filtered:
|
||||
for index, plugin in enumerate(filtered):
|
||||
if plugin["id"] == selected_id:
|
||||
table.move_cursor(row=index)
|
||||
break
|
||||
else:
|
||||
self._set_status("No version-locked plugins for this installation.")
|
||||
table.move_cursor(row=0)
|
||||
elif filtered:
|
||||
table.move_cursor(row=0)
|
||||
|
||||
def _filtered_plugins(self) -> list[dict[str, Any]]:
|
||||
query = self.filter_query.strip().lower()
|
||||
if not query:
|
||||
return list(self.plugin_rows)
|
||||
return [
|
||||
plugin
|
||||
for plugin in self.plugin_rows
|
||||
if query in str(plugin.get("name", "")).lower()
|
||||
or query in str(plugin.get("id", "")).lower()
|
||||
or query in str(plugin.get("asset", "")).lower()
|
||||
]
|
||||
|
||||
def _clear_filter_state(self) -> None:
|
||||
self.filter_query = ""
|
||||
try:
|
||||
filter_input = self.query_one("#filter", Input)
|
||||
except Exception:
|
||||
return
|
||||
if filter_input.value:
|
||||
with filter_input.prevent(Input.Changed):
|
||||
filter_input.value = ""
|
||||
filter_input.can_focus = False
|
||||
self._set_filter_visible(False)
|
||||
|
||||
def _set_filter_visible(self, visible: bool) -> None:
|
||||
filter_input = self.query_one("#filter", Input)
|
||||
filter_input.set_class(not visible, "-hidden")
|
||||
|
||||
def _filter_visible(self) -> bool:
|
||||
try:
|
||||
return not self.query_one("#filter", Input).has_class("-hidden")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _filter_input_focused(self) -> bool:
|
||||
focused = self.focused
|
||||
return focused is not None and getattr(focused, "id", None) == "filter"
|
||||
|
||||
def _set_plugin_status_hint(self) -> None:
|
||||
if not self.plugin_rows:
|
||||
self._set_status("No version-locked plugins for this installation.")
|
||||
return
|
||||
back_hint = "" if len(self.choices) == 1 else " b returns to installations."
|
||||
filter_hint = f" / filters ({len(self._filtered_plugins())}/{len(self.plugin_rows)})." if self.filter_query.strip() else " / filters."
|
||||
self._set_status(
|
||||
"Click or Space toggles selected. d disables all. e enables all. "
|
||||
f"s saves known-good. g restores known-good.{filter_hint}{back_hint}"
|
||||
)
|
||||
|
||||
def _cursor_index(self, row_count: int) -> int | None:
|
||||
table = self.query_one(DataTable)
|
||||
|
||||
@@ -11,7 +11,7 @@ from zipfile import ZipFile
|
||||
|
||||
from rich.text import Text
|
||||
from textual.coordinate import Coordinate
|
||||
from textual.widgets import DataTable
|
||||
from textual.widgets import DataTable, Input
|
||||
|
||||
from plugin_helper.bootstrap import _run_ipa, build_bootstrap_command, ensure_healthy_bootstrap
|
||||
from plugin_helper.bsipa import check_bsipa_health, planning_requires_bootstrap
|
||||
@@ -1707,17 +1707,19 @@ instance = "1.40.8"
|
||||
self.assertEqual(report["plugins"][0]["asset"], "Example.dll")
|
||||
self.assertEqual(report["plugins"][0]["fileCount"], 1)
|
||||
|
||||
def test_installed_plugins_report_starts_from_version_lock(self) -> None:
|
||||
def test_installed_plugins_report_sorts_by_name(self) -> None:
|
||||
registry = Registry(
|
||||
{
|
||||
"alpha": RegistryPlugin(id="alpha", name="Alpha", repo="owner/alpha"),
|
||||
"beta": RegistryPlugin(id="beta", name="Beta", repo="owner/beta"),
|
||||
"zebra": RegistryPlugin(id="zebra", name="Zebra", repo="owner/zebra"),
|
||||
}
|
||||
)
|
||||
lockfile = Lockfile(
|
||||
beat_saber_version="1.40.8",
|
||||
instance="1.40.8",
|
||||
plugins=(
|
||||
LockedPlugin(id="zebra", repo="owner/zebra", tag="v3.0.0", asset="Zebra.dll", sha256="z"),
|
||||
LockedPlugin(id="alpha", repo="owner/alpha", tag="v1.0.0", asset="Alpha.dll", sha256="a"),
|
||||
LockedPlugin(id="beta", repo="owner/beta", tag="v2.0.0", asset="Beta.dll", sha256="b"),
|
||||
),
|
||||
@@ -1729,9 +1731,9 @@ instance = "1.40.8"
|
||||
lockfile=lockfile,
|
||||
)
|
||||
|
||||
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta"])
|
||||
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled"])
|
||||
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0])
|
||||
self.assertEqual([plugin["id"] for plugin in report["plugins"]], ["alpha", "beta", "zebra"])
|
||||
self.assertEqual([plugin["status"] for plugin in report["plugins"]], ["disabled", "disabled", "disabled"])
|
||||
self.assertEqual([plugin["fileCount"] for plugin in report["plugins"]], [0, 0, 0])
|
||||
self.assertEqual(report["plugins"][1]["version"], "v2.0.0")
|
||||
|
||||
def test_update_check_reports_current_matching_asset(self) -> None:
|
||||
@@ -2604,8 +2606,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
table = app.query_one(DataTable)
|
||||
self.assertEqual(table.row_count, 2)
|
||||
self.assertEqual(app.mode, "installations")
|
||||
self.assertEqual(str(table.get_cell_at(Coordinate(0, 3))), "/tmp/state-linux/installs/linux")
|
||||
self.assertEqual(str(table.get_cell_at(Coordinate(1, 3))), "/tmp/state-windows/installs/windows")
|
||||
self.assertEqual(table.get_cell_at(Coordinate(0, 3)), str(Path("/tmp/state-linux/installs/linux")))
|
||||
self.assertEqual(table.get_cell_at(Coordinate(1, 3)), str(Path("/tmp/state-windows/installs/windows")))
|
||||
|
||||
async def test_single_instance_skips_installation_picker(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -2634,6 +2636,43 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn("example", updated["plugins"])
|
||||
self.assertIn("example", updated["disabledPlugins"])
|
||||
|
||||
async def test_click_toggles_plugin_like_space(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)
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
||||
# Header is y=0; first data row is y=1. Click the second plugin row.
|
||||
await pilot.click(DataTable, offset=(2, 2))
|
||||
await pilot.pause()
|
||||
self.assertEqual(table.cursor_row, 1)
|
||||
self.assertEqual(app.plugin_rows[1]["id"], "beta")
|
||||
self.assertEqual(app.plugin_rows[1]["status"], "disabled")
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Beta.dll").exists())
|
||||
self.assertTrue((instance / "Plugins" / "Alpha.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
self.assertNotIn("beta", updated["plugins"])
|
||||
self.assertIn("beta", updated["disabledPlugins"])
|
||||
self.assertIn("alpha", updated["plugins"])
|
||||
|
||||
async def test_click_toggles_already_selected_plugin(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
||||
await pilot.click(DataTable, offset=(2, 1))
|
||||
await pilot.pause()
|
||||
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
||||
|
||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||
updated = load_installed_state(state, "1.40.8", install_id=app.choices[0].install_id)
|
||||
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))
|
||||
@@ -2770,6 +2809,50 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertNotIn("example", updated.get("disabledPlugins", {}))
|
||||
self.assertIn("hash mismatch", app.status_message)
|
||||
|
||||
async def test_slash_filters_plugin_list(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("/")
|
||||
await pilot.pause()
|
||||
filter_input = app.query_one("#filter", Input)
|
||||
self.assertFalse(filter_input.has_class("-hidden"))
|
||||
self.assertTrue(filter_input.has_focus)
|
||||
await pilot.press("b", "e", "t")
|
||||
await pilot.pause()
|
||||
self.assertEqual(app.filter_query, "bet")
|
||||
self.assertEqual(table.row_count, 1)
|
||||
self.assertEqual(app._filtered_plugins()[0]["id"], "beta")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
self.assertFalse(filter_input.has_focus)
|
||||
self.assertEqual(table.row_count, 1)
|
||||
await pilot.press("space")
|
||||
await pilot.pause()
|
||||
self.assertEqual(app.plugin_rows[1]["id"], "beta")
|
||||
self.assertEqual(app.plugin_rows[1]["status"], "disabled")
|
||||
|
||||
async def test_escape_clears_plugin_filter(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)
|
||||
await pilot.press("/")
|
||||
await pilot.press("a", "l")
|
||||
await pilot.pause()
|
||||
self.assertEqual(table.row_count, 1)
|
||||
self.assertEqual(app._filtered_plugins()[0]["id"], "alpha")
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
self.assertEqual(app.filter_query, "")
|
||||
self.assertEqual(table.row_count, 2)
|
||||
self.assertTrue(app.query_one("#filter", Input).has_class("-hidden"))
|
||||
self.assertIn("Filter cleared", app.status_message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user