Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f4738b262 | |||
| 31a8438c8f | |||
| a076ac9db4 | |||
| 911e3ab50e | |||
| d8f4a7f883 | |||
| 59432da5bf | |||
| 4606b368a0 | |||
| 80167a7f3f | |||
| 91cb80bbe2 | |||
| df1c7ca9d2 | |||
| 0f96db8d29 | |||
| 8042f1b9f6 | |||
| 757eb1f655 | |||
| 49fb694aaf | |||
| 508e290a24 | |||
| 413996f9f3 | |||
| cda4e000ee | |||
| 2e9f62d415 | |||
| 4ef821adc9 | |||
| 21f4eacefb | |||
| 72738a256b | |||
| a54c146772 | |||
| 43a4cdfe45 | |||
| 39d481467a | |||
| 3afcf1c12e | |||
| da140848ba | |||
| 288853a6cc | |||
| 365e251408 | |||
| eaa5eccb98 | |||
| eabeae00d6 | |||
| d209d6c336 | |||
| eff5d5728f | |||
| 2ee7a904d6 | |||
| f477870f74 | |||
| 2ccb3d469f | |||
| 37ad1d2d19 | |||
| 88f3467882 | |||
| c63b39ff01 | |||
| 4bb01146e6 | |||
| 35a476f6ab | |||
| 1744ae1dcc | |||
| 7a38bb22f1 | |||
| b325bf2a39 | |||
| 43ddfc652d | |||
| 52d1049c98 | |||
| 4c4b783aa7 | |||
| 2c1c94640f | |||
| 8476f869b4 | |||
| ad87874e09 | |||
| fd299e1267 | |||
| 374bfe6834 | |||
| 47158a264a | |||
| 812600a3ec | |||
| 8efba693b3 | |||
| 39afc09df9 | |||
| b94fe0a053 | |||
| 1c2321b438 | |||
| e6d96088fb | |||
| 5466ad61c3 | |||
| 0a2e2a2f47 | |||
| 950ca71a0a | |||
| 889e792b3c | |||
| c6ff848bfa | |||
| 5d4b79c041 | |||
| 28be0da96e | |||
| a74c6bba10 | |||
| ebd4d4115a | |||
| b91f0b06ab | |||
| 41af98dbfb | |||
| cf0e092705 | |||
| 93bcd358cb | |||
| 3cb744bf6c | |||
| 53c9f92344 | |||
| 5f6cf77f53 | |||
| 7caca61c1e | |||
| 3f5b5ca4a0 | |||
| ec0ae76d70 | |||
| 13984f886f | |||
| 29e5b1946d | |||
| 09ffdcae32 | |||
| 7fc6ffdda9 | |||
| 0085d1e52e | |||
| ccd36913de | |||
| 4dcc4e92a2 | |||
| a4dab93c36 | |||
| 39adbc42f0 | |||
| 71da1d9484 | |||
| 8020084e77 | |||
| 0b4331bef2 | |||
| b284b33669 | |||
| effe0608e1 | |||
| 773b54ca08 | |||
| 0c3a5b9e75 | |||
| dc3ae1971d | |||
| 350dff37cf | |||
| a47616086c | |||
| 96fb2ed8c7 | |||
| bacead72ae | |||
| 56450f73c8 | |||
| 68e260f831 | |||
| 1559224ff9 | |||
| 4b8004ea80 | |||
| e3fef23ef0 | |||
| 5f43a8fd4f | |||
| e4766f8a25 | |||
| 17deb2658e | |||
| b4d934f22a | |||
| 11e61b72b3 | |||
| 989b045be7 | |||
| bbe708b767 | |||
| b6513fb096 | |||
| a4d9069a3d | |||
| be49f1e02b | |||
| 0417f3d4fa | |||
| 3feca3769a |
@@ -24,14 +24,15 @@ jobs:
|
||||
with:
|
||||
node-version: 22.11.0
|
||||
cache: "npm"
|
||||
|
||||
# Install and setup rpm/Flatpak for Ubuntu
|
||||
- name: Install rpm/flatpak packages (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get install -y flatpak flatpak-builder rpm libarchive-tools
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run package
|
||||
|
||||
# Install and setup Flatpak for Ubuntu
|
||||
- name: Install flatpak packages (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get install -y flatpak flatpak-builder
|
||||
|
||||
- name: Setup flatpak repo (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
|
||||
@@ -20,8 +20,11 @@ jobs:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install flatpak packages
|
||||
run: sudo apt-get install -y flatpak flatpak-builder
|
||||
- name: update apt-get
|
||||
run: sudo apt-get update
|
||||
|
||||
- name: Install rpm/flatpak packages
|
||||
run: sudo apt-get install -y flatpak flatpak-builder rpm libarchive-tools
|
||||
|
||||
- name: Setup flatpak repo
|
||||
run: |
|
||||
@@ -39,6 +42,9 @@ jobs:
|
||||
- name: Build deb
|
||||
run: npx electron-builder --config electron-builder.config.js --publish always --linux deb --x64
|
||||
|
||||
- name: Build rpm
|
||||
run: npx electron-builder --config electron-builder.config.js --publish always --linux rpm --x64
|
||||
|
||||
- name: Build flatpak
|
||||
run: env DEBUG="@malept/flatpak-bundler" npx electron-builder --config electron-builder.config.js --publish always --linux flatpak
|
||||
|
||||
|
||||
@@ -1,4 +1,148 @@
|
||||
## Not released yet ~
|
||||
[PR Merged since the last release](https://github.com/Zagrios/bs-manager/pulls?q=is%3Apr+is%3Amerged+merged%3A%3E2025-01-18)\
|
||||
[PR Merged since the last alpha](https://github.com/Zagrios/bs-manager/pulls?q=is%3Apr+is%3Amerged+merged%3A%3E2025-01-11+)
|
||||
|
||||
## [1.4.18](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.18) (Jan 18, 2025)
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue where enabling Oculus sideloading was not working if BSManager installation path contained spaces #735
|
||||
- The IPA folder is now cleared each time BSIPA is installed to prevent potential crashes caused by conflicts with old BSIPA files #746
|
||||
- Added a check to ensure the file system supports symlinks before moving contents to the shared folder #739
|
||||
|
||||
### Other changes
|
||||
- Events occurring in `bs-admin-start.exe` and `oculus-allow-dev-sideloaded.exe` are now logged #735
|
||||
|
||||
## [1.4.17](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.17) (Jan 2, 2025)
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue where original Steam installations of Beat Saber did not have any mods available
|
||||
- Mod descriptions should no longer contain HTML tags
|
||||
|
||||
## [1.4.16](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.16) (Jan 2, 2025)
|
||||
|
||||
### Fixes
|
||||
- Fixed infinite mod loading when a mod file could not be found on BeatMods #725 (Thanks to @silentrald)
|
||||
- Fixed an issue where no mods were shown as installed when a mod dependency could not be found #726 (Thanks to @silentrald)
|
||||
- Fixed minor UI issues
|
||||
|
||||
## [1.4.15](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.15) (Jan 2, 2025)
|
||||
|
||||
### Fixes
|
||||
- Use the sideloading feature of Oculus to prevent errors when launching Oculus versions of Beat Saber #720
|
||||
- Oculus token starting with `OC` are now accepted
|
||||
|
||||
### Other changes
|
||||
- Mods now load faster #508
|
||||
- Switched to the new BeatMods API for loading mods
|
||||
|
||||
|
||||
## [1.4.14](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.14) (Dec 2, 2024)
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue where downloading private playlists through OneClick was always resulting in an error #679 (Thanks to @Top-Cat)
|
||||
- Beat Saber file verification was broken due to an error in DepotDownloader #677
|
||||
|
||||
|
||||
## [1.4.13](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.13) (Nov 26, 2024)
|
||||
|
||||
### Features
|
||||
- Added the possibility to unselect all mods from the mods panel #666
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue where downloading maps with subfolders was not possible #649 (Thanks to @silentrald)
|
||||
- Corrected the mods search bar color in the light theme #660 (Thanks to @chk1)
|
||||
|
||||
### Other changes
|
||||
- Added the BeatLeader icon to the OneClick playlist settings #653
|
||||
- A warning now appears before downloading an outdated BeatSaber version #654
|
||||
- Oculus BeatSaber versions no longer launch if the Oculus library is not found #657 (Thanks to @LiamillionSS)
|
||||
- Maps from BeatSaver are now sorted by relevance by default #662 (Thanks to @Top-Cat)
|
||||
- Removed the `--no-yeet` argument when launching original copies of BeatSaber #667
|
||||
- Added warnings for outdated versions in the versions view #673
|
||||
|
||||
|
||||
## [1.4.12](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.12) (Nov 6, 2024)
|
||||
|
||||
### Fixes
|
||||
- Fixed OneClick playlist download that has been broken in the previous release #645
|
||||
- Fixed an issue where downloading playlists could fail if the playlist's file contained special characters #645
|
||||
|
||||
## [1.4.11](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.11) (Nov 4, 2024)
|
||||
|
||||
### Fixes
|
||||
- BSManager was not working if the path to BSM's installation folder contained URL special characters #636
|
||||
- Linking folders was no longer working if the folders contained files
|
||||
- Under certain conditions, loading maps could result in a black screen
|
||||
|
||||
### Other changes
|
||||
- The "Broken Models" notification has been removed #640
|
||||
|
||||
|
||||
## [1.4.10](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.10) (Oct 27, 2024)
|
||||
|
||||
### Fixes
|
||||
- Mods using a manifest file were sometimes not detected as installed #564
|
||||
- BSIPA installation was always considered as successfull even if an error occured #511
|
||||
- Some map filter tags were no longer working #619 (Thanks to @silentrald)
|
||||
- Fixed an issue where, under certain conditions, linking a folder could result in the loss of its contents #568
|
||||
|
||||
### Other changes
|
||||
- Default mods are no longer reselected when mods are already installed #535
|
||||
- The `DLC` folder has been removed from the default list of folders that can be linked #628
|
||||
- The naming scheme for downloaded maps and playlists has been updated to prevent duplicates when downloading maps or playlists from Beat Saber or other tools
|
||||
|
||||
|
||||
## [1.4.9](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.9) (Oct 15, 2024)
|
||||
|
||||
### Fixes
|
||||
- Updated DepotDownloader dependency to fix connection errors with Steam
|
||||
|
||||
### Other changes
|
||||
- Added support for `info.dat` v4
|
||||
|
||||
|
||||
## [1.4.8](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.8) (Aug 1, 2024)
|
||||
|
||||
### Other changes
|
||||
- Added checks to avoid logging sensitive data in log files
|
||||
- The oldest log files are deleted to retain a maximum of 5 log files
|
||||
|
||||
|
||||
## [1.4.7](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.7) (Mar 13, 2024)
|
||||
|
||||
### Fixes
|
||||
- Fixed being unable to download models with BSManager #436
|
||||
|
||||
### Other changes
|
||||
- Fixed a typo in german translation (Thanks to @fllppi)
|
||||
|
||||
## [1.4.6](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.6) (Mar 6, 2024)
|
||||
|
||||
### Features
|
||||
- It's now possible to exclude already installed maps when downloading maps from BSManager #426 (Thanks to @Liborsaf)
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue where BSManager couldn't get path to user's documents under certain conditions, preventing any action from the user #431
|
||||
- To prevent the last launched Beat Saber version getting wiped by Oculus auto-updates, the symlink created to launch the version is now deleted after the game stops #432
|
||||
|
||||
## [1.4.5](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.5) (Feb 12, 2024)
|
||||
|
||||
### Fixes
|
||||
- Fixed an issue preventing launching downgraded Steam versions under certain conditions #415
|
||||
- Fixed an issue preventing launching downgraded Oculus versions under certain conditions #421
|
||||
- Fixed an issue where SteamVR was not properly restored after launching Beat Saber from a shortcut with FPFC mode #417 (Thanks to @slinkstr)
|
||||
|
||||
## [1.4.4](https://github.com/Zagrios/bs-manager/releases/edit/v1.4.4) (Feb 1, 2024)
|
||||
|
||||
### Fixes
|
||||
- When launching an Oculus Beat Saber version, the Oculus app is also started if needed to avoid crashes #405
|
||||
- If Steam is running as admin, Beat Saber is now also started as admin to avoid crashes #404
|
||||
- Fixed the issue where BSManager could fail to start due to a missing DLL #400
|
||||
- Fixed the issue where BSManager could not locate the Oculus library #398
|
||||
|
||||
### Other changes
|
||||
- Added some tooltips to the maps UIs #397
|
||||
- Updated the maps panel UI for consistency with the rest of BSManager UIs #399
|
||||
|
||||
## [1.4.3](https://github.com/Zagrios/bs-manager/releases/tag/v1.4.3) (Dec 27, 2023)
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
<hr>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
----------------------------------------
|
||||
BADGES
|
||||
@@ -58,7 +57,7 @@
|
||||
src="https://img.shields.io/badge/-DISCORD-5865f2?style=for-the-badge&logo=discord&logoColor=ffffff"
|
||||
alt="discord" /></a>
|
||||
<a href="https://twitter.com/BSManager_"><img
|
||||
src="https://img.shields.io/badge/-Twitter-black?style=for-the-badge&logo=X" alt="Twitter" /></a>
|
||||
src="https://img.shields.io/badge/-Twitter-black?style=for-the-badge&logo=X" alt="Twitter" /></a>
|
||||
<a href="https://www.bsmanager.io">
|
||||
<img src="https://img.shields.io/badge/-WebSite-00649c?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiPgogIDxnIGZpbGw9IiMzYjgyZmYiPgogICAgPHBhdGggZD0ibTYyNi43NSA0ODUuOTEgMjAuODIgMzUxLjk0YTM1LjIxIDM1LjIxIDAgMCAxLTE1LjQ5IDMxLjM2bC0xNDQuNzEgOTYuMTRoLS4wNmEzNS41NiAzNS41NiAwIDAgMS0xMS4zMSA0Ljc3IDM0Ljg1IDM0Ljg1IDAgMCAxLTIzLjUxLTIuODdMMTM2LjU4IDgwOS45YTExNS40NSAxMTUuNDUgMCAwIDEtNjMuNzUtOTYuNDlMNTIgMzYxLjQ2YTM1LjI2IDM1LjI2IDAgMCAxIDE1LjIzLTMxLjIxbDE0NS4wNS05Ni4zNmEzNS43MiAzNS43MiAwIDAgMSAxMS4yNy00LjcgMzQuODcgMzQuODcgMCAwIDEgMjMuNTEgMi44N0w1NjMgMzg5LjQyYTExNS40MSAxMTUuNDEgMCAwIDEgNjMuNzUgOTYuNDlaIi8+CiAgICA8cGF0aCBmaWx0ZXI9ImJyaWdodG5lc3MoMzAlKSIgZD0ibTYxMy43OCA0ODYuNjcgMjAuODIgMzUyYTIyLjM0IDIyLjM0IDAgMCAxLTMyLjI2IDIxLjMxTDI4Ni40IDcwMi41N0ExMDIuNDMgMTAyLjQzIDAgMCAxIDIyOS44MyA2MTdMMjA5IDI2NWEyMi4zNSAyMi4zNSAwIDAgMSAzMi4yNi0yMS4zMkw1NTcuMiA0MDEuMDVhMTAyLjQyIDEwMi40MiAwIDAgMSA1Ni41OCA4NS42MloiLz4KICA8L2c+CiAgPHBhdGggZD0iTTcxOC4yOSA3NzYuNDggNzAwIDgwNC4xOGMtNDAuNjMgMzkuNS04Ny4wNiA2Ny4yNS0xMzMuODEgOTAuNzUtODcuMzIgNDMuOS0yMDMuNjcgNzcuMTktMjQ5IDkwLjM0YTQ4IDQ4IDAgMCAxLTI2LjE3LjE2Yy02NC0xNy43Mi0xODguNjItNzItMjM2LjI4LTEyNS40OWE0LjIzIDQuMjMgMCAwIDEgNS4zLTYuNDVjMzUuNjQgMjAuODcgMTExLjIyIDU3LjY0IDE1Ni4zNyA3NC4yMiA0OS4xOCAxOC4wNiA4MS4yOSA4Ljc0IDkyLjI4LTMuMjZhNC43NyA0Ljc3IDAgMCAxIDcuMjQuMjFjMTkuNTEgMjQgMTA3LjI2IDQuNiAxNjYuNjQtMTMgNjQuMzQtMTkgMTg1Ljg1LTc2LjU2IDIzNS43Mi0xMzUuMThabS0xOTYuMiAxNTcuMzMtMTQ3LjQ1IDU1LjY4YTMuOTIgMy45MiAwIDAgMCAxLjQ0IDcuNThsNTQuNjItLjc3YTM1LjYzIDM1LjYzIDAgMCAwIDE5LTUuNzlsNzYtNDkuNTVhNCA0IDAgMCAwLTMuNjEtNy4xNVpNMjE4LjkyIDE5NS41NmMtMjEuMTQuMDctMzMuNTUgMzUuODYtMzMuMTYgODkuMDguMiAyOCAyLjU4IDkyLjc0IDQuNzkgMTQ3LjczLjIzIDUuNzEtOC4xOCA2LjQ4LTkgLjgxbC0zOC40Mi0yNzAuNzFhNC4zOCA0LjM4IDAgMCAxIDcuMTItNCA2My42MyA2My42MyAwIDAgMCAyMS45MyAxMS44NSA0LjYxIDQuNjEgMCAwIDAgNS45LTMuNzJjNC4yLTI3LjcgMTYuNjMtNTguNzQgMzcuMzUtODguNzRhNC4zNiA0LjM2IDAgMCAxIDcuNTYuNjljMTAuODYgMjMuODcgNDkgMTAzLjggOTMuMzQgMTUyLjc5YTQuNjUgNC42NSAwIDAgMS01LjYyIDcuMjJjLTM2LjU3LTE5LjM4LTgzLjAxLTQzLjAyLTkxLjc5LTQzWm0tMTUuNDcgMzg0LjhhNC41MSA0LjUxIDAgMCAxLTQuNDYtMy45MmwtMTMuODItMTA3LjI5YTQuNSA0LjUgMCAwIDEgOC45My0xLjE1bDEzLjgyIDEwNy4yOWE0LjUgNC41IDAgMCAxLTMuODkgNSAzLjg2IDMuODYgMCAwIDEtLjU4LjA3Wk01NzcuMDggNTQuNThjLTQ2Ljc5IDE1Ljg1LTExNS40NSA0MC41Ni0xNTguNzMgNjIuNzlhMzUuNSAzNS41IDAgMCAwLTE4LjU3IDI0LjUzYy0zLjk1IDE5LjQ5LTExIDU2LjA5LTE5IDEwNy4yNi0uNyA0LjQ4LTcuMzYgMy43OC03LjExLS43NWwxMC42Ni0xOTQuNzNhMy43OCAzLjc4IDAgMCAxIDcuNDYtLjY2bDMuNDkgMTQuODVhMi41NyAyLjU3IDAgMCAwIDQuNjkuNzdjNi4xOC0xMCAyMi4zNy0zMi43OSA1Ny4zMi02NWE0LjEzIDQuMTMgMCAwIDEgNi41NiA0LjcybC0zMi41NCA3Mi44OXM3OC43OC0xOS4zNyAxNDQtMzMuMDljNC4xMS0uODcgNS42OSA1LjA4IDEuNzcgNi40MloiIGZpbGw9IiNmZmYiIHN0cm9rZT0iYmxhY2siLz4KICA8ZyBmaWxsPSIjZjQ0Ij4KICAgIDxwYXRoIGZpbHRlcj0iYnJpZ2h0bmVzcyg2MCUpIHNhdHVyYXRlKDEyMCUpIiBkPSJtOTQ3LjA3IDQxMC4zNS01Ny43NSAzNDcuNzlhMzUuMTggMzUuMTggMCAwIDEtMjIuMDYgMjcuMTVsLTE2Mi40MyA2MS42NWgtLjA2YTM1LjUyIDM1LjUyIDAgMCAxLTEyLjA2IDIuMTEgMzQuODMgMzQuODMgMCAwIDEtMjIuMjktOGwtMjczLjE3LTIyMy41YTExNS40MyAxMTUuNDMgMCAwIDEtNDAuNzYtMTA4LjIybDU3Ljc1LTM0Ny44QTM1LjI1IDM1LjI1IDAgMCAxIDQzNiAxMzQuNDdsMTYyLjgzLTYxLjc5YTM1LjUxIDM1LjUxIDAgMCAxIDEyLTIuMDggMzQuODMgMzQuODMgMCAwIDEgMjIuMjkgOGwyNzMuMTkgMjIzLjUyYTExNS4zOCAxMTUuMzggMCAwIDEgNDAuNzYgMTA4LjIzWiIvPgogICAgPHBhdGggZD0iTTkzNC4yNSA0MDguMjIgODc2LjUgNzU2YTIyLjM0IDIyLjM0IDAgMCAxLTM2LjE4IDEzLjYzbC0yNzMuMTctMjIzLjVhMTAyLjQyIDEwMi40MiAwIDAgMS0zNi4xNy05Nmw1Ny43NS0zNDcuODNhMjIuMzQgMjIuMzQgMCAwIDEgMzYuMTgtMTMuNjNsMjczLjE3IDIyMy41MWExMDIuNDIgMTAyLjQyIDAgMCAxIDM2LjE3IDk2LjA0WiIvPgogIDwvZz4KICA8ZWxsaXBzZSBmaWxsPSIjZmZmIiBzdHJva2U9ImJsYWNrIiBjeD0iNzMyLjYxIiBjeT0iNDI5LjE2IiByeD0iNjIuODMiIHJ5PSIxMTAuNzMiIHRyYW5zZm9ybT0icm90YXRlKC0xNS40NSA3MzIuNzAzIDQyOS4xOTkpIi8+Cjwvc3ZnPg==" alt="Website" /></a>
|
||||
<a href="https://www.patreon.com/bsmanager"><img
|
||||
@@ -72,76 +71,64 @@
|
||||
-->
|
||||
<div>
|
||||
<hr>
|
||||
<details open>
|
||||
<details open>
|
||||
<summary><b>Table of Contents</b></summary>
|
||||
<ul>
|
||||
<li>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#bsmanager-your-beat-saber-assistant">BSManager: Your Beat Saber Assistant</a></summary>
|
||||
<ul>
|
||||
<summary><a href="#bsmanager-your-beat-saber-assistant">BSManager: Your Beat Saber Assistant</a></summary>
|
||||
<ul>
|
||||
<li><a href="#what-is-it">What is it?</a></li>
|
||||
<li><a href="#why-use-it">Why use it?</a></li>
|
||||
<li><a href="#built-with">Built with</a></li>
|
||||
<li><a href="#how-to-install">How to install</a></li>
|
||||
</ul>
|
||||
<li><a href="#how-to-install">How to install?</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
</li>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#version-management">Version Management</a></summary>
|
||||
<ul>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#downloading-multiple-versions">Downloading multiple versions</a></summary>
|
||||
<ul>
|
||||
<li>via Steam account</li>
|
||||
<li>via Oculus (PCVR) account</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li><a href="#multi-version-sharing">Multi-version Sharing</a></li>
|
||||
<li><a href="#importing-an-existing-version-of-beat-saber">Importing an existing version of Beat Saber</a>
|
||||
</li>
|
||||
<li><a href="#launch-arguments">Launch arguments</a></li>
|
||||
<li><a href="#shortcut-creation">Shortcut creation</a></li>
|
||||
</ul>
|
||||
<summary><a href="#version-management">Version Management</a></summary>
|
||||
<ul>
|
||||
<li><a href="#downloading-multiple-versions">Downloading Multiple Versions</a></li>
|
||||
<li><a href="#multi-version-sharing">Multi-Version Sharing</a></li>
|
||||
<li><a href="#importing-an-existing-version-of-beat-saber">Importing an Existing Version of Beat Saber</a></li>
|
||||
<li><a href="#launch-arguments">Launch Arguments</a></li>
|
||||
<li><a href="#shortcut-creation">Shortcut Creation</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
</li>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#download-and-installation">Download and Installation</a></summary>
|
||||
<ul>
|
||||
<li><a href="#maps">Maps</a></li>
|
||||
<li><a href="#playlists">Playlists</a></li>
|
||||
<li><a href="#mods">Mods</a></li>
|
||||
<li><a href="#models">Models</a></li>
|
||||
</ul>
|
||||
<summary><a href="#download-and-installation">Download and Installation</a></summary>
|
||||
<ul>
|
||||
<li><a href="#features">Features</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
</li>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#customization">Customization</a></summary>
|
||||
<ul>
|
||||
<summary><a href="#customization">Customization</a></summary>
|
||||
<ul>
|
||||
<li><a href="#tool">Tool</a></li>
|
||||
<li><a href="#beat-saber-instance">Beat Saber Instance</a></li>
|
||||
</ul>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
</li>
|
||||
<li>
|
||||
<details>
|
||||
<summary><a href="#more-information">More Information</a></summary>
|
||||
<ul>
|
||||
<summary><a href="#more-information">More Information</a></summary>
|
||||
<ul>
|
||||
<li><a href="#support">Support</a></li>
|
||||
<li><a href="#credits">Credits</a></li>
|
||||
<li><a href="#Contributing">Contributing</a></li>
|
||||
</ul>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div align="right">
|
||||
</details>
|
||||
<div align="right">
|
||||
[<a href="#readme-bot">Go to bottom</a>]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -155,7 +142,6 @@
|
||||
<h1><b>BSManager: Your Beat Saber Assistant</b></h1>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
WHAT IS IT?
|
||||
----------------------------------------
|
||||
@@ -166,7 +152,6 @@
|
||||
Saber</a> experience by centralizing the management of mods, maps, and game versions in one place.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
WHY USE IT?
|
||||
----------------------------------------
|
||||
@@ -189,7 +174,6 @@
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/012b0196-74ef-4ee5-86a4-49e835612f96"/>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
BUILT WITH
|
||||
----------------------------------------
|
||||
@@ -219,7 +203,6 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
HOW TO INSTALL
|
||||
----------------------------------------
|
||||
@@ -231,8 +214,12 @@
|
||||
<div>
|
||||
<h3><b>Windows Installation</b></h3>
|
||||
<ul>
|
||||
<li>Download the <a href="https://github.com/Zagrios/bs-manager/releases/latest">latest release</a> from <a
|
||||
href="https://github.com/Zagrios/bs-manager/releases">Releases</a>.</li>
|
||||
<li>Download and install <a href="https://www.bsmanager.io">BSManager</a> from one of the following sources:</li>
|
||||
<ul>
|
||||
<li><a href="https://github.com/Zagrios/bs-manager/releases/latest">GitHub</a></li>
|
||||
<li><a href="https://www.bsmanager.io">Official Website</a></li>
|
||||
<li><a href="https://www.nexusmods.com/beatsaber/mods/18?tab=files">Nexus Mods</a></li>
|
||||
</ul>
|
||||
<li>Execute the installer and <a href="https://github.com/Zagrios/bs-manager">BSManager</a> will start itself.</li>
|
||||
<li>Once it's done, if you want to download a version, select a version and download it! </li>
|
||||
<li>From the version page you can choose options, launch the version, and more!</li>
|
||||
@@ -244,8 +231,8 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3><b>Linux installation</b></h3>
|
||||
<p>Refer to <a href="https://github.com/Zagrios/bs-manager/wiki/Linux">Linux wiki</a></p>
|
||||
<h3><b>Linux Installation</b></h3>
|
||||
<p>BSManager is also available on Linux! Follow the step-by-step guide in our <a href="https://github.com/Zagrios/bs-manager/wiki/Linux">Linux installation wiki</a> to set it up properly.</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@@ -264,48 +251,27 @@
|
||||
existing ones, provided that you own the game on a Steam or Oculus (PC) account.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
DOWNLOADING MULTIPLE VERSIONS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Downloading Multiple Versions</h2>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> provides two authentication methods to access your
|
||||
Steam account, ensuring safe and flexible access to
|
||||
different game versions:</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/Zagrios/bs-manager/assets/40648115/09d579de-8b8e-45d3-8829-21a25cc7daf8"/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> allows you to safely and easily access different versions of Beat Saber, ensuring flexibility for modding or version-specific preferences.</p>
|
||||
<p>To download a version, you can authenticate using two methods:</p>
|
||||
<ul>
|
||||
<li><strong>via Steam</strong>: Use either your credentials, which will not be stored by <a
|
||||
href="https://github.com/Zagrios/bs-manager">BSManager</a> or
|
||||
<a href="https://github.com/SteamRE/DepotDownloader">DepotDownloader</a>, or opt for a quick and secure
|
||||
authentication via QR with the Steam Guard app.</li>
|
||||
<li><strong>via Steam</strong>: Authenticate securely using your Steam credentials or by scanning a QR code with the Steam Guard app.</li>
|
||||
<li><strong>via Oculus</strong>: BSManager redirects you to the official Oculus website for authentication to retrieve your token securely. Alternatively, you can follow <a href="https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token">this guide</a> to obtain your token manually and paste it into BSManager.</li>
|
||||
</ul>
|
||||
<p>For detailed instructions on installing versions, follow this tutorial:
|
||||
👉 <a href="https://github.com/Zagrios/bs-manager/wiki/install-or-import-a-version">How to install or import a version</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/Zagrios/bs-manager/assets/40648115/26da3a19-a936-4e6b-b79b-a139d7bd55de" />
|
||||
|
||||
<img src="https://github.com/Zagrios/bs-manager/assets/40648115/09d579de-8b8e-45d3-8829-21a25cc7daf8" alt=""/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
<li><strong>via Oculus</strong>: For Oculus users, <a href="https://github.com/Zagrios/bs-manager">BSManager</a>
|
||||
requires you to retrieve a connection token by following the instructions in this guide: <a href="https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token">How to obtain your Oculus Token</a>. Once obtained, please insert it into the form.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img height=450 src="https://github.com/Zagrios/bs-manager/assets/40648115/3674e629-542d-4de3-b8db-b248f25126d7" />
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
MULTI-VERSION SHARING
|
||||
----------------------------------------
|
||||
@@ -320,10 +286,9 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/ba39aceb-f55f-4e58-8386-51fcfaad4957"/>
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/ba39aceb-f55f-4e58-8386-51fcfaad4957"/>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
IMPORTING AN EXISTING VERSION OF BEAT SABER
|
||||
----------------------------------------
|
||||
@@ -340,49 +305,53 @@
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/cdedb951-d97a-474d-a217-2422be3919f7"/>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
LAUNCH ARGUMENTS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Launch Arguments</h2>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> provides efficient management of <a
|
||||
href="https://beatsaber.com/">Beat Saber</a> launch arguments, including three predefined ones to
|
||||
enhance your gaming experience:</p>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> provides advanced management of launch arguments for <a href="https://beatsaber.com/">Beat Saber</a>, allowing you to customize your gaming experience.</p>
|
||||
|
||||
<h3>Predefined Arguments</h3>
|
||||
<ul>
|
||||
<li><strong>Oculus Mode</strong>: Launches the game directly in Oculus mode, avoiding the use of STEAM VR.</li>
|
||||
<li><strong>FPFC Mode</strong>: Enables "FPFC" mode, allowing you to control the game without using a VR
|
||||
headset.</li>
|
||||
<li><strong>Debug Mode</strong>: Activates debugging mode, generating Windows log outputs for IPA.</li>
|
||||
<li><strong>Oculus Mode</strong>: Allows you to use Oculus's VR composer while bypassing SteamVR, which may improve performance. (Only useful if you launch the game via Steam).</li>
|
||||
<li><strong>FPFC Mode</strong>: Enables "First Person Flying Controller" mode, allowing you to navigate menus using the keyboard (WASD) and mouse without a VR headset.</li>
|
||||
<li><strong>Debug Mode</strong>: Activates the IPA debug console, displaying logs used by mods.</li>
|
||||
<li><strong>Skip Steam</strong>: Prevents Steam from opening automatically with Beat Saber, useful if you use an alternative VR runtime like WIVRn or Monado.</li>
|
||||
<li><strong>Map Editor</strong>: Launches the official Beat Saber map editor instead of the game.</li>
|
||||
</ul>
|
||||
<p>You also have the freedom to add your own custom launch arguments by inserting them into "Advanced Launch" and
|
||||
separating them with a semicolon (;).</p>
|
||||
|
||||
<h3>Custom Arguments</h3>
|
||||
<p>You can also add your own custom launch arguments by inserting them into "Advanced Launch" and separating them with a semicolon (;).</p>
|
||||
|
||||
<h3>Pinning Arguments</h3>
|
||||
<p>You can now pin your favorite launch arguments for quick access. By default, only <strong>Oculus Mode</strong> and <strong>Debug Mode</strong> are pinned.</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/Zagrios/bs-manager/assets/40648115/1244d49f-20e1-400c-9818-528f05cfadf5" />
|
||||
<img src="https://github.com/user-attachments/assets/c247b237-9c49-4e7b-9366-aeb1dd3b7b9a" alt="" />
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
SHORTCUT CREATION
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Shortcut Creation</h2>
|
||||
<p>Create shortcuts to easily access your different versions of <a href="https://beatsaber.com/">Beat Saber</a> with
|
||||
<a href="https://github.com/Zagrios/bs-manager">BSManager</a>. After selecting your
|
||||
version, go to the version settings, click on "Create a Shortcut", and then select your launch options. Thus,
|
||||
you can quickly and easily launch your preferred version of <a href="https://beatsaber.com/">Beat Saber</a> with the
|
||||
desired settings.</p>
|
||||
<p>Create shortcuts to easily access your different versions of <a href="https://beatsaber.com/">Beat Saber</a> with <a href="https://github.com/Zagrios/bs-manager">BSManager</a>.
|
||||
</p>
|
||||
<p>To learn how to create a shortcut, follow the tutorial available here:
|
||||
👉 <a href="https://github.com/Zagrios/bs-manager/wiki/create-a-shortcut">Shortcut Creation Guide</a>
|
||||
</p>
|
||||
<p>This allows you to quickly and easily launch your preferred version of <a href="https://beatsaber.com/">Beat Saber</a> with the desired settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/bfa1e97d-6392-4dec-a1fc-bb05048cf6d2" />
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
----------------------------------------
|
||||
DOWNLOAD AND INSTALLATION
|
||||
@@ -392,79 +361,30 @@
|
||||
<hr>
|
||||
<h1><b>Download and Installation</b></h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> provides centralized and organized management of
|
||||
beatmaps, mods, and models, integrating download features and sharing between <a href="https://beatsaber.com/">Beat
|
||||
Saber</a> versions.</p>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> centralizes and simplifies the management of beatmaps, playlists, mods, and models, offering seamless downloads and sharing between different <a href="https://beatsaber.com/">Beat Saber</a> versions.</p>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
MAPS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Maps</h2>
|
||||
<p><a href="https://github.com/Zagrios/bs-manager">BSManager</a> integrates the <a href="https://beatsaver.com">Beat
|
||||
Saver</a> API to provide you with a comprehensive list of maps downloadable directly from its interface. Moreover,
|
||||
the OneClick feature allows you to download maps with one click from various sources such as <a
|
||||
href="https://beatsaver.com">Beat Saver</a>, <a href="https://bsaber.com">BeastSaber</a>, <a
|
||||
href="https://scoresaber.com">ScoreSaber</a>, and <a href="https://www.beatleader.xyz">Beat Leader</a>. To add
|
||||
maps, simply select the desired version or the "shared" tab, then click "ADD".</p>
|
||||
<h2>Features</h2>
|
||||
<p>With <a href="https://github.com/Zagrios/bs-manager">BSManager</a>, you can manage your:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>Maps:</b> Browse and download maps directly from sources like <a href="https://beatsaver.com">Beat Saver</a>, <a href="https://bsaber.com">BeastSaber</a>, <a href="https://scoresaber.com">ScoreSaber</a>, and <a href="https://www.beatleader.xyz">Beat Leader</a>.</li>
|
||||
<li><b>Playlists:</b> Search for and download playlists directly from BSManager, just like maps. 😉
|
||||
<li><b>Mods:</b> Manage and install mods via the "Mods" tab, offering the same features as Mod Assistant.</li>
|
||||
<li><b>Models:</b> Add and manage models easily with the integration of the <a href="https://modelsaber.com">Model Saber</a> API.</li>
|
||||
</ul>
|
||||
<p>For detailed instructions on each feature, check the following guides:</p>
|
||||
<ul>
|
||||
<li>👉 <a href="https://github.com/Zagrios/bs-manager/wiki/install-any-map">Install Any Map</a></li>
|
||||
<li>👉 <a href="https://github.com/Zagrios/bs-manager/wiki/install-all-playlists-you-want">Install Playlists</a></li>
|
||||
<li>👉 <a href="https://github.com/Zagrios/bs-manager/wiki/install-mods-on-your-version">Install Mods</a></li>
|
||||
<li>👉 <a href="https://github.com/Zagrios/bs-manager/wiki/modify-models-in-your-beat-saber">Manage Models</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/3f9f9968-ac55-465e-bc28-17aebfc787ea"/>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
PLAYLISTS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Playlists</h2>
|
||||
<p>Although the playlist management interface is not yet developed, <a
|
||||
href="https://github.com/Zagrios/bs-manager">BSManager</a> offers a OneClick feature for playlists from <a
|
||||
href="https://beatsaver.com">Beat Saver</a>.</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/733fe6bb-c157-4c47-8a79-f8062380bcc8" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
MODS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Mods</h2>
|
||||
<p>Manage your mods easily with <a href="https://github.com/Zagrios/bs-manager">BSManager</a>. After downloading a
|
||||
version, navigate to the "Mods" tab of the selected version to manage and install mods. <a
|
||||
href="https://github.com/Zagrios/bs-manager">BSManager</a> operates similarly to Mod Assistant and offers the same
|
||||
mods as this tool.</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/ca8f0727-b2dd-41f3-bbea-9bcaf61a89f1"/>
|
||||
|
||||
</div>
|
||||
|
||||
<!--
|
||||
MODELS
|
||||
----------------------------------------
|
||||
-->
|
||||
<div>
|
||||
<h2>Models</h2>
|
||||
<p>Add and manage models simply via the corresponding tab on the desired version or by going to the "shared" tab. The
|
||||
<a href="https://modelsaber.com">Model Saber</a> API is integrated to provide easy and direct access to models
|
||||
available for <a href="https://beatsaber.com/">Beat Saber</a>.</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/d983b34b-9f4c-4274-aee4-4c1c03dd90d0"/>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
----------------------------------------
|
||||
CUSTOMIZATION
|
||||
@@ -495,7 +415,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<div align="center">
|
||||
<video src = "https://github.com/Zagrios/bs-manager/assets/40648115/d2c1b0ec-a2f5-41b5-99d5-c4c2cd34cbd4" />
|
||||
</div>
|
||||
|
||||
@@ -520,7 +440,6 @@
|
||||
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/f66a2c99-770e-42ca-9a71-b1806946ad0c" />
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
----------------------------------------
|
||||
MORE INFORMATION
|
||||
@@ -546,7 +465,6 @@
|
||||
GitHub</a>.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
CREDITS
|
||||
----------------------------------------
|
||||
@@ -554,12 +472,12 @@
|
||||
<div>
|
||||
<h2>Credits</h2>
|
||||
<ul>
|
||||
<li><a href="https://github.com/Zagrios">Zagrios - Mathieu Gries</a> - Lead Developer & Founder.</li>
|
||||
<li><a href="https://github.com/Zagrios">Zagrios</a> - Lead Developer & Founder.</li>
|
||||
<li><a href="https://github.com/Iluhadesu">Iluhadesu</a> - Co-Developer & Co-Founder, Discord Bot Developer.</li>
|
||||
<li><a href="https://github.com/GaetanGrd">GaetanGrd</a> - Co-Developer & Co-Founder, Documentation Lead.</li>
|
||||
<li><a href="https://github.com/cheddZy">cheddZy</a> - Icon Creator.</li>
|
||||
<li><a href="https://github.com/Insprill">Insprill</a> - Co-Developer, Linux Developer, AUR Maintainer.</li>
|
||||
<li><a href="https://github.com/silentrald">silentrald</a> - Co-Developer, Linux Developer, deb and flatpak Maintainer.</li>
|
||||
<li><a href="https://github.com/silentrald">silentrald</a> - Co-Developer, Linux Developer, deb/rpm/flatpak Maintainer.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -575,12 +493,11 @@
|
||||
href="https://github.com/Zagrios/bs-manager">BSManager</a>!</p>
|
||||
<div align="center">
|
||||
<a href="https://github.com/Zagrios/bs-manager/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Zagrios/bs-manager" />
|
||||
<img src="https://contrib.rocks/image?repo=Zagrios/bs-manager" alt="Contributor list" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!--
|
||||
----------------------------------------
|
||||
Others
|
||||
|
||||
@@ -813,5 +813,14 @@
|
||||
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/65df6b24c3b8a7f415080ac01394917cedabf9fc.png",
|
||||
"ReleaseDate": "1733846128",
|
||||
"year": "2024"
|
||||
},
|
||||
{
|
||||
"BSVersion": "1.40.1",
|
||||
"BSManifest": "8655702238937650396",
|
||||
"OculusBinaryId": "8526525354113835",
|
||||
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/510696972023234581",
|
||||
"ReleaseImg": "https://clan.fastly.steamstatic.com/images/32055887/5cc1ca353151ed31da2764716cdcbe89442aeeeb.png",
|
||||
"ReleaseDate": "1738144954",
|
||||
"year": "2025"
|
||||
}
|
||||
]
|
||||
@@ -138,5 +138,9 @@
|
||||
{
|
||||
"username": "Alexander Herman",
|
||||
"type": "gold"
|
||||
},
|
||||
{
|
||||
"username": "Arts Rimuro Suraimu",
|
||||
"type": "gold"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Anheften",
|
||||
"unpin": "Lösen"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "Konnte den Umgebungsvariablen-String nicht richtig parsen."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "veraltet"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "Diese Version ist veraltet, und einige Mods oder Funktionen funktionieren möglicherweise nicht wie erwartet. Es wird empfohlen, die empfohlene Version ({recommendedVersion}) von Beat Saber zu verwenden, um die neuesten Funktionen und Fehlerbehebungen zu genießen.",
|
||||
"advanced-launch": {
|
||||
"button": "Startoptionen",
|
||||
"placeholder": "Weitere Argumente, bspw: --revert; --nowait"
|
||||
"placeholder": "Startoptionen, bspw: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Steam überspringen",
|
||||
"skipsteam-description": "Verhindert, dass Steam automatisch mit Beat Saber geöffnet wird. Aktivieren Sie dies, wenn Sie eine andere VR-Laufzeit wie WiVRn oder Monado verwenden, mit der SteamVR interferieren könnte.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Mods bereits installiert",
|
||||
"description": "Alle ausgewählten Mods sind bereits installiert"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Veraltetes Mod",
|
||||
"title-plural": "Veraltete Mods",
|
||||
"description": "Das Mod {name} ist veraltet. Möchten Sie es aktualisieren?",
|
||||
"description-plural": "Diese Version enthält {nb} veraltete Mods. Möchten Sie sie aktualisieren?",
|
||||
"dont-remind-me": "Nicht mehr daran erinnern",
|
||||
"update": "Aktualisieren"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -237,6 +251,7 @@
|
||||
"en-US": "Englisch, USA",
|
||||
"fr-FR": "Französisch",
|
||||
"es-ES": "Spanisch",
|
||||
"pt-BR": "Portugiesisch",
|
||||
"it-IT": "Italienisch",
|
||||
"de-DE": "Deutsch",
|
||||
"ru-RU": "Russisch",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager hat nicht die erforderlichen Berechtigungen, um den Ordner zu verknüpfen.",
|
||||
"EACCES": "BSManager hat nicht die erforderlichen Berechtigungen, um den Ordner zu verknüpfen.",
|
||||
"ENOSPC": "Die Festplatte ist voll, machen Sie Platz und versuchen Sie es erneut.",
|
||||
"LinkingNotSupported": "Ordnerverknüpfung wird auf diesem Dateisystem nicht unterstützt.",
|
||||
"UNKNOWN_ERROR": "Ein unbekannter Fehler ist beim Verknüpfen des Ordners aufgetreten."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "Could not properly parse the env var string."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "outdated"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "This version is outdated, and some mods or features may no longer work as expected. Prefer using the recommended version ({recommendedVersion}) of Beat Saber to enjoy the latest features and bugfixes.",
|
||||
"advanced-launch": {
|
||||
"button": "Launch options",
|
||||
"placeholder": "Additional arguments ex: --revert; --nowait"
|
||||
"placeholder": "Launch options ex: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Skip Steam",
|
||||
"skipsteam-description": "Stops Steam from opening automatically with Beat Saber, enable if you are using a different VR runtime like WiVRn or Monado that SteamVR may interfere with.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Mods already installed",
|
||||
"description": "All selected mods are already installed"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Outdated Mod",
|
||||
"title-plural": "Outdated Mods",
|
||||
"description": "{name} mod is outdated. Do you want to update it?",
|
||||
"description-plural": "This version has {nb} outdated mods. Do you want to update them?",
|
||||
"dont-remind-me": "Don't remind me",
|
||||
"update": "Update"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "Spanish",
|
||||
"it-IT": "Italian",
|
||||
"de-DE": "German",
|
||||
"pt-BR": "Portuguese (Brazil)",
|
||||
"ru-RU": "Russian",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-TW": "Chinese (Traditional)",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager does not have the necessary permissions to link the folder.",
|
||||
"EACCES": "BSManager does not have the necessary permissions to link the folder.",
|
||||
"ENOSPC": "The disk is full, make some space and try again.",
|
||||
"LinkingNotSupported": "Folder linking is not supported on this file system.",
|
||||
"UNKNOWN_ERROR": "An unknown error has occurred while linking the folder."
|
||||
}
|
||||
},
|
||||
@@ -802,6 +818,11 @@
|
||||
"multiple": "These maps will also be removed from versions using shared maps"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete-duplicate-maps": {
|
||||
"title": "Delete maps?",
|
||||
"desc": "Only the map \"{map}\" is a duplicate. Are you sure you want to delete it?",
|
||||
"desc-plural": "{nb} duplicate maps have been found. Are you sure you want to delete them?"
|
||||
}
|
||||
},
|
||||
"link-contents":{
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Fijar",
|
||||
"unpin": "Desfijar"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "No se pudo analizar correctamente la cadena de la variable de entorno."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "obsoleto"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "Esta versión está desactualizada, y algunos mods o funciones pueden no funcionar como se espera. Es mejor usar la versión recomendada ({recommendedVersion}) de Beat Saber para disfrutar de las últimas características y correcciones.",
|
||||
"advanced-launch": {
|
||||
"button": "Opciones de lanzamiento",
|
||||
"placeholder": "Argumentos adicionales ej: --revert; --no-wait"
|
||||
"placeholder": "Opciones de lanzamiento ej: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Saltar Steam",
|
||||
"skipsteam-description": "Evita que Steam se abra automáticamente con Beat Saber, habilita esto si estás usando un runtime de VR diferente como WiVRn o Monado que SteamVR podría interferir.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Mods ya instalados",
|
||||
"description": "Todos los mods seleccionados ya están instalados"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Mod desactualizado",
|
||||
"title-plural": "Mods desactualizados",
|
||||
"description": "El mod {name} está desactualizado. ¿Quieres actualizarlo?",
|
||||
"description-plural": "Esta versión tiene {nb} mods desactualizados. ¿Quieres actualizarlos?",
|
||||
"dont-remind-me": "No me lo recuerdes",
|
||||
"update": "Actualizar"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Alemán",
|
||||
"pt-BR": "Portugués (Brasil)",
|
||||
"ru-RU": "Ruso",
|
||||
"zh-CN": "Chino simplificado",
|
||||
"zh-TW": "Chino tradicional",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager no tiene los permisos necesarios para enlazar la carpeta.",
|
||||
"EACCES": "BSManager no tiene los permisos necesarios para enlazar la carpeta.",
|
||||
"ENOSPC": "El disco está lleno, libera espacio e inténtalo de nuevo.",
|
||||
"LinkingNotSupported": "El enlace de carpetas no es compatible con este sistema de archivos.",
|
||||
"UNKNOWN_ERROR": "Se ha producido un error desconocido al enlazar la carpeta."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Épingler",
|
||||
"unpin": "Désépingler"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "Impossible d'analyser correctement la chaîne de variable d'environnement."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "obsolète"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "Cette version est obsolète et certains mods ou fonctionnalités peuvent ne plus fonctionner comme prévu. Préférez utiliser la version recommandée ({recommendedVersion}) de Beat Saber pour profiter des dernières fonctionnalités et correctifs.",
|
||||
"advanced-launch": {
|
||||
"button": "Options de lancement",
|
||||
"placeholder": "Arguments supplémentaires ex: --revert; --nowait"
|
||||
"placeholder": "Options de lancement ex: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Ignorer Steam",
|
||||
"skipsteam-description": "Empêche Steam de s'ouvrir automatiquement avec Beat Saber, activez-le si vous utilisez un autre runtime VR comme WiVRn ou Monado avec lequel SteamVR pourrait interférer.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Mods déjà installées",
|
||||
"description": "Tous les mods séléctionnées sont déjà installées"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Mod obsolète",
|
||||
"title-plural": "Mods obsolètes",
|
||||
"description": "Le mod {name} est obsolète. Voulez-vous le mettre à jour ?",
|
||||
"description-plural": "Cette version contient {nb} mods obsolètes. Voulez-vous les mettre à jour ?",
|
||||
"dont-remind-me": "Ne plus me rappeler",
|
||||
"update": "Mettre à jour"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -237,6 +251,7 @@
|
||||
"en-US": "Anglais, États-Unis",
|
||||
"fr-FR": "Français",
|
||||
"es-ES": "Espagnol",
|
||||
"pt-BR": "Portugais (Brésil)",
|
||||
"it-IT": "Italien",
|
||||
"de-DE": "Allemand",
|
||||
"ru-RU": "Russe",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager n'a pas les autorisations nécessaires pour lier le dossier.",
|
||||
"EACCES": "BSManager n'a pas les autorisations nécessaires pour lier le dossier.",
|
||||
"ENOSPC": "Le disque est plein, faites de la place et réessayez.",
|
||||
"LinkingNotSupported": "La liaison de dossier n'est pas supportée sur ce système de fichiers.",
|
||||
"UNKNOWN_ERROR": "Une erreur inconnue est survenue lors de la liaison du dossier."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Fissa",
|
||||
"unpin": "Rimuovi Fissaggio"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "Impossibile analizzare correttamente la stringa della variabile d'ambiente."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "obsoleta"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "Questa versione è obsoleta, e alcune mod o funzioni non potrebbero più funzionare correttamente. Consigliamo di usare la versione raccomandata ({recommendedVersion}) di Beat Saber per godere delle ultime funzioni e bugfix.",
|
||||
"advanced-launch": {
|
||||
"button": "Opzioni di Lancio",
|
||||
"placeholder": "Argomenti aggiuntivi es: --revert; --nowait"
|
||||
"placeholder": "Opzioni di Lancio es: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Salta Steam",
|
||||
"skipsteam-description": "Ferma Steam da aprirsi automaticamente con Beat Saber, abilitalo se stai usando un VR runtime differente come WiVRn o Monado con cui SteamVR potrebbe interferire.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Mod già installata",
|
||||
"description": "Tutte le mod selezionate sono già installate"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Mod obsoleto",
|
||||
"title-plural": "Mod obsoleti",
|
||||
"description": "Il mod {name} è obsoleto. Vuoi aggiornarlo?",
|
||||
"description-plural": "Questa versione ha {nb} mod obsoleti. Vuoi aggiornarli?",
|
||||
"dont-remind-me": "Non ricordarmelo",
|
||||
"update": "Aggiorna"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "Spagnolo",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Tedesco",
|
||||
"pt-BR": "Portoghese (Brasile)",
|
||||
"ru-RU": "Russo",
|
||||
"zh-CN": "Cinese (Semplificato)",
|
||||
"zh-TW": "Cinese (Tradizionale)",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager non ha i permessi necessari per condividere la cartella.",
|
||||
"EACCES": "BSManager non ha i permessi necessari per condividere la cartella.",
|
||||
"ENOSPC": "Il disco è pieno, libera un pò di spazio e riprova.",
|
||||
"LinkingNotSupported": "Il collegamento delle cartelle non è supportato su questo file system.",
|
||||
"UNKNOWN_ERROR": "C'è stato un errore sconosciuto durante la condivisione della cartella."
|
||||
}
|
||||
},
|
||||
@@ -802,6 +818,11 @@
|
||||
"multiple": "Queste mappe saranno rimosse anche dalle versioni che usano mappe condivise"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete-duplicate-maps": {
|
||||
"title": "Eliminare le mappe?",
|
||||
"desc": "Solo la mappa \"{map}\" è un duplicato. Sei sicuro di volerla eliminare?",
|
||||
"desc-plural": "Sono state trovate {nb} mappe duplicate. Sei sicuro di volerle eliminare?"
|
||||
}
|
||||
},
|
||||
"link-contents":{
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "固定",
|
||||
"unpin": "固定解除"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "環境変数の文字列を正しく解析できませんでした。"
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "時代遅れ"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "このバージョンは古いため、一部のMODや機能が期待通りに動作しない可能性があります。最新の機能やバグ修正を楽しむには、推奨バージョン ({recommendedVersion}) のBeat Saberを使用することをお勧めします。",
|
||||
"advanced-launch": {
|
||||
"button": "起動オプション",
|
||||
"placeholder": "追加引数 例:--revert; --nowait"
|
||||
"placeholder": "起動オプション 例:KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Steamをスキップ",
|
||||
"skipsteam-description": "Beat Saberと一緒にSteamが自動的に開くのを防ぎます。SteamVRが干渉する可能性のあるWiVRnやMonadoなど、別のVRランタイムを使用している場合は有効にしてください。",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "すでにインストール済みのMOD",
|
||||
"description": "選択したすべてのMODはすでにインストールされています"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "古いMod",
|
||||
"title-plural": "古いMod",
|
||||
"description": "{name} Modが古くなっています。更新しますか?",
|
||||
"description-plural": "このバージョンには古いModが{nb}個あります。更新しますか?",
|
||||
"dont-remind-me": "もう知らせない",
|
||||
"update": "更新する"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "スペイン語",
|
||||
"it-IT": "イタリア語",
|
||||
"de-DE": "ドイツ語",
|
||||
"pt-BR": "ポルトガル語 (ブラジル)",
|
||||
"ru-RU": "ロシア語",
|
||||
"zh-CN": "簡体字中国",
|
||||
"zh-TW": "繁体字中国",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManagerにはフォルダをリンクするための必要な権限がありません。",
|
||||
"EACCES": "BSManagerにはフォルダをリンクするための必要な権限がありません。",
|
||||
"ENOSPC": "ディスクがいっぱいです。空き容量を作ってもう一度試してください。",
|
||||
"LinkingNotSupported": "このファイルシステムではフォルダのリンクはサポートされていません。",
|
||||
"UNKNOWN_ERROR": "フォルダをリンク中に不明なエラーが発生しました。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "고정",
|
||||
"unpin": "고정 해제"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "환경 변수 문자열을 제대로 파싱할 수 없습니다."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "구식"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "이 버전은 오래되어 일부 모드나 기능이 예상대로 작동하지 않을 수 있습니다. 최신 기능과 버그 수정을 사용하려면 권장 버전({recommendedVersion})의 Beat Saber를 사용하는 것이 좋습니다.",
|
||||
"advanced-launch": {
|
||||
"button": "실행 옵션",
|
||||
"placeholder": "추가 인수 예: --revert; --nowait"
|
||||
"placeholder": "실행 옵션 예: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Steam 실행 건너뛰기",
|
||||
"skipsteam-description": "Beat Saber와 함께 Steam이 자동으로 열리는 것을 방지합니다. SteamVR이 간섭할 수 있는 WiVRn 또는 Monado와 같은 다른 VR 런타임을 사용하는 경우 활성화하세요.",
|
||||
@@ -148,7 +153,16 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "이미 설치된 모드",
|
||||
"description": "선택한 모든 모드가 이미 설치되었습니다."
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "구식 모드",
|
||||
"title-plural": "구식 모드",
|
||||
"description": "{name} 모드가 오래되었습니다. 업데이트하시겠습니까?",
|
||||
"description-plural": "이 버전에는 {nb}개의 구식 모드가 있습니다. 업데이트하시겠습니까?",
|
||||
"dont-remind-me": "다시는 알리지 않기",
|
||||
"update": "업데이트"
|
||||
}
|
||||
|
||||
},
|
||||
"drop-zone": {
|
||||
"text": "모드를 가져오기",
|
||||
@@ -227,6 +241,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +254,7 @@
|
||||
"es-ES": "스페인어",
|
||||
"it-IT": "이탈리아어",
|
||||
"de-DE": "독일어",
|
||||
"pt-BR": "포르투갈 인 (브라질)",
|
||||
"ru-RU": "러시아어",
|
||||
"zh-CN": "중국어 (간체)",
|
||||
"zh-TW": "중국어 (번체)",
|
||||
@@ -641,6 +657,7 @@
|
||||
"EPERM": "BSManager에 폴더를 연결하기 위한 권한이 부족합니다.",
|
||||
"EACCES": "BSManager에 폴더를 연결하기 위한 권한이 부족합니다.",
|
||||
"ENOSPC": "디스크가 가득 찼습니다. 공간을 확보하고 다시 시도하세요.",
|
||||
"LinkingNotSupported": "이 파일 시스템에서는 폴더 연결이 지원되지 않습니다.",
|
||||
"UNKNOWN_ERROR": "폴더를 연결하는 중 알 수 없는 오류가 발생했습니다."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "Закрепить",
|
||||
"unpin": "Открепить"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "Не удалось правильно разобрать строку переменной окружения."
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "устаревший"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "Эта версия устарела, и некоторые моды или функции могут работать не так, как ожидалось. Рекомендуется использовать рекомендуемую версию ({recommendedVersion}) Beat Saber, чтобы воспользоваться последними функциями и исправлениями ошибок.",
|
||||
"advanced-launch": {
|
||||
"button": "Параметры запуска",
|
||||
"placeholder": "Параметры запуска, например: --revert; --nowait"
|
||||
"placeholder": "Параметры запуска, например: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "Пропустить Steam",
|
||||
"skipsteam-description": "Предотвращает автоматическое открытие Steam с Beat Saber, включите, если вы используете другую VR-среду, такую как WiVRn или Monado, с которой SteamVR может мешать.",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "Моды уже установлены",
|
||||
"description": "Все выбранные моды уже установлены"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "Устаревший мод",
|
||||
"title-plural": "Устаревшие моды",
|
||||
"description": "Мод {name} устарел. Хотите его обновить?",
|
||||
"description-plural": "В этой версии есть {nb} устаревших модов. Хотите их обновить?",
|
||||
"dont-remind-me": "Не напоминать мне",
|
||||
"update": "Обновить"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "Испанский",
|
||||
"it-IT": "Итальянец",
|
||||
"de-DE": "Немецкий",
|
||||
"pt-BR": "португальский (Бразилия)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "Китайский (упрощенный)",
|
||||
"zh-TW": "Китайский (традиционный)",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager не имеет необходимых прав для связывания папки.",
|
||||
"EACCES": "BSManager не имеет необходимых прав для связывания папки.",
|
||||
"ENOSPC": "Диск заполнен, освободите место и попробуйте снова.",
|
||||
"LinkingNotSupported": "Ссылки на папки не поддерживаются в этой файловой системе.",
|
||||
"UNKNOWN_ERROR": "Произошла неизвестная ошибка при связывании папки."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "固定",
|
||||
"unpin": "取消固定"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "無法正確解析環境變數字串。"
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "過時"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "此版本已過時,某些模組或功能可能無法按預期運作。建議使用推薦版本的 Beat Saber ({recommendedVersion}),以享受最新功能和修復。",
|
||||
"advanced-launch": {
|
||||
"button": "啟動選項",
|
||||
"placeholder": "額外啟動參數,例如: --revert; --nowait"
|
||||
"placeholder": "啟動選項,例如: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "跳過 Steam",
|
||||
"skipsteam-description": "防止 Steam 與 Beat Saber 自動打開,如果您使用的是其他 VR 執行時,如 WiVRn 或 Monado,SteamVR 可能會干擾,請啟用此選項。",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "模組已安裝",
|
||||
"description": "所有選中的模組已經安裝"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "過時的模組",
|
||||
"title-plural": "過時的模組",
|
||||
"description": "{name} 模組已過時。您想更新嗎?",
|
||||
"description-plural": "此版本有 {nb} 個過時的模組。您想更新它們嗎?",
|
||||
"dont-remind-me": "不要提醒我",
|
||||
"update": "更新"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -227,6 +240,7 @@
|
||||
"es-ES": "Español",
|
||||
"it-IT": "Italiano",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"ru-RU": "Русский",
|
||||
"zh-CN": "簡體中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -239,6 +253,7 @@
|
||||
"es-ES": "西班牙語",
|
||||
"it-IT": "義大利語",
|
||||
"de-DE": "德語",
|
||||
"pt-BR": "葡萄牙語 (巴西)",
|
||||
"ru-RU": "俄語",
|
||||
"zh-CN": "簡體中文",
|
||||
"zh-TW": "正體中文",
|
||||
@@ -641,6 +656,7 @@
|
||||
"EPERM": "BSManager沒有必要的權限來連結文件夾。",
|
||||
"EACCES": "BSManager沒有必要的權限來連結文件夾。",
|
||||
"ENOSPC": "磁碟已滿,請騰出空間後再試。",
|
||||
"LinkingNotSupported": "該檔案系統不支援資料夾連結。",
|
||||
"UNKNOWN_ERROR": "連結文件夾時發生未知錯誤。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"pin": "固定",
|
||||
"unpin": "取消固定"
|
||||
},
|
||||
"generic": {
|
||||
"env": {
|
||||
"parse": "无法正确解析环境变量字符串。"
|
||||
}
|
||||
},
|
||||
"title-bar": {
|
||||
"outdated": "过时"
|
||||
},
|
||||
@@ -51,7 +56,7 @@
|
||||
"outdated-tippy": "此版本已过时,某些模组或功能可能无法按预期运行。建议使用推荐版本的 Beat Saber ({recommendedVersion}),以享受最新功能和修复。",
|
||||
"advanced-launch": {
|
||||
"button": "启动选项",
|
||||
"placeholder": "额外启动参数,例如: --revert; --nowait"
|
||||
"placeholder": "启动选项,例如: KEY=VALUE %command% fpfc"
|
||||
},
|
||||
"skipsteam": "跳过 Steam",
|
||||
"skipsteam-description": "防止 Steam 与 Beat Saber 自动打开,如果您使用的是其他 VR 运行时,如 WiVRn 或 Monado,SteamVR 可能会干扰,请启用此选项。",
|
||||
@@ -148,6 +153,14 @@
|
||||
"all-mods-already-installed": {
|
||||
"title": "模组已安装",
|
||||
"description": "所有选中的模组已经安装"
|
||||
},
|
||||
"outdated-mods": {
|
||||
"title": "过时的模组",
|
||||
"title-plural": "过时的模组",
|
||||
"description": "{name} 模组已过时。您想更新吗?",
|
||||
"description-plural": "此版本有 {nb} 个过时的模组。您想更新它们吗?",
|
||||
"dont-remind-me": "不要提醒我",
|
||||
"update": "更新"
|
||||
}
|
||||
},
|
||||
"drop-zone": {
|
||||
@@ -239,6 +252,7 @@
|
||||
"es-ES": "西班牙语",
|
||||
"it-IT": "意大利语",
|
||||
"de-DE": "德语",
|
||||
"pt-BR": "葡萄牙语 (巴西)",
|
||||
"ru-RU": "俄语",
|
||||
"zh-CN": "简体中文",
|
||||
"zh-TW": "正体中文",
|
||||
@@ -641,6 +655,7 @@
|
||||
"EPERM": "BSManager没有必要的权限来链接文件夹。",
|
||||
"EACCES": "BSManager没有必要的权限来链接文件夹。",
|
||||
"ENOSPC": "磁盘已满,请腾出空间后再试。",
|
||||
"LinkingNotSupported": "该文件系统不支持文件夹链接。",
|
||||
"UNKNOWN_ERROR": "链接文件夹时发生未知错误。"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,2 @@
|
||||
# Add write permissions to anybody so that this can be sync with the
|
||||
# github's bs-versions.json when starting bsmanager
|
||||
/usr/bin/chmod +002 /opt/BSManager/resources/assets/jsons/bs-versions.json
|
||||
|
||||
# https://github.com/electron/electron/issues/42510
|
||||
/usr/bin/chmod 4755 /opt/BSManager/chrome-sandbox
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
## Table of Contents
|
||||
|
||||
- [With BSManager](#with-bsmanager)
|
||||
- [Install playlists](#how-to-install-playlists-with-bsmanager)
|
||||
- [Import playlists](#how-to-import-playlist-with-bsmanager)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [One-Click Installation](#one-click-installation)
|
||||
|
||||
## With BSManager
|
||||
|
||||
(Coming with version 1.5.0)
|
||||
### How to Install Playlists with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch BSManager and select the version of *Beat Saber* where you want to install playlists from the list on the left side of the interface.
|
||||
3. Go to the `Playlist` section by clicking on the __`Maps`__ tab.
|
||||
4. Click the __`Add`__ button, then select __`Browse playlist`__ to open the list of online playlists.
|
||||
5. Use the search bar at the top of the interface to filter playlists or sort the list.
|
||||
6. Download to install it.
|
||||
7. Wait for the installation to complete. Once it's finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) and enjoy your new playlists!
|
||||
|
||||
> [!NOTE]
|
||||
> You can click on a playlist's image to view the maps it contains and download only the maps you want.
|
||||
|
||||
### How to Import Playlist with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch BSManager and select the version of *Beat Saber* where you want to install playlists from the list on the left side of the interface.
|
||||
3. Go to the `playlist` section by clicking on the __`Maps`__ tab.
|
||||
4. From here, there are two possible methods:
|
||||
1. Click the __`Add`__ button, then select __`Import a playlist`__ and choose the file to import.
|
||||
2. Drag and drop your file directly onto the playlist interface in BSManager.
|
||||
5. Wait for the import to complete. Once finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) and enjoy your new playlists!
|
||||
|
||||
> [!Important]
|
||||
> The file must be in `.bplist` or `.json` format to be imported.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
## Table of Contents
|
||||
|
||||
- [With BSManager](#with-bsmanager)
|
||||
- [Install maps](#how-to-install-maps-with-bsmanager)
|
||||
- [Import maps](#how-to-import-maps-with-bsmanager)
|
||||
- [Manual Installation](#manual-installation)
|
||||
- [One-Click Installation](#one-click-installation)
|
||||
|
||||
## With BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from [GitHub](https://github.com/Zagrios/bs-manager/releases/latest).
|
||||
2. Launch [BSManager](https://www.bsmanager.io) and select the Beat Saber version where you want to install maps from the list on the left of the interface.
|
||||
3. Go to maps by clicking on the __`Maps`__ tab.
|
||||
4. Click on the __`Add`__ button to access the list of online maps.
|
||||
5. Find the maps that you want to install from the provided list. You can use the search bar at the top of the interface to filter maps by __`name`__, __`type`__, __`mods`__, __`tags`__, __`notes per second`__, __`duration`__, and order the list by __`latest`__, __`relevance`__, __`rating`__, or __`curated`__.
|
||||
6. Double-click on the map or simply click on the download button to install the map.
|
||||
7. Wait for the installation to complete. Once it's finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new maps!
|
||||
### How to Install Maps with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch BSManager and select the version of *Beat Saber* where you want to install maps from the list on the left side of the interface.
|
||||
3. Go to the `Maps` section by clicking on the __`Maps`__ tab.
|
||||
4. Click the __`Add`__ button to access the list of online maps.
|
||||
5. Use the search bar at the top of the interface to filter maps by __`name`__, __`type`__, __`mods`__, __`tags`__, __`notes per second`__, or __`duration`__, and order the list by __`latest`__, __`relevance`__, __`rating`__, or __`curated`__.
|
||||
6. Download to install it.
|
||||
7. Wait for the installation to complete. Once it's finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new maps!
|
||||
|
||||
### How to Import Maps with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch BSManager and select the version of *Beat Saber* where you want to install maps from the list on the left side of the interface.
|
||||
3. Go to the `Maps` section by clicking on the __`Maps`__ tab.
|
||||
4. From here, there are two possible methods:
|
||||
1. Click the __`Add`__ button, then select __`Import maps`__ and choose the .zip file to import.
|
||||
2. Drag and drop your .zip file directly onto the maps interface in BSManager.
|
||||
5. Wait for the import to complete. Once finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new maps!
|
||||
|
||||
> [!Important]
|
||||
> The file must be in `.zip` format to be imported.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
||||
@@ -12,17 +12,20 @@
|
||||
- [With BSManager](#with-bsmanager)
|
||||
- [One-Click Installation](#one-click-installation)
|
||||
|
||||
# With BSManager
|
||||
## With BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from [GitHub](https://github.com/Zagrios/bs-manager/releases/latest), [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files), or [Website](https://www.bsmanager.io).
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch [BSManager](https://www.bsmanager.io) and select the Beat Saber version where you want to install models from the list on the left of the interface.
|
||||
3. Go to models by clicking on the __`Models`__ tab.
|
||||
4. Click on the __`Add`__ button to access the list of online models.
|
||||
5. Find the models that you want to install from the provided list. You can use the search bar at the top of the interface to filter models by __`name`__, __`type`__, and order the list by __`date`__, __`name`__, or __`author`__.
|
||||
6. Double-click on the model or simply click on the download button to install the model.
|
||||
6. Download to install the model.
|
||||
7. Wait for the installation to complete. Once it's finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new models!
|
||||
|
||||
# One-Click Installation
|
||||
## One-Click Installation
|
||||
|
||||
1. Launch [BSManager](https://www.bsmanager.io) and make sure that the One-Click installation feature is enabled for models.
|
||||
2. Go to one of the supported websites and search for the models you want to install.
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
## Table of Contents
|
||||
|
||||
- [With BSManager](#with-bsmanager)
|
||||
- [Install mods](#how-to-install-mods-with-bsmanager)
|
||||
- [Import mods](#how-to-import-mods-with-bsmanager)
|
||||
- [Manual Installation](#manual-installation)
|
||||
|
||||
## With BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from [GitHub](https://github.com/Zagrios/bs-manager/releases/latest), [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files), or the [official website](https://www.bsmanager.io).
|
||||
### How to Install Mods with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
2. Launch [BSManager](https://www.bsmanager.io) and select the version of Beat Saber that you want to use from the list on the left side of the interface.
|
||||
3. Go to mods by clicking on the __`Mods`__ tab.
|
||||
4. Find the mods that you want to install from the provided list. You can use the search bar at the top of the interface to filter mods by name.
|
||||
@@ -13,6 +20,22 @@
|
||||
6. Click __`Install or Update`__ at the bottom-right corner of the interface.
|
||||
7. Wait for the installation to complete. Once it's finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new mods!
|
||||
|
||||
### How to Import Mods with BSManager
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Launch BSManager and select the version of *Beat Saber* where you want to install mods from the list on the left side of the interface.
|
||||
3. Go to the `Mods` tab.
|
||||
4. From here, there are two possible methods:
|
||||
1. Click on __`⁝`__ button, then select __`Import mods`__ and choose the .zip or .dll file to import.
|
||||
2. Drag and drop your .zip or .dll file directly onto the mods interface in BSManager.
|
||||
5. Wait for the import to complete. Once finished, you can launch Beat Saber directly from [BSManager](https://www.bsmanager.io) to enjoy your new mods!
|
||||
|
||||
> [!Important]
|
||||
> The file must be in `.zip` or `.dll` format to be imported.
|
||||
|
||||
## Manual Installation
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
|
After Width: | Height: | Size: 112 KiB |
@@ -15,6 +15,10 @@
|
||||
4. Choose your launch options.
|
||||
5. Click on __`Create a Shortcut`__. It should now appear on your desktop.
|
||||
|
||||
<div align="center">
|
||||
<img src="../wiki/Guides/Installation-and-updates/Create-a-shortcut/BsmShortcutInterface.png" alt="SeeTxtFile.png" />
|
||||
</div>
|
||||
|
||||
### On Taskbar
|
||||
|
||||
1. Open Notepad and drag & drop the version shortcut you need.
|
||||
@@ -31,25 +35,12 @@ start <version link>
|
||||
|
||||
## How to create a shortcut on Steam
|
||||
|
||||
1. In [BSManager](https://www.bsmanager.io), select the version you want to open in Steam from the list on the left side.
|
||||
2. Click the gear icon at the top-right corner and select __`Create a Shortcut`__. This will create a shortcut on your desktop for launching the game.
|
||||
3. Open Notepad (or any other text editor) and drag & drop the shortcut file into the text editor. You will see a string of text similar to this:
|
||||
|
||||
```yaml
|
||||
bsmanager://launch/?version=1.29.1&versionIno=3940649674381032
|
||||
```
|
||||
|
||||
4. Copy this text and remove any spaces.
|
||||
|
||||
<div align="center">
|
||||
<img src="../wiki/Guides/Installation-and-updates/Create-a-shortcut/SeeTxtFile.png" alt="SeeTxtFile.png" />
|
||||
</div>
|
||||
|
||||
5. Open Steam and add [BSManager](https://www.bsmanager.io) as a non-Steam game.
|
||||
6. In your Steam library, right-click on [BSManager](https://www.bsmanager.io), select __`Properties`__, and add the link from step 4 as a launch parameter.
|
||||
7. Disable "Desktop Game Theatre" and scroll down to enable "Include in VR Library" so you can launch it from your VR headset.
|
||||
8. Rename this entry to whatever you want and customize the name and the icon of the shortcut if desired.
|
||||
1. Launch [BSManager](https://www.bsmanager.io) and select your version.
|
||||
2. Click on the gear icon at the top-right corner of the version.
|
||||
3. Click on the __`Create a Shortcut`__ button.
|
||||
4. Choose your launch options.
|
||||
5. Click on __`Create a Shortcut`__. It should now appear on your desktop.
|
||||
|
||||
<div align="center">
|
||||
<img src="../wiki/Guides/Installation-and-updates/Create-a-shortcut/SteamShortcut.png" alt="SteamShortcut.png" />
|
||||
<img src="../wiki/Guides/Installation-and-updates/Create-a-shortcut/SteamShortcutCheck.png" alt="SeeTxtFile.png" />
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -10,7 +10,10 @@
|
||||
|
||||
## Install a Version with Steam
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from [GitHub](https://github.com/Zagrios/bs-manager/releases/latest), [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files), or the [official website](https://www.bsmanager.io).
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Open the __`Add Versions`__ interface and select the version you wish to download.
|
||||
3. Choose __`Steam`__ as your platform.
|
||||
4. Enter your Steam credentials (ID and password) or scan the QR code using the Steam app on your phone.
|
||||
@@ -28,15 +31,29 @@
|
||||
> - Ensure that Beat Saber is in your __Oculus PCVR__ library to enable downgrading.
|
||||
> - You must run Beat Saber at least once on your computer before attempting to launch retrograde Oculus versions from [BSManager](https://www.bsmanager.io).
|
||||
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from [GitHub](https://github.com/Zagrios/bs-manager/releases/latest), [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files), or the [official website](https://www.bsmanager.io).
|
||||
1. Download and install [BSManager](https://www.bsmanager.io) from one of the following sources:
|
||||
- [GitHub](https://github.com/Zagrios/bs-manager/releases/latest)
|
||||
- [Official Website](https://www.bsmanager.io)
|
||||
- [Nexus Mods](https://www.nexusmods.com/beatsaber/mods/18?tab=files)
|
||||
2. Open the __`Add Versions`__ interface and select the version you wish to download.
|
||||
3. Choose __`Oculus Store`__ as your platform.
|
||||
4. Follow [this guide](How-to-obtain-your-Oculus-Token) to obtain your Oculus token.
|
||||
5. Once logged into Oculus, the download will start automatically. Please wait for it to complete.
|
||||
6. When the download is finished, select the downloaded version and click the __`Launch`__ button to verify that the game launches correctly.
|
||||
4. Click on __`Connect to Meta`__ or __`Enter my login token manually`__ based on your preferred login method.
|
||||
|
||||
### If you selected __`Connect to Meta`__
|
||||
|
||||
1. You will be redirected to the Oculus website. Enter your email and follow the instructions to log in.
|
||||
2. Once logged in, the download will start automatically. Please wait for it to complete.
|
||||
3. When the download finishes, select the downloaded version and click the __`Launch`__ button to verify that the game launches correctly.
|
||||
|
||||
### If you selected __`Enter my login token manually`__
|
||||
|
||||
1. Follow [this guide](How-to-obtain-your-Oculus-Token) to obtain your Oculus token.
|
||||
2. Once the token is entered and validated, the download will start automatically. Please wait for it to complete.
|
||||
3. When the download finishes, select the downloaded version and click the __`Launch`__ button to verify that the game launches correctly.
|
||||
|
||||
<div align="center">
|
||||
<img src="../wiki/Guides/Installation-and-updates/Install-or-import-a-version/OculusLogin.png" alt="OculusLogin.png" />
|
||||
<img src="../wiki/Guides/Installation-and-updates/Install-or-import-a-version/OculusLoginToken.png" alt="OculusLoginToken.png" />
|
||||
</div>
|
||||
|
||||
## How to Import a Version
|
||||
|
||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -49,7 +49,6 @@ Guidance on fixing errors during setup or version updates
|
||||
|
||||
- __🐧Linux__:
|
||||
- [Missing Icons in Game]([Linux]-Missing-Icons-in-Game)
|
||||
- [Permission Denied on "bs-versions.json"]([Linux]-Permission-Denied-on-bs-version.json)
|
||||
- [[Deb] The SUID Sandbox Helper Binary Was Found]([Linux]-[deb]-The-SUID-Sandbox-Helper-Binary-Was-Found)
|
||||
- [[Flatpak] Steam Beat Saber Version Not Showing / Proton Not Detected]([Linux]-[Flatpak]-Steam-Beat-Saber-Version-Not-Showing-Proton-Not-Detected)
|
||||
- [[Flatpak] Changing Installation Folder]([Linux]-[Flatpak]-Changing-Installation-Folder)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<pre>
|
||||
Unhandled Exception UnhandledRejection Error: EACCES: permission denied, open '/opt/BSManager/resources/assets/jsons/bs-versions.json'
|
||||
</pre>
|
||||
|
||||
To fix this issue, the current user must have write permissions to the "bs-versions.json". To correct the permissions do command below:
|
||||
|
||||
```bash
|
||||
chmod +002 /opt/BSManager/resources/assets/jsons/bs-versions.json
|
||||
|
||||
# or
|
||||
|
||||
chown $(whoami) /opt/BSManager/resources/assets/jsons/bs-versions.json
|
||||
```
|
||||
@@ -71,7 +71,6 @@
|
||||
<summary>🐧 Linux</summary>
|
||||
<ul>
|
||||
<li><a href="%5BLinux%5D-Missing-Icons-in-Game">Missing Icons in Game</a></li>
|
||||
<li><a href="%5BLinux%5D-Permission-Denied-on-bs-version.json">Permission Denied on "bs-versions.json"</a></li>
|
||||
<li><a href="%5BLinux%5D-%5Bdeb%5D-The-SUID-Sandbox-Helper-Binary-Was-Found">[Deb] The SUID Sandbox Helper Binary Was Found</a></li>
|
||||
<li><a href="%5BLinux%5D-%5BFlatpak%5D-Steam-Beat-Saber-Version-Not-Showing-Proton-Not-Detected">[Flatpak] Steam Beat Saber Version Not Showing / Proton Not Detected</a></li>
|
||||
<li><a href="%5BLinux%5D-%5BFlatpak%5D-Changing-Installation-Folder">[Flatpak] Changing Installation Folder</a></li>
|
||||
|
||||
@@ -29,6 +29,7 @@ const config = {
|
||||
linux: {
|
||||
target: [
|
||||
"deb",
|
||||
"rpm",
|
||||
],
|
||||
icon: "./build/icons/png",
|
||||
category: "Utility;Game;",
|
||||
@@ -69,6 +70,8 @@ const config = {
|
||||
"--filesystem=~/.steam/steam/steamapps:ro", // for the libraryfolders.vdf
|
||||
"--filesystem=~/.steam/steam/steamapps/common:create", // Steam game folder
|
||||
"--filesystem=~/.steam/steam/steamapps/common/Beat Saber:create", // For installing mods/maps to original Beat Saber version
|
||||
"--filesystem=~/Desktop", // allow writing shortcuts to desktop
|
||||
"--filesystem=~/.steam/steam/userdata", // allow writing steam shortcuts
|
||||
// Allow BSManager to create the compat folder if it does not exist
|
||||
"--filesystem=~/.steam/steam/steamapps/compatdata/620980:create",
|
||||
// Allow communication with network
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.7",
|
||||
"version": "1.5.0-alpha.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.7",
|
||||
"version": "1.5.0-alpha.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@nextui-org/date-picker": "^2.0.7",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "bs-manager",
|
||||
"description": "Manage maps, mods and more for Beat Saber",
|
||||
"main": "./.erb/dll/main.bundle.dev.js",
|
||||
"version": "1.5.0-alpha.8",
|
||||
"version": "1.5.2",
|
||||
"scripts": {
|
||||
"build-rust-scripts": "ts-node ./.erb/scripts/build-rust-scripts.js",
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
@@ -55,7 +55,7 @@
|
||||
"url": "https://github.com/Zagrios"
|
||||
},
|
||||
"contributors": [],
|
||||
"license": "MIT",
|
||||
"license": "GPL-3.0-only",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Zagrios/bs-manager/issues"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.8",
|
||||
"version": "1.5.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.8",
|
||||
"version": "1.5.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.8",
|
||||
"version": "1.5.2",
|
||||
"description": "BSManager",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { parseEnvString } from "main/helpers/env.helpers";
|
||||
|
||||
describe("Test parseEnvString", () => {
|
||||
|
||||
it("Empty", () => {
|
||||
const envVars = parseEnvString("");
|
||||
expect(envVars).toEqual({});
|
||||
});
|
||||
|
||||
it("Single test; no quotes", () => {
|
||||
const envString = "HELLO=World!";
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual({
|
||||
HELLO: "World!",
|
||||
});
|
||||
});
|
||||
|
||||
it("Single test; single quotes", () => {
|
||||
const envString = "SINGLE_QOUTE='Single quote with spaces'";
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual({
|
||||
SINGLE_QOUTE: "Single quote with spaces",
|
||||
});
|
||||
});
|
||||
|
||||
it("Single test; double quotes", () => {
|
||||
const envString = 'DOUBLE_QOUTE="Some random quote."';
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual({
|
||||
DOUBLE_QOUTE: "Some random quote.",
|
||||
});
|
||||
});
|
||||
|
||||
it("Single test; empty value", () => {
|
||||
const envString = "EMPTY=";
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual({
|
||||
EMPTY: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("Multiple test; combined", () => {
|
||||
const envString = `HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=`
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual(expect.objectContaining({
|
||||
HELLO: "World!",
|
||||
DOUBLE_QUOTE: "Two Words",
|
||||
SINGLE_QUOTE: "",
|
||||
EMPTY: ""
|
||||
}));
|
||||
});
|
||||
|
||||
it("Key with numbers and lower case", () => {
|
||||
const envString = "H3ll0=world";
|
||||
const envVars = parseEnvString(envString);
|
||||
expect(envVars).toEqual({
|
||||
H3ll0: "world",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mkdir, pathExistsSync, rm, writeFile } from "fs-extra";
|
||||
import { getSize } from "main/helpers/fs.helpers";
|
||||
import path from "path";
|
||||
|
||||
jest.mock("electron", () => ({ app: {
|
||||
getPath: () => "",
|
||||
getName: () => "",
|
||||
}}));
|
||||
jest.mock("electron-log", () => ({
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_FOLDER = path.resolve(__dirname, "..", "assets", "fs");
|
||||
|
||||
describe("Test fs.helpers getSize", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
if (pathExistsSync(TEST_FOLDER)) {
|
||||
await rm(TEST_FOLDER, { recursive: true, force: true });
|
||||
}
|
||||
await mkdir(TEST_FOLDER);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(TEST_FOLDER, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should return 0 for empty folder", async () => {
|
||||
const size = await getSize(TEST_FOLDER);
|
||||
expect(size).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw error for non-existing folder", async () => {
|
||||
await expect(getSize(`${TEST_FOLDER}1`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should return the total size of files in the directory", async () => {
|
||||
const filePath = path.join(TEST_FOLDER, "testFile.bin");
|
||||
const buffer = Buffer.alloc(10);
|
||||
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const size = await getSize(TEST_FOLDER);
|
||||
expect(size).toBe(10);
|
||||
});
|
||||
|
||||
it("should include the sizes of all files in the directory", async () => {
|
||||
const filePath1 = path.join(TEST_FOLDER, "testFile1.bin");
|
||||
const filePath2 = path.join(TEST_FOLDER, "testFile2.bin");
|
||||
const buffer = Buffer.alloc(10);
|
||||
|
||||
await writeFile(filePath1, buffer);
|
||||
await writeFile(filePath2, buffer);
|
||||
|
||||
const size = await getSize(TEST_FOLDER);
|
||||
expect(size).toBe(20);
|
||||
});
|
||||
|
||||
it("should include the sizes of files in nested directories", async () => {
|
||||
const subFolder = path.join(TEST_FOLDER, "subFolder");
|
||||
const filePath1 = path.join(TEST_FOLDER, "testFile1.bin");
|
||||
const filePath2 = path.join(subFolder, "testFile2.bin");
|
||||
const buffer = Buffer.alloc(10);
|
||||
|
||||
await mkdir(subFolder);
|
||||
|
||||
await writeFile(filePath1, buffer);
|
||||
await writeFile(filePath2, buffer);
|
||||
|
||||
const size = await getSize(TEST_FOLDER);
|
||||
expect(size).toBe(20);
|
||||
});
|
||||
|
||||
it("should not include files beyond the default depth limit", async () => {
|
||||
const subFolder = path.join(TEST_FOLDER, "1", "2", "3", "4", "5");
|
||||
const filePath = path.join(subFolder, "testFile.bin");
|
||||
const buffer = Buffer.alloc(10);
|
||||
|
||||
await mkdir(subFolder, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const size = await getSize(TEST_FOLDER);
|
||||
expect(size).toBe(0);
|
||||
});
|
||||
|
||||
it("should include files within the specified depth limit", async () => {
|
||||
const subFolder = path.join(TEST_FOLDER, "1", "2", "3", "4", "5");
|
||||
const filePath = path.join(subFolder, "testFile.bin");
|
||||
const filePath2 = path.join(TEST_FOLDER, "1", "2", "testFile2.bin");
|
||||
const buffer = Buffer.alloc(10);
|
||||
|
||||
await mkdir(subFolder, { recursive: true });
|
||||
await writeFile(filePath, buffer);
|
||||
await writeFile(filePath2, buffer);
|
||||
|
||||
const size = await getSize(TEST_FOLDER, 6);
|
||||
expect(size).toBe(20);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
import { ProviderPlatform } from "shared/models/provider-platform.enum";
|
||||
|
||||
export function execOnOs<T>(executions: { [key in ProviderPlatform]?: () => T }, noError = false): T {
|
||||
@@ -10,4 +11,118 @@ export function execOnOs<T>(executions: { [key in ProviderPlatform]?: () => T },
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
enum EnvParserState {
|
||||
NAME_START,
|
||||
NAME,
|
||||
VALUE_START,
|
||||
VALUE,
|
||||
QUOTE_VALUE,
|
||||
DQUOTE_VALUE,
|
||||
SPACE,
|
||||
ERROR,
|
||||
};
|
||||
|
||||
const isAlphaCharacter = (c: string) =>
|
||||
(c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
||||
const isNumber = (c: string) => c >= "0" && c <= "9";
|
||||
|
||||
export function parseEnvString(envString: string): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
let state: EnvParserState = EnvParserState.NAME_START;
|
||||
let index = 0;
|
||||
let newName = "";
|
||||
for (let pos = 0; pos < envString.length; ++pos) {
|
||||
const c = envString[pos];
|
||||
|
||||
switch (state) {
|
||||
case EnvParserState.NAME_START:
|
||||
if (isAlphaCharacter(c) || c === "_") {
|
||||
state = EnvParserState.NAME;
|
||||
index = pos;
|
||||
} else if (c !== " ") {
|
||||
state = EnvParserState.ERROR;
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.NAME:
|
||||
if (c === "=") {
|
||||
state = EnvParserState.VALUE_START;
|
||||
newName = envString.substring(index, pos);
|
||||
index = pos + 1;
|
||||
} else if (!isAlphaCharacter(c) && !isNumber(c) && c !== "_") {
|
||||
state = EnvParserState.ERROR;
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.VALUE_START:
|
||||
if (c === "'") {
|
||||
++index;
|
||||
state = EnvParserState.QUOTE_VALUE;
|
||||
} else if (c === '"') {
|
||||
++index;
|
||||
state = EnvParserState.DQUOTE_VALUE;
|
||||
} else if (c === " ") {
|
||||
state = EnvParserState.NAME_START;
|
||||
envVars[newName] = "";
|
||||
} else {
|
||||
state = EnvParserState.VALUE;
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.VALUE:
|
||||
if (c === " ") {
|
||||
state = EnvParserState.NAME_START;
|
||||
envVars[newName] = envString.substring(index, pos);
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.QUOTE_VALUE:
|
||||
if (c === "'") {
|
||||
state = EnvParserState.SPACE;
|
||||
envVars[newName] = envString.substring(index, pos);
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.DQUOTE_VALUE:
|
||||
if (c === '"') {
|
||||
state = EnvParserState.SPACE;
|
||||
envVars[newName] = envString.substring(index, pos);
|
||||
}
|
||||
break;
|
||||
|
||||
case EnvParserState.SPACE:
|
||||
if (c === " ") {
|
||||
state = EnvParserState.NAME_START;
|
||||
} else {
|
||||
state = EnvParserState.ERROR;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
if (state === EnvParserState.ERROR) {
|
||||
throw new CustomError(
|
||||
`parseEnvString failed: invalid character at position ${pos}`,
|
||||
"generic.env.parse"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (state === EnvParserState.VALUE_START || state === EnvParserState.VALUE) {
|
||||
envVars[newName] = envString.substring(index);
|
||||
return envVars;
|
||||
}
|
||||
|
||||
if (state === EnvParserState.NAME_START || state === EnvParserState.SPACE) {
|
||||
return envVars;
|
||||
}
|
||||
|
||||
throw new CustomError(
|
||||
"parseEnvString failed: invalid ending state",
|
||||
"generic.env.parse"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CopyOptions, MoveOptions, copy, createReadStream, ensureDir, move, pathExists, pathExistsSync, realpath, stat, symlink } from "fs-extra";
|
||||
import { access, mkdir, rm, readdir, unlink, lstat, readlink } from "fs/promises";
|
||||
import { CopyOptions, MoveOptions, RmOptions, copy, createReadStream, ensureDir, move, pathExists, pathExistsSync, realpath, rm, stat, symlink, unlink, unlinkSync } from "fs-extra";
|
||||
import { access, mkdir, readdir, lstat, readlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import { Observable, concatMap, from } from "rxjs";
|
||||
import log from "electron-log";
|
||||
@@ -28,18 +28,53 @@ export async function ensureFolderExist(path: string): Promise<void> {
|
||||
.then(() => {});
|
||||
}
|
||||
|
||||
export async function deleteFolder(folderPath: string): Promise<void> {
|
||||
export async function deleteFile(filepath: string) {
|
||||
try {
|
||||
log.info("Deleting file", `"${filepath}"`);
|
||||
await unlink(filepath);
|
||||
} catch (error: any) {
|
||||
log.error("Could not delete file", `"${filepath}"`);
|
||||
throw CustomError.fromError(error, "generic.fs.delete-file");
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteFileSync(filepath: string) {
|
||||
try {
|
||||
log.info("Deleting file", `"${filepath}"`);
|
||||
unlinkSync(filepath);
|
||||
} catch (error: any) {
|
||||
log.error("Could not delete file", `"${filepath}"`);
|
||||
throw CustomError.fromError(error, "generic.fs.delete-file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFolder(folderPath: string, options?: RmOptions) {
|
||||
if (!(await pathExist(folderPath))) {
|
||||
return;
|
||||
}
|
||||
return rm(folderPath, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
options = options || { recursive: true, force: true };
|
||||
log.info("Deleting folder", `"${folderPath}"`, options);
|
||||
await rm(folderPath, options);
|
||||
} catch (error: any) {
|
||||
log.error("Could not delete folder", `"${folderPath}"`);
|
||||
throw CustomError.fromError(error, "generic.fs.delete-folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlinkPath(path: string): Promise<void> {
|
||||
if (!(await pathExist(path))) {
|
||||
export function deleteFolderSync(folderPath: string, options?: RmOptions) {
|
||||
if (!pathExistsSync(folderPath)) {
|
||||
return;
|
||||
}
|
||||
return unlink(path);
|
||||
|
||||
try {
|
||||
options = options || { recursive: true, force: true };
|
||||
log.info("Deleting folder", `"${folderPath}"`, options);
|
||||
} catch (error: any) {
|
||||
log.error("Could not delete folder", `"${folderPath}"`);
|
||||
throw CustomError.fromError(error, "generic.fs.delete-folder");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFoldersInFolder(folderPath: string, opts?: { ignoreSymlinkTargetError?: boolean }): Promise<string[]> {
|
||||
@@ -81,6 +116,7 @@ export async function getFilesInFolder(folderPath: string): Promise<string[]> {
|
||||
}
|
||||
|
||||
export function moveFolderContent(src: string, dest: string, option?: MoveOptions): Observable<Progression> {
|
||||
log.info(`(moveFolderContent) Moving ${src} to ${dest}`);
|
||||
const progress: Progression = { current: 0, total: 0 };
|
||||
return new Observable<Progression>(subscriber => {
|
||||
subscriber.next(progress);
|
||||
@@ -96,21 +132,32 @@ export function moveFolderContent(src: string, dest: string, option?: MoveOption
|
||||
const files = await readdir(src, { encoding: "utf-8", withFileTypes: true });
|
||||
progress.total = files.length;
|
||||
|
||||
for(const file of files){
|
||||
for (const file of files) {
|
||||
const srcFullPath = path.join(src, file.name);
|
||||
const destFullPath = path.join(dest, file.name);
|
||||
|
||||
const srcChilds = file.isDirectory() ? await readdir(srcFullPath, { encoding: "utf-8", recursive: true }) : [];
|
||||
const allChildsAlreadyExist = srcChilds.every(child => pathExistsSync(path.join(destFullPath, child)));
|
||||
|
||||
if(file.isFile() || !allChildsAlreadyExist){
|
||||
if (file.isFile() || !allChildsAlreadyExist) {
|
||||
const prevSize = await getSize(srcFullPath);
|
||||
await move(srcFullPath, destFullPath, option);
|
||||
const afterSize = await getSize(destFullPath);
|
||||
|
||||
// The size after moving should be the same or greater than the size before moving but never less
|
||||
if (afterSize < prevSize) {
|
||||
throw new CustomError(`File size mismath. before: ${prevSize}, after: ${afterSize} (${srcFullPath})`, "FILE_SIZE_MISMATCH");
|
||||
}
|
||||
} else {
|
||||
log.info(`Skipping ${srcFullPath} to ${destFullPath}, all child already exist in destination`);
|
||||
}
|
||||
|
||||
progress.current++;
|
||||
subscriber.next(progress);
|
||||
}
|
||||
})().catch(err => subscriber.error(CustomError.fromError(err, err?.code))).finally(() => subscriber.complete());
|
||||
})()
|
||||
.catch(err => subscriber.error(CustomError.fromError(err, err?.code)))
|
||||
.finally(() => subscriber.complete());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +196,7 @@ export async function copyDirectoryWithJunctions(src: string, dest: string, opti
|
||||
await copy(sourcePath, destinationPath, options);
|
||||
} else if (item.isSymbolicLink()) {
|
||||
if (options?.overwrite) {
|
||||
await unlinkPath(destinationPath);
|
||||
await deleteFile(destinationPath);
|
||||
}
|
||||
const symlinkTarget = await readlink(sourcePath);
|
||||
const relativePath = path.relative(src, symlinkTarget);
|
||||
@@ -169,8 +216,7 @@ export function hashFile(filePath: string, algorithm = "sha256"): Promise<string
|
||||
});
|
||||
}
|
||||
|
||||
export async function dirSize(dirPath: string): Promise<number>{
|
||||
|
||||
export async function dirSize(dirPath: string): Promise<number> {
|
||||
const entries = await readdir(dirPath);
|
||||
|
||||
const paths = entries.map(async entry => {
|
||||
@@ -189,11 +235,10 @@ export async function dirSize(dirPath: string): Promise<number>{
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (await Promise.all(paths)).flat(Infinity).reduce((acc, size ) => acc + size, 0);
|
||||
return (await Promise.all(paths)).flat(Infinity).reduce((acc, size) => acc + size, 0);
|
||||
}
|
||||
|
||||
export function rxCopy(src: string, dest: string, option?: CopyOptions): Observable<Progression> {
|
||||
|
||||
const dirSizePromise = dirSize(src).catch(err => {
|
||||
log.error("dirSizePromise", err);
|
||||
return 0;
|
||||
@@ -205,15 +250,19 @@ export function rxCopy(src: string, dest: string, option?: CopyOptions): Observa
|
||||
|
||||
return new Observable<Progression>(sub => {
|
||||
sub.next(progress);
|
||||
copy(src, dest, {...option, filter: (src) => {
|
||||
stat(src).then(stats => {
|
||||
progress.current += stats.size;
|
||||
sub.next(progress);
|
||||
});
|
||||
return true;
|
||||
}})
|
||||
.then(() => sub.complete()).catch(err => sub.error(err))
|
||||
})
|
||||
copy(src, dest, {
|
||||
...option,
|
||||
filter: src => {
|
||||
stat(src).then(stats => {
|
||||
progress.current += stats.size;
|
||||
sub.next(progress);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.then(() => sub.complete())
|
||||
.catch(err => sub.error(err));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -246,7 +295,7 @@ export function ensurePathNotAlreadyExistSync(path: string): string {
|
||||
return destPath;
|
||||
}
|
||||
|
||||
export async function isJunction(path: string): Promise<boolean>{
|
||||
export async function isJunction(path: string): Promise<boolean> {
|
||||
const [stats, lstats] = await Promise.all([stat(path), lstat(path)]);
|
||||
return lstats.isSymbolicLink() && stats.isDirectory();
|
||||
}
|
||||
@@ -254,7 +303,7 @@ export async function isJunction(path: string): Promise<boolean>{
|
||||
export function resolveGUIDPath(guidPath: string): string {
|
||||
const guidVolume = path.parse(guidPath).root;
|
||||
const command = `powershell -command "(Get-WmiObject -Class Win32_Volume | Where-Object { $_.DeviceID -like '${guidVolume}' }).DriveLetter"`;
|
||||
const {result: driveLetter, error} = tryit(() => execSync(command).toString().trim());
|
||||
const { result: driveLetter, error } = tryit(() => execSync(command).toString().trim());
|
||||
if (!driveLetter || error) {
|
||||
throw new Error("Unable to resolve GUID path", error);
|
||||
}
|
||||
@@ -274,6 +323,37 @@ export function getUniqueFileNamePath(filePath: string): string {
|
||||
return path.join(dir, newFileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {Error} Can throw file system errors
|
||||
*/
|
||||
export async function getSize(targetPath: string, maxDepth = 5): Promise<number> {
|
||||
const visited = new Set<string>();
|
||||
|
||||
const computeSize = async (currentPath: string, depth: number): Promise<number> => {
|
||||
if (visited.has(currentPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
visited.add(currentPath);
|
||||
|
||||
const stats = await stat(currentPath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
if (!stats.isDirectory() || depth >= maxDepth) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entries = await readdir(currentPath);
|
||||
const sizes = await Promise.all(entries.map(entry => computeSize(path.join(currentPath, entry), depth + 1)));
|
||||
return sizes.reduce((acc, cur) => acc + cur, 0);
|
||||
};
|
||||
|
||||
return computeSize(targetPath, 0);
|
||||
}
|
||||
|
||||
export interface Progression<T = unknown, D = unknown> {
|
||||
total: number;
|
||||
current: number;
|
||||
|
||||
@@ -23,9 +23,11 @@ import { LivShortcut } from "./services/liv/liv-shortcut.service";
|
||||
import { SteamLauncherService } from "./services/bs-launcher/steam-launcher.service";
|
||||
import { FileAssociationService } from "./services/file-association.service";
|
||||
import { SongDetailsCacheService } from "./services/additional-content/maps/song-details-cache.service";
|
||||
import { readdirSync, statSync, unlinkSync } from "fs-extra";
|
||||
import { Dirent, readdirSync } from "fs-extra";
|
||||
import { StaticConfigurationService } from "./services/static-configuration.service";
|
||||
import { configureProxy } from './helpers/proxy.helpers';
|
||||
import { deleteFileSync, deleteFolderSync } from "./helpers/fs.helpers";
|
||||
import { tryit } from "shared/helpers/error.helpers";
|
||||
|
||||
const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true";
|
||||
const staticConfig = StaticConfigurationService.getInstance();
|
||||
@@ -37,7 +39,7 @@ export const filterPatterns = new Set<RegExp>();
|
||||
filterPatterns.add(/(FRL|OC)\S{10,}/g);
|
||||
|
||||
initLogger();
|
||||
deleteOlestLogs();
|
||||
deleteOldestLogs();
|
||||
deleteOldLogs();
|
||||
|
||||
staticConfig.take("disable-hadware-acceleration", disabled => {
|
||||
@@ -156,11 +158,30 @@ if (!gotTheLock) {
|
||||
}).catch(log.error);
|
||||
}
|
||||
|
||||
function convertDateToDateString(date: Date): string {
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function initLogger(){
|
||||
log.transports.file.level = "info";
|
||||
|
||||
let filepath = "";
|
||||
let currentDateString = convertDateToDateString(new Date());
|
||||
log.transports.file.resolvePath = () => {
|
||||
const now = new Date();
|
||||
return path.join(app.getPath("logs"), `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-v${app.getVersion()}.log`);
|
||||
const nowString = convertDateToDateString(now);
|
||||
if (filepath && nowString === currentDateString) {
|
||||
return filepath;
|
||||
}
|
||||
|
||||
filepath = path.join(
|
||||
app.getPath("logs"), nowString,
|
||||
`${now.getTime()}-v${app.getVersion()}.log`
|
||||
);
|
||||
currentDateString = nowString;
|
||||
return filepath;
|
||||
};
|
||||
|
||||
log.hooks.push((message) => {
|
||||
@@ -203,70 +224,38 @@ function initLogger(){
|
||||
log.catchErrors();
|
||||
}
|
||||
|
||||
function getLogFilesEntries() {
|
||||
// Keep only the past week (7 days) of logs
|
||||
function deleteOldLogs(): void {
|
||||
let deleteLogFolders: Dirent[] = [];
|
||||
try {
|
||||
const logsFolder = app.getPath("logs");
|
||||
let logs = readdirSync(logsFolder, { withFileTypes: true });
|
||||
const filterDate = convertDateToDateString(
|
||||
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
);
|
||||
deleteLogFolders = readdirSync(app.getPath("logs"), { withFileTypes: true })
|
||||
.filter(folder => folder.isDirectory() && folder.name <= filterDate);
|
||||
} catch (error) {
|
||||
log.error("Error while deleting old logs:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
logs = logs.filter(file => file.isFile() && path.extname(file.name) === ".log");
|
||||
|
||||
logs.sort((a, b) => {
|
||||
const aStat = statSync(path.join(logsFolder, a.name));
|
||||
const bStat = statSync(path.join(logsFolder, b.name));
|
||||
return bStat.mtime.getTime() - aStat.mtime.getTime();
|
||||
});
|
||||
|
||||
return logs.map(file => {
|
||||
const filePath = path.join(logsFolder, file.name);
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
name: file.name,
|
||||
stats: stat
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
log.error('Error while retrieving log files entries:', err);
|
||||
return [];
|
||||
for (const folder of deleteLogFolders) {
|
||||
const folderPath = path.join(folder.parentPath, folder.name);
|
||||
tryit(() => deleteFolderSync(folderPath));
|
||||
}
|
||||
}
|
||||
|
||||
// keep only the last 5 logs
|
||||
function deleteOldLogs(): void{
|
||||
try {
|
||||
let logs = getLogFilesEntries();
|
||||
// Obsolete behavior, delete log files that are on the parent log folder
|
||||
function deleteOldestLogs(): void {
|
||||
const logsFolder = app.getPath("logs");
|
||||
const logs = readdirSync(logsFolder, { withFileTypes: true })
|
||||
.filter(file => file.isFile() && path.extname(file.name) === ".log");
|
||||
|
||||
logs = logs.slice(5);
|
||||
|
||||
logs.forEach(file => {
|
||||
try {
|
||||
unlinkSync(file.path);
|
||||
log.info(`Deleted log file: ${file.path}`);
|
||||
} catch (err) {
|
||||
log.error(`Error deleting file ${file.path}:`, err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
log.error("Error while deleting old logs:", err);
|
||||
for (const file of logs) {
|
||||
const filepath = path.join(file.parentPath, file.name);
|
||||
tryit(() => deleteFileSync(filepath));
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary function to delete logs before 2024-07-31
|
||||
function deleteOlestLogs(): void{
|
||||
// delete all logs before 2024-07-31
|
||||
const date = new Date(2024, 6, 31); // month is 0-based
|
||||
const logs = getLogFilesEntries().filter(file => file.stats.mtime.getTime() < date.getTime());
|
||||
|
||||
logs.forEach(file => {
|
||||
try {
|
||||
unlinkSync(file.path);
|
||||
log.info(`Deleted log file: ${file.path}`);
|
||||
} catch (err) {
|
||||
log.error(`Error deleting file ${file.path}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function addFilterStringLog(filter: string): void {
|
||||
filterStrings.add(filter);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from "path";
|
||||
import { RequestService } from "../request.service";
|
||||
import { copyFileSync } from "fs-extra";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { Progression, ensureFolderExist, unlinkPath } from "../../helpers/fs.helpers";
|
||||
import { Progression, deleteFile, ensureFolderExist } from "../../helpers/fs.helpers";
|
||||
import { MODEL_FILE_EXTENSIONS, MODEL_TYPES, MODEL_TYPE_FOLDERS } from "../../../shared/models/models/constants";
|
||||
import { InstallationLocationService } from "../installation-location.service";
|
||||
import { Observable, Subscription, lastValueFrom } from "rxjs";
|
||||
@@ -105,7 +105,9 @@ export class LocalModelsManagerService {
|
||||
}
|
||||
|
||||
public async oneClickDownloadModel(model: MSModel): Promise<void> {
|
||||
if (!model) { return; }
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
const versions = await this.localVersion.getInstalledVersions();
|
||||
const downloaded = await lastValueFrom(this.downloadModel(model, versions.pop()));
|
||||
@@ -198,7 +200,7 @@ export class LocalModelsManagerService {
|
||||
};
|
||||
|
||||
for (const model of models) {
|
||||
await unlinkPath(model.path);
|
||||
await deleteFile(model.path);
|
||||
progression.data.push(model);
|
||||
progression.current = progression.data.length;
|
||||
subscriber.next(progression);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/mo
|
||||
import { readFileSync, Stats } from "fs";
|
||||
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
|
||||
import { copy, ensureDir, pathExists, pathExistsSync, realpath, writeFileSync } from "fs-extra";
|
||||
import { Progression, getUniqueFileNamePath, unlinkPath } from "../../helpers/fs.helpers";
|
||||
import { Progression, deleteFile, getUniqueFileNamePath } from "../../helpers/fs.helpers";
|
||||
import { FileAssociationService } from "../file-association.service";
|
||||
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
|
||||
import { sToMs } from "shared/helpers/time.helpers";
|
||||
@@ -242,7 +242,9 @@ export class LocalPlaylistsManagerService {
|
||||
...localBPList,
|
||||
duration: 0,
|
||||
nbMaps: localBPList.songs?.length ?? 0,
|
||||
id: localBPList.customData?.syncURL ? tryExtractPlaylistId(localBPList.customData.syncURL) : undefined
|
||||
id: localBPList.customData?.syncURL ? tryExtractPlaylistId(localBPList.customData.syncURL) : undefined,
|
||||
minNps: Infinity,
|
||||
maxNps: -Infinity,
|
||||
}
|
||||
|
||||
const mappers = new Set<number>();
|
||||
@@ -254,12 +256,25 @@ export class LocalPlaylistsManagerService {
|
||||
|
||||
bpListDetails.duration += songDetails?.duration ? +songDetails.duration : 0;
|
||||
mappers.add(songDetails.uploader?.id);
|
||||
bpListDetails.minNps = Math.min(bpListDetails?.minNps ?? 0, Math.min(...songDetails.difficulties?.map(d => d?.nps || 0) ?? [0]));
|
||||
bpListDetails.maxNps = Math.max(bpListDetails?.maxNps ?? 0, Math.max(...songDetails.difficulties?.map(d => d?.nps || 0) ?? [0]));
|
||||
|
||||
const mapNps = songDetails.difficulties?.map(d => d?.nps).filter(nps => typeof nps === "number" && !Number.isNaN(nps));
|
||||
|
||||
if(mapNps?.length){
|
||||
bpListDetails.minNps = Math.min(bpListDetails.minNps, ...mapNps);
|
||||
bpListDetails.maxNps = Math.max(bpListDetails.maxNps, ...mapNps);
|
||||
}
|
||||
|
||||
song.songDetails = songDetails;
|
||||
}
|
||||
|
||||
if(!Number.isFinite(bpListDetails.minNps)){
|
||||
bpListDetails.minNps = 0;
|
||||
}
|
||||
|
||||
if(!Number.isFinite(bpListDetails.maxNps)){
|
||||
bpListDetails.maxNps = 0;
|
||||
}
|
||||
|
||||
bpListDetails.nbMappers = mappers.size;
|
||||
|
||||
return bpListDetails;
|
||||
@@ -394,7 +409,7 @@ export class LocalPlaylistsManagerService {
|
||||
}
|
||||
|
||||
public deletePlaylistFile(bpList: LocalBPList): Observable<void>{
|
||||
return from(unlinkPath(bpList.path));
|
||||
return from(deleteFile(bpList.path));
|
||||
}
|
||||
|
||||
public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}): Observable<Progression<string>> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InstallationLocationService } from "../../installation-location.service
|
||||
import { UtilsService } from "../../utils.service";
|
||||
import crypto, { BinaryLike } from "crypto";
|
||||
import { lstatSync } from "fs";
|
||||
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra";
|
||||
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath } from "fs-extra";
|
||||
import { RequestService } from "../../request.service";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { DeepLinkService } from "../../deep-link.service";
|
||||
@@ -15,7 +15,7 @@ import log from "electron-log";
|
||||
import { WindowManagerService } from "../../window-manager.service";
|
||||
import { Observable, Subject, lastValueFrom } from "rxjs";
|
||||
import { Archive } from "../../../models/archive.class";
|
||||
import { Progression, deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers";
|
||||
import { Progression, deleteFile, deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers";
|
||||
import { readFile } from "fs/promises";
|
||||
import { FolderLinkerService } from "../../folder-linker.service";
|
||||
import { allSettled } from "../../../../shared/helpers/promise.helpers";
|
||||
@@ -280,6 +280,8 @@ export class LocalMapsManagerService {
|
||||
return new Observable<Progression>(observer => {
|
||||
const progress: Progression = { total: hashs.length, current: 0 };
|
||||
|
||||
observer.next(progress);
|
||||
|
||||
(async () => {
|
||||
const versionMapsPath = await this.getMapsFolderPath(version);
|
||||
const mapsPaths = await getFoldersInFolder(versionMapsPath);
|
||||
@@ -447,7 +449,7 @@ export class LocalMapsManagerService {
|
||||
const zip = await BsmZipExtractor.fromPath(zipPath);
|
||||
await zip.extract(mapPath);
|
||||
zip.close();
|
||||
await unlink(zipPath);
|
||||
await deleteFile(zipPath);
|
||||
|
||||
const localMap = await this.loadMapInfoFromPath(mapPath);
|
||||
localMap.songDetails = this.songDetailsCache.getSongDetails(localMap.hash);
|
||||
|
||||
@@ -8,6 +8,34 @@ import { LinuxService } from "../linux.service";
|
||||
import { BsmShellLog, bsmSpawn } from "main/helpers/os.helpers";
|
||||
import { IS_FLATPAK } from "main/constants";
|
||||
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
|
||||
import { parseEnvString } from "main/helpers/env.helpers";
|
||||
|
||||
export function buildBsLaunchArgs(launchOptions: LaunchOption): string[] {
|
||||
const launchArgs = [];
|
||||
|
||||
if(!launchOptions.version.steam && !launchOptions.version.oculus){
|
||||
launchArgs.push("--no-yeet")
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.OCULUS)) {
|
||||
launchArgs.push("-vrmode");
|
||||
launchArgs.push("oculus");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.FPFC)) {
|
||||
launchArgs.push("fpfc");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.DEBUG)) {
|
||||
launchArgs.push("--verbose");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.EDITOR)) {
|
||||
launchArgs.push("editor");
|
||||
}
|
||||
|
||||
if (launchOptions.command) {
|
||||
launchArgs.push(launchOptions.command);
|
||||
}
|
||||
|
||||
return Array.from(new Set(launchArgs).values());
|
||||
}
|
||||
|
||||
export abstract class AbstractLauncherService {
|
||||
|
||||
@@ -19,34 +47,9 @@ export abstract class AbstractLauncherService {
|
||||
this.localVersions = BSLocalVersionService.getInstance();
|
||||
}
|
||||
|
||||
protected buildBsLaunchArgs(launchOptions: LaunchOption): string[]{
|
||||
const launchArgs = [];
|
||||
private readonly COMMAND_FORMAT = "%command%";
|
||||
|
||||
if(!launchOptions.version.steam && !launchOptions.version.oculus){
|
||||
launchArgs.push("--no-yeet")
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.OCULUS)) {
|
||||
launchArgs.push("-vrmode");
|
||||
launchArgs.push("oculus");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.FPFC)) {
|
||||
launchArgs.push("fpfc");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.DEBUG)) {
|
||||
launchArgs.push("--verbose");
|
||||
}
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.EDITOR)) {
|
||||
launchArgs.push("editor");
|
||||
}
|
||||
|
||||
if (launchOptions.additionalArgs) {
|
||||
launchArgs.push(...launchOptions.additionalArgs);
|
||||
}
|
||||
|
||||
return Array.from(new Set(launchArgs).values());
|
||||
}
|
||||
|
||||
protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||
protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): ChildProcessWithoutNullStreams {
|
||||
|
||||
const spawnOptions: SpawnOptionsWithoutStdio = { detached: true, cwd: path.dirname(bsExePath), ...(options || {}) };
|
||||
|
||||
@@ -57,7 +60,7 @@ export abstract class AbstractLauncherService {
|
||||
spawnOptions.shell = true; // For windows to spawn properly
|
||||
return bsmSpawn(`"${bsExePath}"`, {
|
||||
args, options: spawnOptions, log: BsmShellLog.Command,
|
||||
linux: { prefix: this.linux.getProtonPrefix() },
|
||||
linux: { prefix: options?.protonPrefix || "" },
|
||||
flatpak: {
|
||||
host: IS_FLATPAK,
|
||||
env: [
|
||||
@@ -70,6 +73,8 @@ export abstract class AbstractLauncherService {
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
|
||||
"STEAM_COMPAT_APP_ID",
|
||||
"SteamEnv",
|
||||
"PROTON_LOG",
|
||||
"PROTON_LOG_DIR",
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -78,7 +83,7 @@ export abstract class AbstractLauncherService {
|
||||
protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
|
||||
const process = this.launchBSProcess(bsExePath, args, options);
|
||||
|
||||
let timoutId: NodeJS.Timeout;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const exit = new Promise<number>((resolve, reject) => {
|
||||
// Don't remove, useful for debugging!
|
||||
@@ -101,7 +106,7 @@ export abstract class AbstractLauncherService {
|
||||
|
||||
const unrefAfter = options?.unrefAfter ?? sToMs(10);
|
||||
|
||||
timoutId = setTimeout(() => {
|
||||
timeoutId = setTimeout(() => {
|
||||
log.error("BS process unref after timeout", unrefAfter);
|
||||
process.unref();
|
||||
process.removeAllListeners();
|
||||
@@ -109,13 +114,44 @@ export abstract class AbstractLauncherService {
|
||||
}, unrefAfter);
|
||||
|
||||
}).finally(() => {
|
||||
clearTimeout(timoutId);
|
||||
clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
return { process, exit };
|
||||
}
|
||||
|
||||
protected injectAdditionalArgsEnvs(
|
||||
launchOptions: LaunchOption,
|
||||
env: Record<string, string>
|
||||
) {
|
||||
if (!launchOptions.command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { command } = launchOptions;
|
||||
const index = command.indexOf(this.COMMAND_FORMAT);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const envString = command.substring(0, index);
|
||||
log.info("Parsing env string ", `"${envString}"`)
|
||||
for (const [ key, value ] of Object.entries(parseEnvString(envString))) {
|
||||
if (key in env) {
|
||||
log.warn("Ignoring", `${key}=${value}`, "already set env launch command");
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info("Injecting", `${key}="${value}"`, "to the env launch command");
|
||||
env[key] = value;
|
||||
}
|
||||
|
||||
launchOptions.command = command.substring(index + this.COMMAND_FORMAT.length);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type SpawnBsProcessOptions = {
|
||||
protonPrefix?: string;
|
||||
unrefAfter?: number;
|
||||
} & SpawnOptionsWithoutStdio;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { LaunchMod, LaunchMods } from "shared/models/bs-launch/launch-option.int
|
||||
import { StaticConfigurationService } from "../static-configuration.service";
|
||||
import { SteamService } from "../steam.service";
|
||||
import { tryit } from "shared/helpers/error.helpers";
|
||||
import { LinuxService } from "../linux.service";
|
||||
|
||||
export class BSLauncherService {
|
||||
private static instance: BSLauncherService;
|
||||
@@ -37,6 +38,7 @@ export class BSLauncherService {
|
||||
private readonly steam: SteamService;
|
||||
private readonly oculusLauncher: OculusLauncherService;
|
||||
private readonly staticConfig: StaticConfigurationService;
|
||||
private readonly linux: LinuxService;
|
||||
|
||||
public static getInstance(): BSLauncherService {
|
||||
if (!BSLauncherService.instance) {
|
||||
@@ -55,6 +57,7 @@ export class BSLauncherService {
|
||||
this.oculusLauncher = OculusLauncherService.getInstance();
|
||||
this.staticConfig = StaticConfigurationService.getInstance();
|
||||
this.steam = SteamService.getInstance();
|
||||
this.linux = LinuxService.getInstance();
|
||||
|
||||
this.bsmProtocolService.on("launch", link => {
|
||||
log.info("Launch from bsm protocol", link.toString());
|
||||
@@ -92,10 +95,6 @@ export class BSLauncherService {
|
||||
|
||||
const params = objectFromEntries(shortcutLink.searchParams.entries()) as ShortcutParams;
|
||||
|
||||
if(typeof params.additionalArgs === "string"){
|
||||
params.additionalArgs = [params.additionalArgs];
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -120,7 +119,7 @@ export class BSLauncherService {
|
||||
oculus: params.versionOculus === "true",
|
||||
ino: +params.versionIno
|
||||
},
|
||||
additionalArgs: params.additionalArgs,
|
||||
command: params.command,
|
||||
launchMods,
|
||||
};
|
||||
|
||||
@@ -138,7 +137,7 @@ export class BSLauncherService {
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.OCULUS)){ res.oculusMode = "true"; }
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.FPFC)){ res.desktopMode = "true"; }
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.DEBUG)){ res.debug = "true"; }
|
||||
if(launchOptions.additionalArgs){ res.additionalArgs = launchOptions.additionalArgs; }
|
||||
if(launchOptions.command){ res.command = launchOptions.command; }
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.SKIP_STEAM)){ res.skipSteam = "true"; }
|
||||
if(launchOptions.launchMods?.includes(LaunchMods.PROTON_LOGS)){ res.protonLogs = "true"; }
|
||||
|
||||
@@ -182,7 +181,7 @@ export class BSLauncherService {
|
||||
* @returns {Promise<string>} Path of the icon
|
||||
*/
|
||||
private async createShortcutIco(color: Color): Promise<string>{
|
||||
const pngBuffer = await this.createShortcutPngBuffer(color);
|
||||
const pngBuffer = this.createShortcutPngBuffer(color);
|
||||
const icoBuffer = await toIco([pngBuffer]);
|
||||
|
||||
await ensureDir(IMAGE_CACHE_PATH);
|
||||
@@ -208,17 +207,28 @@ export class BSLauncherService {
|
||||
if(steamShortcut){
|
||||
const userId = await tryit(() => this.steam.getActiveUser());
|
||||
const exePath = app.getPath("exe");
|
||||
return this.steam.createShortcut({
|
||||
AppName: shortcutName,
|
||||
Exe: exePath,
|
||||
StartDir: path.dirname(exePath),
|
||||
LaunchOptions: this.createLaunchLink(launchOptions),
|
||||
icon: await this.createShortcutPng(shortcutIconColor),
|
||||
OpenVR: "\u0001"
|
||||
}, userId.result).then(() => true).catch(e => {
|
||||
log.error(e);
|
||||
return false;
|
||||
});
|
||||
const icon = await this.createShortcutPng(shortcutIconColor)
|
||||
return this.steam.createShortcut(
|
||||
process.platform === "win32"
|
||||
? {
|
||||
AppName: shortcutName,
|
||||
Exe: exePath,
|
||||
StartDir: path.dirname(exePath),
|
||||
LaunchOptions: this.createLaunchLink(launchOptions),
|
||||
OpenVR: "\u0001", icon,
|
||||
}
|
||||
: await this.linux.getSteamShortcutData(
|
||||
shortcutName, icon, launchOptions,
|
||||
await this.steam.getSteamPath(),
|
||||
await this.localVersionService.getVersionPath(launchOptions.version)
|
||||
),
|
||||
userId.result
|
||||
)
|
||||
.then(() => true)
|
||||
.catch(e => {
|
||||
log.error(e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return execOnOs({
|
||||
@@ -230,12 +240,13 @@ export class BSLauncherService {
|
||||
description: [shortcutName, launchOptions.version.color].join(" "), // <= Need color in description to help windows know that the shortcut is different
|
||||
})
|
||||
),
|
||||
linux: async () => (
|
||||
createDesktopUrlShortcut(path.join(app.getPath("desktop"), `${shortcutName}.desktop`), {
|
||||
name: shortcutName,
|
||||
url: shortcutUrl,
|
||||
icon: await this.createShortcutPng(shortcutIconColor),
|
||||
})
|
||||
linux: async () => this.linux.createDesktopShortcut(
|
||||
path.join(app.getPath("desktop"), `${shortcutName}.desktop`),
|
||||
shortcutName,
|
||||
await this.createShortcutPng(shortcutIconColor),
|
||||
launchOptions,
|
||||
await this.steam.getSteamPath(),
|
||||
await this.localVersionService.getVersionPath(launchOptions.version)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -272,7 +283,7 @@ type ShortcutParams = {
|
||||
oculusMode?: string;
|
||||
desktopMode?: string;
|
||||
debug?: string;
|
||||
additionalArgs?: string[];
|
||||
command?: string;
|
||||
skipSteam?: string;
|
||||
protonLogs?: string;
|
||||
version: string;
|
||||
@@ -282,26 +293,3 @@ type ShortcutParams = {
|
||||
versionOculus?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create .desktop file for url shortcut (only for linux)
|
||||
* @param {string} shortcutPath
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
function createDesktopUrlShortcut(shortcutPath: string, options?: {
|
||||
url: string
|
||||
name: string,
|
||||
icon: string
|
||||
}): Promise<boolean> {
|
||||
const { url, name, icon } = options || {};
|
||||
|
||||
const data = [
|
||||
"[Desktop Entry]",
|
||||
"Type=Link",
|
||||
`Name=${name}`,
|
||||
`Icon=${icon}`,
|
||||
`URL=${url}`
|
||||
].join("\n");
|
||||
|
||||
return writeFile(shortcutPath, data).then(() => true);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Observable, ReplaySubject } from "rxjs";
|
||||
import { Observable } from "rxjs";
|
||||
import { StoreLauncherInterface } from "./store-launcher.interface";
|
||||
import { BSLaunchError, BSLaunchEvent, BSLaunchEventData, LaunchOption } from "../../../shared/models/bs-launch";
|
||||
import { OculusService } from "../oculus.service";
|
||||
@@ -6,10 +6,9 @@ import { BS_EXECUTABLE } from "../../constants";
|
||||
import path from "path";
|
||||
import log from "electron-log";
|
||||
import { pathExists } from "fs-extra";
|
||||
import { AbstractLauncherService } from "./abstract-launcher.service";
|
||||
import { AbstractLauncherService, buildBsLaunchArgs } from "./abstract-launcher.service";
|
||||
import { isProcessRunning } from "../../helpers/os.helpers";
|
||||
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
|
||||
import { UtilsService } from "../utils.service";
|
||||
|
||||
export class OculusLauncherService extends AbstractLauncherService implements StoreLauncherInterface {
|
||||
|
||||
@@ -23,14 +22,10 @@ export class OculusLauncherService extends AbstractLauncherService implements St
|
||||
}
|
||||
|
||||
private readonly oculus: OculusService;
|
||||
private readonly util: UtilsService;
|
||||
|
||||
private readonly oculusLib$ = new ReplaySubject<string>();
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
this.oculus = OculusService.getInstance();
|
||||
this.util = UtilsService.getInstance();
|
||||
}
|
||||
|
||||
public launch(launchOptions: LaunchOption): Observable<BSLaunchEventData> {
|
||||
@@ -54,10 +49,17 @@ export class OculusLauncherService extends AbstractLauncherService implements St
|
||||
// Make sure Oculus is running
|
||||
await this.oculus.startOculus().catch(err => log.error("Error while starting Oculus", err));
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
this.injectAdditionalArgsEnvs(launchOptions, env);
|
||||
|
||||
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
|
||||
|
||||
// Launch Beat Saber
|
||||
const process = this.launchBs(exePath, this.buildBsLaunchArgs(launchOptions));
|
||||
const process = this.launchBs(
|
||||
exePath,
|
||||
buildBsLaunchArgs(launchOptions),
|
||||
{ env }
|
||||
);
|
||||
|
||||
return process.exit.catch(err => {
|
||||
throw CustomError.fromError(err, BSLaunchError.BS_EXIT_ERROR);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SteamService } from "../steam.service";
|
||||
import path from "path";
|
||||
import { BS_APP_ID, BS_EXECUTABLE, STEAMVR_APP_ID } from "../../constants";
|
||||
import log from "electron-log";
|
||||
import { AbstractLauncherService } from "./abstract-launcher.service";
|
||||
import { AbstractLauncherService, buildBsLaunchArgs } from "./abstract-launcher.service";
|
||||
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
|
||||
import { UtilsService } from "../utils.service";
|
||||
import { exec } from "child_process";
|
||||
@@ -89,7 +89,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
|
||||
obs.next({type: BSLaunchWarning.UNABLE_TO_LAUNCH_STEAM});
|
||||
});
|
||||
}
|
||||
else {
|
||||
else if(skipSteam) {
|
||||
obs.next({ type: BSLaunchEvent.SKIPPING_STEAM_LAUNCH});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
|
||||
await this.restoreSteamVR().catch(log.error);
|
||||
}
|
||||
|
||||
const launchArgs = this.buildBsLaunchArgs(launchOptions);
|
||||
const steamPath = await this.steam.getSteamPath();
|
||||
|
||||
const env = {
|
||||
@@ -112,17 +111,28 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
|
||||
"SteamGameId": BS_APP_ID,
|
||||
};
|
||||
|
||||
let protonPrefix = "";
|
||||
// Linux setup
|
||||
if (process.platform === "linux") {
|
||||
await this.linux.setupLaunch(launchOptions, steamPath, bsFolderPath, env);
|
||||
const linuxSetup = await this.linux.setupLaunch(
|
||||
launchOptions, steamPath, bsFolderPath
|
||||
);
|
||||
protonPrefix = linuxSetup.protonPrefix;
|
||||
Object.assign(env, linuxSetup.env);
|
||||
}
|
||||
|
||||
this.injectAdditionalArgsEnvs(launchOptions, env);
|
||||
const launchArgs = buildBsLaunchArgs(launchOptions);
|
||||
|
||||
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
|
||||
|
||||
const spawnOpts = { env, cwd: bsFolderPath };
|
||||
|
||||
const launchPromise = !launchOptions.admin ? (
|
||||
this.launchBs(bsExePath, launchArgs, spawnOpts).exit
|
||||
this.launchBs(bsExePath, launchArgs, {
|
||||
...spawnOpts,
|
||||
protonPrefix
|
||||
}).exit
|
||||
) : (
|
||||
new Promise<number>(resolve => {
|
||||
const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${bsExePath}" ${launchArgs.join(" ")} --log-path "${path.join(app.getPath("logs"), "bs-admin-start.log")}"`, spawnOpts);
|
||||
|
||||
@@ -5,8 +5,6 @@ import { BSVersion } from "shared/bs-version.interface";
|
||||
import { RequestService } from "./request.service";
|
||||
import { readJSON } from "fs-extra";
|
||||
import { allSettled } from "../../shared/helpers/promise.helpers";
|
||||
import { LinuxService } from "./linux.service";
|
||||
import { IS_FLATPAK } from "main/constants";
|
||||
import { StaticConfigurationService } from "./static-configuration.service";
|
||||
|
||||
export class BSVersionLibService {
|
||||
@@ -15,7 +13,6 @@ export class BSVersionLibService {
|
||||
|
||||
private static instance: BSVersionLibService;
|
||||
|
||||
private readonly linuxService: LinuxService;
|
||||
private readonly utilsService: UtilsService;
|
||||
private readonly requestService: RequestService;
|
||||
private readonly configService: StaticConfigurationService;
|
||||
@@ -23,7 +20,6 @@ export class BSVersionLibService {
|
||||
private bsVersions: BSVersion[];
|
||||
|
||||
private constructor() {
|
||||
this.linuxService = LinuxService.getInstance();
|
||||
this.utilsService = UtilsService.getInstance();
|
||||
this.requestService = RequestService.getInstance();
|
||||
this.configService = StaticConfigurationService.getInstance();
|
||||
@@ -40,15 +36,10 @@ export class BSVersionLibService {
|
||||
return this.requestService.getJSON<BSVersion[]>(this.REMOTE_BS_VERSIONS_URL).then(res => res.data);
|
||||
}
|
||||
|
||||
private async shouldLoadFromConfig(): Promise<boolean> {
|
||||
// Some special cases of readonly memory installations
|
||||
return process.platform === "linux" && (IS_FLATPAK || this.linuxService.isNixOS());
|
||||
}
|
||||
|
||||
private async getLocalVersions(): Promise<BSVersion[]> {
|
||||
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
|
||||
|
||||
if (!(await this.shouldLoadFromConfig())) {
|
||||
if (process.platform !== "linux") {
|
||||
return readJSON(localVersionsPath);
|
||||
}
|
||||
|
||||
@@ -60,7 +51,7 @@ export class BSVersionLibService {
|
||||
}
|
||||
|
||||
private async updateLocalVersions(versions: BSVersion[]): Promise<void> {
|
||||
if (await this.shouldLoadFromConfig()) {
|
||||
if (process.platform === "linux") {
|
||||
this.configService.set("versions", versions);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { InstallationLocationService } from "./installation-location.service";
|
||||
import log from "electron-log";
|
||||
import { deleteFolder, ensureFolderExist, moveFolderContent, pathExist, unlinkPath } from "../helpers/fs.helpers";
|
||||
import { deleteFile, deleteFileSync, deleteFolder, deleteFolderSync, ensureFolderExist, moveFolderContent, pathExist } from "../helpers/fs.helpers";
|
||||
import { lstat, symlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import { copy, readlink } from "fs-extra";
|
||||
import { copy, mkdirSync, readlink, symlinkSync } from "fs-extra";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { noop } from "shared/helpers/function.helpers";
|
||||
import { StaticConfigurationService } from "./static-configuration.service";
|
||||
import { randomUUID } from "crypto";
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
import { tryit } from "shared/helpers/error.helpers";
|
||||
|
||||
export class FolderLinkerService {
|
||||
private static instance: FolderLinkerService;
|
||||
@@ -41,6 +44,29 @@ export class FolderLinkerService {
|
||||
}
|
||||
}
|
||||
|
||||
public isLinkingSupported(): boolean {
|
||||
const uuid = randomUUID();
|
||||
const installationPath = this.installLocationService.installationDirectory();
|
||||
const testFolder = path.join(installationPath, uuid);
|
||||
const testLink = path.join(installationPath, `${uuid}_link`);
|
||||
|
||||
const resLink = tryit(() => {
|
||||
mkdirSync(testFolder);
|
||||
symlinkSync(testFolder, testLink, this.getLinkingType());
|
||||
});
|
||||
|
||||
tryit(() => {
|
||||
deleteFolderSync(testFolder);
|
||||
deleteFileSync(testLink);
|
||||
});
|
||||
|
||||
if(resLink.error){
|
||||
log.error("Unable to create symlink", resLink.error);
|
||||
}
|
||||
|
||||
return !resLink.error;
|
||||
}
|
||||
|
||||
public sharedFolder(): string {
|
||||
return this.installLocationService.sharedContentPath();
|
||||
}
|
||||
@@ -74,6 +100,10 @@ export class FolderLinkerService {
|
||||
}
|
||||
|
||||
public async linkFolder(folderPath: string, options?: LinkOptions): Promise<void> {
|
||||
if(!this.isLinkingSupported()){
|
||||
throw new CustomError("Linking is not supported on this platform", "LinkingNotSupported");
|
||||
}
|
||||
|
||||
const sharedPath = await this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
|
||||
if (await this.isFolderSymlink(folderPath)) {
|
||||
@@ -83,7 +113,7 @@ export class FolderLinkerService {
|
||||
if (isTargetedToSharedPath) {
|
||||
return;
|
||||
}
|
||||
await unlinkPath(folderPath);
|
||||
await deleteFile(folderPath);
|
||||
|
||||
log.info(`Linking ${folderPath} to ${sharedPath}; type: ${this.linkingType}`);
|
||||
return symlink(sharedPath, folderPath, this.getLinkingType());
|
||||
@@ -111,7 +141,7 @@ export class FolderLinkerService {
|
||||
if (!(await this.isFolderSymlink(folderPath))) {
|
||||
return;
|
||||
}
|
||||
await unlinkPath(folderPath);
|
||||
await deleteFile(folderPath);
|
||||
|
||||
const sharedPath = await this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import fs from "fs-extra";
|
||||
import log from "electron-log";
|
||||
import path from "path";
|
||||
import { BS_APP_ID, IS_FLATPAK, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
|
||||
import { BS_APP_ID, BS_EXECUTABLE, IS_FLATPAK, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
|
||||
import { InstallationLocationService } from "./installation-location.service";
|
||||
import { StaticConfigurationService } from "./static-configuration.service";
|
||||
import { SteamService } from "./steam.service";
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
import { BSLaunchError, LaunchOption } from "shared/models/bs-launch";
|
||||
import { BsmShellLog, bsmExec } from "main/helpers/os.helpers";
|
||||
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
|
||||
import { SteamShortcutData } from "shared/models/steam/shortcut.model";
|
||||
import { buildBsLaunchArgs } from "./bs-launcher/abstract-launcher.service";
|
||||
|
||||
export class LinuxService {
|
||||
private static instance: LinuxService;
|
||||
@@ -19,46 +21,46 @@ export class LinuxService {
|
||||
return LinuxService.instance;
|
||||
}
|
||||
|
||||
private readonly installLocationService: InstallationLocationService;
|
||||
private readonly staticConfig: StaticConfigurationService;
|
||||
private readonly steamService: SteamService;
|
||||
private protonPrefix = "";
|
||||
|
||||
private nixOS: boolean | undefined;
|
||||
|
||||
private constructor() {
|
||||
this.installLocationService = InstallationLocationService.getInstance();
|
||||
this.staticConfig = StaticConfigurationService.getInstance();
|
||||
this.steamService = SteamService.getInstance();
|
||||
}
|
||||
|
||||
// === Launching === //
|
||||
|
||||
private async getCompatDataPath() {
|
||||
// Create the compat data path if it doesn't exist.
|
||||
// If the user never ran Beat Saber through steam before
|
||||
// using bsmanager, it won't exist, and proton will fail
|
||||
// to launch the game.
|
||||
const commonFolder = await this.steamService.getGameFolder(BS_APP_ID);
|
||||
const compatDataPath = path.resolve(commonFolder, "..", "compatdata", BS_APP_ID);
|
||||
if (!fs.existsSync(compatDataPath)) {
|
||||
log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`);
|
||||
fs.mkdirSync(compatDataPath);
|
||||
}
|
||||
return compatDataPath;
|
||||
private getCompatDataPath() {
|
||||
const sharedFolder = this.installLocationService.sharedContentPath();
|
||||
return path.resolve(sharedFolder, "compatdata");
|
||||
}
|
||||
|
||||
public async setupLaunch(
|
||||
launchOptions: LaunchOption,
|
||||
steamPath: string,
|
||||
bsFolderPath: string,
|
||||
env: Record<string, string>
|
||||
) {
|
||||
bsFolderPath: string
|
||||
): Promise<{
|
||||
protonPrefix: string;
|
||||
env: Record<string, string>;
|
||||
}> {
|
||||
if (launchOptions.admin) {
|
||||
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
|
||||
launchOptions.admin = false;
|
||||
}
|
||||
|
||||
const compatDataPath = this.getCompatDataPath();
|
||||
const protonPath = await this.getProtonPath();
|
||||
return {
|
||||
protonPrefix: await this.isNixOS()
|
||||
? `steam-run "${protonPath}" run`
|
||||
: `"${protonPath}" run`,
|
||||
env: await this.buildEnvVariables(launchOptions, steamPath, bsFolderPath)
|
||||
};
|
||||
}
|
||||
|
||||
private async getProtonPath(): Promise<string> {
|
||||
if (!this.staticConfig.has("proton-folder")) {
|
||||
throw CustomError.fromError(
|
||||
new Error("Proton folder not set"),
|
||||
@@ -76,27 +78,41 @@ export class LinuxService {
|
||||
);
|
||||
}
|
||||
|
||||
this.protonPrefix = await this.isNixOS()
|
||||
? `steam-run "${protonPath}" run`
|
||||
: `"${protonPath}" run`;
|
||||
return protonPath;
|
||||
}
|
||||
|
||||
private async buildEnvVariables(
|
||||
launchOptions: LaunchOption,
|
||||
steamPath: string,
|
||||
bsFolderPath: string
|
||||
): Promise<Record<string, string>> {
|
||||
// Create the compat data path if it doesn't exist.
|
||||
// If the user never ran Beat Saber through steam before
|
||||
// using bsmanager, it won't exist, and proton will fail
|
||||
// to launch the game.
|
||||
const compatDataPath = this.getCompatDataPath();
|
||||
if (!fs.existsSync(compatDataPath)) {
|
||||
log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`);
|
||||
await fs.ensureDir(compatDataPath);
|
||||
}
|
||||
|
||||
// Setup Proton environment variables
|
||||
Object.assign(env, {
|
||||
const envVars: Record<string, string> = {
|
||||
"WINEDLLOVERRIDES": "winhttp=n,b", // Required for mods to work
|
||||
"STEAM_COMPAT_DATA_PATH": compatDataPath,
|
||||
"STEAM_COMPAT_INSTALL_PATH": bsFolderPath,
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": steamPath,
|
||||
"STEAM_COMPAT_APP_ID": BS_APP_ID,
|
||||
// Run game in steam environment; fixes #585 for unicode song titles
|
||||
"SteamEnv": 1,
|
||||
});
|
||||
"SteamEnv": "1",
|
||||
};
|
||||
|
||||
if (launchOptions.launchMods?.includes(LaunchMods.PROTON_LOGS)) {
|
||||
Object.assign(env, {
|
||||
"PROTON_LOG": 1,
|
||||
"PROTON_LOG_DIR": path.join(bsFolderPath, "Logs"),
|
||||
});
|
||||
envVars.PROTON_LOG = "1";
|
||||
envVars.PROTON_LOG_DIR = path.join(bsFolderPath, "Logs");
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
public verifyProtonPath(protonFolder: string = ""): boolean {
|
||||
@@ -129,13 +145,10 @@ export class LinuxService {
|
||||
return winePath;
|
||||
}
|
||||
|
||||
public async getWinePrefixPath(): Promise<string> {
|
||||
return path.join(await this.getCompatDataPath(), "pfx");
|
||||
}
|
||||
|
||||
public getProtonPrefix(): string {
|
||||
// Set in setupLaunch
|
||||
return this.protonPrefix;
|
||||
public getWinePrefixPath(): string {
|
||||
const compatDataPath = this.getCompatDataPath();
|
||||
return fs.existsSync(compatDataPath)
|
||||
? path.join(compatDataPath, "pfx") : "";
|
||||
}
|
||||
|
||||
// === NixOS Specific === //
|
||||
@@ -158,4 +171,96 @@ export class LinuxService {
|
||||
|
||||
return this.nixOS;
|
||||
}
|
||||
|
||||
// === Shortcuts === //
|
||||
|
||||
private getCommand(
|
||||
protonPrefix: string,
|
||||
bsFolderPath: string,
|
||||
env: Record<string, string>,
|
||||
launchOptions: LaunchOption
|
||||
): string {
|
||||
const envString = Object.entries(env)
|
||||
.map(([ key, value ]) => `${key}="${value}"`)
|
||||
.join(" ");
|
||||
const bsExe = path.join(bsFolderPath, BS_EXECUTABLE);
|
||||
const args = buildBsLaunchArgs(launchOptions).join(" ");
|
||||
return `${envString} ${protonPrefix} "${bsExe}" ${args}`;
|
||||
}
|
||||
|
||||
public async createDesktopShortcut(
|
||||
shortcutPath: string,
|
||||
name: string,
|
||||
icon: string,
|
||||
launchOptions: LaunchOption,
|
||||
steamPath: string,
|
||||
bsFolderPath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const {
|
||||
protonPrefix, env
|
||||
} = await this.setupLaunch(launchOptions, steamPath, bsFolderPath);
|
||||
|
||||
Object.assign(env, {
|
||||
"SteamAppId": BS_APP_ID,
|
||||
"SteamOverlayGameId": BS_APP_ID,
|
||||
"SteamGameId": BS_APP_ID,
|
||||
});
|
||||
|
||||
const command = this.getCommand(
|
||||
protonPrefix, bsFolderPath,
|
||||
env, launchOptions
|
||||
);
|
||||
|
||||
const desktopEntry = [
|
||||
"[Desktop Entry]",
|
||||
"Type=Application",
|
||||
`Name=${name}`,
|
||||
`Icon=${icon}`,
|
||||
`Path=${bsFolderPath}`,
|
||||
`Exec=${command}`
|
||||
].join("\n");
|
||||
|
||||
await fs.writeFile(shortcutPath, desktopEntry);
|
||||
log.info("Created shorcut at ", `"${shortcutPath}/${name}"`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error("Could not create shortcut", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getSteamShortcutData(
|
||||
shortcutName: string,
|
||||
icon: string,
|
||||
launchOptions: LaunchOption,
|
||||
steamPath: string,
|
||||
bsFolderPath: string
|
||||
): Promise<SteamShortcutData> {
|
||||
const env = await this.buildEnvVariables(
|
||||
launchOptions, steamPath, bsFolderPath
|
||||
);
|
||||
Object.assign(env, {
|
||||
"SteamAppId": BS_APP_ID,
|
||||
"SteamOverlayGameId": BS_APP_ID,
|
||||
"SteamGameId": BS_APP_ID,
|
||||
});
|
||||
|
||||
const protonPrefix = await this.isNixOS()
|
||||
? "steam-run %command% run"
|
||||
: "%command% run";
|
||||
|
||||
return {
|
||||
AppName: shortcutName,
|
||||
Exe: await this.getProtonPath(),
|
||||
StartDir: bsFolderPath,
|
||||
icon,
|
||||
OpenVR: "\x01",
|
||||
LaunchOptions: this.getCommand(
|
||||
protonPrefix, bsFolderPath,
|
||||
env, launchOptions
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ export class BeatModsApiService {
|
||||
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`).then(({ data }) => {
|
||||
this.updateModsHashCache(data?.modVersions ?? []);
|
||||
return data?.modVersions?.at(0);
|
||||
}).catch((e): undefined => {
|
||||
log.error(`Failed to get mod by hash: ${hash}`, e);
|
||||
}).catch((): undefined => {
|
||||
log.error(`Failed to get mod by hash: ${hash}`);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import md5File from "md5-file";
|
||||
import { RequestService } from "../request.service";
|
||||
import { BS_EXECUTABLE } from "../../constants";
|
||||
import log from "electron-log";
|
||||
import { deleteFolder, pathExist, Progression, unlinkPath } from "../../helpers/fs.helpers";
|
||||
import { deleteFile, deleteFolder, pathExist, Progression } from "../../helpers/fs.helpers";
|
||||
import { lastValueFrom, Observable } from "rxjs";
|
||||
import recursiveReadDir from "recursive-readdir";
|
||||
import { sToMs } from "../../../shared/helpers/time.helpers";
|
||||
import { copyFile, ensureDir, pathExistsSync } from "fs-extra";
|
||||
import { copyFile, ensureDir, pathExistsSync, readdirSync } from "fs-extra";
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
import { popElement } from "shared/helpers/array.helpers";
|
||||
import { LinuxService } from "../linux.service";
|
||||
@@ -96,8 +96,14 @@ export class BsModsManagerService {
|
||||
return mod;
|
||||
});
|
||||
|
||||
const mods = await Promise.all(promises);
|
||||
return mods.filter(Boolean);
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
return results.reduce((mods, mod) => {
|
||||
if (mod?.status === "fulfilled" && mod?.value) {
|
||||
mods.push(mod.value);
|
||||
}
|
||||
return mods;
|
||||
}, [] as BbmModVersion[]);
|
||||
}
|
||||
|
||||
private async getBsipaInstalled(version: BSVersion): Promise<BbmModVersion> {
|
||||
@@ -151,12 +157,11 @@ export class BsModsManagerService {
|
||||
}
|
||||
winePath = `"${winePathResult}"`;
|
||||
|
||||
const { error: winePrefixError, result: winePrefixResult } =
|
||||
await tryit(async () => this.linuxService.getWinePrefixPath());
|
||||
if (winePrefixError) {
|
||||
log.warn("Could not get WINEPREFIX value", winePrefixError);
|
||||
const winePrefix = this.linuxService.getWinePrefixPath();
|
||||
if (winePrefix) {
|
||||
env.WINEPREFIX = winePrefix;
|
||||
} else {
|
||||
env.WINEPREFIX = winePrefixResult;
|
||||
log.warn("Could not find BSManager WINEPREFIX path, using system's default value instead");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +209,12 @@ export class BsModsManagerService {
|
||||
private async installMod(mod: BbmFullMod, version: BSVersion): Promise<boolean> {
|
||||
log.info("INSTALL MOD", mod.mod.name, "for version", `${version.BSVersion} - ${version.name}`);
|
||||
|
||||
const isBSIPA = mod.mod.name.toLowerCase() === "bsipa";
|
||||
|
||||
if(isBSIPA){
|
||||
await this.clearIpaFolder(version).catch(e => log.error("Error while clearing IPA folder", e));
|
||||
}
|
||||
|
||||
const downloadUrl = this.getModDownload(mod.version);
|
||||
|
||||
if (!downloadUrl) {
|
||||
@@ -232,7 +243,6 @@ export class BsModsManagerService {
|
||||
}
|
||||
|
||||
const versionPath = await this.bsLocalService.getVersionPath(version);
|
||||
const isBSIPA = mod.mod.name.toLowerCase() === "bsipa";
|
||||
const destDir = isBSIPA ? versionPath : path.join(versionPath, ModsInstallFolder.PENDING);
|
||||
|
||||
log.info("Start extracting mod zip", mod.mod.name, "to", destDir);
|
||||
@@ -259,6 +269,41 @@ export class BsModsManagerService {
|
||||
return res;
|
||||
}
|
||||
|
||||
private async clearIpaFolder(version: BSVersion): Promise<void> {
|
||||
log.info("Clearing IPA folder");
|
||||
|
||||
const versionPath = await this.bsLocalService.getVersionPath(version);
|
||||
const ipaPath = path.join(versionPath, ModsInstallFolder.IPA);
|
||||
|
||||
if(!pathExistsSync(ipaPath)){
|
||||
log.info("IPA folder does not exist, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = readdirSync(ipaPath, { withFileTypes: true });
|
||||
|
||||
for(const content of contents){
|
||||
|
||||
if (content.name === 'Backups' || content.name === 'Pending') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentPath = path.join(ipaPath, content.name);
|
||||
|
||||
const res = await tryit(() => content.isDirectory()
|
||||
? deleteFolder(contentPath)
|
||||
: deleteFile(contentPath)
|
||||
);
|
||||
|
||||
if(res.error){
|
||||
log.error("Error while clearing IPA folder content", content.name, res.error);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("IPA folder cleared successfully");
|
||||
|
||||
}
|
||||
|
||||
private async uninstallBSIPA(mod: BbmFullMod, version: BSVersion): Promise<void> {
|
||||
const verionPath = await this.bsLocalService.getVersionPath(version);
|
||||
const hasIPAExe = await pathExist(path.join(verionPath, "IPA.exe"));
|
||||
@@ -272,7 +317,7 @@ export class BsModsManagerService {
|
||||
|
||||
const promises = mod.version.contentHashes.map(content => {
|
||||
const file = content.path.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
|
||||
return unlinkPath(path.join(verionPath, file));
|
||||
return deleteFile(path.join(verionPath, file));
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
@@ -285,7 +330,10 @@ export class BsModsManagerService {
|
||||
const versionPath = await this.bsLocalService.getVersionPath(version);
|
||||
|
||||
const promises = mod.version.contentHashes.map(async content => {
|
||||
return Promise.all([unlinkPath(path.join(versionPath, content.path)), unlinkPath(path.join(versionPath, "IPA", "Pending", content.path))]);
|
||||
return Promise.all([
|
||||
deleteFile(path.join(versionPath, content.path)),
|
||||
deleteFile(path.join(versionPath, "IPA", "Pending", content.path))
|
||||
]);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
@@ -302,8 +350,8 @@ export class BsModsManagerService {
|
||||
|
||||
const bsipa = await this.getBsipaInstalled(version);
|
||||
|
||||
const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS), this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING)]);
|
||||
const libsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.LIBS), this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING)]);
|
||||
const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING), this.getModsInDir(version, ModsInstallFolder.PLUGINS)]);
|
||||
const libsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING), this.getModsInDir(version, ModsInstallFolder.LIBS)]);
|
||||
|
||||
const dirMods = pluginsMods.flat().concat(libsMods.flat());
|
||||
|
||||
@@ -491,9 +539,11 @@ export class BsModsManagerService {
|
||||
const versionMods = await this.getAvailableMods(version);
|
||||
const installedMods = await this.getInstalledMods(version);
|
||||
|
||||
const fullInstalledMods: BbmFullMod[] = installedMods?.map(version => {
|
||||
return { version, mod: versionMods.find(mod => version.modId === mod.mod.id)?.mod };
|
||||
}) ?? [];
|
||||
const fullInstalledMods = (installedMods || []).reduce((mods, version) => {
|
||||
const mod = versionMods.find(mod => version.modId === mod.mod.id)?.mod;
|
||||
if (mod) { mods.push({ version, mod }); }
|
||||
return mods;
|
||||
}, [] as BbmFullMod[]);
|
||||
|
||||
const progress = { current: 0, total: fullInstalledMods.length };
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { createWriteStream, WriteStream } from 'fs';
|
||||
import { Progression } from 'main/helpers/fs.helpers';
|
||||
import { deleteFileSync, Progression } from 'main/helpers/fs.helpers';
|
||||
import { Observable } from 'rxjs';
|
||||
import { shareReplay, tap } from 'rxjs/operators';
|
||||
import log from 'electron-log';
|
||||
import got from 'got';
|
||||
import { IncomingHttpHeaders, IncomingMessage } from 'http';
|
||||
import { unlinkSync } from 'fs-extra';
|
||||
import { tryit } from 'shared/helpers/error.helpers';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import internal from 'stream';
|
||||
import { app } from 'electron';
|
||||
|
||||
export class RequestService {
|
||||
private static instance: RequestService;
|
||||
private preferredFamily: number | undefined = undefined;
|
||||
private readonly baseHeaders = {
|
||||
'User-Agent': `BSManager/${app.getVersion()} (Electron/${process.versions.electron} Chrome/${process.versions.chrome} Node/${process.versions.node})`,
|
||||
}
|
||||
|
||||
|
||||
public static getInstance(): RequestService {
|
||||
if (!RequestService.instance) {
|
||||
RequestService.instance = new RequestService();
|
||||
}
|
||||
return RequestService.instance;
|
||||
|
||||
}
|
||||
|
||||
private constructor() {}
|
||||
@@ -33,7 +38,7 @@ export class RequestService {
|
||||
try {
|
||||
|
||||
// @ts-ignore (ESM is not well supported in this project, We need to move out electron-react-boilerplate, and use Vite)
|
||||
const res = await got(url, { dnsLookupIpVersion: family, responseType: 'json' });
|
||||
const res = await got(url, { dnsLookupIpVersion: family, responseType: 'json', headers: this.baseHeaders });
|
||||
this.preferredFamily = family;
|
||||
return { data: res.body as T, headers: res.headers };
|
||||
} catch (err) {
|
||||
@@ -68,7 +73,7 @@ export class RequestService {
|
||||
let file: WriteStream | undefined;
|
||||
|
||||
// @ts-ignore (ESM is not well supported in this project, We need to move out electron-react-boilerplate, and use Vite)
|
||||
stream = got.stream(url, { dnsLookupIpVersion: family });
|
||||
stream = got.stream(url, { dnsLookupIpVersion: family, headers: this.baseHeaders });
|
||||
|
||||
stream.on('response', (response) => {
|
||||
this.preferredFamily = family;
|
||||
@@ -84,7 +89,7 @@ export class RequestService {
|
||||
|
||||
pipeline(stream, file).catch(err => {
|
||||
file?.destroy();
|
||||
tryit(() => unlinkSync(dest));
|
||||
tryit(() => deleteFileSync(dest));
|
||||
subscriber.error(err);
|
||||
});
|
||||
});
|
||||
@@ -131,6 +136,7 @@ export class RequestService {
|
||||
data: null,
|
||||
};
|
||||
|
||||
const headers = { ...this.baseHeaders, ...(options?.headers ?? {}) };
|
||||
const familiesToTry = this.preferredFamily ? [this.preferredFamily, this.preferredFamily === 4 ? 6 : 4] : [4, 6];
|
||||
|
||||
let attempt = 0;
|
||||
@@ -143,9 +149,8 @@ export class RequestService {
|
||||
}
|
||||
|
||||
const family = familiesToTry[attempt++];
|
||||
|
||||
// @ts-ignore (ESM is not well supported in this project, We need to move out electron-react-boilerplate, and use Vite)
|
||||
stream = got.stream(url, { dnsLookupIpVersion: family, ...(options ?? {}) });
|
||||
stream = got.stream(url, { dnsLookupIpVersion: family, ...(options ?? {}), headers });
|
||||
|
||||
let data = Buffer.alloc(0);
|
||||
let response: IncomingMessage;
|
||||
|
||||
@@ -211,7 +211,7 @@ export class SteamService {
|
||||
for (const userId of userIds) {
|
||||
const shortcuts = await this.getShortcuts(userId).catch(e => {
|
||||
log.warn("Error while reading shortcuts", e);
|
||||
return [];
|
||||
return [] as SteamShortcut[];
|
||||
});
|
||||
shortcuts.push(new SteamShortcut(shortcutData));
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean,
|
||||
const color = useThemeColor("second-color");
|
||||
|
||||
const [launchOption, setLaunchOptions] = useState(bsLauncher.getLaunchOptions(data));
|
||||
const [advanced, setAdvanced] = useState(!!launchOption.additionalArgs?.length);
|
||||
const [additionalArgsString, setAdditionalArgsString] = useState(launchOption.additionalArgs?.join("; ") ?? "");
|
||||
const [advanced, setAdvanced] = useState(!!launchOption.command?.length);
|
||||
const [command, setCommand] = useState(launchOption.command || "");
|
||||
const [steamShortcut, setSteamShortcut] = useState(false);
|
||||
|
||||
const isSteamVersion = useMemo(() => {
|
||||
@@ -31,13 +31,7 @@ export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean,
|
||||
}, [data]);
|
||||
|
||||
const completeModal = () => {
|
||||
|
||||
if(advanced) {
|
||||
launchOption.additionalArgs = additionalArgsString.split(";").map(arg => arg.trim()).filter(arg => arg.length);
|
||||
} else {
|
||||
launchOption.additionalArgs = undefined;
|
||||
}
|
||||
|
||||
launchOption.command = command.trim();
|
||||
resolver({exitCode: ModalExitCode.COMPLETED, data: { launchOption, steamShortcut }});
|
||||
}
|
||||
|
||||
@@ -95,8 +89,8 @@ export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean,
|
||||
type="text"
|
||||
className="w-full rounded-md text-center outline-none bg-light-main-color-3 dark:bg-main-color-3"
|
||||
placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")}
|
||||
value={additionalArgsString}
|
||||
onChange={e => setAdditionalArgsString(e.target.value)}
|
||||
value={command}
|
||||
onChange={e => setCommand(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PlaylistsManagerService } from "renderer/services/playlists-manager.ser
|
||||
import { MapsManagerService } from "renderer/services/maps-manager.service";
|
||||
import { map } from "rxjs";
|
||||
|
||||
export const LinkContentModal: ModalComponent<boolean, {version: BSVersion, contentType: "maps"|"playlists"|"avatars"|"sabers"|"platforms"|"blocks"}> = ({options: { data: { version, contentType } }, resolver }) => {
|
||||
export const LinkContentModal: ModalComponent<void, {version: BSVersion, contentType: "maps"|"playlists"|"avatars"|"sabers"|"platforms"|"blocks"}> = ({options: { data: { version, contentType } }, resolver }) => {
|
||||
const { text: t, element: te } = useTranslationV2();
|
||||
|
||||
const versionLinker = useService(VersionFolderLinkerService);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IpcService } from "renderer/services/ipc.service";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { logRenderError } from "renderer";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { DEFAULT_PLAYLIST_COVER } from "shared/models/playlists/local-playlist.models";
|
||||
|
||||
type OutProps = {
|
||||
playlistTitle: string;
|
||||
@@ -34,7 +35,9 @@ export const EditPlaylistInfosModal: ModalComponent<OutProps, Props> = ({ resolv
|
||||
const [title, setTitle] = useState(playlistTitle);
|
||||
const [description, setDescription] = useState(playlistDescription);
|
||||
const [author, setAuthor] = useState(playlistAuthor ?? steamDownloader.getSteamUsername());
|
||||
const [base64, setBase64] = useState(base64Image);
|
||||
|
||||
// Playlist cover or Default playlist cover
|
||||
const [base64, setBase64] = useState(base64Image || DEFAULT_PLAYLIST_COVER);
|
||||
|
||||
const handleClickImage = async () => {
|
||||
const res = await lastValueFrom(ipc.sendV2("choose-image", { base64: true })).catch(logRenderError) as string[];
|
||||
@@ -60,12 +63,11 @@ export const EditPlaylistInfosModal: ModalComponent<OutProps, Props> = ({ resolv
|
||||
</h1>
|
||||
<div className="w-full flex flex-col justify-center items-center">
|
||||
<button className="flex justify-center items-center relative size-36 border-2 border-gray-400 bg-theme-1 rounded-md overflow-hidden" onClick={handleClickImage}>
|
||||
{base64 ? (
|
||||
{base64 && (
|
||||
<BsmImage className="absolute size-full cursor-pointer" base64={base64} />
|
||||
) : (
|
||||
<span className="absolute size-full flex justify-center items-center p-2">{t("playlist.choose-image")}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="size-full flex justify-center items-center mb-1">{t("playlist.choose-image")}</span>
|
||||
<div className="w-full">
|
||||
<label className="font-bold cursor-pointer tracking-wide" htmlFor="playlist-title">{t("playlist.title")}</label>
|
||||
<input id="playlist-title" type="text" className="w-full bg-theme-1 px-1 py-0.5 rounded-md outline-none h-9" value={title} placeholder={t("playlist.playlist-title")} onChange={e => setTitle(e.target.value)}/>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function PlaylistDetailsTemplate({title, imagebase64, imageUrl, author, d
|
||||
return (
|
||||
<div className="flex flex-col w-screen max-w-2xl h-screen max-h-[calc(100vh-1.25rem)] translate-y-2.5 bg-theme-1 rounded-t-lg overflow-hidden">
|
||||
<header className="shrink-0 relative h-36 overflow-hidden flex flex-row p-3">
|
||||
<button className="absolute top-2 right-2 z-[1]" onClickCapture={onClose}>
|
||||
<button className="absolute top-2 right-2 z-[2]" onClickCapture={onClose}>
|
||||
<CrossIcon className="size-3.5 text-gray-200"/>
|
||||
</button>
|
||||
<BsmImage className="absolute top-0 left-0 size-full object-cover blur-xl scale-150 brightness-75 saturate-200" base64={imagebase64} image={imageUrl}/>
|
||||
|
||||
@@ -56,14 +56,14 @@ export function NavBar() {
|
||||
<NavBarSpliter />
|
||||
<NavBarItem isActive={["/blah","/"].includes(route)}>
|
||||
<Tippy placement="right" content={t("nav-bar.add-version")} className="!bg-neutral-900" arrow={false}>
|
||||
<Link className="rounded-md w-9 h-9 flex justify-center items-center hover:bg-light-main-color-3 dark:hover:bg-main-color-3" to="blah">
|
||||
<Link className="rounded-md w-9 h-9 flex justify-center items-center hover:bg-theme-3" to="blah">
|
||||
<BsmIcon icon="add" className="text-blue-500 h-[34px]" style={{ color }} />
|
||||
</Link>
|
||||
</Tippy>
|
||||
</NavBarItem>
|
||||
<NavBarItem isActive={route === "/settings"}>
|
||||
<Tippy placement="right" content={t("nav-bar.settings")} className="!bg-neutral-900" arrow={false}>
|
||||
<Link className="rounded-md w-9 h-9 flex justify-center items-center hover:bg-light-main-color-3 dark:hover:bg-main-color-3" to="settings">
|
||||
<Link className="rounded-md w-9 h-9 flex justify-center items-center hover:bg-theme-3" to="settings">
|
||||
<BsmIcon icon="settings" className="text-blue-500 h-7" style={{ color }} />
|
||||
</Link>
|
||||
</Tippy>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function BsmCheckbox({ className, checked, onChange, disabled }: Props) {
|
||||
<GlowEffect visible={!disabled && hovered} className="!w-[calc(100%+6px)] !h-[calc(100%+6px)] !-top-[3px] !-left-[3px]" />
|
||||
|
||||
<span
|
||||
className={`w-full h-full flex items-center justify-center rounded-md border-2 border-current overflow-hidden bg-light-main-color-3 dark:bg-main-color-1 ${disabled ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
className={`w-full h-full flex items-center justify-center rounded-md border-2 border-main-color-1 dark:border-light-main-color-1 overflow-hidden bg-light-main-color-3 dark:bg-main-color-1 ${disabled ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClickCapture={e => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
|
||||
@@ -58,6 +58,7 @@ import { ChineseIcon } from "./flags/chinese-icon.component";
|
||||
import { ChineseTraditionalIcon } from "./flags/chineseTraditional-icon.component";
|
||||
import { JapanIcon } from "./flags/japan-icon.component";
|
||||
import { KoreaIcon } from "./flags/korea-icon.component";
|
||||
import { BrazilIcon } from "./flags/brasil-icon.component";
|
||||
import { ChevronTopIcon } from "./icons/chevron-top-icon.component";
|
||||
import { EyeCrossIcon } from "./icons/eye-cross-icon.component";
|
||||
import { ShortcutIcon } from "./icons/shortcut-icon.component";
|
||||
@@ -72,7 +73,7 @@ import { ArrowUpwardIcon } from "./icons/arrow-upward-icon.component";
|
||||
|
||||
|
||||
|
||||
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "browse" | "add-file" | "arrow-upward" | "cancel" | "warning" | "fr-FR-flag" | "es-ES-flag" | "it-IT-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag" | "ko-KR-flag" | "null" );
|
||||
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "browse" | "add-file" | "arrow-upward" | "cancel" | "warning" | "fr-FR-flag" | "es-ES-flag" | "it-IT-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag" | "ko-KR-flag" | "pt-BR-flag" | "null" );
|
||||
|
||||
export const BsmIcon = memo(({ className, icon, style }: { className?: string; icon: BsmIconType; style?: CSSProperties }) => {
|
||||
// TODO : Very ugly very messy, need to find a better way to do this
|
||||
@@ -142,6 +143,10 @@ export const BsmIcon = memo(({ className, icon, style }: { className?: string; i
|
||||
if (icon === "ko-KR-flag"){
|
||||
return <KoreaIcon className={className} style={style} />;
|
||||
}
|
||||
if (icon === "pt-BR-flag"){
|
||||
return <BrazilIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
if (icon === "task") {
|
||||
return <TaskIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createSvgIcon } from "../svg-icon.type";
|
||||
|
||||
export const BrazilIcon = createSvgIcon((props, ref) => {
|
||||
return (
|
||||
<svg ref={ref} {...props} width="71" height="48" viewBox="0 0 71 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none"><rect fill="#52C162" x=".15" y=".13" width="70" height="47" rx="6.36"/>
|
||||
<path fill="#F6D660" d="m7.93 23.63 27.21-21 27.21 21-27.21 21z"/>
|
||||
<path d="M31 18.55c-2.83 0-5.612.737-8.07 2.14a12.56 12.56 0 0 0 23.44 8.59A16.41 16.41 0 0 0 31 18.55" fill="#4758A9"/>
|
||||
<path d="M31 18.55a16.41 16.41 0 0 1 15.37 10.73 12.5 12.5 0 0 0 1-2.87C42.964 17.945 32.834 14.23 24 17.84a12.5 12.5 0 0 0-1.06 2.85A16.3 16.3 0 0 1 31 18.55" fill="#FFF"/>
|
||||
<path d="M47.4 26.42A12.57 12.57 0 0 0 24 17.84c8.848-3.633 19.005.09 23.41 8.58z" fill="#4758A9"/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
@@ -55,12 +55,14 @@ export function LaunchModItem({ id, icon: Icon, label, description, active, visi
|
||||
<Tippy theme="default" className="break-words" placement="top" content={description}>
|
||||
<button id={id} className={cn("grow rounded-md bg-theme-1 relative flex justify-center items-center h-10 py-1 px-3", visible === false && "hidden")} onClick={e => { e.preventDefault(); e.stopPropagation(); onChange?.(!active) }}>
|
||||
<BsmCheckbox className="h-4 aspect-square z-[1] relative mr-1.5" checked={active} onChange={onChange}/>
|
||||
{Icon && <Icon className="h-full w-fit py-0.5 mr-1.5"/>}
|
||||
<span className="font-bold">{label}</span>
|
||||
{Icon && <Icon className="h-full w-fit py-0.5 mr-1.5 text-gray-800 dark:text-gray-200"/>}
|
||||
<span className="font-bold text-gray-800 dark:text-gray-200">{label}</span>
|
||||
{onPinChange && (
|
||||
<Tippy theme="default" placement="right" content={pinned ? t("misc.unpin") : t("misc.pin")} hideOnClick>
|
||||
<button className="h-full py-2 px-1.5" onClick={e => { e.preventDefault(); e.stopPropagation(); onPinChange?.(!pinned) }}>
|
||||
{pinned ? <UnpinIcon className="size-full"/> : <PinIcon className="size-full"/>}
|
||||
{pinned
|
||||
? <UnpinIcon className="size-full text-gray-800 dark:text-gray-200"/>
|
||||
: <PinIcon className="size-full text-gray-800 dark:text-gray-200"/>}
|
||||
</button>
|
||||
</Tippy>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function LaunchSlide({ version }: Props) {
|
||||
const versions = useService(BSVersionManagerService);
|
||||
|
||||
const [advancedLaunch, setAdvancedLaunch] = useState(false);
|
||||
const [additionalArgsString, setAdditionalArgsString] = useState<string>(configService.get<string>("additionnal-args") || "");
|
||||
const [command, setCommand] = useState<string>(configService.get<string>("launch-command") || "");
|
||||
const versionDownloading = useObservable(() => bsDownloader.downloadingVersion$);
|
||||
const [activeLaunchMods, setActiveLaunchMods] = useState<LaunchMod[]>(configService.get("launch-mods") ?? []);
|
||||
const [pinnedLaunchMods, setPinnedLaunchMods] = useState<LaunchMod[]>(configService.get("pinned-launch-mods" as DefaultConfigKey) ?? []);
|
||||
@@ -46,8 +46,8 @@ export function LaunchSlide({ version }: Props) {
|
||||
const versionRunning = useObservable(() => bsLauncherService.versionRunning$);
|
||||
|
||||
useEffect(() => {
|
||||
configService.set("additionnal-args", additionalArgsString);
|
||||
}, [additionalArgsString]);
|
||||
configService.set("launch-command", command);
|
||||
}, [command]);
|
||||
|
||||
useEffect(() => {
|
||||
configService.set("pinned-launch-mods", pinnedLaunchMods);
|
||||
@@ -146,12 +146,10 @@ export function LaunchSlide({ version }: Props) {
|
||||
}, [activeLaunchMods, pinnedLaunchMods, version]);
|
||||
|
||||
const launch = async () => {
|
||||
const additionalArgs = additionalArgsString?.split(";").map(arg => arg.trim()).filter(arg => arg.length > 0);
|
||||
|
||||
const launch$ = bsLauncherService.launch({
|
||||
version,
|
||||
launchMods: activeLaunchMods,
|
||||
additionalArgs: advancedLaunch ? additionalArgs : [],
|
||||
command,
|
||||
});
|
||||
|
||||
return lastValueFrom(launch$).catch(() => {});
|
||||
@@ -183,6 +181,7 @@ export function LaunchSlide({ version }: Props) {
|
||||
if(launchMod?.visible === false || !launchMod?.pinned) { return undefined; }
|
||||
return (
|
||||
<LaunchModToogle
|
||||
key={launchMod.id}
|
||||
icon={launchMod.icon}
|
||||
infoText={launchMod.description}
|
||||
text={launchMod.label}
|
||||
@@ -194,9 +193,9 @@ export function LaunchSlide({ version }: Props) {
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col items-center justify-center gap-3">
|
||||
<div className="relative">
|
||||
<GlowEffect className="!rounded-full" visible={!!(activeLaunchMods?.length || additionalArgsString)}/>
|
||||
<GlowEffect className="!rounded-full" visible={!!(activeLaunchMods?.length || command)}/>
|
||||
<BsmButton
|
||||
className={cn("rounded-full w-fit text-lg py-1 px-7 bg-theme-2 text-gray-800 dark:text-white", (advancedLaunch && additionalArgsString) ? "" : "shadow-md shadow-black")}
|
||||
className={cn("rounded-full w-fit text-lg py-1 px-7 bg-theme-2 text-gray-800 dark:text-white", (advancedLaunch && command) ? "" : "shadow-md shadow-black")}
|
||||
text="pages.version-viewer.launch-mods.advanced-launch.button"
|
||||
withBar={false}
|
||||
onClick={e => {
|
||||
@@ -206,12 +205,12 @@ export function LaunchSlide({ version }: Props) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LaunchOptionsPanel open={advancedLaunch} launchMods={launchModItems} launchArgs={additionalArgsString} className="w-full max-w-3xl mt-3" onLaunchArgsChange={setAdditionalArgsString}/>
|
||||
<LaunchOptionsPanel open={advancedLaunch} launchMods={launchModItems} launchArgs={command} className="w-full max-w-3xl mt-3" onLaunchArgsChange={setCommand}/>
|
||||
<div className='grow flex justify-center items-center p-2'>
|
||||
<BsmButton
|
||||
onClick={launch}
|
||||
active={JSON.stringify(version) === JSON.stringify(versionRunning)}
|
||||
className='relative text-5xl text-gray-800 dark:text-gray-200 font-bold tracking-wide pt-1 pb-3 px-7 rounded-lg shadow-md italic shadow-black active:scale-90 transition-transform'
|
||||
className="relative text-5xl text-gray-800 dark:text-gray-200 font-bold tracking-wide pt-1 pb-3 px-7 rounded-lg shadow-md italic shadow-black active:scale-90 transition-transform"
|
||||
text="misc.launch"
|
||||
disabled={equal(version, versionDownloading)}
|
||||
/>
|
||||
|
||||
@@ -17,10 +17,9 @@ type Props = {
|
||||
uninstallMod?: (mods: BbmFullMod) => void;
|
||||
uninstallAllMods?: () => void;
|
||||
unselectAllMods?: () => void;
|
||||
openModsDropZone?: () => void;
|
||||
};
|
||||
|
||||
export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreInfoMod, onWantInfos, disabled, uninstallMod, uninstallAllMods, unselectAllMods, openModsDropZone }: Props) {
|
||||
export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreInfoMod, onWantInfos, disabled, uninstallMod, uninstallAllMods, unselectAllMods }: Props) {
|
||||
|
||||
const [filter, setFilter] = useState("");
|
||||
const [filterEnabled, setFilterEnabled] = useState(false);
|
||||
@@ -70,7 +69,6 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
|
||||
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8 whitespace-nowrap">{t("pages.version-viewer.mods.mods-grid.header-bar.description")}</span>
|
||||
<span className="z-10 sticky top-0 bg-inherit border-b-2 border-main-color-1 h-8 flex justify-start items-center py-1 pl-[3px] min-w-[50px]">
|
||||
<BsmDropdownButton className="h-full aspect-square relative rounded-full bg-light-main-color-1 dark:bg-main-color-3" withBar={false} icon="three-dots" buttonClassName="!rounded-full !p-[2px] !bg-light-main-color-2 dark:!bg-main-color-2 hover:!bg-light-main-color-1 dark:hover:!bg-main-color-3" menuTranslationY="5px" items={[
|
||||
{ text: "pages.version-viewer.mods.mods-grid.header-bar.dropdown.import-mods", icon: "download", onClick: () => openModsDropZone?.() },
|
||||
{ text: "pages.version-viewer.mods.mods-grid.header-bar.dropdown.unselect-all", icon: "cancel", onClick: () => unselectAllMods?.() },
|
||||
{ text: "pages.version-viewer.mods.mods-grid.header-bar.dropdown.uninstall-all", icon: "trash", onClick: () => uninstallAllMods?.() }
|
||||
]} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { forwardRef, ReactNode, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BbmCategories, BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
|
||||
import { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
|
||||
import { ModsGrid } from "./mods-grid.component";
|
||||
import { ConfigurationService } from "renderer/services/configuration.service";
|
||||
import { BsmButton } from "renderer/components/shared/bsm-button.component";
|
||||
@@ -11,7 +11,6 @@ import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { useTranslation, useTranslationV2 } from "renderer/hooks/use-translation.hook";
|
||||
import { LinkOpenerService } from "renderer/services/link-opener.service";
|
||||
import { useInView } from "framer-motion";
|
||||
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
|
||||
import { ModsDisclaimerModal } from "renderer/components/modal/modal-types/mods-disclaimer-modal.component";
|
||||
import { OsDiagnosticService } from "renderer/services/os-diagnostic.service";
|
||||
@@ -20,9 +19,17 @@ import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { NotificationService } from "renderer/services/notification.service";
|
||||
import { noop } from "shared/helpers/function.helpers";
|
||||
import { UninstallAllModsModal } from "renderer/components/modal/modal-types/uninstall-all-mods-modal.component";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { ProgressBarService } from "renderer/services/progress-bar.service";
|
||||
import { Dropzone } from "renderer/components/shared/dropzone.component";
|
||||
|
||||
export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; onDisclamerDecline: () => void }) {
|
||||
export type ModsSlideRef = {
|
||||
loadMods: () => Promise<void>;
|
||||
}
|
||||
|
||||
type Props = { version: BSVersion; isActive?: boolean, onDisclamerDecline: () => void };
|
||||
|
||||
export const ModsSlide = forwardRef<ModsSlideRef, Props>(({ version, isActive, onDisclamerDecline }, forwaredRef) => {
|
||||
const ACCEPTED_DISCLAIMER_KEY = "accepted-mods-disclaimer";
|
||||
|
||||
const { text: t } = useTranslationV2();
|
||||
@@ -33,9 +40,8 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
const linkOpener = useService(LinkOpenerService);
|
||||
const modals = useService(ModalService);
|
||||
const os = useService(OsDiagnosticService);
|
||||
const progress = useService(ProgressBarService);
|
||||
|
||||
const ref = useRef(null);
|
||||
const isVisible = useInView(ref, { amount: 0.5 });
|
||||
const [modsAvailable, setModsAvailable] = useState(null as Map<BbmCategories, BbmFullMod[]>);
|
||||
const [modsInstalled, setModsInstalled] = useState(null as Map<BbmCategories, BbmFullMod[]>);
|
||||
const [modsSelected, setModsSelected] = useState([] as BbmFullMod[]);
|
||||
@@ -100,14 +106,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
return Array.from(collectedDependencies);
|
||||
};
|
||||
|
||||
const installMods = (reinstallAll: boolean): void => {
|
||||
|
||||
setReinstallAllMods(false);
|
||||
|
||||
if (installing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getModsToInstall = (reinstallAll?: boolean): BbmFullMod[] => {
|
||||
let modsToInstall = [
|
||||
...modsSelected,
|
||||
...getAllDependencies(modsSelected, Array.from(modsAvailable.values()).flat())
|
||||
@@ -127,18 +126,30 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
set.delete(null);
|
||||
set.delete(undefined);
|
||||
|
||||
modsToInstall = Array.from(set); // Remove duplicates
|
||||
return Array.from(set); // Remove duplicates
|
||||
}
|
||||
|
||||
const installMods = (reinstallAll: boolean): Promise<void> => {
|
||||
|
||||
setReinstallAllMods(false);
|
||||
|
||||
if (installing) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const modsToInstall = getModsToInstall(reinstallAll);
|
||||
|
||||
if (!modsToInstall.length) {
|
||||
notification.notifyInfo({ title: "pages.version-viewer.mods.notifications.all-mods-already-installed.title", desc: "pages.version-viewer.mods.notifications.all-mods-already-installed.description" });
|
||||
loadMods();
|
||||
return;
|
||||
return loadMods();
|
||||
}
|
||||
|
||||
setInstalling(() => true)
|
||||
lastValueFrom(modsManager.installMods(modsToInstall, version)).then(() => {
|
||||
loadMods();
|
||||
}).catch(noop).finally(() => setInstalling(() => false));
|
||||
return lastValueFrom(modsManager.installMods(modsToInstall, version)).then(() => (
|
||||
loadMods()
|
||||
)).catch(noop).finally(() => (
|
||||
setInstalling(() => false)
|
||||
));
|
||||
};
|
||||
|
||||
const importMods = (files: string[]): void => {
|
||||
@@ -179,29 +190,22 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const promise = async (): Promise<[BbmFullMod[], BbmModVersion[]]> => {
|
||||
const available = await lastValueFrom(modsManager.getAvailableMods(version));
|
||||
const installed = await lastValueFrom(modsManager.getInstalledMods(version));
|
||||
return [available, installed];
|
||||
}
|
||||
|
||||
return promise().then(([available, installed]) => {
|
||||
return modsManager.getVersionModsState(version).then(({ available, installed }) => {
|
||||
const defaultMods = installed?.length ? [] : available.filter(m => m.mod.category === BbmCategories.Core || m.mod.category === BbmCategories.Essential);
|
||||
setModsAvailable(() => modsToCategoryMap(available));
|
||||
|
||||
const installedMods: BbmFullMod[] = installed.map(version => {
|
||||
const mod = available.find(m => m.mod.id === version.modId);
|
||||
return mod ? { ...mod, version } : null;
|
||||
}).filter(mod => mod);
|
||||
|
||||
setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.mod.name.toLowerCase()) || installedMods.some(i => m.mod.id === i.mod.id)));
|
||||
setModsInstalled(modsToCategoryMap(installedMods));
|
||||
setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.mod.name.toLowerCase()) || installed.some(i => m.mod.id === i.mod.id)));
|
||||
setModsInstalled(modsToCategoryMap(installed));
|
||||
});
|
||||
};
|
||||
|
||||
useImperativeHandle(forwaredRef, () => ({
|
||||
loadMods
|
||||
}), [version]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if(!isVisible || !isOnline){
|
||||
if(!isActive || !isOnline){
|
||||
return noop();
|
||||
}
|
||||
|
||||
@@ -231,7 +235,22 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
setModsAvailable(null);
|
||||
setModsInstalled(null);
|
||||
};
|
||||
}, [isVisible, isOnline, version]);
|
||||
}, [isActive, isOnline, version]);
|
||||
|
||||
useEffect(() => {
|
||||
// Center the progress bar between buttons
|
||||
if(isActive){
|
||||
progress.setStyle({ paddingLeft: "150px", paddingRight: "190px", bottom: "24px" });
|
||||
|
||||
} else {
|
||||
progress.setStyle(null);
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
progress.setStyle(null);
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (modsAvailable) {
|
||||
@@ -268,17 +287,21 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
uninstallMod={uninstallMod}
|
||||
uninstallAllMods={uninstallAllMods}
|
||||
unselectAllMods={unselectAllMods}
|
||||
openModsDropZone={() => setModsDropZoneOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center justify-between px-3 py-2">
|
||||
<BsmButton className="flex items-center justify-center rounded-md px-1 h-8" text="pages.version-viewer.mods.buttons.more-infos" typeColor="cancel" withBar={false} disabled={!moreInfoMod} onClick={handleOpenMoreInfo} style={{ width: downloadWith }}/>
|
||||
<div ref={downloadRef} className="flex h-8 justify-center items-center gap-px overflow-hidden rounded-md">
|
||||
<div className="grow h-full relative">
|
||||
<BsmButton className="relative left-0 flex items-center justify-center px-2 size-full transition-[top] duration-200 ease-in-out" text="pages.version-viewer.mods.buttons.install-or-update" typeColor="primary" withBar={false} onClick={() => installMods(false)} style={{ top: reinstallAllMods ? "-100%" : "0" }} />
|
||||
<BsmButton className="relative left-0 flex items-center justify-center px-2 size-full transition-[top] duration-200 ease-in-out " text="pages.version-viewer.mods.buttons.reinstall-all" typeColor="primary" withBar={false} onClick={() => installMods(true)} style={{ top: reinstallAllMods ? "-100%" : "0" }}/>
|
||||
<div className="flex gap-2">
|
||||
<div ref={downloadRef} className="flex h-8 justify-center items-center gap-px overflow-hidden rounded-md">
|
||||
<div className="grow h-full relative">
|
||||
<BsmButton className="relative left-0 flex items-center justify-center px-2 size-full transition-[top] duration-200 ease-in-out" text="pages.version-viewer.mods.buttons.install-or-update" typeColor="primary" withBar={false} onClick={() => installMods(false)} style={{ top: reinstallAllMods ? "-100%" : "0" }} />
|
||||
<BsmButton className="relative left-0 flex items-center justify-center px-2 size-full transition-[top] duration-200 ease-in-out " text="pages.version-viewer.mods.buttons.reinstall-all" typeColor="primary" withBar={false} onClick={() => installMods(true)} style={{ top: reinstallAllMods ? "-100%" : "0" }}/>
|
||||
</div>
|
||||
<BsmButton className="flex items-center justify-center shrink-0 h-full" iconClassName="transition-transform size-full ease-in-out duration-200" iconStyle={{ transform: reinstallAllMods ? "rotate(360deg)" : "rotate(180deg)" }} icon="chevron-top" typeColor="primary" withBar={false} onClick={() => setReinstallAllMods(prev => !prev)}/>
|
||||
</div>
|
||||
<BsmButton className="flex items-center justify-center shrink-0 h-full" iconClassName="transition-transform size-full ease-in-out duration-200" iconStyle={{ transform: reinstallAllMods ? "rotate(360deg)" : "rotate(180deg)" }} icon="chevron-top" typeColor="primary" withBar={false} onClick={() => setReinstallAllMods(prev => !prev)}/>
|
||||
<Tippy content={t("pages.version-viewer.mods.mods-grid.header-bar.dropdown.import-mods")} theme="default">
|
||||
<BsmButton className="flex items-center justify-center rounded-md px-1 h-8 aspect-square" icon="download" typeColor="cancel" withBar={false} onClick={() => setModsDropZoneOpen(true)}/>
|
||||
</Tippy>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -286,7 +309,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="shrink-0 w-full h-full px-8 pb-7 flex justify-center">
|
||||
<div className="shrink-0 w-full h-full px-8 pb-7 flex justify-center">
|
||||
<Dropzone
|
||||
className="w-full h-full shrink-0"
|
||||
onFiles={importMods}
|
||||
@@ -306,7 +329,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
|
||||
</Dropzone>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function ModStatus({ text, image, spin = false, children }: { text: string; image: string; spin?: boolean, children?: ReactNode}) {
|
||||
const t = useTranslation();
|
||||
|
||||
@@ -25,7 +25,7 @@ export const defaultConfiguration: {
|
||||
"second-color": "#ff4444",
|
||||
theme: "os",
|
||||
language: window.navigator.language.length <= 2 ? `${window.navigator.language}-${window.navigator.language.toLocaleUpperCase()}` : window.navigator.language,
|
||||
supported_languages: ["en-US", "en-EN", "fr-FR", "es-ES", "it-IT", "de-DE", "ru-RU", "zh-CN", "zh-TW", "ja-JP", "ko-KR"],
|
||||
supported_languages: ["en-US", "en-EN", "fr-FR", "es-ES", "it-IT", "de-DE", "ru-RU", "zh-CN", "zh-TW", "ja-JP", "ko-KR", "pt-BR"],
|
||||
default_mods: ["SongCore", "WhyIsThereNoLeaderboard", "BeatSaverDownloader", "BeatSaverVoting", "PlaylistManager"],
|
||||
"default-shared-folders": [
|
||||
window.electron.path.join("Beat Saber_Data", "CustomLevels"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||
import { TabNavBar } from "renderer/components/shared/tab-nav-bar.component";
|
||||
import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component";
|
||||
@@ -10,7 +10,7 @@ import { ModalExitCode, ModalService } from "../services/modale.service";
|
||||
import DefautVersionImage from "../../../assets/images/default-version-img.jpg";
|
||||
import { IpcService } from "renderer/services/ipc.service";
|
||||
import { LaunchSlide } from "renderer/components/version-viewer/slides/launch/launch-slide.component";
|
||||
import { ModsSlide } from "renderer/components/version-viewer/slides/mods/mods-slide.component";
|
||||
import { ModsSlide, ModsSlideRef } from "renderer/components/version-viewer/slides/mods/mods-slide.component";
|
||||
import { UninstallModal } from "renderer/components/modal/modal-types/uninstall-modal.component";
|
||||
import { MapsPlaylistsPanel } from "renderer/components/maps-playlists-panel/maps-playlists-panel.component";
|
||||
import { ShareFoldersModal } from "renderer/components/modal/modal-types/share-folders-modal.component";
|
||||
@@ -25,12 +25,18 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
|
||||
import { safeLt } from "shared/helpers/semver.helpers";
|
||||
import { ConfigurationService } from "renderer/services/configuration.service";
|
||||
import { logRenderError } from "renderer";
|
||||
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
|
||||
import { noop } from "shared/helpers/function.helpers";
|
||||
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
|
||||
|
||||
export function VersionViewer() {
|
||||
|
||||
const { text: t } = useTranslationV2();
|
||||
|
||||
const bsUninstallerService = useService(BSUninstallerService);
|
||||
const bsVersionManagerService = useService(BSVersionManagerService);
|
||||
const modalService = useService(ModalService);
|
||||
const modsService = useService(BsModsManagerService);
|
||||
const bsDownloader = useService(BsDownloaderService);
|
||||
const ipcService = useService(IpcService);
|
||||
const bsLauncher = useService(BSLauncherService);
|
||||
@@ -40,10 +46,12 @@ export function VersionViewer() {
|
||||
const { state, pathname: url } = useLocation() as { state: BSVersion; pathname: string };
|
||||
const navigate = useNavigate();
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
const modsSlideRef = useRef<ModsSlideRef>(null);
|
||||
|
||||
useOnUpdate(() => {
|
||||
|
||||
checkIsVersionOutaded();
|
||||
checkOutdatedMods();
|
||||
|
||||
}, [state]);
|
||||
|
||||
@@ -70,6 +78,42 @@ export function VersionViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
const checkOutdatedMods = async () => {
|
||||
|
||||
if(config.get("not-show-outdated-mods-notification")){
|
||||
return;
|
||||
}
|
||||
|
||||
const { available: availableMods, installed: installedMods } = await modsService.getVersionModsState(state);
|
||||
|
||||
const modsToUpdate = availableMods.filter(availableMod => {
|
||||
const installedMod = installedMods.find(installedMod => installedMod.mod.id === availableMod.mod.id);
|
||||
return installedMod && safeLt(installedMod.version.modVersion, availableMod.version.modVersion);
|
||||
});
|
||||
|
||||
if(!modsToUpdate.length){
|
||||
return;
|
||||
}
|
||||
|
||||
const choice = await notification.notifyInfo({
|
||||
title: modsToUpdate.length > 1 ? t("pages.version-viewer.mods.notifications.outdated-mods.title-plural") : t("pages.version-viewer.mods.notifications.outdated-mods.title"),
|
||||
desc: modsToUpdate.length > 1 ? t("pages.version-viewer.mods.notifications.outdated-mods.description-plural", { nb: `${modsToUpdate.length}` }) : t("pages.version-viewer.mods.notifications.outdated-mods.description", { name: `${modsToUpdate[0].mod.name}` }),
|
||||
duration: 9000,
|
||||
actions: [
|
||||
{ id: "0", title: "pages.version-viewer.mods.notifications.outdated-mods.dont-remind-me", cancel: true },
|
||||
{ id: "1", title: "pages.version-viewer.mods.notifications.outdated-mods.update" }
|
||||
]
|
||||
});
|
||||
|
||||
if(choice === "1"){
|
||||
lastValueFrom(modsService.installMods(modsToUpdate, state)).then(() => {
|
||||
modsSlideRef?.current?.loadMods?.();
|
||||
}).catch(noop);
|
||||
} else if(choice === "0"){
|
||||
config.set("not-show-outdated-mods-notification", true);
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToVersion = (version?: BSVersion) => {
|
||||
if (!version) {
|
||||
return navigate("/available-versions");
|
||||
@@ -153,7 +197,7 @@ export function VersionViewer() {
|
||||
<div className="w-full shrink-0 px-3 pb-3 flex flex-col items-center">
|
||||
<ModelsPanel version={state} isActive={currentTabIndex === 2} goToMods={() => setCurrentTabIndex(() => 3)} />
|
||||
</div>
|
||||
<ModsSlide version={state} onDisclamerDecline={handleModsDisclaimerDecline} />
|
||||
<ModsSlide ref={modsSlideRef} version={state} isActive={currentTabIndex === 3} onDisclamerDecline={handleModsDisclaimerDecline} />
|
||||
</div>
|
||||
</div>
|
||||
<BsmDropdownButton className="absolute top-3 right-4 h-9 w-9 bg-light-main-color-2 dark:bg-main-color-2 rounded-md" items={[
|
||||
|
||||
@@ -36,19 +36,11 @@ export class BSLauncherService {
|
||||
this.modals = ModalService.getInstance();
|
||||
}
|
||||
|
||||
private notRewindBackupOculus(): boolean{
|
||||
return this.config.get<boolean>("not-rewind-backup-oculus");
|
||||
}
|
||||
|
||||
private setNotRewindBackupOculus(value: boolean): void{
|
||||
this.config.set("not-rewind-backup-oculus", value);
|
||||
}
|
||||
|
||||
public getLaunchOptions(version: BSVersion): LaunchOption{
|
||||
public getLaunchOptions(version: BSVersion): LaunchOption {
|
||||
return {
|
||||
version,
|
||||
launchMods: this.config.get("launch-mods") ?? [],
|
||||
additionalArgs: (this.config.get<string>("additionnal-args") || "").split(";").map(arg => arg.trim()).filter(arg => arg.length > 0),
|
||||
command: this.config.get<string>("launch-command") || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +53,12 @@ export class BSLauncherService {
|
||||
this.notificationService.notifySuccess({title: `notifications.bs-launch.success.titles.${event.type}`, desc: `notifications.bs-launch.success.msg.${event.type}`});
|
||||
},
|
||||
error: (err: CustomError) => {
|
||||
if(!err?.code || !Object.values(BSLaunchError).includes(err.code as BSLaunchError)){
|
||||
if (err?.code?.startsWith("generic.")) {
|
||||
this.notificationService.notifyError({
|
||||
title: "notifications.bs-launch.errors.titles.UNKNOWN_ERROR",
|
||||
desc: err.code,
|
||||
});
|
||||
} else if(!err?.code || !Object.values(BSLaunchError).includes(err.code as BSLaunchError)){
|
||||
this.notificationService.notifyError({title: "notifications.bs-launch.errors.titles.UNKNOWN_ERROR", desc: "notifications.bs-launch.errors.msg.UNKNOWN_ERROR"});
|
||||
} else {
|
||||
this.notificationService.notifyError({title: `notifications.bs-launch.errors.titles.${err.code}`, desc: `notifications.bs-launch.errors.msg.${err.code}`, duration: sToMs(9)})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NotificationService } from "./notification.service";
|
||||
import { Progression } from "main/helpers/fs.helpers";
|
||||
import { ProgressionInterface } from "shared/models/progress-bar";
|
||||
import { BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
|
||||
import { logRenderError } from "renderer";
|
||||
|
||||
export class BsModsManagerService {
|
||||
private static instance: BsModsManagerService;
|
||||
@@ -48,7 +49,8 @@ export class BsModsManagerService {
|
||||
|
||||
return new Observable<Progression>(obs => {
|
||||
const install$ = this.ipcService.sendV2("bs-mods.install-mods", { mods, version });
|
||||
this.progressBar.show(install$.pipe(catchError(() => of({ current: 0, total: 0} as Progression))), { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" });
|
||||
this.progressBar.show(install$.pipe(catchError(() => of({ current: 0, total: 0} as Progression))));
|
||||
|
||||
|
||||
const sub = install$.pipe(
|
||||
tap({
|
||||
@@ -179,4 +181,27 @@ export class BsModsManagerService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getVersionModsState(version: BSVersion): Promise<{available: BbmFullMod[], installed: BbmFullMod[]}> {
|
||||
const [available, installed]: [BbmFullMod[], BbmModVersion[]] = await (async () => {
|
||||
const available = await lastValueFrom(this.getAvailableMods(version));
|
||||
const installed = await lastValueFrom(this.getInstalledMods(version));
|
||||
return [available ?? [], installed ?? []] as [BbmFullMod[], BbmModVersion[]]; // Make TS happy
|
||||
})().catch(e => {
|
||||
logRenderError(e);
|
||||
return [[], []] as [BbmFullMod[], BbmModVersion[]]; // Make TS happy
|
||||
})
|
||||
|
||||
const installedMods: BbmFullMod[] = installed.reduce((acc, installedMod) => {
|
||||
const mod = available.find(m => m.mod.id === installedMod.modId);
|
||||
|
||||
if(mod){
|
||||
acc.push({ ...mod, version: installedMod } as BbmFullMod);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return { available: (available ?? []), installed: (installedMods ?? []) };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ModelsManagerService {
|
||||
return this.versionFolderLinked.linkVersionFolder({
|
||||
version,
|
||||
relativeFolder: MODEL_TYPE_FOLDERS[type],
|
||||
options: { keepContents: res.data !== false },
|
||||
options: { keepContents: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ export class PlaylistsManagerService {
|
||||
return this.linker.linkVersionFolder({
|
||||
version,
|
||||
relativeFolder: PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER,
|
||||
options: { keepContents: !!modalRes.data },
|
||||
options: { keepContents: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ export class ProgressBarService {
|
||||
this.subscribreTo(obs);
|
||||
}
|
||||
this._visible$.next(true);
|
||||
this._style$.next(style);
|
||||
|
||||
if(style) {
|
||||
this._style$.next(style);
|
||||
}
|
||||
}
|
||||
|
||||
public showFake(speed: number, style?: CSSProperties, label?: string): void {
|
||||
|
||||
@@ -21,7 +21,7 @@ export class VersionFolderLinkerService {
|
||||
return VersionFolderLinkerService.instance;
|
||||
}
|
||||
|
||||
private readonly KNOWN_ERROR_CODES = ["EPERM", "EACCES", "ENOSPC"];
|
||||
private readonly KNOWN_ERROR_CODES = ["EPERM", "EACCES", "ENOSPC", "LinkingNotSupported"];
|
||||
|
||||
private readonly ipcService: IpcService;
|
||||
private readonly progress: ProgressBarService;
|
||||
|
||||
@@ -8,8 +8,8 @@ export function tryit<Return>(func: () => Return): TryitReturn<Return> {
|
||||
|
||||
if(isPromise(result)){
|
||||
return result
|
||||
.then((value) => ({ error: null, result: value }))
|
||||
.catch((err) => ({ error: err instanceof Error ? err : new Error(`${err}`), result: null })) as Return extends Promise<any>
|
||||
.then((value) => ({ error: null as null, result: value }))
|
||||
.catch((err) => ({ error: err instanceof Error ? err : new Error(`${err}`), result: null as null })) as Return extends Promise<any>
|
||||
? Promise<{error: Error, result: undefined} | {error: undefined, result: Awaited<Return>}>
|
||||
: {error: Error, result: undefined} | {error: undefined, result: Return};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ export type LaunchMod = typeof LaunchMods[keyof typeof LaunchMods];
|
||||
export interface LaunchOption {
|
||||
version: BSVersion,
|
||||
launchMods?: LaunchMod[],
|
||||
additionalArgs?: string[],
|
||||
command?: string,
|
||||
admin?: boolean
|
||||
}
|
||||
|
||||