Compare commits
11 Commits
407abfe6ec
...
1b4087c5b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b4087c5b5 | |||
| 74ac0a1c1d | |||
| 400e69289c | |||
| b75d8ccc57 | |||
| c86e51b85a | |||
| f160e4b349 | |||
| dbc4c7e0b4 | |||
| c67878cac0 | |||
| 6af1b03c35 | |||
| 7f0fcc8d20 | |||
| 13d9481168 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,23 +5,15 @@ Guidance for coding agents working in this repo.
|
|||||||
## Project Shape
|
## Project Shape
|
||||||
|
|
||||||
- This repo manages Beat Saber plugins for BSManager instances.
|
- 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
|
- Keep plugin source checkouts under `~/src/<owner>/<repo>` when a
|
||||||
locked or registry plugin has a GitHub source repo. Prefer checking out the
|
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
|
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`
|
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.
|
and set/add `origin` to the upstream repo instead of replacing local work.
|
||||||
- Prefer repo-local state for planned installs unless the task explicitly
|
- 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
|
targets the user's live default state. For mounted Windows installs, prefer
|
||||||
Linux install and `--state-dir .state-windows` for the mounted Windows install
|
the shared Windows-partition state directory configured in
|
||||||
when both roots contain the same instance name.
|
`plugin-helper.local.toml` when both roots contain the same instance name.
|
||||||
|
|
||||||
## Workflow Rules
|
## 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.
|
`.venv/bin/python` for helper commands and tests when it exists.
|
||||||
- For human-style inspection, prefer the menu with repo-local state:
|
- For human-style inspection, prefer the menu with repo-local state:
|
||||||
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
|
`PYTHONPATH=src .venv/bin/python -m plugin_helper --state-dir .state menu`.
|
||||||
- When targeting the local Linux BSManager install, pass
|
|
||||||
`--instances-root ~/.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
|
- Use the helper commands instead of manually copying plugin files into an
|
||||||
instance.
|
instance.
|
||||||
- When adding, updating, building, or investigating a GitHub-hosted plugin,
|
- 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:
|
- Treat BSIPA as a bootstrap phase:
|
||||||
- `bootstrap` installs the locked BSIPA archive and records generated files.
|
- `bootstrap` installs the locked BSIPA archive and records generated files.
|
||||||
- ordinary plugin plans should depend on healthy bootstrap state.
|
- 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
|
- After completing repo changes, suggest a concise commit message in the final
|
||||||
response unless the user already asked you to commit.
|
response unless the user already asked you to commit.
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ If `XDG_STATE_HOME` is not set, the state directory defaults to:
|
|||||||
|
|
||||||
Override the instance root with `--instances-root`,
|
Override the instance root with `--instances-root`,
|
||||||
`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search
|
`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
|
Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or
|
||||||
`plugin-helper.local.toml`.
|
`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
|
CLI flags override environment variables, environment variables override local
|
||||||
config, and local config overrides built-in defaults.
|
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
|
## Commands
|
||||||
|
|
||||||
For normal use, run the Textual menu from the repo root:
|
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
|
The menu reads `plugin-helper.local.toml` when present, shows each discovered
|
||||||
Beat Saber install with its resolved state directory, and lets you toggle
|
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
|
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
|
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all
|
||||||
disabled managed plugins.
|
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
|
The individual subcommands are mostly for automation and debugging. If you use
|
||||||
them, pass `--state-dir` directly only when you intentionally want to override
|
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
|
arguments such as `--no-yeet fpfc` can make the game fail command-line
|
||||||
parsing after BSIPA and plugins have already loaded.
|
parsing after BSIPA and plugins have already loaded.
|
||||||
- BSIPA is managed as a first-class bootstrap phase. The `bootstrap` command
|
- 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
|
applies the locked `bsipa` root archive, runs `IPA.exe -n` (natively on
|
||||||
records every bootstrap-relevant file under root `IPA.exe*`, `winhttp.dll`,
|
Windows or through Proton on Linux), and records every bootstrap-relevant
|
||||||
`Libs/`, and `IPA/`, including backups created during patching.
|
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
|
- If an instance lockfile includes `bsipa`, ordinary plugin plans require a
|
||||||
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
|
recorded bootstrap state plus a `Logs/_latest.log` that shows BSIPA startup.
|
||||||
Use `bootstrap-check` before planning a batch when you want a quick gate.
|
Use `bootstrap-check` before planning a batch when you want a quick gate.
|
||||||
|
|||||||
+2
-2
@@ -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
|
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
|
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
|
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
|
```text
|
||||||
.state/ local Linux BSManager state
|
.state/ local Linux BSManager state
|
||||||
.state-windows/ mounted Windows 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
|
The registry and lockfile remain shared for a Beat Saber version. Downloads may
|
||||||
|
|||||||
@@ -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/`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 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. |
|
| 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
|
Purpose: restore private plugin set only after public/dependency-heavy mods are
|
||||||
known good.
|
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 |
|
| 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 | | <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_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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup and menu. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song counters. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song detail panel. |
|
| 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 | Verify in-song visuals. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify song preview. |
|
| 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 | Verify in-song visuals. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify in-song HUD. |
|
| 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 | Verify overlay. |
|
| 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 | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify startup. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify avatar load. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency for Naluluna visuals. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify skybox sample manifest and skybox load. |
|
| 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 | | <span style="color:#8b949e; font-weight:600">todo</span> | local/private | Verify dependency load. |
|
| 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
|
## 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. |
|
| 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 | [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. |
|
| 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. |
|
| 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. |
|
| 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> | | |
|
| 5 | | <span style="color:#8b949e; font-weight:600">todo</span> | | |
|
||||||
| 6 | | <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. |
|
| 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. |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,6 +15,12 @@ Windows instances.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Use a separate checkout or working copy on the Windows partition.
|
- 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:
|
- Use a Windows-specific config file:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -22,46 +28,55 @@ Windows instances.
|
|||||||
```
|
```
|
||||||
|
|
||||||
- Keep native Windows state local to that checkout, such as `.state/`, so it
|
- 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`
|
does not mix with Linux-managed state directories.
|
||||||
directories.
|
|
||||||
|
|
||||||
## Current Compatibility Notes
|
## Current Compatibility Notes
|
||||||
|
|
||||||
- Core scan, plan, apply, uninstall, disable, and enable flows are mostly
|
- Core scan, plan, apply, uninstall, disable, and enable flows are mostly
|
||||||
platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and
|
platform-neutral. They use `pathlib`, `zipfile`, `shutil`, JSON/TOML, and
|
||||||
local file hashes.
|
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
|
`/home/pleb/Windows/...`. A Windows-specific TOML file handles this for normal
|
||||||
use.
|
use.
|
||||||
- Multiple `--instances-root` values use `os.pathsep`; that means `;` on
|
- 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
|
- The Textual TUI dependency supports Windows and Python 3.13, but the best
|
||||||
terminal target is Windows Terminal or a modern PowerShell host.
|
terminal target is Windows Terminal or a modern PowerShell host.
|
||||||
|
|
||||||
## Work Items
|
## Work Items
|
||||||
|
|
||||||
|
### Done
|
||||||
|
|
||||||
- Add native Windows bootstrap support.
|
- Add native Windows bootstrap support.
|
||||||
- Current bootstrap assumes Proton.
|
- `bootstrap` auto-detects Windows and runs `IPA.exe -n` directly.
|
||||||
- Native Windows should run `IPA.exe -n` directly from the Beat Saber instance.
|
- `--native` forces native mode; `--proton` remains for Linux/Proton installs.
|
||||||
- Timeout cleanup needs Windows-compatible process handling instead of
|
- Timeout cleanup uses `process.terminate()` / `process.kill()` on Windows
|
||||||
POSIX process groups.
|
instead of POSIX process groups.
|
||||||
- Decide whether `bootstrap-check` should accept a recorded native Windows
|
- Bootstrap state records `bootstrapMode: "native"` or `"proton"`.
|
||||||
bootstrap state without a Proton launch history.
|
- `bootstrap-check` accepts recorded native Windows bootstrap state without
|
||||||
- Add Windows-aware default paths or keep requiring `--config
|
`Logs/_latest.log` when `IPA.exe -n` completed successfully
|
||||||
plugin-helper.windows.toml` for native use.
|
(`ipaExitCode == 0`, not timed out).
|
||||||
- Update README examples for PowerShell:
|
- Add Windows-aware default paths when no config is present:
|
||||||
- editable install
|
`~/BSManager/BSInstances` and `%LOCALAPPDATA%/plugin-helper`.
|
||||||
- `--config plugin-helper.windows.toml`
|
Normal native use still prefers `--config plugin-helper.windows.toml`.
|
||||||
- Windows path-list separator `;`
|
- Add `--config` and `--profile` CLI flags with `[[profiles]]` TOML support.
|
||||||
- Add or adjust tests for Windows behavior.
|
- Add `plugin-helper.windows.toml.example` as the committed template.
|
||||||
- Skip or rewrite the POSIX-only `_run_ipa` timeout test on Windows.
|
- Update README examples for PowerShell, profiles, and the `;` path separator.
|
||||||
- Add tests for native Windows config path resolution.
|
- Add tests for profile config resolution, native bootstrap command
|
||||||
- Add tests for native bootstrap command construction.
|
construction, native bootstrap health checks, and platform-gated IPA timeout
|
||||||
|
behavior.
|
||||||
- Review backup and restore helpers on native Windows.
|
- Review backup and restore helpers on native Windows.
|
||||||
- `sync_windows_data_repo` and `restore_windows_data_repo` should work with
|
- `sync_windows_data_repo` and `restore_windows_data_repo` work with ordinary
|
||||||
ordinary Windows paths.
|
Windows paths; `infer_windows_appdata_path` now keys off `BSManager` rather
|
||||||
- The older tar-based `backup_userdata` helper uses `NamedTemporaryFile` in a
|
than the first `Users` path segment.
|
||||||
way that may not be Windows-friendly if it becomes part of the CLI later.
|
- `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
|
## 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
|
If instance discovery fails, verify the BSManager instance root in
|
||||||
`plugin-helper.windows.toml`.
|
`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` |
|
||||||
|
|||||||
@@ -402,3 +402,21 @@ asset = "JDFixer.dll"
|
|||||||
sha256 = "16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d"
|
sha256 = "16b7dad9906d838dab40ce48a9b304be4847f18e700ddd31f2293d1065f4529d"
|
||||||
install_strategy = "dll-to-plugins"
|
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."
|
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."
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[[profiles]]
|
||||||
|
id = "windows"
|
||||||
|
label = "Native Windows BSManager"
|
||||||
|
instances_root = "~/BSManager/BSInstances"
|
||||||
|
state_dir = ".state"
|
||||||
@@ -937,3 +937,29 @@ required = true
|
|||||||
id = "bsipa"
|
id = "bsipa"
|
||||||
constraint = ">=4.3.7"
|
constraint = ">=4.3.7"
|
||||||
required = true
|
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
|
||||||
|
|||||||
+102
-16
@@ -8,7 +8,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.request import Request, urlopen
|
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 .fsutil import sha256_file
|
||||||
from .installer import apply_plan
|
from .installer import apply_plan
|
||||||
from .models import LockedPlugin, Lockfile, Registry
|
from .models import LockedPlugin, Lockfile, Registry
|
||||||
@@ -130,31 +131,63 @@ def fetch_locked_bsipa_archive(lockfile: Lockfile, state_root: Path) -> dict[str
|
|||||||
return result
|
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(
|
def _run_ipa(
|
||||||
*,
|
*,
|
||||||
command: list[str],
|
command: list[str],
|
||||||
instance_path: Path,
|
instance_path: Path,
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
|
native: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
process = subprocess.Popen(
|
popen_kwargs: dict[str, Any] = {
|
||||||
command,
|
"cwd": instance_path,
|
||||||
cwd=instance_path,
|
"text": True,
|
||||||
env=_proton_env(instance_path),
|
"stdout": subprocess.PIPE,
|
||||||
text=True,
|
"stderr": subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
}
|
||||||
stderr=subprocess.PIPE,
|
if native:
|
||||||
start_new_session=True,
|
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:
|
try:
|
||||||
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
||||||
timed_out = False
|
timed_out = False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
timed_out = True
|
timed_out = True
|
||||||
os.killpg(process.pid, signal.SIGTERM)
|
_terminate_process(process)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = process.communicate(timeout=5)
|
stdout, stderr = process.communicate(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
_kill_process(process)
|
||||||
stdout, stderr = process.communicate()
|
stdout, stderr = process.communicate()
|
||||||
return {
|
return {
|
||||||
"returncode": process.returncode,
|
"returncode": process.returncode,
|
||||||
@@ -175,10 +208,14 @@ def run_bootstrap(
|
|||||||
state_root: Path,
|
state_root: Path,
|
||||||
repo_root: Path,
|
repo_root: Path,
|
||||||
proton: Path | None = None,
|
proton: Path | None = None,
|
||||||
|
native: bool | None = None,
|
||||||
progress: Callable[[str], None] | None = None,
|
progress: Callable[[str], None] | None = None,
|
||||||
ipa_timeout_seconds: int = 120,
|
ipa_timeout_seconds: int = 120,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
tell = progress or (lambda _message: None)
|
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")
|
tell("Fetching locked BSIPA archive")
|
||||||
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
|
fetched = fetch_locked_bsipa_archive(lockfile, state_root)
|
||||||
if fetched.get("cached"):
|
if fetched.get("cached"):
|
||||||
@@ -210,30 +247,34 @@ def run_bootstrap(
|
|||||||
if not ipa.is_file():
|
if not ipa.is_file():
|
||||||
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
|
raise FileNotFoundError(f"BSIPA archive did not install IPA.exe: {ipa}")
|
||||||
|
|
||||||
|
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()
|
proton_path = proton or _default_proton()
|
||||||
if not proton_path.is_file():
|
if not proton_path.is_file():
|
||||||
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
|
raise FileNotFoundError(f"Proton executable not found: {proton_path}")
|
||||||
|
|
||||||
command = [str(proton_path), "run", str(ipa), "-n"]
|
|
||||||
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
|
tell(f"Running IPA.exe -n through Proton; timeout {ipa_timeout_seconds}s")
|
||||||
|
|
||||||
completed = _run_ipa(
|
completed = _run_ipa(
|
||||||
command=command,
|
command=command,
|
||||||
instance_path=instance_path,
|
instance_path=instance_path,
|
||||||
timeout_seconds=ipa_timeout_seconds,
|
timeout_seconds=ipa_timeout_seconds,
|
||||||
|
native=use_native,
|
||||||
)
|
)
|
||||||
tell("Scanning bootstrap files after IPA.exe -n")
|
tell("Scanning bootstrap files after IPA.exe -n")
|
||||||
after = scan_bootstrap_files(instance_path)
|
after = scan_bootstrap_files(instance_path)
|
||||||
delta = _files_delta(before, after)
|
delta = _files_delta(before, after)
|
||||||
state = {
|
state: dict[str, Any] = {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"instance": instance,
|
"instance": instance,
|
||||||
"beatSaberVersion": beat_saber_version,
|
"beatSaberVersion": beat_saber_version,
|
||||||
"bootstrappedAt": _now_iso(),
|
"bootstrappedAt": _now_iso(),
|
||||||
"plugin": BSIPA_PLUGIN_ID,
|
"plugin": BSIPA_PLUGIN_ID,
|
||||||
|
"bootstrapMode": bootstrap_mode,
|
||||||
"archive": fetched,
|
"archive": fetched,
|
||||||
"planPath": str(plan_path),
|
"planPath": str(plan_path),
|
||||||
"applied": apply_result["applied"],
|
"applied": apply_result["applied"],
|
||||||
"proton": str(proton_path),
|
|
||||||
"command": command,
|
"command": command,
|
||||||
"ipaExitCode": completed["returncode"],
|
"ipaExitCode": completed["returncode"],
|
||||||
"ipaTimedOut": completed["timedOut"],
|
"ipaTimedOut": completed["timedOut"],
|
||||||
@@ -244,6 +285,8 @@ def run_bootstrap(
|
|||||||
"delta": delta,
|
"delta": delta,
|
||||||
"health": {},
|
"health": {},
|
||||||
}
|
}
|
||||||
|
if not use_native:
|
||||||
|
state["proton"] = str(proton or _default_proton())
|
||||||
save_bootstrap_state(state_root, instance, state)
|
save_bootstrap_state(state_root, instance, state)
|
||||||
state["health"] = check_bsipa_health(instance_path, state_root, instance)
|
state["health"] = check_bsipa_health(instance_path, state_root, instance)
|
||||||
save_bootstrap_state(state_root, instance, state)
|
save_bootstrap_state(state_root, instance, state)
|
||||||
@@ -253,3 +296,46 @@ def run_bootstrap(
|
|||||||
if completed["returncode"] != 0:
|
if completed["returncode"] != 0:
|
||||||
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
|
raise RuntimeError(f"IPA.exe -n failed with exit code {completed['returncode']}; state written to {state['statePath']}")
|
||||||
return state
|
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))
|
||||||
|
|||||||
@@ -10,10 +10,29 @@ from .state import bootstrap_state_path, load_bootstrap_state
|
|||||||
BSIPA_PLUGIN_ID = "bsipa"
|
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:
|
def latest_log_path(instance_path: Path) -> Path:
|
||||||
return instance_path / "Logs" / "_latest.log"
|
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]:
|
def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) -> dict[str, Any]:
|
||||||
state = load_bootstrap_state(state_root, instance)
|
state = load_bootstrap_state(state_root, instance)
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
@@ -27,11 +46,14 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
|
|||||||
messages.append(f"missing {rel}/")
|
messages.append(f"missing {rel}/")
|
||||||
|
|
||||||
log_path = latest_log_path(instance_path)
|
log_path = latest_log_path(instance_path)
|
||||||
|
native_bootstrap = _native_bootstrap_satisfied(state)
|
||||||
if not log_path.is_file():
|
if not log_path.is_file():
|
||||||
|
if not native_bootstrap:
|
||||||
messages.append("missing Logs/_latest.log")
|
messages.append("missing Logs/_latest.log")
|
||||||
else:
|
else:
|
||||||
text = log_path.read_text(encoding="utf-8", errors="replace")
|
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:
|
if "Beat Saber IPA (BSIPA):" not in text and "Beat Saber IPA" not in text:
|
||||||
|
if not native_bootstrap:
|
||||||
messages.append("Logs/_latest.log does not show BSIPA startup")
|
messages.append("Logs/_latest.log does not show BSIPA startup")
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
@@ -44,4 +66,5 @@ def check_bsipa_health(instance_path: Path, state_root: Path, instance: str) ->
|
|||||||
"logPath": str(log_path),
|
"logPath": str(log_path),
|
||||||
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
|
"logSha256": sha256_file(log_path) if log_path.is_file() else None,
|
||||||
"bootstrapRecordedAt": state.get("updatedAt"),
|
"bootstrapRecordedAt": state.get("updatedAt"),
|
||||||
|
"bootstrapMode": state.get("bootstrapMode"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ def print_updates(report: dict[str, Any]) -> None:
|
|||||||
|
|
||||||
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
|
def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = False) -> None:
|
||||||
default = argparse.SUPPRESS if suppress_default else 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("--instances-root", default=default, help="BSManager instances root")
|
||||||
parser.add_argument("--state-dir", default=default, help="plugin-helper state directory")
|
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 = subcommands.add_parser(
|
||||||
"bootstrap",
|
"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()],
|
parents=[_common_parent()],
|
||||||
)
|
)
|
||||||
bootstrap.add_argument("--instance", required=True)
|
bootstrap.add_argument("--instance", required=True)
|
||||||
bootstrap.add_argument("--registry", default="registry/plugins.toml")
|
bootstrap.add_argument("--registry", default="registry/plugins.toml")
|
||||||
bootstrap.add_argument("--lockfile")
|
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.add_argument("--json", action="store_true", help="Print full JSON bootstrap output")
|
||||||
|
|
||||||
bootstrap_check = subcommands.add_parser(
|
bootstrap_check = subcommands.add_parser(
|
||||||
@@ -282,6 +285,8 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
runtime = resolve_runtime_config(
|
runtime = resolve_runtime_config(
|
||||||
instances_root_value=getattr(args, "instances_root", None),
|
instances_root_value=getattr(args, "instances_root", None),
|
||||||
state_dir_value=getattr(args, "state_dir", 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
|
inst_roots = runtime.instances_roots
|
||||||
st_root = runtime.state_root
|
st_root = runtime.state_root
|
||||||
@@ -409,6 +414,7 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
state_root=st_root,
|
state_root=st_root,
|
||||||
repo_root=root,
|
repo_root=root,
|
||||||
proton=Path(args.proton).expanduser() if args.proton else None,
|
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),
|
progress=lambda message: print(f" {message}", flush=True),
|
||||||
)
|
)
|
||||||
if args.json:
|
if args.json:
|
||||||
@@ -498,6 +504,7 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
if args.command == "enable":
|
if args.command == "enable":
|
||||||
instance = get_instance(inst_roots, args.instance)
|
instance = get_instance(inst_roots, args.instance)
|
||||||
|
progress = (lambda message: print(f" {message}", flush=True))
|
||||||
result = enable_disabled_plugin(
|
result = enable_disabled_plugin(
|
||||||
instance=args.instance,
|
instance=args.instance,
|
||||||
instance_path=instance.path,
|
instance_path=instance.path,
|
||||||
@@ -505,6 +512,7 @@ def run(argv: list[str] | None = None) -> int:
|
|||||||
plugin_id=args.plugin,
|
plugin_id=args.plugin,
|
||||||
registry=args.registry,
|
registry=args.registry,
|
||||||
lockfile=args.lockfile,
|
lockfile=args.lockfile,
|
||||||
|
progress=progress,
|
||||||
)
|
)
|
||||||
print(f"Enabled: {args.plugin}")
|
print(f"Enabled: {args.plugin}")
|
||||||
print(f"Plan: {result['planPath']}")
|
print(f"Plan: {result['planPath']}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,12 +11,22 @@ from typing import Any
|
|||||||
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||||
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
|
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
|
||||||
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
|
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)
|
@dataclass(frozen=True)
|
||||||
class LocalConfig:
|
class LocalConfig:
|
||||||
instances_roots: list[Path] | None
|
instances_roots: list[Path] | None
|
||||||
state_root: Path | None
|
state_root: Path | None
|
||||||
|
profiles: tuple[Profile, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -24,6 +35,12 @@ class RuntimeConfig:
|
|||||||
state_root: Path
|
state_root: Path
|
||||||
config_path: Path
|
config_path: Path
|
||||||
config_loaded: bool
|
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:
|
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")
|
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||||
if raw:
|
if raw:
|
||||||
return _resolve_path_list(raw, base or repo_root())
|
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:
|
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")
|
env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR")
|
||||||
if env_state:
|
if env_state:
|
||||||
return _resolve_path(env_state, repo_root())
|
return _resolve_path(env_state, repo_root())
|
||||||
xdg_state = os.environ.get("XDG_STATE_HOME")
|
return _default_state_root()
|
||||||
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
|
||||||
return base / "plugin-helper"
|
|
||||||
|
|
||||||
|
|
||||||
def default_config_path(root: Path | None = None) -> Path:
|
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:
|
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]
|
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]:
|
def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]:
|
||||||
repo = root or repo_root()
|
repo = root or repo_root()
|
||||||
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
|
path = _resolve_path(config_path, repo) if config_path else default_config_path(repo)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
if config_path:
|
if config_path:
|
||||||
raise FileNotFoundError(f"plugin-helper config not found: {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:
|
with path.open("rb") as handle:
|
||||||
data: dict[str, Any] = tomllib.load(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
|
base = path.parent
|
||||||
instances_value = data.get("instances_root")
|
instances_value = data.get("instances_root")
|
||||||
state_value = data.get("state_dir")
|
state_value = data.get("state_dir")
|
||||||
|
profiles = _parse_profiles(data, base)
|
||||||
return (
|
return (
|
||||||
LocalConfig(
|
LocalConfig(
|
||||||
instances_roots=_resolve_path_list(instances_value, base) if instances_value else None,
|
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,
|
state_root=_resolve_path(state_value, base) if state_value else None,
|
||||||
|
profiles=profiles,
|
||||||
),
|
),
|
||||||
path,
|
path,
|
||||||
True,
|
True,
|
||||||
@@ -102,28 +145,57 @@ def _env_state_root(root: Path) -> Path | None:
|
|||||||
|
|
||||||
|
|
||||||
def _default_state_root() -> Path:
|
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")
|
xdg_state = os.environ.get("XDG_STATE_HOME")
|
||||||
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state"
|
||||||
return base / "plugin-helper"
|
return base / "plugin-helper"
|
||||||
|
|
||||||
|
|
||||||
def _default_instances_roots() -> list[Path]:
|
def _default_instances_roots() -> list[Path]:
|
||||||
|
if is_windows():
|
||||||
|
return [Path.home() / "BSManager" / "BSInstances"]
|
||||||
return [DEFAULT_INSTANCES_ROOT]
|
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(
|
def resolve_runtime_config(
|
||||||
*,
|
*,
|
||||||
instances_root_value: str | None = None,
|
instances_root_value: str | None = None,
|
||||||
state_dir_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,
|
root: Path | None = None,
|
||||||
) -> RuntimeConfig:
|
) -> RuntimeConfig:
|
||||||
repo = root or repo_root()
|
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 = (
|
resolved_instances = (
|
||||||
_resolve_path_list(instances_root_value, repo)
|
_resolve_path_list(instances_root_value, repo)
|
||||||
if instances_root_value
|
if instances_root_value
|
||||||
else _env_instances_roots(repo)
|
else _env_instances_roots(repo)
|
||||||
|
or (profile.instances_roots if profile else None)
|
||||||
or local_config.instances_roots
|
or local_config.instances_roots
|
||||||
or _default_instances_roots()
|
or _default_instances_roots()
|
||||||
)
|
)
|
||||||
@@ -131,6 +203,7 @@ def resolve_runtime_config(
|
|||||||
_resolve_path(state_dir_value, repo)
|
_resolve_path(state_dir_value, repo)
|
||||||
if state_dir_value
|
if state_dir_value
|
||||||
else _env_state_root(repo)
|
else _env_state_root(repo)
|
||||||
|
or (profile.state_root if profile else None)
|
||||||
or local_config.state_root
|
or local_config.state_root
|
||||||
or _default_state_root()
|
or _default_state_root()
|
||||||
)
|
)
|
||||||
@@ -140,4 +213,6 @@ def resolve_runtime_config(
|
|||||||
state_root=resolved_state,
|
state_root=resolved_state,
|
||||||
config_path=loaded_path,
|
config_path=loaded_path,
|
||||||
config_loaded=loaded,
|
config_loaded=loaded,
|
||||||
|
profile_id=profile.id if profile else None,
|
||||||
|
profile_label=profile.label if profile else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .bootstrap import ensure_healthy_bootstrap
|
||||||
from .config import repo_root
|
from .config import repo_root
|
||||||
from .installer import apply_plan
|
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 .planner import create_plan
|
||||||
from .state import load_installed_state
|
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(
|
def enable_disabled_plugin(
|
||||||
*,
|
*,
|
||||||
instance: str,
|
instance: str,
|
||||||
@@ -19,25 +38,38 @@ def enable_disabled_plugin(
|
|||||||
registry: str = "registry/plugins.toml",
|
registry: str = "registry/plugins.toml",
|
||||||
lockfile: str | None = None,
|
lockfile: str | None = None,
|
||||||
repo: Path | None = None,
|
repo: Path | None = None,
|
||||||
|
progress: Callable[[str], None] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
installed_state = load_installed_state(state_root, instance)
|
installed_state = load_installed_state(state_root, instance)
|
||||||
if plugin_id not in installed_state.get("disabledPlugins", {}):
|
if plugin_id not in installed_state.get("disabledPlugins", {}):
|
||||||
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
|
raise KeyError(f"plugin is not recorded as disabled: {plugin_id}")
|
||||||
|
|
||||||
root = repo or repo_root()
|
root, registry_path, _lock_path, loaded_lockfile, loaded_registry = _resolve_paths(
|
||||||
registry_path = (root / registry).resolve() if not Path(registry).is_absolute() else Path(registry)
|
instance=instance,
|
||||||
lock_path = Path(lockfile) if lockfile else root / "locks" / f"{instance}.lock.toml"
|
registry=registry,
|
||||||
if not lock_path.is_absolute():
|
lockfile=lockfile,
|
||||||
lock_path = (root / lock_path).resolve()
|
repo=repo,
|
||||||
loaded_lockfile = load_lockfile(lock_path)
|
)
|
||||||
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
|
if not any(plugin.id == plugin_id for plugin in loaded_lockfile.plugins):
|
||||||
raise KeyError(f"plugin is disabled but not locked for this instance: {plugin_id}")
|
raise KeyError(f"plugin is 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(
|
plan, path = create_plan(
|
||||||
instance=instance,
|
instance=instance,
|
||||||
instance_path=instance_path,
|
instance_path=instance_path,
|
||||||
beat_saber_version=loaded_lockfile.beat_saber_version,
|
beat_saber_version=loaded_lockfile.beat_saber_version,
|
||||||
registry=load_registry(registry_path),
|
registry=loaded_registry,
|
||||||
lockfile=loaded_lockfile,
|
lockfile=loaded_lockfile,
|
||||||
state_root=state_root,
|
state_root=state_root,
|
||||||
repo_root=root,
|
repo_root=root,
|
||||||
@@ -45,3 +77,69 @@ def enable_disabled_plugin(
|
|||||||
)
|
)
|
||||||
result = apply_plan(plan, state_root)
|
result = apply_plan(plan, state_root)
|
||||||
return {"planPath": str(path), **result}
|
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}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
from .fsutil import ensure_relative, sha256_bytes, sha256_file
|
||||||
from .models import Lockfile, Registry, VALID_STRATEGIES
|
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
|
from .state import downloads_dir, plans_dir, plugin_downloads_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -81,13 +81,10 @@ def create_plan(
|
|||||||
changes: list[dict[str, Any]] = []
|
changes: list[dict[str, Any]] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
has_locked_bsipa = any(plugin.id == BSIPA_PLUGIN_ID for plugin in lockfile.plugins)
|
if require_bootstrap and planning_requires_bootstrap(lockfile.plugins, selected_ids):
|
||||||
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:
|
|
||||||
health = check_bsipa_health(instance_path, state_root, instance)
|
health = check_bsipa_health(instance_path, state_root, instance)
|
||||||
if not health["ok"]:
|
if not health["ok"]:
|
||||||
joined = "; ".join(health["messages"])
|
raise ValueError(bootstrap_health_error(health))
|
||||||
raise ValueError(f"BSIPA bootstrap is not healthy; run bootstrap first: {joined}")
|
|
||||||
|
|
||||||
for locked in lockfile.plugins:
|
for locked in lockfile.plugins:
|
||||||
if locked.id not in selected_ids:
|
if locked.id not in selected_ids:
|
||||||
|
|||||||
+79
-22
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -9,9 +10,10 @@ from textual.app import App, ComposeResult
|
|||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import DataTable, Footer, Header, Static
|
from textual.widgets import DataTable, Footer, Header, Static
|
||||||
|
|
||||||
|
from .bsipa import BSIPA_PLUGIN_ID
|
||||||
from .installer import disable_plugin
|
from .installer import disable_plugin
|
||||||
from .models import load_lockfile, load_registry
|
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 .reports import installed_plugins_report
|
||||||
from .state import load_installed_state
|
from .state import load_installed_state
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ class PluginHelperTui(App[int]):
|
|||||||
self.selected_installation: InstallationChoice | None = None
|
self.selected_installation: InstallationChoice | None = None
|
||||||
self.plugin_rows: list[dict[str, Any]] = []
|
self.plugin_rows: list[dict[str, Any]] = []
|
||||||
self.status_message = ""
|
self.status_message = ""
|
||||||
|
self._busy = False
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
yield Header(show_clock=False)
|
yield Header(show_clock=False)
|
||||||
@@ -74,6 +77,10 @@ class PluginHelperTui(App[int]):
|
|||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
|
if len(self.choices) == 1:
|
||||||
|
self.selected_installation = self.choices[0]
|
||||||
|
self._show_plugins()
|
||||||
|
return
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_select(self) -> None:
|
def action_select(self) -> None:
|
||||||
@@ -87,16 +94,20 @@ class PluginHelperTui(App[int]):
|
|||||||
|
|
||||||
def action_back(self) -> None:
|
def action_back(self) -> None:
|
||||||
if self.mode == "plugins":
|
if self.mode == "plugins":
|
||||||
|
if len(self.choices) == 1:
|
||||||
|
return
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_refresh(self) -> None:
|
def action_refresh(self) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
if self.mode == "plugins":
|
if self.mode == "plugins":
|
||||||
self._show_plugins()
|
self._show_plugins()
|
||||||
else:
|
else:
|
||||||
self._show_installations()
|
self._show_installations()
|
||||||
|
|
||||||
def action_toggle_plugin(self) -> None:
|
async def action_toggle_plugin(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
index = self._cursor_index(len(self.plugin_rows))
|
index = self._cursor_index(len(self.plugin_rows))
|
||||||
if index is None:
|
if index is None:
|
||||||
@@ -104,26 +115,32 @@ class PluginHelperTui(App[int]):
|
|||||||
plugin = self.plugin_rows[index]
|
plugin = self.plugin_rows[index]
|
||||||
plugin_id = plugin["id"]
|
plugin_id = plugin["id"]
|
||||||
target = self.selected_installation
|
target = self.selected_installation
|
||||||
|
self._busy = True
|
||||||
try:
|
try:
|
||||||
if plugin["status"] == "enabled":
|
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_name,
|
||||||
target.instance_path,
|
target.instance_path,
|
||||||
target.state_root,
|
target.state_root,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force=False,
|
False,
|
||||||
)
|
)
|
||||||
if not result["stateUpdated"]:
|
if not result["stateUpdated"]:
|
||||||
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||||
return
|
return
|
||||||
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
|
||||||
elif plugin["status"] == "disabled":
|
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=target.instance_name,
|
||||||
instance_path=target.instance_path,
|
instance_path=target.instance_path,
|
||||||
state_root=target.state_root,
|
state_root=target.state_root,
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
repo=self.repo_root,
|
repo=self.repo_root,
|
||||||
|
progress=self._bootstrap_progress,
|
||||||
)
|
)
|
||||||
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
|
||||||
else:
|
else:
|
||||||
@@ -132,24 +149,40 @@ class PluginHelperTui(App[int]):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
self._set_status(f"Could not toggle {plugin_id}: {exc}")
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
self._show_plugins(preserve_status=True)
|
self._show_plugins(preserve_status=True)
|
||||||
|
|
||||||
def action_disable_all_plugins(self) -> None:
|
async def action_disable_all_plugins(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
target = self.selected_installation
|
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
|
changed = 0
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
try:
|
||||||
for plugin in enabled:
|
for plugin in enabled:
|
||||||
plugin_id = plugin["id"]
|
plugin_id = plugin["id"]
|
||||||
try:
|
try:
|
||||||
result = disable_plugin(
|
result = await asyncio.to_thread(
|
||||||
|
disable_plugin,
|
||||||
target.instance_name,
|
target.instance_name,
|
||||||
target.instance_path,
|
target.instance_path,
|
||||||
target.state_root,
|
target.state_root,
|
||||||
plugin_id,
|
plugin_id,
|
||||||
force=False,
|
False,
|
||||||
)
|
)
|
||||||
if result["stateUpdated"]:
|
if result["stateUpdated"]:
|
||||||
changed += 1
|
changed += 1
|
||||||
@@ -157,32 +190,48 @@ class PluginHelperTui(App[int]):
|
|||||||
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
|
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"{plugin_id}: {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._set_bulk_status("Disabled", changed, errors)
|
||||||
self._show_plugins(preserve_status=True)
|
self._show_plugins(preserve_status=True)
|
||||||
|
|
||||||
def action_enable_all_plugins(self) -> None:
|
async def action_enable_all_plugins(self) -> None:
|
||||||
if self.mode != "plugins" or self.selected_installation is None:
|
if self._busy or self.mode != "plugins" or self.selected_installation is None:
|
||||||
return
|
return
|
||||||
target = self.selected_installation
|
target = self.selected_installation
|
||||||
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
|
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
|
||||||
changed = 0
|
if not disabled:
|
||||||
errors: list[str] = []
|
self._set_status("No disabled plugins to enable.")
|
||||||
for plugin in disabled:
|
return
|
||||||
plugin_id = plugin["id"]
|
plugin_ids = [plugin["id"] for plugin in disabled]
|
||||||
|
self._busy = True
|
||||||
|
self._set_status(f"Enabling {len(plugin_ids)} plugins...")
|
||||||
try:
|
try:
|
||||||
enable_disabled_plugin(
|
result = await asyncio.to_thread(
|
||||||
|
enable_disabled_plugins,
|
||||||
instance=target.instance_name,
|
instance=target.instance_name,
|
||||||
instance_path=target.instance_path,
|
instance_path=target.instance_path,
|
||||||
state_root=target.state_root,
|
state_root=target.state_root,
|
||||||
plugin_id=plugin_id,
|
plugin_ids=plugin_ids,
|
||||||
repo=self.repo_root,
|
repo=self.repo_root,
|
||||||
|
progress=self._bootstrap_progress,
|
||||||
)
|
)
|
||||||
changed += 1
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errors.append(f"{plugin_id}: {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._set_bulk_status("Enabled", changed, errors)
|
||||||
self._show_plugins(preserve_status=True)
|
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:
|
def _show_installations(self) -> None:
|
||||||
self.mode = "installations"
|
self.mode = "installations"
|
||||||
self.plugin_rows = []
|
self.plugin_rows = []
|
||||||
@@ -207,6 +256,9 @@ class PluginHelperTui(App[int]):
|
|||||||
self._show_installations()
|
self._show_installations()
|
||||||
return
|
return
|
||||||
target = self.selected_installation
|
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.mode = "plugins"
|
||||||
self._set_title(f"{target.install_label} / {target.instance_name}")
|
self._set_title(f"{target.install_label} / {target.instance_name}")
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
@@ -236,9 +288,14 @@ class PluginHelperTui(App[int]):
|
|||||||
str(plugin["fileCount"]),
|
str(plugin["fileCount"]),
|
||||||
plugin["asset"],
|
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 not preserve_status:
|
||||||
if self.plugin_rows:
|
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:
|
else:
|
||||||
self._set_status("No managed plugins recorded for this installation.")
|
self._set_status("No managed plugins recorded for this installation.")
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import shutil
|
|||||||
import tarfile
|
import tarfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .fsutil import sha256_file
|
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)
|
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:
|
with tarfile.open(destination, "w:gz") as archive:
|
||||||
archive.add(source, arcname="UserData")
|
archive.add(source, arcname="UserData")
|
||||||
with NamedTemporaryFile("w", encoding="utf-8", suffix=".json") as handle:
|
archive.add(manifest_path, arcname="manifest.json")
|
||||||
json.dump(manifest, handle, indent=2, sort_keys=True)
|
manifest_path.unlink(missing_ok=True)
|
||||||
handle.write("\n")
|
|
||||||
handle.flush()
|
|
||||||
archive.add(handle.name, arcname="manifest.json")
|
|
||||||
return {"archive": str(destination), "manifest": manifest}
|
return {"archive": str(destination), "manifest": manifest}
|
||||||
|
|
||||||
|
|
||||||
def infer_windows_appdata_path(instance_path: Path) -> Path:
|
def infer_windows_appdata_path(instance_path: Path) -> Path:
|
||||||
parts = instance_path.resolve().parts
|
parts = instance_path.resolve().parts
|
||||||
try:
|
try:
|
||||||
users_index = parts.index("Users")
|
bsmanager_index = parts.index("BSManager")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"cannot infer Windows user profile from instance path: {instance_path}") from 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}")
|
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"
|
return profile / "AppData" / "LocalLow" / "Hyperbolic Magnetism" / "Beat Saber"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+462
-14
@@ -13,15 +13,17 @@ from rich.text import Text
|
|||||||
from textual.coordinate import Coordinate
|
from textual.coordinate import Coordinate
|
||||||
from textual.widgets import DataTable
|
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.beatmods import by_version_id, normalize_mods
|
||||||
from plugin_helper.checker import check_lock
|
from plugin_helper.checker import check_lock
|
||||||
from plugin_helper.cli import installed_plugins_report, run
|
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.fsutil import sha256_file
|
||||||
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
|
from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin
|
||||||
from plugin_helper.instances import get_instance, list_instances
|
from plugin_helper.instances import get_instance, list_instances
|
||||||
from plugin_helper.models import Lockfile, LockedPlugin, Registry, RegistryPlugin
|
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.planner import create_plan
|
||||||
from plugin_helper.scanner import scan_bootstrap_files, scan_instance
|
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
|
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)
|
root = Path(tmp)
|
||||||
xdg = root / "xdg-state"
|
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)
|
runtime = resolve_runtime_config(root=root)
|
||||||
|
|
||||||
self.assertEqual(runtime.state_root, xdg / "plugin-helper")
|
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:
|
def test_runtime_default_state_uses_home_local_state(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(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)
|
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:
|
def test_no_args_prints_help_when_not_interactive(self) -> None:
|
||||||
output = StringIO()
|
output = StringIO()
|
||||||
@@ -253,6 +262,8 @@ state_dir = "config-state"
|
|||||||
run_menu.assert_called_once()
|
run_menu.assert_called_once()
|
||||||
|
|
||||||
def test_run_ipa_timeout_returns_control(self) -> None:
|
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:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
result = _run_ipa(
|
result = _run_ipa(
|
||||||
command=["python", "-c", "import time; time.sleep(30)"],
|
command=["python", "-c", "import time; time.sleep(30)"],
|
||||||
@@ -263,6 +274,333 @@ state_dir = "config-state"
|
|||||||
self.assertTrue(result["timedOut"])
|
self.assertTrue(result["timedOut"])
|
||||||
self.assertNotEqual(result["returncode"], 0)
|
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:
|
def test_plan_apply_and_uninstall_dll(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
work = Path(tmp)
|
work = Path(tmp)
|
||||||
@@ -689,13 +1027,21 @@ sha256 = "{sha256_file(asset)}"
|
|||||||
self.assertTrue(Path(result["archive"]).exists())
|
self.assertTrue(Path(result["archive"]).exists())
|
||||||
self.assertEqual(result["manifest"]["fileCount"], 1)
|
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:
|
def test_infer_windows_appdata_path_from_mounted_instance(self) -> None:
|
||||||
instance = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances/1.44.1")
|
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(
|
if is_windows():
|
||||||
infer_windows_appdata_path(instance),
|
self.skipTest("POSIX mount paths are Linux-specific")
|
||||||
Path("/home/pleb/Windows/Users/pleb/AppData/LocalLow/Hyperbolic Magnetism/Beat Saber"),
|
self.assertEqual(infer_windows_appdata_path(instance), expected)
|
||||||
)
|
|
||||||
|
|
||||||
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
|
def test_sync_windows_data_repo_copies_into_stable_backup_root(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
@@ -747,6 +1093,8 @@ sha256 = "{sha256_file(asset)}"
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_infer_appdata_path_uses_windows_or_proton(self) -> None:
|
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")
|
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")
|
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
|
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):
|
class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
||||||
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
|
async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None:
|
||||||
choices = [
|
choices = [
|
||||||
@@ -1148,30 +1565,61 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(table.row_count, 2)
|
self.assertEqual(table.row_count, 2)
|
||||||
self.assertEqual(app.mode, "installations")
|
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:
|
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
self.assertEqual(app.mode, "plugins")
|
||||||
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
self.assertEqual(app.plugin_rows[0]["status"], "enabled")
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
|
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||||
updated = load_installed_state(state, "1.40.8")
|
updated = load_installed_state(state, "1.40.8")
|
||||||
self.assertNotIn("example", updated["plugins"])
|
self.assertNotIn("example", updated["plugins"])
|
||||||
self.assertIn("example", updated["disabledPlugins"])
|
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:
|
async def test_space_enables_disabled_plugin_from_asset(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
self.assertEqual(app.mode, "plugins")
|
||||||
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
self.assertEqual(app.plugin_rows[0]["status"], "disabled")
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
updated = load_installed_state(state, "1.40.8")
|
||||||
@@ -1183,8 +1631,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
app, instance, state = _make_tui_fixture(Path(tmp))
|
app, instance, state = _make_tui_fixture(Path(tmp))
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("d")
|
await pilot.press("d")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
self.assertFalse((instance / "Plugins" / "Example.dll").exists())
|
||||||
updated = load_installed_state(state, "1.40.8")
|
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)
|
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("e")
|
await pilot.press("e")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
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)
|
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.press("enter")
|
|
||||||
await pilot.press("space")
|
await pilot.press("space")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
|
||||||
updated = load_installed_state(state, "1.40.8")
|
updated = load_installed_state(state, "1.40.8")
|
||||||
|
|||||||
Reference in New Issue
Block a user