Compare commits

...

34 Commits

Author SHA1 Message Date
MathieuG-P ed17e521ca bumb to 1.4.17 2025-01-02 19:04:02 +01:00
MathieuG-P 139f15ee71 Striptags in mods summary and description 2025-01-02 18:58:50 +01:00
MathieuG-P 93d2761d08 Fix no mods for original steam copy of BS 2025-01-02 18:52:25 +01:00
MathieuG-P 48899f5869 bumb to v1.4.16 2025-01-02 17:47:16 +01:00
MathieuG-P 44bfe6ca24 Merge pull request #726 from silentrald/hotfix/filter-out-null-mods
[hotfix] fix issue where installed mods are not reflected in mods tab
2025-01-02 17:39:37 +01:00
silentrald 526344f838 [hotfix] fix issue where installed mods are not reflected in mods tab 2025-01-03 00:30:20 +08:00
MathieuG-P 979232a0f8 Available mods should be loaded first 2025-01-02 16:12:13 +01:00
MathieuG-P 8c7a22378c Fix scroll-bar in the mods slide view 2025-01-02 16:04:16 +01:00
MathieuG-P b69978cc51 Add filter pattern for "OC" oculus token 2025-01-02 15:39:27 +01:00
MathieuG-P 1ad81a68fc Merge pull request #725 from silentrald/hotfix/non-existent-mods-in-bbm
[hotfix] fix isue where querying mods with non-existent hash in BBM
2025-01-02 15:18:42 +01:00
silentrald e08c7b7109 [hotfix] fix isue where querying mods with non-existent hash in BBM 2025-01-02 19:25:23 +08:00
MathieuG-P 2991ff4ad2 Essential mods are now the default mods 2025-01-02 00:29:29 +01:00
MathieuG-P 14eadbe274 bumb to 1.4.15 2025-01-02 00:06:04 +01:00
MathieuG-P 9089f84f33 [bugfix] We can now enter oculus token starting with "OC" 2025-01-02 00:04:23 +01:00
MathieuG-P f43eae8222 Merge pull request #720 from Zagrios/bugfix/fix-oculus-launch
[bugfix] fix oculus launch by using sideloading

(cherry picked from commit f77ff2561d)
2025-01-01 23:37:45 +01:00
MathieuG-P 9ecb3daf55 [chore] migrate to the new mods repo API 2025-01-01 23:26:20 +01:00
MathieuG-P 054f457055 delete 2024-12-29 18:40:15 +01:00
MathieuG-P 0f7c42a90c Merge pull request #508 from Zagrios/qol/mods-load-faster
[QOL] Mods load faster

(cherry picked from commit a6c1ba9fee)

+ init migration to BadBeatMods
2024-12-28 22:54:32 +01:00
MathieuG-P 992f41238c Update to 1.4.14 2024-12-02 20:26:03 +01:00
MathieuG-P 17d7b4ee31 Fix depotdownloader file validation broken 2024-12-02 20:25:05 +01:00
MathieuG-P 0e7ae4dd99 Merge pull request #679 from beatmaps-io/signed-playlists
Pass query parameters to playlist downloader

(cherry picked from commit 127a538509)
2024-12-02 20:15:08 +01:00
MathieuG-P ca1049977e change certificate sha 2024-11-26 20:01:46 +01:00
MathieuG-P 46684fb757 Merge pull request #673 from Zagrios/chore/add-bs-version-outated-alert-in-version-view
[chore] added a beat saber version outdated warning in the version view

(cherry picked from commit 877879f335)
2024-11-25 21:20:53 +01:00
MathieuG-P b620cf19b5 Merge pull request #671 from Zagrios/bugfix/close-btn-supporters-view-wrong-color
[bugfix] Close button in the supporters view had the wrong color in the white theme

(cherry picked from commit 229a149f00)
2024-11-25 21:04:08 +01:00
MathieuG-P e40e010ab5 Merge pull request #667 from Zagrios/bugfix/do-no-apply-no-yeet-on-original-copies-of-bs
[bugfix] Do not launch BS with the --no-yeet arg when launching original copies of BS (Steam/Oculus)

(cherry picked from commit fd30709af9)
2024-11-25 21:03:37 +01:00
MathieuG-P efebd221c9 Merge pull request #666 from Zagrios/feature/add-unselect-all-mods-button
[feature] add "unselect all" action in mods panel

(cherry picked from commit c73bde2cd9)
2024-11-25 21:03:04 +01:00
MathieuG-P 4805e74a2a Merge pull request #662 from beatmaps-io/default-bsv-map-sort
Set default beat saver map sort order to Relevance

(cherry picked from commit 39739dfb49)
2024-11-25 20:52:41 +01:00
MathieuG-P 52d9fe4cf2 Merge pull request #661 from beatmaps-io/master
Use correct beatsaver api url

(cherry picked from commit c9286c5419)
2024-11-25 20:45:48 +01:00
MathieuG-P dff827a06c Merge pull request #660 from chk1/bugfix/658
[bugfix-658] Change background color of Mods search box for light mode

(cherry picked from commit 578e79edac)
2024-11-25 20:45:26 +01:00
MathieuG-P e6ee58b560 Merge pull request #657 from LiamillionSS/bugfix/527
[bugfix/527] Prevents oculus version from launching if the original oculus version is not present

(cherry picked from commit e1f421831b)
2024-11-25 20:44:54 +01:00
MathieuG-P 7c871f84a4 Merge pull request #654 from Zagrios/chore/add-outdate-bs-version-warn-modal
[chore] Added a pre-download warning modal for outdated BS versions

(cherry picked from commit e17b7f70fd)
2024-11-25 20:42:20 +01:00
MathieuG-P 6aed7810d8 Merge pull request #653 from Zagrios/chore/add-beatleader-icon-to-playlist-oneclickt-setting
[chore] Added beatleader icon to playlist oneclick setting + change tippys color

(cherry picked from commit 6c39e5f43d)
2024-11-25 20:31:09 +01:00
MathieuG-P 6f332f66bc Merge pull request #649 from silentrald/bugfix/non-standard-zip-extraction
[bugfix] handle extracting non-conformant zip files

(cherry picked from commit f7f5f768fc)
2024-11-25 20:30:29 +01:00
MathieuG-P 6dde2fa5d0 update to v1.4.13 2024-11-25 19:46:57 +01:00
89 changed files with 12785 additions and 6054 deletions
+13
View File
@@ -52,6 +52,19 @@ module.exports = {
Function: false,
}
}]
"import/extensions": "off",
"lines-between-class-members": "off",
"no-throw-literal": "warn",
"no-use-before-define": "off",
"no-useless-constructor": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
'react/jsx-filename-extension': [2, { 'extensions': ['.js', '.jsx', '.ts', '.tsx'] }],
"no-shadow": "off",
"react/function-component-definition": "off",
"jsx-a11y/control-has-associated-label": "off",
"react/button-has-type": "off",
"max-classes-per-file": "off",
},
parserOptions: {
ecmaVersion: 2020,
+2
View File
@@ -15,3 +15,5 @@
*.ttf binary
*.woff binary
*.woff2 binary
assets/scripts/* binary
*.zip binary
+5 -4
View File
@@ -13,7 +13,7 @@ jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
@@ -26,6 +26,7 @@ jobs:
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm test
# Currently typescript, eslint, and prettier are unhappy
continue-on-error: true
- name: Unit tests
run: npm run test:unit
+17
View File
@@ -785,6 +785,23 @@
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4669760442595134838",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/57cfa043407c091e67b7e0a8ae86fe5590deae33.png",
"ReleaseDate": "1728399815",
"year": "2024"
},
{
"BSVersion": "1.39.0",
"BSManifest": "5365094543246905054",
"OculusBinaryId": "7978663688900007",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4451339035198095638",
"ReleaseImg": "https://clan.cloudflare.steamstatic.com/images/32055887/fd8e2c783e46decdf5e10fed1e16f124616d9321.png",
"ReleaseDate": "1731426932",
"year": "2024"
},
{
"BSVersion": "1.39.1",
"BSManifest": "2179614296547473280",
"OculusBinaryId": "8189133517853022",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4451340304252928024",
"ReleaseDate": "1732107748",
"year": "2024",
"recommended": true
}
+36 -15
View File
@@ -14,7 +14,12 @@
"refuse": "Ablehnen",
"apply": "Übernehmen",
"copy": "Kopieren",
"copied": "Kopiert!"
"copied": "Kopiert!",
"confirm": "Bestätigen",
"choose-folder": "Ordner Wählen",
"unknown": "Ein unbekannter Fehler ist aufgetreten ¯\\_(ツ)_/¯",
"warning": "Achtung",
"continue": "Weiter"
},
"nav-bar": {
"add-version": "Version Hinzufügen",
@@ -32,7 +37,8 @@
"desktop": "FPFC Modus",
"desktop-description": "Auf diese Weise kannst du WASD und die Maus verwenden, um im Spiel durch das Menü zu navigieren. Dies erleichtert das Testen erheblich, da Du dein Headset nicht aufsetzen musst!",
"debug": "Debug Modus",
"debug-description": "Aktiviert das Ausgabeprotokollfenster für IPA. Dies zeigt die Debug-Konsole, die Mods verwenden.",
"debug-description": "Aktiviert das Ausgabeprotokollfenster für IPA. Dies zeigt die Debug-Konsole, welche die Mods verwenden.",
"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": "Erweiterter Start",
"placeholder": "Weitere Argumente Bspw: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "Neueste",
"description": "Beschreibung",
"dropdown": {
"uninstall-all": "Alle deinstallieren"
"uninstall-all": "Alle deinstallieren",
"unselect-all": "Alle abwählen"
}
}
}
@@ -380,16 +387,20 @@
"BS_ALREADY_RUNNING": "Beat Saber läuft bereits",
"EXE_NOT_FINDED": "Dateien Fehlen",
"EXIT": "Abrupter Stop",
"OCULUS_LIB_NOT_FOUND": "Oculus-Bibliothek nicht gefunden"
"OCULUS_LIB_NOT_FOUND": "Oculus-Bibliothek nicht gefunden",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Original Oculus-Version nicht gefunden"
},
"msg": {
"UNKNOWN_ERROR": "Ein unbekannter Fehler ist aufgetreten.",
"STEAM_NOT_RUNNING": "Steam muss laufen, um Beat Saber zu starten.",
"OCULUS_NOT_RUNNING": "Oculus muss ausgeführt werden, um Beat Saber zu starten.",
"BS_ALREADY_RUNNING": "Schließe Beat Saber, bevor Du es erneut startest.",
"EXE_NOT_FINDED": "Einige Dateien scheinen zu fehlen, versuche die Dateien zu überprüfen.",
"EXIT": "Beat Saber stoppte abrupt, versuche die Dateien zu überprüfen.",
"OCULUS_LIB_NOT_FOUND": "Überprüfe, ob die Oculus-Anwendung korrekt installiert ist und dass die Bibliotheken in Oculus richtig definiert sind."
"BS_ALREADY_RUNNING": "Schließe Beat Saber, bevor du es erneut startest.",
"EXE_NOT_FINDED": "Einige Dateien scheinen zu fehlen. Versuche, die Dateien zu überprüfen.",
"EXIT": "Beat Saber stoppte abrupt. Versuche, die Dateien zu überprüfen.",
"OCULUS_LIB_NOT_FOUND": "Überprüfe, ob die Oculus-Anwendung korrekt installiert ist und dass die Bibliotheken in Oculus richtig definiert sind.",
"PROTON_NOT_SET": "Stellen Sie den Proton-Pfad in den Einstellungen ein.",
"PROTON_NOT_FOUND": "Stellen Sie den Proton-Pfad in den Einstellungen ein.",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saber muss vor dem Öffnen über die Oculus-App installiert werden."
},
"actions": {
"STEAM_NOT_RUNNING": "Starte Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "Beim Erstellen der Verknüpfung ist ein Fehler aufgetreten."
}
},
"bs-version-oudated": {
"title": "Veraltete Version",
"msg": "Diese Version von Beat Saber ist veraltet. Verwenden Sie die empfohlene Version, um die neuesten Funktionen und Fehlerbehebungen zu nutzen.",
"actions": {
"do-not-remind": "Nicht mehr daran erinnern",
"ok": "Ok"
}
}
},
"modals": {
@@ -699,13 +718,12 @@
"stay": "Angemeldet bleiben",
"connect-to-meta": "Mit Meta verbinden"
},
"original-version-backup-oculus": {
"title": "Achtung",
"body": {
"must-be-installed-once": "Sie müssen Beat Saber mindestens einmal aus dem Oculus Store installiert haben, sonst könnte sich Beat Saber nach dem Start automatisch schließen.",
"will-backup": "Um diese Version zu starten, wird der ursprüngliche Installationsordner von Beat Saber in Ihrer Oculus-Bibliothek umbenannt und beim Schließen von Beat Saber automatisch wiederhergestellt."
},
"not-remind-me": "Nicht mehr erinnern",
"enable-oculus-sideloaded-apps": {
"title": "Sideloading aktivieren",
"info-1": "Um Beat Saber starten zu können, muss die Möglichkeit aktiviert werden, Sideloading-Apps auszuführen. BSManager wird Administratorrechte anfordern, um diese Funktion automatisch zu aktivieren.",
"info-2": "Die Sideloading-Funktion ermöglicht das Starten von Spielen, die sich außerhalb Ihres Oculus-Bibliotheksordners befinden.",
"info-3": "Nachdem das Sideloading aktiviert wurde, bleibt die Funktion aktiv, und Sie werden nicht mehr aufgefordert, sie zu aktivieren.",
"i-want-to-do-it-myself": "Ich möchte es selbst machen",
"understood": "Verstanden"
},
"enter-meta-token": {
@@ -735,6 +753,9 @@
},
"launch-as-admin": "Als Administrator starten",
"not-remind-me": "Nicht mehr erinnern"
},
"bs-version-outdated": {
"body": "Die Version {outdatedVersion} ist veraltet, und einige Mods oder Funktionen funktionieren möglicherweise nicht mehr wie erwartet. Bitte lade die neueste empfohlene Version ({recommendedVersion}) von Beat Saber herunter, um die neuesten Funktionen und Fehlerbehebungen zu nutzen."
}
},
"maps": {
+32 -11
View File
@@ -14,7 +14,12 @@
"refuse": "Refuse",
"apply": "Apply",
"copy": "Copy",
"copied": "Copied!"
"copied": "Copied!",
"confirm": "Confirm",
"choose-folder": "Choose folder",
"unknown": "An unknown error occurred ¯\\_(ツ)_/¯",
"warning": "Warning",
"continue": "Continue"
},
"nav-bar": {
"add-version": "Add a version",
@@ -33,6 +38,7 @@
"desktop-description": "First Person Flying Controller mode allows you to use WASD and the mouse to navigate around the menu in game. This makes testing much easier, because you don't have to put on your headset!",
"debug": "Debug Mode",
"debug-description": "Enables the output log window for IPA. This will show the debug console that mods use.",
"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": "Advanced launch",
"placeholder": "Additional arguments ex: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "Latest",
"description": "Description",
"dropdown": {
"uninstall-all": "Uninstall all"
"uninstall-all": "Uninstall all",
"unselect-all": "Unselect all"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber already running",
"EXE_NOT_FINDED": "Missing files",
"EXIT": "Abrupt stop",
"OCULUS_LIB_NOT_FOUND": "Oculus library not found"
"OCULUS_LIB_NOT_FOUND": "Oculus library not found",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Original Oculus version not found"
},
"msg": {
"UNKNOWN_ERROR": "An unknown error has occurred.",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "Close BeatSaber before launching it again.",
"EXE_NOT_FINDED": "Some files seem to be missing, try to verify the files.",
"EXIT": "BeatSaber stopped abruptly, tries to check the files.",
"OCULUS_LIB_NOT_FOUND": "Check that the Oculus application is properly installed, and that the libraries are correctly defined in Oculus."
"OCULUS_LIB_NOT_FOUND": "Check that the Oculus application is properly installed, and that the libraries are correctly defined in Oculus.",
"PROTON_NOT_SET": "Set the Proton path in settings.",
"PROTON_NOT_FOUND": "Set the Proton path in settings.",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saber must be installed from the Oculus app before opening."
},
"actions": {
"STEAM_NOT_RUNNING": "Launch Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "An error occurred while creating the shortcut."
}
},
"bs-version-oudated": {
"title": "Version Outdated",
"msg": "This Beat Saber version is outdated, use the recommended version to enjoy the latest features and bugfixes",
"actions": {
"do-not-remind": "Do not remind me",
"ok": "Ok"
}
}
},
"modals": {
@@ -699,13 +718,12 @@
"stay": "Remember me",
"connect-to-meta": "Connect to Meta"
},
"original-version-backup-oculus": {
"title": "Warning",
"body": {
"must-be-installed-once": "You must have installed Beat Saber at least once from the Oculus Store, otherwise Beat Saber might automatically close after launching.",
"will-backup": "To launch this version, the original installation folder of Beat Saber located in your Oculus library will be renamed and will be automatically restored when Beat Saber is closed."
},
"not-remind-me": "Do not remind me",
"enable-oculus-sideloaded-apps": {
"title": "Enable Sideloading",
"info-1": "In order to launch Beat Saber, the ability to run sideloaded apps must be enabled. BSManager will request administrator rights to enable this feature automatically.",
"info-2": "The sideloaded apps feature allows launching games located outside your Oculus library folder.",
"info-3": "After sideloading is activated, the feature will remain active, and you will no longer be prompted to enable it.",
"i-want-to-do-it-myself": "I want to do it myself",
"understood": "Understood"
},
"enter-meta-token": {
@@ -735,6 +753,9 @@
},
"launch-as-admin": "Launch as Administrator",
"not-remind-me": "Do not remind me"
},
"bs-version-outdated": {
"body": "The version {outdatedVersion} is outdated, and some mods or features may no longer work as expected. Please download the latest recommended version ({recommendedVersion}) of Beat Saber to enjoy the latest features and bugfixes."
}
},
"maps": {
+32 -11
View File
@@ -14,7 +14,12 @@
"refuse": "Rechazar",
"apply": "Aplicar",
"copy": "Copiar",
"copied": "¡Copiado!"
"copied": "¡Copiado!",
"confirm": "Confirmar",
"choose-folder": "Elige la carpeta",
"unknown": "Se ha producido un error desconocido ¯\\_(ツ)_/¯",
"warning": "Advertencia",
"continue": "Continuar"
},
"nav-bar": {
"add-version": "Agregar una versión",
@@ -33,6 +38,7 @@
"desktop-description": "El modo FPFC te permite usar WASD y el mouse para navegar por el menú en juego. Esto hace que las pruebas sean mucho más fáciles, ¡porque no tienes que ponerte las gafas VR!",
"debug": "Modo Depuración",
"debug-description": "Habilita la ventana de registro de salida para IPA. Esto mostrará la consola de depuración que usan los mods.",
"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 avanzadas",
"placeholder": "Argumentos adicionales ej: --revert; --no-wait"
@@ -82,7 +88,8 @@
"latest": "Último",
"description": "Descripción",
"dropdown": {
"uninstall-all": "Desinstalar todos"
"uninstall-all": "Desinstalar todos",
"unselect-all": "Deseleccionar todo"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber ya está en marcha",
"EXE_NOT_FINDED": "Archivos no encontrados",
"EXIT": "Parada abrupta",
"OCULUS_LIB_NOT_FOUND": "Librería Oculus no encontrada"
"OCULUS_LIB_NOT_FOUND": "Librería Oculus no encontrada",
"ORIGINAL_OCULUS_NOT_INSTALLED": "No se encontró la versión original de Oculus"
},
"msg": {
"UNKNOWN_ERROR": "Se ha producido un error desconocido.",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "Cierra Beat Saber antes de volver a iniciarlo.",
"EXE_NOT_FINDED": "Parece que faltan algunos archivos, intenta verificar los archivos.",
"EXIT": "Beat Saber se detuvo abruptamente, trata de verificar los archivos.",
"OCULUS_LIB_NOT_FOUND": "Verifica que la aplicación Oculus esté correctamente instalada y que las librerías estén bien definidas en Oculus."
"OCULUS_LIB_NOT_FOUND": "Verifica que la aplicación Oculus esté correctamente instalada y que las librerías estén bien definidas en Oculus.",
"PROTON_NOT_SET": "Establece la ruta de Proton en la configuración.",
"PROTON_NOT_FOUND": "Establece la ruta de Proton en la configuración.",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saber debe instalarse desde la aplicación de Oculus antes de abrirlo."
},
"actions": {
"STEAM_NOT_RUNNING": "Iniciar Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "Se produjo un error al crear el acceso directo."
}
},
"bs-version-oudated": {
"title": "Versión desactualizada",
"msg": "Esta versión de Beat Saber está desactualizada. Usa la versión recomendada para disfrutar de las últimas características y correcciones.",
"actions": {
"do-not-remind": "No volver a recordármelo",
"ok": "Ok"
}
}
},
"modals": {
@@ -699,13 +718,12 @@
"stay": "Recuérdame",
"connect-to-meta": "Conectarse a Meta"
},
"original-version-backup-oculus": {
"title": "Atención",
"body": {
"must-be-installed-once": "Debes haber instalado Beat Saber al menos una vez desde la Oculus Store, de lo contrario, Beat Saber podría cerrarse automáticamente después de iniciar.",
"will-backup": "Para lanzar esta versión, la carpeta de instalación original de Beat Saber ubicada en tu biblioteca de Oculus será renombrada y se restaurará automáticamente al cerrar Beat Saber."
},
"not-remind-me": "No volver a recordármelo",
"enable-oculus-sideloaded-apps": {
"title": "Habilitar Sideloading",
"info-1": "Para poder iniciar Beat Saber, se debe habilitar la capacidad de ejecutar aplicaciones en sideloading. BSManager solicitará permisos de administrador para habilitar esta función automáticamente.",
"info-2": "La función de sideloading permite iniciar juegos ubicados fuera de la carpeta de la biblioteca de Oculus.",
"info-3": "Una vez que se active el sideloading, la función permanecerá activa y ya no se le pedirá que la habilite.",
"i-want-to-do-it-myself": "Quiero hacerlo yo mismo",
"understood": "Entendido"
},
"enter-meta-token": {
@@ -735,6 +753,9 @@
},
"launch-as-admin": "Iniciar como Administrador",
"not-remind-me": "No volver a recordármelo"
},
"bs-version-outdated": {
"body": "La versión {outdatedVersion} está obsoleta, y algunos mods o funciones pueden no funcionar como se espera. Por favor, descarga la última versión recomendada ({recommendedVersion}) de Beat Saber para disfrutar de las últimas funciones y correcciones de errores."
}
},
"maps": {
+33 -12
View File
@@ -14,7 +14,12 @@
"refuse": "Refuser",
"apply": "Appliquer",
"copy": "Copier",
"copied": "Copié !"
"copied": "Copié !",
"confirm": "Confirmer",
"choose-folder": "Choisir un dossier",
"unknown": "Une erreur inconnue s'est produite ¯\\_(ツ)_/¯",
"warning": "Attention",
"continue": "Continuer"
},
"nav-bar": {
"add-version": "Ajouter une version",
@@ -33,6 +38,7 @@
"desktop-description": "Cela vous permet d'utiliser WASD et la souris pour naviguer dans le menu en jeu. Cela rend les tests beaucoup plus faciles, car vous n'avez pas à mettre votre casque!",
"debug": "Mode Debug",
"debug-description": "Active la fenêtre de log pour IPA. Cela affichera la console de débogage utilisée par les mods.",
"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": "Lancement avancé",
"placeholder": "Arguments supplémentaires ex: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "Récent",
"description": "Description",
"dropdown": {
"uninstall-all": "Tout désinstaller"
"uninstall-all": "Tout désinstaller",
"unselect-all": "Tout désélectionner"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber est déjà lancé",
"EXE_NOT_FINDED": "Fichiers manquants",
"EXIT": "Arrêt brutal",
"OCULUS_LIB_NOT_FOUND": "Librairie Oculus introuvable"
"OCULUS_LIB_NOT_FOUND": "Librairie Oculus introuvable",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Version originale Oculus non trouvée"
},
"msg": {
"UNKNOWN_ERROR": "Une erreur inconnue s'est produite.",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "Ferme Beat Saber avant de le lancer à nouveau.",
"EXE_NOT_FINDED": "Quelques fichiers semblent manquants. Essaye de vérifier les fichiers.",
"EXIT": "Beat Saber s'est arrêté brusquement, essaye de vérifier les fichiers.",
"OCULUS_LIB_NOT_FOUND": "Vérifie que l'application Oculus est correctement installée, et que les librairies sont bien définies dans Oculus."
"OCULUS_LIB_NOT_FOUND": "Vérifie que l'application Oculus est correctement installée, et que les librairies sont bien définies dans Oculus.",
"PROTON_NOT_SET": "Définissez le chemin de Proton dans les paramètres.",
"PROTON_NOT_FOUND": "Définissez le chemin de Proton dans les paramètres.",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saber doit être installé depuis l'application Oculus avant de l'ouvrir."
},
"actions": {
"STEAM_NOT_RUNNING": "Lancer Steam"
@@ -501,6 +512,14 @@
"error": {
"msg": "Une erreur s'est produite lors de la création du raccourci."
}
},
"bs-version-oudated": {
"title": "Version obsolète",
"msg": "Cette version de Beat Saber est obsolète. Utilisez la version recommandée pour profiter des dernières fonctionnalités et correctifs.",
"actions": {
"do-not-remind": "Ne plus me rappeler",
"ok": "Ok"
}
}
},
"modals": {
@@ -684,7 +703,7 @@
},
"create-launch-shortcut": {
"title": "Créer un raccourci",
"desc": "Créer un raccourci te permettra de démarrer Beat Saber avec les options choisi sans passé par BSManager.",
"desc": "Créer un raccourci te permettra de démarrer Beat Saber avec les options choisies sans passer par BSManager.",
"launch-options": "Options de lancement",
"advanced-launch": "Lancement avancé",
"valid-btn": "Créer le raccourci"
@@ -700,13 +719,12 @@
"stay": "Se souvenir de moi",
"connect-to-meta": "Se connecter à Meta"
},
"original-version-backup-oculus": {
"title": "Attention",
"body": {
"must-be-installed-once": "Vous devez avoir installé au moins une fois Beat Saber depuis l'Oculus Store, sinon Beat Saber pourrait se fermer automatiquement après son lancement.",
"will-backup": "Afin de lancer cette version, le dossier d'installation original de Beat Saber se trouvant dans votre bibliothèque Oculus va être renommé et sera automatiquement restauré à l'arrêt de Beat Saber."
},
"not-remind-me": "Ne plus me rappeler",
"enable-oculus-sideloaded-apps": {
"title": "Activer le sideloading",
"info-1": "Pour lancer Beat Saber, la possibilité d'exécuter des applications en sideloading doit être activée. BSManager demandera les droits administrateur pour activer cette fonctionnalité automatiquement.",
"info-2": "La fonctionnalité de sideloading permet de lancer des jeux situés en dehors de votre dossier de bibliothèque Oculus.",
"info-3": "Une fois le sideloading activé, la fonctionnalité restera active et vous ne serez plus invité à lactiver.",
"i-want-to-do-it-myself": "Je veux le faire moi-même",
"understood": "J'ai compris"
},
"enter-meta-token": {
@@ -736,6 +754,9 @@
},
"launch-as-admin": "Lancer en administateur",
"not-remind-me": "Ne plus me rappeler"
},
"bs-version-outdated": {
"body": "La version {outdatedVersion} est obsolète, et certains mods ou fonctionnalités peuvent ne plus fonctionner comme prévu. Veuillez télécharger la dernière version recommandée ({recommendedVersion}) de Beat Saber pour profiter des dernières fonctionnalités et corrections de bugs."
}
},
"maps": {
+33 -12
View File
@@ -14,7 +14,12 @@
"refuse": "拒否する",
"apply": "適用",
"copy": "コピー",
"copied": "コピー済み!"
"copied": "コピー済み!",
"confirm": "確認",
"choose-folder": "フォルダーを選択",
"unknown": "不明なエラーが発生しました (´・ω・`)",
"warning": "警告",
"continue": "続ける"
},
"nav-bar": {
"add-version": "バージョンを追加",
@@ -33,6 +38,7 @@
"desktop-description": "ファーストパーソン・フライング・コントローラー・モードでは、WASDとマウスを使ってゲーム内のメニューを操作できます。ヘッドセットを装着する必要がないので、テストがより簡単になります!",
"debug": "デバックモード",
"debug-description": "IPAの出力ログウィンドウを有効にします。これはMODのデバッグコンソールを表示します。",
"outdated-tippy": "このバージョンは古いため、一部のMODや機能が期待通りに動作しない可能性があります。最新の機能やバグ修正を楽しむには、推奨バージョン ({recommendedVersion}) のBeat Saberを使用することをお勧めします。",
"advanced-launch": {
"button": "高度な起動",
"placeholder": "追加引数 例:--revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "最新",
"description": "說明",
"dropdown": {
"uninstall-all": "全てアンインストールする"
"uninstall-all": "全てアンインストールする",
"unselect-all": "すべて選択解除"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saberはすでに起動しています",
"EXE_NOT_FINDED": "ファイルが見つかりません",
"EXIT": "緊急停止",
"OCULUS_LIB_NOT_FOUND": "Oculusライブラリが見つかりません"
"OCULUS_LIB_NOT_FOUND": "Oculusライブラリが見つかりません",
"ORIGINAL_OCULUS_NOT_INSTALLED": "オリジナルのOculusバージョンが見つかりませんでした"
},
"msg": {
"UNKNOWN_ERROR": "不明なエラーが発生しました。",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "再度起動する前にBeatSaberを閉じてください。",
"EXE_NOT_FINDED": "いくつかのファイルが見つからないようです。",
"EXIT": "BeatSaberが緊急停止したため、ファイルをチェックします。",
"OCULUS_LIB_NOT_FOUND": "Oculusアプリケーションが正しくインストールしていることと、Oculusライブラリが正しく定義されていることを確認してください。"
"OCULUS_LIB_NOT_FOUND": "Oculusアプリケーションが正しくインストールしていることと、Oculusライブラリが正しく定義されていることを確認してください。",
"PROTON_NOT_SET": "設定でProtonのパスを設定してください。",
"PROTON_NOT_FOUND": "設定でProtonのパスを設定してください。",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saberは、開く前にOculusアプリからインストールする必要があります。"
},
"actions": {
"STEAM_NOT_RUNNING": "Steamを起動"
@@ -500,6 +511,14 @@
"error": {
"msg": "ショートカットの作成中にエラーが発生しました。"
}
},
"bs-version-oudated": {
"title": "バージョンが古い",
"msg": "このBeat Saberのバージョンは古いため、推奨バージョンを使用して最新の機能やバグ修正をお楽しみください。",
"actions": {
"do-not-remind": "再度通知しない",
"ok": "OK"
}
}
},
"modals": {
@@ -699,14 +718,13 @@
"stay": "記憶する",
"connect-to-meta": "Metaに接続する"
},
"original-version-backup-oculus": {
"title": "注意",
"body": {
"must-be-installed-once": "少なくとも一度はOculus StoreからBeat Saberをインストールしている必要があります。そうしないとBeat Saberは起動後に自動的に終了する可能性があります。",
"will-backup": "このバージョンを起動するために、OculusライブラリにあるBeat Saberの元のインストールフォルダは名前が変更され、Beat Saberの終了時に自動的に復元されます。"
},
"not-remind-me": "二度と表示しないでください",
"understood": "分かった"
"enable-oculus-sideloaded-apps": {
"title": "サイドローディングを有効化",
"info-1": "Beat Saberを起動するには、サイドローディングアプリを実行する機能を有効にする必要があります。BSManagerは、この機能を自動的に有効にするために管理者権限を要求します。",
"info-2": "サイドローディング機能により、Oculusライブラリフォルダ外にあるゲームを起動できます。",
"info-3": "サイドローディングを有効化すると、この機能はアクティブなままとなり、再度有効化を求められることはありません。",
"i-want-to-do-it-myself": "自分でやりたい",
"understood": "了解しました"
},
"enter-meta-token": {
"title": "Oculusトークン",
@@ -735,6 +753,9 @@
},
"launch-as-admin": "管理者として起動",
"not-remind-me": "Больше не напоминать"
},
"bs-version-outdated": {
"body": "バージョン {outdatedVersion} は古いため、一部のモッドや機能が期待通りに動作しない場合があります。最新の機能やバグ修正を楽しむために、推奨される最新バージョンの Beat Saber ({recommendedVersion}) をダウンロードしてください。"
}
},
"maps": {
+33 -12
View File
@@ -14,7 +14,12 @@
"refuse": "Отказаться",
"apply": "Применить",
"copy": "Скопировать",
"copied": "Скопировано!"
"copied": "Скопировано!",
"confirm": "Подтвердить",
"choose-folder": "Изменить папку",
"unknown": "Неизвестная ошибка ¯\\_(ツ)_/¯",
"warning": "Внимание",
"continue": "Продолжить"
},
"nav-bar": {
"add-version": "Добавить версию игры",
@@ -33,6 +38,7 @@
"desktop-description": "Режим Летающих контроллеров от первого лица(FPFC) позволит вам использовать меню с помощью WASD и мышки. Это облегчает тестирование модов, потому что вам не нужно подключать шлем!",
"debug": "Режим отладки",
"debug-description": "Включает окно логов для IPA. В окне будут отображаться логи модов.",
"outdated-tippy": "Эта версия устарела, и некоторые моды или функции могут работать не так, как ожидалось. Рекомендуется использовать рекомендуемую версию ({recommendedVersion}) Beat Saber, чтобы воспользоваться последними функциями и исправлениями ошибок.",
"advanced-launch": {
"button": "Расширенный запуск",
"placeholder": "Параметры запуска, например: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "Latest",
"description": "Описание",
"dropdown": {
"uninstall-all": "Удалить всё"
"uninstall-all": "Удалить всё",
"unselect-all": "Снять все выделения"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber уде запущен",
"EXE_NOT_FINDED": "Отсутствуют файлы",
"EXIT": "Вылет",
"OCULUS_LIB_NOT_FOUND": "Библиотека Oculus не найдена"
"OCULUS_LIB_NOT_FOUND": "Библиотека Oculus не найдена",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Оригинальная версия Oculus не найдена"
},
"msg": {
"UNKNOWN_ERROR": "Возникла неизвестная ошибка.",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "Закройте Beat Saber, чтобы запустить его.",
"EXE_NOT_FINDED": "Некоторые файлы отсутствуют, пробуем их проверить.",
"EXIT": "Beat Saber вылетел, пытаемся проверить файлы.",
"OCULUS_LIB_NOT_FOUND": "Проверьте, правильно ли установлено приложение Oculus, и правильно ли определены библиотеки в Oculus."
"OCULUS_LIB_NOT_FOUND": "Проверьте, правильно ли установлено приложение Oculus, и правильно ли определены библиотеки в Oculus.",
"PROTON_NOT_SET": "Установите путь к Proton в настройках.",
"PROTON_NOT_FOUND": "Установите путь к Proton в настройках.",
"ORIGINAL_OCULUS_NOT_INSTALLED": "Beat Saber должен быть установлен из приложения Oculus перед открытием."
},
"actions": {
"STEAM_NOT_RUNNING": "Запустить Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "Ошибка создания ярлыка."
}
},
"bs-version-oudated": {
"title": "Устаревшая версия",
"msg": "Эта версия Beat Saber устарела. Используйте рекомендуемую версию, чтобы воспользоваться последними функциями и исправлениями ошибок.",
"actions": {
"do-not-remind": "Больше не напоминать",
"ok": "Ок"
}
}
},
"modals": {
@@ -699,14 +718,13 @@
"stay": "Запомнить меня",
"connect-to-meta": "Подключиться к Meta"
},
"original-version-backup-oculus": {
"title": "Внимание",
"body": {
"must-be-installed-once": "Вы должны были установить Beat Saber хотя бы один раз из Oculus Store, иначе Beat Saber может автоматически закрыться после запуска.",
"will-backup": "Для запуска этой версии исходная папка установки Beat Saber, находящаяся в вашей библиотеке Oculus, будет переименована и автоматически восстановлена при выходе из Beat Saber."
},
"not-remind-me": "Больше не напоминать",
"understood": "Понял"
"enable-oculus-sideloaded-apps": {
"title": "Включить Sideloading",
"info-1": "Чтобы запустить Beat Saber, необходимо включить возможность запуска приложений через sideloading. BSManager запросит права администратора для автоматического включения этой функции.",
"info-2": "Функция sideloading позволяет запускать игры, расположенные за пределами папки библиотеки Oculus.",
"info-3": "После активации сайдлоадинга функция останется активной, и вам больше не будет предложено её включить.",
"i-want-to-do-it-myself": "Я хочу сделать это сам",
"understood": "Понято"
},
"enter-meta-token": {
"title": "Токен Oculus",
@@ -734,6 +752,9 @@
"info-3": "Обратите внимание, что не рекомендуется предоставлять права администратора Steam, так как это также влияет на установленные игры и моды, представляя угрозу безопасности."
},
"launch-as-admin": "Запустить от имени администратора"
},
"bs-version-outdated": {
"body": "Версия {outdatedVersion} устарела, и некоторые моды или функции могут работать некорректно. Пожалуйста, скачайте последнюю рекомендуемую версию ({recommendedVersion}) Beat Saber, чтобы воспользоваться новейшими функциями и исправлениями ошибок."
}
},
"maps": {
+33 -12
View File
@@ -14,7 +14,12 @@
"refuse": "拒絕",
"apply": "應用",
"copy": "複製",
"copied": "已複製!"
"copied": "已複製!",
"confirm": "確認",
"choose-folder": "選擇文件夾",
"unknown": "發生了一個錯誤 ¯\\_(ツ)_/¯",
"warning": "警告",
"continue": "繼續"
},
"nav-bar": {
"add-version": "新增版本",
@@ -33,6 +38,7 @@
"desktop-description": "第一人稱飛控模式允許你使用鍵盤 WASD 和滑鼠在遊戲中進行導航。你無需帶上頭顯即可進行測試!",
"debug": "Debug 模式",
"debug-description": "為 IPA 啟用輸出日誌窗口。 會顯示所用 Mod 的 debug 輸出",
"outdated-tippy": "此版本已過時,某些模組或功能可能無法按預期運作。建議使用推薦版本的 Beat Saber ({recommendedVersion}),以享受最新功能和修復。",
"advanced-launch": {
"button": "高級啟動",
"placeholder": "額外啟動參數,例如: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "最新",
"description": "描述",
"dropdown": {
"uninstall-all": "全部移除"
"uninstall-all": "全部移除",
"unselect-all": "取消全選"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber 已在運行",
"EXE_NOT_FINDED": "缺少文件",
"EXIT": "突然停止",
"OCULUS_LIB_NOT_FOUND": "未找到 Oculus 庫"
"OCULUS_LIB_NOT_FOUND": "未找到 Oculus 庫",
"ORIGINAL_OCULUS_NOT_INSTALLED": "找不到原始Oculus版本"
},
"msg": {
"UNKNOWN_ERROR": "發生未知錯誤。",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "再次啟動前請關閉 BeatSaber。",
"EXE_NOT_FINDED": "某些文件似乎遺失了,嘗試驗證文件。",
"EXIT": "BeatSaber 突然停止,請檢查文件。",
"OCULUS_LIB_NOT_FOUND": "檢查 Oculus 應用是否正確安裝,以及庫是否在 Oculus 中正確定義。"
"OCULUS_LIB_NOT_FOUND": "檢查 Oculus 應用是否正確安裝,以及庫是否在 Oculus 中正確定義。",
"PROTON_NOT_SET": "在設定中設置Proton路徑。",
"PROTON_NOT_FOUND": "在設定中設置Proton路徑。",
"ORIGINAL_OCULUS_NOT_INSTALLED": "在打開之前,必須從Oculus應用程式安裝Beat Saber。"
},
"actions": {
"STEAM_NOT_RUNNING": "啟動 Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "創建捷徑時發生錯誤"
}
},
"bs-version-oudated": {
"title": "版本過時",
"msg": "此 Beat Saber 版本已過時,請使用推薦版本以享受最新功能和修復。",
"actions": {
"do-not-remind": "不要再提醒我",
"ok": "確定"
}
}
},
"modals": {
@@ -699,14 +718,13 @@
"stay": "記住我",
"connect-to-meta": "連接到 Meta"
},
"original-version-backup-oculus": {
"title": "警告",
"body": {
"must-be-installed-once": "你必須至少從 Oculus 商店安裝過一次 BeatSaber,否則 BeatSaber 可能會在啟動後自動關閉。",
"will-backup": "為了啟動這個版本,位於您的Oculus庫中的Beat Saber的原始安裝文件夾將被重新命名,並且在Beat Saber關閉時會自動恢復。"
},
"not-remind-me": "不再提醒我",
"understood": "明白了"
"enable-oculus-sideloaded-apps": {
"title": "啟用旁載",
"info-1": "為了啟動 Beat Saber,必須啟用執行旁載應用程式的功能。BSManager 將請求管理員權限以自動啟用此功能。",
"info-2": "旁載功能允許啟動位於 Oculus 資料庫資料夾之外的遊戲。",
"info-3": "啟用 sideloading 後,此功能將保持啟用狀態,您將不再被提示啟用它。",
"i-want-to-do-it-myself": "我想自己完成",
"understood": "了解"
},
"enter-meta-token": {
"title": "Oculus令牌",
@@ -735,6 +753,9 @@
},
"launch-as-admin": "以管理員身份啟動",
"not-remind-me": "不再提醒我"
},
"bs-version-outdated": {
"body": "版本 {outdatedVersion} 已過時,某些模組或功能可能無法正常運作。請下載最新推薦的 Beat Saber 版本 ({recommendedVersion}),以享受最新功能和修正。"
}
},
"maps": {
+32 -11
View File
@@ -14,7 +14,12 @@
"refuse": "拒绝",
"apply": "应用",
"copy": "复制",
"copied": "已复制!"
"copied": "已复制!",
"confirm": "确认",
"choose-folder": "选择文件夹",
"Unknown": "发生了一个错误 ¯\\_(ツ)_/¯",
"warning": "警告",
"continue": "继续"
},
"nav-bar": {
"add-version": "添加版本",
@@ -33,6 +38,7 @@
"desktop-description": "第一人称飞控模式允许你使用键盘 WASD 和鼠标在游戏中进行导航。你无需带上头显即可进行测试!",
"debug": "Debug 模式",
"debug-description": "为 IPA 启用输出日志窗口。 会显示所用 Mod 的 debug 输出",
"outdated-tippy": "此版本已过时,某些模组或功能可能无法按预期运行。建议使用推荐版本的 Beat Saber ({recommendedVersion}),以享受最新功能和修复。",
"advanced-launch": {
"button": "高级启动",
"placeholder": "额外启动参数,例如: --revert; --nowait"
@@ -82,7 +88,8 @@
"latest": "最新",
"description": "描述",
"dropdown": {
"uninstall-all": "全部卸载"
"uninstall-all": "全部卸载",
"unselect-all": "取消全选"
}
}
}
@@ -380,7 +387,8 @@
"BS_ALREADY_RUNNING": "Beat Saber 已在运行",
"EXE_NOT_FINDED": "缺少文件",
"EXIT": "突然停止",
"OCULUS_LIB_NOT_FOUND": "未找到 Oculus 库"
"OCULUS_LIB_NOT_FOUND": "未找到 Oculus 库",
"ORIGINAL_OCULUS_NOT_INSTALLED": "未找到原始Oculus版本"
},
"msg": {
"UNKNOWN_ERROR": "发生未知错误。",
@@ -389,7 +397,10 @@
"BS_ALREADY_RUNNING": "再次启动前请关闭 BeatSaber。",
"EXE_NOT_FINDED": "某些文件似乎丢失了,尝试验证文件。",
"EXIT": "BeatSaber 突然停止,请检查文件。",
"OCULUS_LIB_NOT_FOUND": "检查 Oculus 应用是否正确安装,以及库是否在 Oculus 中正确定义。"
"OCULUS_LIB_NOT_FOUND": "检查 Oculus 应用是否正确安装,以及库是否在 Oculus 中正确定义。",
"PROTON_NOT_SET": "在设置中设置Proton路径。",
"PROTON_NOT_FOUND": "在设置中设置Proton路径。",
"ORIGINAL_OCULUS_NOT_INSTALLED": "在打开之前,必须从Oculus应用程序安装Beat Saber。"
},
"actions": {
"STEAM_NOT_RUNNING": "启动 Steam"
@@ -500,6 +511,14 @@
"error": {
"msg": "创建快捷方式时发生错误"
}
},
"bs-version-oudated": {
"title": "版本过时",
"msg": "此 Beat Saber 版本已过时,请使用推荐版本以享受最新功能和修复。",
"actions": {
"do-not-remind": "不再提醒",
"ok": "确定"
}
}
},
"modals": {
@@ -699,13 +718,12 @@
"stay": "记住我",
"connect-to-meta": "连接到 Meta"
},
"original-version-backup-oculus": {
"title": "警告",
"body": {
"must-be-installed-once": "你必须至少从 Oculus 商店安装过一次 BeatSaber,否则 BeatSaber 可能会在启动后自动关闭。",
"will-backup": "为了启动这个版本,位于您的Oculus库中的Beat Saber的原始安装文件夹将被重命名,并且在Beat Saber关闭时会自动恢复。"
},
"not-remind-me": "不再提醒我",
"enable-oculus-sideloaded-apps": {
"title": "启用旁加载",
"info-1": "为了启动 Beat Saber,必须启用运行旁加载应用程序的功能。BSManager 将请求管理员权限以自动启用此功能。",
"info-2": "旁加载功能允许启动位于 Oculus 库文件夹之外的游戏。",
"info-3": "激活 sideloading 后,该功能将保持激活状态,您将不再被提示启用它。",
"i-want-to-do-it-myself": "我想自己完成",
"understood": "明白了"
},
"enter-meta-token": {
@@ -735,6 +753,9 @@
},
"launch-as-admin": "以管理员身份启动",
"not-remind-me": "不再提醒我"
},
"bs-version-outdated": {
"body": "版本 {outdatedVersion} 已过时,某些模组或功能可能无法正常工作。请下载最新推荐的 Beat Saber 版本 ({recommendedVersion}),以享受最新功能和修复。"
}
},
"maps": {
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,6 @@
"version": "8.0.0"
},
"configProperties": {
"System.Globalization.Invariant": true,
"System.Globalization.PredefinedCulturesOnly": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+11
View File
@@ -0,0 +1,11 @@
Enabling Oculus sideloading allows games located outside if your Oculus library to be played on your Oculus Quest.
- **Step 1:** Start the regedit application by pressing `Win + R` and typing `regedit` in the dialog box.
- **Step 2:** Navigate to `HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Oculus VR, LLC\Oculus`.
- **Step 3:** Right-click on the right panel and select `New > DWORD (32-bit) Value`.
- **Step 4:** Name the new value `AllowDevSideloaded` and set the value to `1`.
You should end up with something like this:
![image](https://raw.githubusercontent.com/Zagrios/bs-manager/refs/heads/master/docs/assets/enable-oculus-sideloading.png)
After completing these steps, you should be able to start Beat Saber from BSManager and play it on your Oculus Quest. If you are still having issues, please join our [Discord](https://discord.gg/uSqbHVpKdV) server for further assistance.
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+166
View File
@@ -0,0 +1,166 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "oculus-allow-dev-sideloaded"
version = "0.1.0"
dependencies = [
"winreg",
"winres",
]
[[package]]
name = "proc-macro2"
version = "1.0.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "unicode-ident"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml",
]
@@ -1,22 +1,20 @@
[package]
name = "oculus_symlink_cleaner"
name = "oculus-allow-dev-sideloaded"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
winres = "0.1.12"
[dependencies]
sysinfo = "0.30.6"
winreg = "0.52.0"
[package.metadata.winres]
FileDescription = "Enable Sideloaded Apps"
LegalCopyright = "Copyright © 2024 Zagrios"
CompanyName = "Zagrios"
[profile.release]
codegen-units = 1
lto = true
opt-level = "z"
[package.metadata.winres]
FileDescription = "Clean Oculus Symlink after Beat Saber ends"
LegalCopyright = "Copyright © 2024 Zagrios"
CompanyName = "Zagrios"
+20
View File
@@ -0,0 +1,20 @@
extern crate winres;
fn main() {
if cfg!(target_os = "windows") {
let mut res = winres::WindowsResource::new();
res.set_manifest(r#"
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
"#);
res.set_icon("./icon.ico");
res.compile().unwrap();
}
}

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+22
View File
@@ -0,0 +1,22 @@
use winreg::enums::HKEY_LOCAL_MACHINE;
use winreg::RegKey;
const PATH: &str = "SOFTWARE\\Wow6432Node\\Oculus VR, LLC\\Oculus";
fn main() {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
// Create (or open if it already exists) the subkey
let (key, _) = match hklm.create_subkey(PATH) {
Ok(res) => res,
Err(err) => return println!("{}", err.to_string()),
};
let res = key.set_value("AllowDevSideloaded", &1u32);
if let Err(err) = res {
return println!("{}", err.to_string());
}
println!("AllowDevSideloaded = 1 has been successfully set in {PATH}");
}
-2
View File
@@ -1,2 +0,0 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
-281
View File
@@ -1,281 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "core-foundation-sys"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
name = "crossbeam-deque"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
[[package]]
name = "either"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
[[package]]
name = "libc"
version = "0.2.153"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
[[package]]
name = "ntapi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
dependencies = [
"winapi",
]
[[package]]
name = "oculus_symlink_cleaner"
version = "0.1.0"
dependencies = [
"sysinfo",
"winres",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "proc-macro2"
version = "1.0.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4963ed1bc86e4f3ee217022bd855b297cef07fb9eac5dfa1f788b220b49b3bd"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "serde"
version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.197"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sysinfo"
version = "0.30.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6746919caf9f2a85bff759535664c060109f21975c5ac2e8652e60102bd4d196"
dependencies = [
"cfg-if",
"core-foundation-sys",
"libc",
"ntapi",
"once_cell",
"rayon",
"windows",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core",
"windows-targets",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
[[package]]
name = "windows_i686_gnu"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
[[package]]
name = "windows_i686_msvc"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml",
]
-9
View File
@@ -1,9 +0,0 @@
extern crate winres;
fn main() {
if cfg!(target_os = "windows") {
let mut res = winres::WindowsResource::new();
res.set_icon("./icon.ico");
res.compile().unwrap();
}
}
-78
View File
@@ -1,78 +0,0 @@
use sysinfo::{Pid, ProcessRefreshKind, System};
use std::{env, thread, time::Duration, fs};
use std::ffi::OsStr;
use std::path::Path;
use std::str::FromStr;
const BEAT_SABER_OCULUS_FOLDER_NAME: &str = "hyperbolic-magnetism-beat-saber";
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Usage: {} <pid> <path_to_directory>", args[0]);
std::process::exit(1);
}
let pid: Pid = Pid::from_str(&args[1]).expect("Invalid pid");
let directory_path = Path::new(&args[2]);
if directory_path.file_name() != Some(OsStr::new(BEAT_SABER_OCULUS_FOLDER_NAME)) {
eprintln!("Directory name is not {}", BEAT_SABER_OCULUS_FOLDER_NAME);
std::process::exit(1);
}
// we want to monitor only processes
let mut system = System::new_with_specifics(sysinfo::RefreshKind::new().with_processes(ProcessRefreshKind::everything()));
loop {
system.refresh_processes();
let process = system.process(pid);
match process {
Some(_) => {
println!("Process {} is still running.", pid);
thread::sleep(Duration::from_secs(2));
},
None => {
println!("Process {} has stopped, deleting directory {:?}", pid, directory_path);
match delete_dir_if_is_symlink(directory_path) {
Ok(_) => {
println!("Directory {:?} deleted successfully.", directory_path);
if let Err(e) = rename_specific_backup_directory(directory_path.parent().unwrap()) {
eprintln!("Failed to rename backup directory: {}", e);
}
},
Err(e) => eprintln!("Failed to delete directory {:?}: {}", directory_path, e),
}
break;
}
}
}
}
fn delete_dir_if_is_symlink(path: &Path) -> Result<(), Box<dyn std::error::Error>>{
match path.symlink_metadata()?.file_type().is_symlink() {
true => {
fs::remove_dir_all(path)?;
Ok(())
},
false => {
eprintln!("Path {:?} is not a symlink.", path);
Err("Path is not a symlink.".into())
}
}
}
fn rename_specific_backup_directory(parent_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let backup_dir_name = format!("{}.bsmbak", BEAT_SABER_OCULUS_FOLDER_NAME);
let backup_path = parent_path.join(&backup_dir_name);
if backup_path.exists() && backup_path.is_dir() {
let new_path = parent_path.join(BEAT_SABER_OCULUS_FOLDER_NAME);
fs::rename(&backup_path, &new_path)?;
println!("Renamed {:?} to {:?}", backup_path, new_path);
Ok(())
} else {
Err(format!("Backup directory {:?} does not exist or is not a directory.", backup_path).into())
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { Config } from "jest";
const config: Config = {
// testURL: "http://localhost/",
// testEnvironment: "jsdom",
transform: {
"\\.(ts|tsx|js|jsx)$": "ts-jest",
},
moduleNameMapper: {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy",
},
moduleFileExtensions: ["js", "jsx", "ts", "tsx", "json"],
moduleDirectories: ["node_modules", "src"],
testPathIgnorePatterns: ["release/app/dist"],
setupFiles: ["./.erb/scripts/check-build-exists.ts"],
};
export default config;
+10875 -5023
View File
File diff suppressed because it is too large Load Diff
+12 -39
View File
@@ -14,6 +14,7 @@
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest",
"test:unit": "jest ./src/__tests__/unit",
"publish": "npm run build && electron-builder -c.win.certificateSha1=206941d969c4fa8a0e04d9427def361e13b02fd0 --publish always --win --x64"
},
"lint-staged": {
@@ -122,45 +123,17 @@
"beat-saber"
],
"homepage": "https://github.com/Zagrios/bs-manager#readme",
"jest": {
"testURL": "http://localhost/",
"testEnvironment": "jsdom",
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
},
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleDirectories": [
"node_modules",
"src"
],
"testPathIgnorePatterns": [
"release/app/dist"
],
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
]
},
"devDependencies": {
"@electron/rebuild": "^3.2.13",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.5",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/jest-dom": "^6.4.1",
"@testing-library/react": "^13.3.0",
"@types/archiver": "^5.3.1",
"@types/color": "^3.0.3",
"@types/crypto-js": "^4.2.1",
"@types/dateformat": "^5.0.0",
"@types/jest": "^27.5.2",
"@types/jest": "^29.5.11",
"@types/node": "^20",
"@types/node-fetch": "^2.6.3",
"@types/pako": "^2.0.1",
@@ -199,7 +172,7 @@
"eslint-import-resolver-webpack": "^0.13.2",
"eslint-plugin-compat": "^4.0.2",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jest": "^26.8.7",
"eslint-plugin-jest": "^27.6.3",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-promise": "^6.0.0",
"eslint-plugin-react": "^7.30.0",
@@ -207,7 +180,7 @@
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"jest": "^29.7.0",
"lint-staged": "^12.5.0",
"mini-css-extract-plugin": "^2.6.1",
"opencollective-postinstall": "^2.0.3",
@@ -223,9 +196,9 @@
"sass-loader": "^12.6.0",
"style-loader": "^3.3.1",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.3.1",
"tailwindcss": "^3.4.15",
"terser-webpack-plugin": "^5.3.5",
"ts-jest": "^27.1.5",
"ts-jest": "^29.1.2",
"ts-loader": "^9.3.0",
"ts-node": "^10.8.2",
"typescript": "^4.7.4",
@@ -240,6 +213,7 @@
"@electron/fuses": "^1.6.2",
"@node-steam/vdf": "^2.2.0",
"@tippyjs/react": "^4.2.6",
"@nextui-org/react": "^2.3.6",
"archiver": "^6.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
@@ -250,15 +224,13 @@
"electron-store": "^8.1.0",
"electron-updater": "^6.1.7",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^10.16.16",
"framer-motion": "^11.2.6",
"fs-extra": "^11.2.0",
"history": "^5.3.0",
"is-elevated": "^3.0.0",
"jszip": "^3.10.1",
"md5-file": "^5.0.0",
"node-abi": "^3.47.0",
"node-abi": "^3.65.0",
"node-fetch": "^2.6.7",
"node-stream-zip": "^1.15.0",
"pako": "^2.1.0",
"ps-list": "^7.2.0",
"qrcode.react": "^3.1.0",
@@ -281,7 +253,8 @@
"tippy.js": "^6.3.7",
"to-ico": "^1.1.5",
"use-double-click": "^1.0.5",
"use-fit-text": "^2.4.0"
"use-fit-text": "^2.4.0",
"yauzl": "^3.2.0"
},
"devEngines": {
"node": ">=14.x",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bs-manager",
"version": "1.4.12",
"version": "1.4.17",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bs-manager",
"version": "1.4.12",
"version": "1.4.17",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bs-manager",
"version": "1.4.12",
"version": "1.4.17",
"description": "BSManager",
"main": "./dist/main/main.js",
"author": {
-9
View File
@@ -1,9 +0,0 @@
import "@testing-library/jest-dom";
import { render } from "@testing-library/react";
import App from "../renderer/windows/App";
describe("App", () => {
it("should render", () => {
expect(render(<App />)).toBeTruthy();
});
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+329
View File
@@ -0,0 +1,329 @@
import path from "path";
import { mkdir, pathExistsSync, readFile, rm } from "fs-extra";
import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class";
const TEST_FOLDER = path.resolve(__dirname, "..", "assets", "zip");
const STANDARD_ZIP = path.join(TEST_FOLDER, "standard.zip");
const WINDOWS_LEGACY_MAP_ZIP = path.join(TEST_FOLDER, "windows_legacy.zip");
const SUBFOLDERS_ZIP = path.join(TEST_FOLDER, "subfolders.zip");
const MANIFEST_ZIP = path.join(TEST_FOLDER, "manifest.zip");
const EMPTY_ZIP = path.join(TEST_FOLDER, "empty.zip");
const UNICODE_ZIP = path.join(TEST_FOLDER, "unicode.zip");
const DESTINATION_FOLDER = path.join(TEST_FOLDER, "out");
describe("Test BsmZipExtractor class", () => {
let zip: BsmZipExtractor;
beforeAll(async () => {
if (pathExistsSync(DESTINATION_FOLDER)) {
await rm(DESTINATION_FOLDER, { recursive: true, force: true });
}
await mkdir(DESTINATION_FOLDER);
});
afterEach(async () => {
if (zip) {
zip.close();
}
if (pathExistsSync(DESTINATION_FOLDER)) {
await rm(DESTINATION_FOLDER, { recursive: true, force: true });
}
await mkdir(DESTINATION_FOLDER);
});
afterAll(async () => {
if (pathExistsSync(DESTINATION_FOLDER)) {
await rm(DESTINATION_FOLDER, { recursive: true, force: true });
}
});
it("Extract standard zip", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const res = await zip.extract(DESTINATION_FOLDER);
expect(res.sort()).toEqual([
"file_1.2.txt",
"file_1.3.txt",
"folder_1.1/",
"folder_1.1/file_2.1.txt",
"folder_1.1/file_2.2.txt",
].sort())
for (const file of [
"file_1.2.txt",
"file_1.3.txt",
]) {
expect(pathExistsSync(path.join(DESTINATION_FOLDER, file)))
.toBe(true);
}
const SUBFOLDER_PATH = path.join(DESTINATION_FOLDER, "folder_1.1");
for (const file of [
"file_2.1.txt",
"file_2.2.txt",
]) {
expect(pathExistsSync(path.join(SUBFOLDER_PATH, file)))
.toBe(true);
}
});
it("Extract map zips using back slashes", async () => {
zip = await BsmZipExtractor.fromPath(WINDOWS_LEGACY_MAP_ZIP);
const res = await zip.extract(DESTINATION_FOLDER);
expect(res.sort()).toEqual([
"BPMInfo.dat",
"cover.jpg",
"ExpertLegacy.dat",
"ExpertPlusLawless.dat",
"ExpertPlusLegacy.dat",
"ExpertPlusStandard.dat",
"ExpertStandard.dat",
"Info.dat",
"song.egg",
"pfp/",
"pfp/galaxyCompressed.jpg",
"pfp/gojiCompressed.jpg",
].sort());
// Expect all the files to exists
for (const file of [
"BPMInfo.dat",
"cover.jpg",
"ExpertLegacy.dat",
"ExpertPlusLawless.dat",
"ExpertPlusLegacy.dat",
"ExpertPlusStandard.dat",
"ExpertStandard.dat",
"Info.dat",
"song.egg",
]) {
expect(pathExistsSync(path.join(DESTINATION_FOLDER, file)))
.toBe(true);
}
const SUBFOLDER_PATH = path.join(DESTINATION_FOLDER, "pfp");
for (const file of [
"galaxyCompressed.jpg",
"gojiCompressed.jpg",
]) {
expect(pathExistsSync(path.join(SUBFOLDER_PATH, file)))
.toBe(true);
}
});
it("Read Zip with multiple subfolders", async () => {
const zip = await BsmZipExtractor.fromPath(SUBFOLDERS_ZIP);
const res = await zip.extract(DESTINATION_FOLDER);
expect(res.sort()).toEqual([
"1/",
"1/2/",
"1/2/3/",
"1/2/3/4/",
"1/2/3/4/5.txt",
].sort());
for (const folder of [
"1/",
"1/2/",
"1/2/3/",
"1/2/3/4/",
]) {
expect(pathExistsSync(path.join(DESTINATION_FOLDER, folder)))
.toBe(true);
}
const file = "1/2/3/4/5.txt";
expect(pathExistsSync(path.join(DESTINATION_FOLDER, file)))
.toBe(true);
});
it("Read manifest.json from zip file", async () => {
const zipBuffer = await readFile(MANIFEST_ZIP);
zip = await BsmZipExtractor.fromBuffer(zipBuffer);
const buffer = await (await zip.findEntry(entry => entry.fileName === "manifest.json")).read();
const manifest = JSON.parse(buffer.toString());
expect(manifest).toBeTruthy();
// Check some of the fields if they are correct
expect(manifest.appId).toBe("some-app-id");
expect(manifest.canonicalName).toBe("some-canonical-name");
expect(manifest.isCore).toBe(true);
});
it("Should find an entry using findEntry", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const entry = await zip.findEntry(entry => entry.fileName === "file_1.2.txt");
expect(entry).toBeTruthy();
expect(entry.fileName).toBe("file_1.2.txt");
const contentBuffer = await entry.read();
const content = contentBuffer.toString();
expect(content).toContain("This is file 1.2"); // Assurez-vous que le contenu correspond
});
it("Should filter entries using filterEntries", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const entries = await zip.filterEntries(entry => !entry.isDirectory && entry.fileName.startsWith("folder_1.1/"));
expect(entries.length).toBe(2);
const fileNames = entries.map(entry => entry.fileName).sort();
expect(fileNames).toEqual([
"folder_1.1/file_2.1.txt",
"folder_1.1/file_2.2.txt",
]);
});
it("Should get an entry by file name using getEntry", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const entry = await zip.getEntry("file_1.3.txt");
expect(entry).toBeTruthy();
expect(entry.fileName).toBe("file_1.3.txt");
const contentBuffer = await entry.read();
const content = contentBuffer.toString();
expect(content).toContain("This is file 1.3"); // Assurez-vous que le contenu correspond
});
it("Should extract only specified entries using entriesNames option", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const res = await zip.extract(DESTINATION_FOLDER, { entriesNames: ["file_1.2.txt", "folder_1.1/file_2.1.txt"] });
expect(res.sort()).toEqual([
"file_1.2.txt",
"folder_1.1/",
"folder_1.1/file_2.1.txt",
].sort());
for (const file of [
"file_1.2.txt",
"folder_1.1/file_2.1.txt",
]) {
expect(pathExistsSync(path.join(DESTINATION_FOLDER, file)))
.toBe(true);
}
// Vérifiez que les autres fichiers ne sont pas extraits
expect(pathExistsSync(path.join(DESTINATION_FOLDER, "file_1.3.txt")))
.toBe(false);
expect(pathExistsSync(path.join(DESTINATION_FOLDER, "folder_1.1/file_2.2.txt")))
.toBe(false);
});
it("Should not extract any files if abortToken is already aborted", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const abortController = new AbortController();
// Abortez avant d'appeler extract
abortController.abort();
const res = await zip.extract(DESTINATION_FOLDER, { abortToken: abortController });
expect(res.length).toBe(0);
// Vérifiez qu'aucun fichier n'a été extrait
expect(pathExistsSync(path.join(DESTINATION_FOLDER, "file_1.2.txt")))
.toBe(false);
});
it("Should throw an error when opening a non-existent zip file", async () => {
await expect(BsmZipExtractor.fromPath("non_existent.zip"))
.rejects
.toThrow();
});
it("Should handle empty zip file correctly", async () => {
zip = await BsmZipExtractor.fromPath(EMPTY_ZIP);
const entries = [];
for await (const entry of zip.entries()) {
entries.push(entry);
}
expect(entries.length).toBe(0);
const res = await zip.extract(DESTINATION_FOLDER);
expect(res.length).toBe(0);
});
it("Should return the same entries on multiple calls to entries()", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
const entries1 = [];
for await (const entry of zip.entries()) {
entries1.push(entry);
}
expect(entries1.length).toBe(5); // Basé sur le contenu de STANDARD_ZIP
const entries2 = [];
for await (const entry of zip.entries()) {
entries2.push(entry);
}
expect(entries2.length).toBe(5);
expect(entries2.map(e => e.fileName).sort()).toEqual(entries1.map(e => e.fileName).sort());
});
it("Should not be able to read entries after zip is closed", async () => {
zip = await BsmZipExtractor.fromPath(STANDARD_ZIP);
zip.close();
await expect(zip.entries().next()).rejects.toThrow();
});
it("Should handle zip file from buffer", async () => {
const zipBuffer = await readFile(STANDARD_ZIP);
zip = await BsmZipExtractor.fromBuffer(zipBuffer);
const entries = [];
for await (const entry of zip.entries()) {
entries.push(entry.fileName);
}
expect(entries.sort()).toEqual([
"file_1.2.txt",
"file_1.3.txt",
"folder_1.1/",
"folder_1.1/file_2.1.txt",
"folder_1.1/file_2.2.txt",
].sort());
});
it("Should read and extract files with Unicode filenames", async () => {
zip = await BsmZipExtractor.fromPath(UNICODE_ZIP);
const entries = [];
for await (const entry of zip.entries()) {
entries.push(entry);
}
expect(entries.map(e => e.fileName)).toContain("こんにちは.txt"); // "Bonjour" en japonais
const res = await zip.extract(DESTINATION_FOLDER);
expect(res).toContain("こんにちは.txt");
expect(pathExistsSync(path.join(DESTINATION_FOLDER, "こんにちは.txt")))
.toBe(true);
});
it("Should throw an error when reading a corrupted zip file", async () => {
const CORRUPTED_ZIP = path.join(TEST_FOLDER, "corrupted.zip");
await expect(BsmZipExtractor.fromPath(CORRUPTED_ZIP)).rejects.toThrow(/.*[Error: Invalid comment length].*/)
});
});
-28
View File
@@ -1,28 +0,0 @@
import JSZip from "jszip";
import { pathExist } from "./fs.helpers";
import path from "path";
import { mkdir, writeFile } from "fs/promises";
export async function extractZip(zip: JSZip, dest: string): Promise<string[]> {
if (!(await pathExist(dest))) {
throw new Error(`Path ${dest} does not exist`);
}
const files: string[] = [];
for (const [relativePath, entry] of Object.entries(zip.files)) {
if (entry.dir) {
continue;
}
const content = await entry.async("nodebuffer");
const outPath = path.join(dest, relativePath);
const outDir = path.dirname(outPath);
await mkdir(outDir, { recursive: true });
await writeFile(outPath, content);
files.push(outPath);
}
return files;
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { BsModsManagerService } from "../services/mods/bs-mods-manager.service";
import { UtilsService } from "../services/utils.service";
import { BSVersion } from "shared/bs-version.interface";
import { IpcRequest } from "shared/models/ipc";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod } from "shared/models/mods/mod.interface";
import { InstallModsResult } from "shared/models/mods";
import log from "electron-log";
import { IpcService } from "../services/ipc.service";
@@ -21,7 +21,7 @@ ipc.on<BSVersion>("get-installed-mods", (req, reply) => {
reply(from(modsManager.getInstalledMods(req.args)));
});
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
@@ -36,7 +36,7 @@ ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: B
});
});
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
+1
View File
@@ -12,3 +12,4 @@ import "./bs-playlist-ipcs";
import "./model-saber.ipcs";
import "./bs-model-ipcs";
import "./bs-version-download/bs-download-ipcs";
import "./oculus.ipcs";
+15
View File
@@ -0,0 +1,15 @@
import { OculusService } from "../services/oculus.service";
import { IpcService } from "../services/ipc.service";
import { from } from "rxjs";
const ipc = IpcService.getInstance();
ipc.on("is-oculus-sideloaded-apps-enabled", (_, reply) => {
const oculusService = OculusService.getInstance();
reply(from(oculusService.isSideLoadedAppsEnabled()));
});
ipc.on("enable-oculus-sideloaded-apps", (_, reply) => {
const oculusService = OculusService.getInstance();
reply(from(oculusService.enableSideloadedApps()));
});
+1 -1
View File
@@ -29,7 +29,7 @@ export const filterPatterns = new Set<RegExp>();
const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true";
// Filter all occulus tokens
filterPatterns.add(/FRL\S{10,}/g);
filterPatterns.add(/(FRL|OC)\S{10,}/g);
initLogger();
deleteOlestLogs();
+218
View File
@@ -0,0 +1,218 @@
import { createWriteStream, ensureDir } from "fs-extra";
import path from "path";
import { Readable } from "stream";
import yauzl, { ZipFile, Options, Entry } from "yauzl"
export class BsmZipExtractor {
private static readonly YAUZL_OPEN_OPTIONS: Options = {
lazyEntries: true,
decodeStrings: true,
autoClose: false,
};
public static fromPath(path: string): Promise<BsmZipExtractor> {
return new Promise((resolve, reject) => {
yauzl.open(path, BsmZipExtractor.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => {
if (error) return reject(error);
resolve(new BsmZipExtractor(zip));
});
});
}
public static fromBuffer(buffer: Buffer): Promise<BsmZipExtractor> {
return new Promise((resolve, reject) => {
yauzl.fromBuffer(buffer, BsmZipExtractor.YAUZL_OPEN_OPTIONS, (error: Error, zip: yauzl.ZipFile) => {
if (error) return reject(error);
resolve(new BsmZipExtractor(zip));
});
});
}
private readonly zip: ZipFile;
private readonly entriesMap = new Map<string, BsmZipExtractorEntry>();
private constructor(zip: ZipFile) {
this.zip = zip;
}
private async readEntry(): Promise<BsmZipExtractorEntry> {
return new Promise((resolve, reject) => {
const onEntry = (entry: Entry) => {
cleanup();
resolve(new BsmZipExtractorEntry({ entry, zip: this.zip }));
};
const onEnd = () => {
cleanup();
resolve(null);
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
this.zip.removeListener("entry", onEntry);
this.zip.removeListener("end", onEnd);
this.zip.removeListener("error", onError);
};
this.zip.once("entry", onEntry);
this.zip.once("end", onEnd);
this.zip.once("error", onError);
this.zip.readEntry();
});
}
// The first time this method is called, it will read all the entries and store them in a map
// The next times it will return the entries from the map to avoid reopening the zip file again
public async *entries(): AsyncGenerator<BsmZipExtractorEntry> {
for (const entry of Array.from(this.entriesMap.values())) {
yield entry;
}
let entry: BsmZipExtractorEntry = await this.readEntry();
while (entry) {
this.entriesMap.set(entry.fileName, entry);
yield entry;
entry = await this.readEntry();
}
}
public async findEntry(func: (entry: BsmZipExtractorEntry) => boolean): Promise<BsmZipExtractorEntry|null> {
for await (const entry of this.entries()) {
if (func(entry)) {
return entry;
}
}
return null;
}
public async filterEntries(func: (entry: BsmZipExtractorEntry) => boolean): Promise<BsmZipExtractorEntry[]> {
const filtered: BsmZipExtractorEntry[] = [];
for await (const entry of this.entries()) {
if (func(entry)) {
filtered.push(entry);
}
}
return filtered;
}
public async getEntry(fileName: string): Promise<BsmZipExtractorEntry> {
return this.entriesMap.get(fileName) ?? this.findEntry(entry => entry.fileName === fileName);
}
/**
* Extracts all entries from the zip file to the destination folder
* @param destination
* @param opt - { entriesNames?: string[], abortToken: AbortController }
* @returns {Promise<string[]>} The list of extracted files (relative paths in the destination folder)
*
* `opt` object:
* - `entriesNames` - The list of entries to extract (can be regexs or glob pattern). If not provided, all entries will be extracted
* - `abortToken` - The AbortController instance to abort the extraction
*
*/
public async extract(destination: string, opt?: { entriesNames?: (string|RegExp)[], abortToken?: AbortController }): Promise<string[]> {
const entriesNames = opt?.entriesNames;
const abortToken = opt?.abortToken;
if (abortToken?.signal?.aborted) {
return [];
}
await ensureDir(destination);
const extracted = new Set<string>()
for await (const entry of this.entries()) {
if (abortToken?.signal?.aborted) {
break;
}
if (entriesNames && !entriesNames.some(name => typeof name === "string" ? entry.fileName === name : name.test(entry.fileName))){
continue;
}
const extractedFile = await entry.extract(destination);
const dirname = path.dirname(extractedFile);
// Make zip that use backslash as separator have the same behavior as zip that use forward slash
if(dirname !== "."){
const split = dirname.split(path.posix.sep);
for (let i = 1; i <= split.length; i++) {
const folder = split.slice(0, i).join(path.posix.sep);
extracted.add(folder + path.posix.sep);
}
}
extracted.add(extractedFile);
}
return Array.from(extracted);
}
public close(): void {
this.zip.close();
}
}
class BsmZipExtractorEntry {
private readonly entry: Entry;
private readonly zip: ZipFile;
constructor(opt: { entry: Entry, zip: ZipFile }) {
this.entry = opt.entry;
this.zip = opt.zip;
}
public async read(): Promise<Buffer> {
return new Promise((resolve, reject) => {
this.zip.openReadStream(this.entry, (error: Error, stream: Readable) => {
if (error) return reject(error);
const buffers: Buffer[] = [];
stream.on("data", (data) => buffers.push(data as Buffer));
stream.on("end", () => resolve(Buffer.concat(buffers)));
stream.on("error", reject);
});
});
}
public async extract(destination: string): Promise<string> {
const destPath = path.join(destination, this.entry.fileName);
if (this.isDirectory) {
return ensureDir(destPath).then(() => this.entry.fileName);
}
return new Promise((resolve, reject) => {
this.zip.openReadStream(this.entry, async (error: Error, stream: Readable) => {
if (error) return reject(error);
await ensureDir(path.dirname(destPath));
const writeStream = createWriteStream(destPath);
stream.pipe(writeStream);
writeStream.on("finish", () => resolve(this.entry.fileName));
writeStream.on("error", reject);
});
});
}
public get isDirectory(): boolean {
return this.entry.fileName.endsWith("/");
}
public get fileName(): string {
return this.entry.fileName;
}
}
+27 -14
View File
@@ -1,4 +1,3 @@
import JSZip from "jszip";
import fetch from "node-fetch";
import { CustomError } from "../../shared/models/exceptions/custom-error.class";
import { mkdirs, createWriteStream, pathExists, WriteStream } from "fs-extra";
@@ -7,6 +6,8 @@ import { inflate } from "pako"
import { EMPTY, Observable, ReplaySubject, Subscriber, catchError, filter, from, lastValueFrom, mergeMap, scan, share, tap } from "rxjs";
import { Progression, hashFile } from "../helpers/fs.helpers";
import { OculusDownloaderErrorCodes } from "../../shared/models/bs-version-download/oculus-download.model";
import { BsmZipExtractor } from "./bsm-zip-extractor.class";
import { tryit } from "../../shared/helpers/error.helpers";
export class OculusDownloader {
@@ -23,26 +24,38 @@ export class OculusDownloader {
return `https://securecdn.oculus.com/binaries/segment/?access_token=${token}&binary_id=${binaryId}&segment_sha256=${segmentSha256}`;
}
private async downloadManifestZip(manifestUrl: string): Promise<JSZip> {
private async downloadManifestZip(manifestUrl: string): Promise<Buffer> {
const response = await fetch(manifestUrl);
const arrBuffer = await response.arrayBuffer();
return JSZip.loadAsync(arrBuffer);
return Buffer.from(arrBuffer);
}
private async getManifest(): Promise<OculusManifest> {
const downloadUrl = this.getDownloadManifestUrl(this.options.accessToken, this.options.binaryId);
const manifestZip = await this.downloadManifestZip(downloadUrl).catch(err => CustomError.throw(err, "DOWNLOAD_MANIFEST_FAILED"));
const manifestFile = manifestZip.file("manifest.json");
const buffer = await this.downloadManifestZip(downloadUrl)
.catch(err => CustomError.throw(err, "DOWNLOAD_MANIFEST_FAILED"));
if(!manifestFile){
const zip = await BsmZipExtractor.fromBuffer(buffer);
const entry = await zip.getEntry("manifest.json");
if(!entry) {
throw new CustomError("Manifest file not found", "MANIFEST_FILE_NOT_FOUND");
}
return manifestFile.async("text").then(JSON.parse).catch(err => CustomError.throw(err, "PARSE_MANIFEST_FILE_FAILED"));
const manifest = await entry.read();
zip.close();
const { result, error } = tryit(() => JSON.parse(manifest.toString()) as OculusManifest);
if(error){
throw CustomError.throw(error, "PARSE_MANIFEST_FILE_FAILED")
}
return result;
}
private downloadManifestFile(file: OculusManifestFile, destination: string): Observable<Progression<OculusManifestFile>> {
const downloadSegment = async (segment: OculusManifestFileSegment): Promise<ArrayBuffer> => {
const segmentUrl = this.getDownloadSegmentUrl(this.options.accessToken, this.options.binaryId, segment[1]);
const response = await fetch(segmentUrl);
@@ -66,11 +79,11 @@ export class OculusDownloader {
const arrBuffer = await downloadSegment(segment);
const inflated = inflate(arrBuffer);
await writeStream.write(inflated);
writeStream.write(inflated);
progress.current += inflated.byteLength;
progress.diff = inflated.byteLength;
sub.next(progress);
}
@@ -86,7 +99,7 @@ export class OculusDownloader {
});
}
private isFileIntegrityValid(file: OculusFileWithName, folder: string): Promise<boolean> {
private async isFileIntegrityValid(file: OculusFileWithName, folder: string): Promise<boolean> {
const [fileName, fileData] = file;
const destination = path.join(folder, fileName);
@@ -138,7 +151,7 @@ export class OculusDownloader {
if(this.isDownloading){
throw new CustomError("Already downloading", "ALREADY_DOWNLOADING");
}
this.options = options;
this.isDownloading = true;
@@ -182,7 +195,7 @@ export class OculusDownloader {
})));
const integrity = await lastValueFrom(this.verifyIntegrity(manifest, options.destination)).catch(err => CustomError.throw(err, "VERIFY_INTEGRITY_FAILED"));
if(integrity.data.length > 0){
throw new CustomError("Some files failed to download", "SOME_FILES_FAILED_TO_DOWNLOAD", integrity.data);
}
@@ -242,4 +255,4 @@ interface Logger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
}
@@ -8,7 +8,6 @@ import { UtilsService } from "../utils.service";
import crypto from "crypto";
import { lstatSync } from "fs";
import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra";
import StreamZip from "node-stream-zip";
import { RequestService } from "../request.service";
import sanitize from "sanitize-filename";
import { DeepLinkService } from "../deep-link.service";
@@ -23,6 +22,7 @@ import { allSettled } from "../../../shared/helpers/promise.helpers";
import { splitIntoChunk } from "../../../shared/helpers/array.helpers";
import { IpcService } from "../ipc.service";
import { parseMapInfoDat } from "../../../shared/parsers/maps/map-info.parser";
import { BsmZipExtractor } from "../../models/bsm-zip-extractor.class";
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
@@ -137,18 +137,15 @@ export class LocalMapsManagerService {
return { mapInfo, coverUrl, songUrl, hash, path: mapPath };
}
private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> {
private async downloadMapZip(zipUrl: string): Promise<string> {
const fileName = `${path.basename(zipUrl, ".zip")}-${crypto.randomUUID()}.zip`;
const tempPath = this.utils.getTempPath();
await ensureFolderExist(this.utils.getTempPath());
const zipPath = (await lastValueFrom(this.reqService.downloadFile(zipUrl, {
return (await lastValueFrom(this.reqService.downloadFile(zipUrl, {
destFolder: tempPath,
filename: fileName
}))).data;
const zip = new StreamZip.async({ file: zipPath });
return { zip, zipPath };
}
private openOneClickDownloadMapWindow(mapId: string, isHash = false): void {
@@ -279,16 +276,15 @@ export class LocalMapsManagerService {
return installedMap;
}
const { zip, zipPath } = await this.downloadMapZip(zipUrl);
const zipPath = await this.downloadMapZip(zipUrl);
const zip = await BsmZipExtractor.fromPath(zipPath);
if (!zip) {
throw `Cannot download ${zipUrl}`;
}
await ensureFolderExist(mapPath);
await zip.extract(null, mapPath);
await zip.close();
await zip.extract(mapPath);
zip.close();
await unlink(zipPath);
const localMap = await this.loadMapInfoFromPath(mapPath);
@@ -12,7 +12,6 @@ import { readFileSync } from "fs";
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copy, copyFile, pathExists, realpath } from "fs-extra";
import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers";
import { IpcService } from "../ipc.service";
import sanitize from "sanitize-filename";
export class LocalPlaylistsManagerService {
@@ -36,7 +35,6 @@ export class LocalPlaylistsManagerService {
private readonly deepLink: DeepLinkService;
private readonly windows: WindowManagerService;
private readonly bsaver: BeatSaverService;
private readonly ipc: IpcService;
private constructor() {
this.maps = LocalMapsManagerService.getInstance();
@@ -45,13 +43,12 @@ export class LocalPlaylistsManagerService {
this.deepLink = DeepLinkService.getInstance();
this.windows = WindowManagerService.getInstance();
this.bsaver = BeatSaverService.getInstance();
this.ipc = IpcService.getInstance();
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
const url = new URL(link);
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") : "";
this.openOneClickDownloadPlaylistWindow(bplistUrl);
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") + url.search : "";
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${encodeURIComponent(bplistUrl)}`);
});
}
@@ -97,10 +94,6 @@ export class LocalPlaylistsManagerService {
return JSON.parse(rawContent);
}
private openOneClickDownloadPlaylistWindow(downloadUrl: string): void {
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`);
}
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
@@ -13,8 +13,11 @@ export abstract class AbstractLauncherService {
}
protected buildBsLaunchArgs(launchOptions: LaunchOption): string[]{
const launchArgs = ["--no-yeet"];
const launchArgs = [];
if(!launchOptions.version.steam && !launchOptions.version.oculus){
launchArgs.push("--no-yeet")
}
if (launchOptions.oculus) {
launchArgs.push("-vrmode");
launchArgs.push("oculus");
@@ -1,20 +1,15 @@
import { Observable, ReplaySubject, catchError, lastValueFrom, of, take, timeout } from "rxjs";
import { Observable, ReplaySubject } from "rxjs";
import { StoreLauncherInterface } from "./store-launcher.interface";
import { BSLaunchError, BSLaunchEvent, BSLaunchEventData, LaunchOption } from "../../../shared/models/bs-launch";
import { OculusService } from "../oculus.service";
import { BS_EXECUTABLE, OCULUS_BS_BACKUP_DIR, OCULUS_BS_DIR } from "../../constants";
import { BS_EXECUTABLE } from "../../constants";
import path from "path";
import log from "electron-log";
import { sToMs } from "../../../shared/helpers/time.helpers";
import { lstat, pathExists, readdir, readlink, rename, symlink, unlink } from "fs-extra";
import { pathExists } from "fs-extra";
import { AbstractLauncherService } from "./abstract-launcher.service";
import { taskRunning } from "../../helpers/os.helpers";
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
import { InstallationLocationService } from "../installation-location.service";
import { ensurePathNotAlreadyExist } from "../../helpers/fs.helpers";
import { UtilsService } from "../utils.service";
import { spawn } from "child_process";
import { tryit } from "../../../shared/helpers/error.helpers";
export class OculusLauncherService extends AbstractLauncherService implements StoreLauncherInterface {
@@ -28,7 +23,6 @@ export class OculusLauncherService extends AbstractLauncherService implements St
}
private readonly oculus: OculusService;
private readonly pathsService: InstallationLocationService;
private readonly util: UtilsService;
private readonly oculusLib$ = new ReplaySubject<string>();
@@ -36,115 +30,11 @@ export class OculusLauncherService extends AbstractLauncherService implements St
private constructor() {
super();
this.oculus = OculusService.getInstance();
this.pathsService = InstallationLocationService.getInstance();
this.util = UtilsService.getInstance();
this.oculus.tryGetGameFolder([OCULUS_BS_DIR, OCULUS_BS_BACKUP_DIR]).then(async dirPath => {
if(dirPath){
return this.oculusLib$.next( path.join(dirPath, "..") );
}
const defaultLib = ((await this.oculus.getOculusLibs()) || []).find(lib => lib.isDefault);
if(defaultLib?.path){ return this.oculusLib$.next(path.join(defaultLib.path, "Software")); }
this.oculusLib$.next(null);
}).catch(err => {
log.error("Error while getting Oculus libs", err);
this.oculusLib$.next(null);
});
}
public async deleteBsSymlinks(): Promise<void> {
const oculusLibPath = await lastValueFrom(this.oculusLib$.pipe(take(1), timeout(sToMs(30)), catchError(() => of(null))));
if(!oculusLibPath || !(await pathExists(oculusLibPath))){
throw new Error("Oculus library not found, deleteBsSymlinks");
}
const libContents = await readdir(oculusLibPath);
const symlinks = (await Promise.all(libContents.map(async dir => {
return (await lstat(path.join(oculusLibPath, dir))).isSymbolicLink() ? dir : null;
}))).filter(Boolean);
log.info("Symlinks found in Oculus library", symlinks);
const bsSymlinks = symlinks.filter(dirent => dirent.startsWith(OCULUS_BS_DIR));
const bsmSymlinks = (await Promise.all(bsSymlinks.map(async symlink => {
const symlinkPath = path.join(oculusLibPath, symlink);
const targetPath = await readlink(symlinkPath).catch(err => log.error(err));
log.info("Oculus Symlink", symlink, "target", targetPath);
if(!targetPath){ return null; }
const bsmVersionsDir = path.join(this.pathsService.INSTALLATION_FOLDER, this.pathsService.VERSIONS_FOLDER);
if(!targetPath.includes(bsmVersionsDir)){ return null; }
return symlink;
}))).filter(Boolean);
await Promise.all(bsmSymlinks.map(symlink => {
log.info("Delete symlink", symlink);
return unlink(path.join(oculusLibPath, symlink));
}));
}
private async backupOriginalBeatSaber(): Promise<void>{
const bsFolder = await this.oculus.getGameFolder(OCULUS_BS_DIR);
if(!bsFolder){ return; }
const backupPath = await ensurePathNotAlreadyExist(path.join(bsFolder, "..", OCULUS_BS_BACKUP_DIR));
log.info("Backing up original Beat Saber", bsFolder, backupPath);
return rename(bsFolder, backupPath);
}
public async restoreOriginalBeatSaber(): Promise<void>{
const bsFolderBackupPath = await this.oculus.getGameFolder(OCULUS_BS_BACKUP_DIR);
if(!(await pathExists(bsFolderBackupPath))){ return; }
const originalPath = path.join(bsFolderBackupPath, "..", OCULUS_BS_DIR);
log.info("Restoring original Beat Saber", bsFolderBackupPath, originalPath);
return rename(bsFolderBackupPath, originalPath);
}
private launchSymlinkCleaner(pid: number, symlinkPath: string){
log.info("Launch Symlink Cleaner", pid, symlinkPath);
const exeName = "oculus_symlink_cleaner.exe";
const scriptPath = this.util.getAssetsScriptsPath();
spawn(path.join(scriptPath, exeName), [`${pid}`, symlinkPath], { detached: true, stdio: "ignore" });
}
public launch(launchOptions: LaunchOption): Observable<BSLaunchEventData> {
const prepareOriginalVersion: () => Promise<string> = async () => {
await this.restoreOriginalBeatSaber();
const bsPath = await this.oculus.getGameFolder(OCULUS_BS_DIR);
if(!bsPath){
throw new Error("Oculus Beat Saber path not found");
}
return bsPath;
}
const prepareDowngradedVersion: () => Promise<string> = async () => {
const oculusLib = await lastValueFrom(this.oculusLib$.pipe(take(1), timeout(sToMs(30)), catchError(() => of(null))));
if(!oculusLib){
throw CustomError.fromError(new Error("No Oculus library found"), BSLaunchError.OCULUS_LIB_NOT_FOUND);
}
// Backup original Beat Saber folder
await this.backupOriginalBeatSaber();
// Create symlink in the oculus library from the BSM BS version
const symlinkTarget = await this.localVersions.getInstalledVersionPath(launchOptions.version);
const symlinkPath = path.join(oculusLib, OCULUS_BS_DIR);
log.info("Creating symlink", symlinkTarget, symlinkPath);
await symlink(symlinkTarget, symlinkPath, "junction");
return symlinkPath;
}
return new Observable<BSLaunchEventData>(obs => {
(async () => {
@@ -154,11 +44,7 @@ export class OculusLauncherService extends AbstractLauncherService implements St
throw CustomError.fromError(new Error("Cannot start two instance of Beat Saber for Oculus"), BSLaunchError.BS_ALREADY_RUNNING);
}
// Remove previously symlinks created by BSM
await this.deleteBsSymlinks().catch(err => log.error("Error while deleting BSM symlinks", err));
const bsPath = await (launchOptions.version.oculus ? prepareOriginalVersion() : prepareDowngradedVersion());
const bsPath = await this.localVersions.getInstalledVersionPath(launchOptions.version);
const exePath = path.join(bsPath, BS_EXECUTABLE);
if(!(await pathExists(exePath))){
@@ -173,13 +59,6 @@ export class OculusLauncherService extends AbstractLauncherService implements St
// Launch Beat Saber
const process = this.launchBs(exePath, this.buildBsLaunchArgs(launchOptions));
if(launchOptions.version.oculus !== true && process?.process?.pid){
const { error } = tryit(() => this.launchSymlinkCleaner(process.process.pid, bsPath));
if(error){
log.error("Error while launching symlink cleaner", error);
}
}
return process.exit.catch(err => {
throw CustomError.fromError(err, BSLaunchError.BS_EXIT_ERROR);
});
+29 -51
View File
@@ -1,22 +1,18 @@
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod, BbmMod, BbmModVersion, BbmPlatform } from "../../../shared/models/mods/mod.interface";
import { RequestService } from "../request.service";
import { BsStore } from "../../../shared/models/bs-store.enum";
export class BeatModsApiService {
private static instance: BeatModsApiService;
private readonly requestService: RequestService;
private readonly BEAT_MODS_VERSIONS = "https://versions.beatmods.com/versions.json";
private readonly BEAT_MODS_ALIAS = "https://alias.beatmods.com/aliases.json";
public readonly MODS_REPO_URL = "https://beatmods.com";
private readonly MODS_REPO_API_URL = `${this.MODS_REPO_URL}/api`;
private readonly BEAT_MODS_API_URL = "https://beatmods.com/api/v1/";
public readonly BEAT_MODS_URL = "https://beatmods.com";
private readonly aliasesCache = new Map<string, BSVersion[]>();
private readonly versionModsCache = new Map<string, Mod[]>();
private allModsCache: Mod[];
private readonly versionModsCache = new Map<string, BbmFullMod[]>();
private readonly modsHashCache = new Map<string, BbmModVersion>();
public static getInstance(): BeatModsApiService {
if (!BeatModsApiService.instance) {
@@ -30,64 +26,46 @@ export class BeatModsApiService {
}
private getVersionModsUrl(version: BSVersion): string {
return `${this.BEAT_MODS_API_URL}mod?status=approved&gameVersion=${version.BSVersion}&sort=&sortDirection=1`;
const platform: BbmPlatform = version.oculus || version.metadata?.store === BsStore.OCULUS ? BbmPlatform.OculusPC : BbmPlatform.SteamPC;
return `${this.MODS_REPO_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
}
private getAllModsUrl(): string {
return `${this.BEAT_MODS_API_URL}mod`;
}
private updateModsHashCache(mods: BbmModVersion[]): void {
private async getVersionAlias(): Promise<Map<string, BSVersion[]>> {
if (this.aliasesCache.size) {
return this.aliasesCache;
if(!Array.isArray(mods)){
return;
}
return this.requestService.getJSON<Record<string, string[]>>(this.BEAT_MODS_ALIAS).then(rawAliases => {
Object.entries(rawAliases).forEach(([key, value]) => {
this.aliasesCache.set(
key,
value.map(s => ({ BSVersion: s } as BSVersion))
);
});
return this.aliasesCache;
});
}
private async getAliasOfVersion(version: BSVersion): Promise<BSVersion> {
return this.getVersionAlias().then(aliases => {
if (Array.from(aliases.keys()).some(k => k === version.BSVersion)) {
return version;
for (const mod of mods) {
for (const content of (mod.contentHashes ?? [])) {
this.modsHashCache.set(content.hash, mod);
}
const alias = Array.from(aliases.entries()).find(([key, value]) => value.find(v => v.BSVersion === version.BSVersion))?.[0];
return { BSVersion: alias } as BSVersion;
});
}
}
private asignDependencies(mod: Mod, mods: Mod[]): Mod {
mod.dependencies = mod.dependencies.map(dep => mods.find(mod => mod.name === dep.name));
return mod;
}
public async getVersionMods(version: BSVersion): Promise<Mod[]> {
public async getVersionMods(version: BSVersion): Promise<BbmFullMod[]> {
if (this.versionModsCache.has(version.BSVersion)) {
return this.versionModsCache.get(version.BSVersion);
}
const alias = await this.getAliasOfVersion(version);
return this.requestService.getJSON<{ mods: {mod: BbmMod, latest: BbmModVersion}[] }>(this.getVersionModsUrl(version)).then(res => {
const fullMods: BbmFullMod[] = res?.mods?.map(mod => ({ mod: mod.mod, version: mod.latest })) ?? [];
this.versionModsCache.set(version.BSVersion, fullMods);
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl(alias)).then(mods => {
mods = mods.map(mod => this.asignDependencies(mod, mods));
this.versionModsCache.set(version.BSVersion, mods);
return mods;
this.updateModsHashCache(fullMods.map(mod => mod.version));
return fullMods;
});
}
public async getAllMods(): Promise<Mod[]> {
if (this.allModsCache) {
return this.allModsCache;
public getModByHash(hash: string): Promise<BbmModVersion> {
if (this.modsHashCache.has(hash)) {
return Promise.resolve(this.modsHashCache.get(hash));
}
return this.requestService.getJSON<Mod[]>(this.getAllModsUrl()).then(mods => {
this.allModsCache = mods;
return this.allModsCache;
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`).then(res => {
this.updateModsHashCache(res?.modVersions ?? []);
return res?.modVersions?.at(0);
});
}
}
+99 -114
View File
@@ -1,5 +1,5 @@
import { BSVersion } from "shared/bs-version.interface";
import { DownloadLink, InstallModsResult, Mod, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { InstallModsResult, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { BeatModsApiService } from "./beat-mods-api.service";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
@@ -11,11 +11,12 @@ import { BS_EXECUTABLE } from "../../constants";
import log from "electron-log";
import { deleteFolder, pathExist, unlinkPath } from "../../helpers/fs.helpers";
import { lastValueFrom } from "rxjs";
import JSZip from "jszip";
import { extractZip } from "../../helpers/zip.helpers";
import recursiveReadDir from "recursive-readdir";
import { sToMs } from "../../../shared/helpers/time.helpers";
import { ensureDir, pathExistsSync } from "fs-extra";
import { pathExistsSync } from "fs-extra";
import { BsmZipExtractor } from "../../models/bsm-zip-extractor.class";
import crypto from "crypto";
import { BbmCategories, BbmFullMod, BbmModVersion } from "../../../shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -25,7 +26,7 @@ export class BsModsManagerService {
private readonly utilsService: UtilsService;
private readonly requestService: RequestService;
private manifestMatches: Mod[];
private manifestMatches: BbmModVersion[];
private nbModsToInstall = 0;
private nbInstalledMods = 0;
@@ -45,29 +46,25 @@ export class BsModsManagerService {
this.bsLocalService = BSLocalVersionService.getInstance();
this.utilsService = UtilsService.getInstance();
this.requestService = RequestService.getInstance();
this.utilsService = UtilsService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
const allMods = await this.beatModsApi.getAllMods();
return allMods.find(mod => {
if (mod.name.toLowerCase() === "bsipa") {
return false;
private async getModFromHash(hash: string): Promise<BbmModVersion | null> {
try {
const mod = await this.beatModsApi.getModByHash(hash);
if(mod?.contentHashes?.some(content => content.path.includes("IPA.exe"))){
return null;
}
return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash));
});
return mod;
} catch (error) {
log.error("Could not get mod from hash", error);
return null;
}
}
private async getIpaFromHash(hash: string): Promise<Mod> {
const allMods = await this.beatModsApi.getAllMods();
return allMods.find(mod => {
if (mod.name.toLowerCase() !== "bsipa") {
return false;
}
return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash));
});
}
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<Mod[]> {
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<BbmModVersion[]> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const modsPath = path.join(bsPath, modsDir);
@@ -84,7 +81,6 @@ export class BsModsManagerService {
return undefined;
}
const hash = await md5File(filePath);
const mod = await this.getModFromHash(hash);
if (!mod) {
@@ -97,7 +93,7 @@ export class BsModsManagerService {
}
if (filePath.toLowerCase().includes("libs")) {
const manifestIndex = this.manifestMatches.findIndex(m => m.name === mod.name);
const manifestIndex = this.manifestMatches.findIndex(m => m.id === mod.id);
if (manifestIndex < 0) {
return undefined;
@@ -110,39 +106,35 @@ export class BsModsManagerService {
});
const mods = await Promise.all(promises);
return mods.filter(Boolean);
return mods.filter(Boolean);
}
private async getBsipaInstalled(version: BSVersion): Promise<Mod> {
private async getBsipaInstalled(version: BSVersion): Promise<BbmModVersion> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const injectorPath = path.join(bsPath, "Beat Saber_Data", "Managed", "IPA.Injector.dll");
if (!(await pathExist(injectorPath))) {
return undefined;
}
const injectorMd5 = await md5File(injectorPath);
return this.getIpaFromHash(injectorMd5);
return this.beatModsApi.getModByHash(injectorMd5);
}
private async downloadZip(zipUrl: string): Promise<JSZip> {
zipUrl = path.join(this.beatModsApi.BEAT_MODS_URL, zipUrl);
private async downloadZip(zipUrl: string): Promise<BsmZipExtractor> {
zipUrl = path.join(this.beatModsApi.MODS_REPO_URL, zipUrl);
log.info("Download mod zip", zipUrl);
const buffer = await lastValueFrom(this.requestService.downloadBuffer(zipUrl))
.then(progress => progress.data)
.catch(e => {
.catch((e: Error) => {
log.error("ZIP", "Error while downloading zip", e);
return undefined;
});
if (!buffer) {
return null;
}
return JSZip.loadAsync(buffer).catch(e => {
log.error("ZIP", "Error while loading zip", e);
return null;
});
return BsmZipExtractor.fromBuffer(buffer);
}
private async executeBSIPA(version: BSVersion, args: string[]): Promise<boolean> {
@@ -190,65 +182,57 @@ export class BsModsManagerService {
});
}
private getModDownload(mod: Mod, version: BSVersion): DownloadLink {
return mod.downloads.find(download => {
const type = download.type.toLowerCase();
return type === "universal" || type === this.bsLocalService.getVersionType(version);
});
private getModDownload(modVersion: BbmModVersion): string {
return `/cdn/mod/${modVersion.zipHash}.zip`
}
private async installMod(mod: Mod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
private async installMod(mod: BbmFullMod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
const download = this.getModDownload(mod, version);
const downloadUrl = this.getModDownload(mod.version);
if (!download) {
if (!downloadUrl) {
return false;
}
log.info("Start download mod zip", mod.name, download.url);
const zip = await this.downloadZip(download.url);
log.info("Mod zip download end", mod.name, download.url, !!zip);
log.info("Start download mod zip", mod.mod.name, downloadUrl);
const zip = await this.downloadZip(downloadUrl);
log.info("Mod zip download end", mod.mod.name, downloadUrl);
if (!zip) {
return false;
}
const crypto = require("crypto");
const files = await zip.files;
let hashCount = 0;
for await (const entry of zip.entries()) {
const buffer = await entry.read();
const md5Hash = crypto.createHash("md5")
.update(buffer)
.digest("hex");
hashCount += +mod.version.contentHashes.some(content => content.hash === md5Hash);
}
const checkedEntries = (
await Promise.all(
Object.values(files).map(async entry => {
const data = await entry.async("nodebuffer");
const entryMd5 = crypto.createHash("md5").update(data).digest("hex");
return download.hashMd5.some(md5 => md5.hash === entryMd5) ? entry : undefined;
})
).catch(e => {
log.error("Error while checking mod zip entries", mod.name, e);
throw e;
})
).filter(entry => !!entry);
if (checkedEntries.length !== download.hashMd5.length) {
if (hashCount !== mod.version.contentHashes.length) {
return false;
}
const verionPath = await this.bsLocalService.getVersionPath(version);
const isBSIPA = mod.name.toLowerCase() === "bsipa";
const destDir = isBSIPA ? verionPath : path.join(verionPath, ModsInstallFolder.PENDING);
const versionPath = await this.bsLocalService.getVersionPath(version);
const isBSIPA = mod.mod.name.toLowerCase() === "bsipa";
const destDir = isBSIPA ? versionPath : path.join(versionPath, ModsInstallFolder.PENDING);
await ensureDir(destDir);
log.info("Start extracting mod zip", mod.name, "to", destDir);
const extracted = await extractZip(zip, destDir)
log.info("Start extracting mod zip", mod.mod.name, "to", destDir);
const extracted = await zip.extract(destDir)
.then(() => true)
.catch(e => {
log.error("Error while extracting mod zip", e);
return false;
})
.finally(() => {
zip.close();
});
log.info("Mod zip extraction end", mod.name, "to", destDir, "success:", extracted);
log.info("Mod zip extraction end", mod.mod.name, "to", destDir, "success:", extracted);
const res = isBSIPA
? extracted &&
@@ -263,23 +247,20 @@ export class BsModsManagerService {
return res;
}
private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]) {
return selectedMods.some(m => {
const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name));
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
private isDependency(mod: BbmFullMod, selectedMods: BbmFullMod[], availableMods: BbmFullMod[]): boolean {
const selectedDepsIds = selectedMods.flatMap(mod => mod.version.dependencies);
const modsDeps = availableMods.filter(m => selectedDepsIds.includes(m.version.id));
const modsDepsDepsIds = modsDeps.flatMap(m => m.version.dependencies);
return selectedDepsIds.includes(mod.version.id) || modsDepsDepsIds.includes(mod.version.id);
}
private async resolveDependencies(mods: Mod[], version: BSVersion): Promise<Mod[]> {
private async resolveDependencies(mods: BbmFullMod[], version: BSVersion): Promise<BbmFullMod[]> {
const availableMods = await this.beatModsApi.getVersionMods(version);
return Array.from(
new Map<string, Mod>(
new Map<number, BbmFullMod>(
availableMods.reduce((res, mod) => {
if (this.isDependency(mod, mods, availableMods)) {
res.push([mod.name, mod]);
if (mod.mod.category === BbmCategories.Core || this.isDependency(mod, mods, availableMods)) {
res.push([mod.mod.id, mod]);
}
return res;
}, [])
@@ -287,9 +268,7 @@ export class BsModsManagerService {
);
}
private async uninstallBSIPA(mod: Mod, version: BSVersion): Promise<void> {
const download = this.getModDownload(mod, version);
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"));
const hasIPADir = await pathExist(path.join(verionPath, "IPA"));
@@ -300,39 +279,40 @@ export class BsModsManagerService {
await this.executeBSIPA(version, ["--revert", "-n"]);
const promises = download.hashMd5.map(files => {
const file = files.file.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
const promises = mod.version.contentHashes.map(content => {
const file = content.path.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
return unlinkPath(path.join(verionPath, file));
});
await Promise.all(promises);
}
private async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
private async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
this.nbUninstalledMods++;
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
if (mod.name.toLowerCase() === "bsipa") {
if (mod.mod.name.toLowerCase() === "bsipa") {
return this.uninstallBSIPA(mod, version);
}
const download = this.getModDownload(mod, version);
const versionPath = await this.bsLocalService.getVersionPath(version);
const promises = download.hashMd5.map(async files => {
return Promise.all([unlinkPath(path.join(versionPath, files.file)), unlinkPath(path.join(versionPath, "IPA", "Pending", files.file))]);
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))]);
});
await Promise.all(promises);
}
public getAvailableMods(version: BSVersion): Promise<Mod[]> {
return this.beatModsApi.getVersionMods(version);
public async getAvailableMods(version: BSVersion): Promise<BbmFullMod[]> {
return this.beatModsApi.getVersionMods(version).catch(() => {
return [] as BbmFullMod[];
});
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]> {
public async getInstalledMods(version: BSVersion): Promise<BbmModVersion[]> {
this.manifestMatches = [];
await this.beatModsApi.getAllMods();
const bsipa = await this.getBsipaInstalled(version);
const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS), this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING)]);
@@ -340,33 +320,33 @@ export class BsModsManagerService {
const dirMods = pluginsMods.flat().concat(libsMods.flat());
const modsDict = new Map<string, Mod>();
const modsDict = new Map<number, BbmModVersion>();
if (bsipa) {
modsDict.set(bsipa.name, bsipa);
modsDict.set(bsipa.id, bsipa);
}
for (const mod of dirMods.flat()) {
if (modsDict.has(mod.name)) {
if (modsDict.has(mod.id)) {
continue;
}
modsDict.set(mod.name, mod);
modsDict.set(mod.id, mod);
}
return Array.from(modsDict.values());
}
public async installMods(mods: Mod[], version: BSVersion): Promise<InstallModsResult> {
public async installMods(mods: BbmFullMod[], version: BSVersion): Promise<InstallModsResult> {
const deps = await this.resolveDependencies(mods, version);
mods.push(...deps);
if (!mods?.length) {
throw "no-mods";
}
const deps = await this.resolveDependencies(mods, version);
mods.push(...deps);
const bsipa = mods.find(mod => mod.name.toLowerCase() === "bsipa");
const bsipa = mods.find(mod => mod.mod.name.toLowerCase() === "bsipa");
if (bsipa) {
mods = mods.filter(mod => mod.name.toLowerCase() !== "bsipa");
mods = mods.filter(mod => mod.mod.name.toLowerCase() !== "bsipa");
}
this.nbModsToInstall = mods.length + (bsipa && 1);
@@ -392,7 +372,7 @@ export class BsModsManagerService {
};
}
public async uninstallMods(mods: Mod[], version: BSVersion): Promise<UninstallModsResult> {
public async uninstallMods(mods: BbmFullMod[], version: BSVersion): Promise<UninstallModsResult> {
if (!mods?.length) {
throw "no-mods";
}
@@ -411,16 +391,21 @@ export class BsModsManagerService {
}
public async uninstallAllMods(version: BSVersion): Promise<UninstallModsResult> {
const mods = await this.getInstalledMods(version);
const versionMods = await this.getAvailableMods(version);
const installedMods = await this.getInstalledMods(version);
if (!mods?.length) {
const fullInstalledMods: BbmFullMod[] = installedMods?.map(version => {
return { version, mod: versionMods.find(mod => version.modId === mod.mod.id)?.mod };
}) ?? [];
if (!fullInstalledMods?.length) {
throw "no-mods";
}
this.nbModsToUninstall = mods.length;
this.nbModsToUninstall = fullInstalledMods.length;
this.nbUninstalledMods = 0;
for (const mod of mods) {
for (const mod of fullInstalledMods) {
await this.uninstallMod(mod, version);
}
+71 -3
View File
@@ -7,14 +7,14 @@ import { shell } from "electron";
import { taskRunning } from "../helpers/os.helpers";
import { sToMs } from "../../shared/helpers/time.helpers";
import { execOnOs } from "../helpers/env.helpers";
import { UtilsService } from "./utils.service";
import { exec } from "child_process";
const { list } = (execOnOs({ win32: () => require("regedit-rs") }, true) ?? {}) as typeof import("regedit-rs");
export class OculusService {
private static instance: OculusService;
private oculusLibraries: OculusLibrary[];
public static getInstance(): OculusService {
if (!OculusService.instance) {
OculusService.instance = new OculusService();
@@ -22,7 +22,14 @@ export class OculusService {
return OculusService.instance;
}
private constructor() {}
private readonly utils: UtilsService;
private oculusLibraries: OculusLibrary[];
private constructor() {
this.utils = UtilsService.getInstance();
}
public async getOculusLibs(): Promise<OculusLibrary[]> {
if (process.platform !== "win32") {
@@ -120,6 +127,67 @@ export class OculusService {
}, sToMs(30));
});
}
public async isSideLoadedAppsEnabled(): Promise<boolean> {
if(process.platform !== "win32"){
log.info("Cannot check sideloaded apps on non-windows platforms");
throw new Error("Cannot check sideloaded apps on non-windows platforms");
}
const regPath = "HKLM\\SOFTWARE\\Wow6432Node\\Oculus VR, LLC\\Oculus";
const res = await list(regPath).then(res => res[regPath]);
if(!res.exists){
log.info("Registry key not found", regPath);
return false;
}
const value = res.values?.AllowDevSideloaded;
if(!value){
log.info("Registry value not found", "AllowDevSideloaded");
return false;
}
return value.value === 1;
}
public async enableSideloadedApps(): Promise<void> {
if(process.platform !== "win32"){
log.info("Cannot enable sideloaded apps on non-windows platforms");
return;
}
const enabled = await this.isSideLoadedAppsEnabled();
if(enabled){
log.info("Sideloaded apps already enabled");
return;
}
const exePath = path.join(this.utils.getAssetsScriptsPath(), "oculus-allow-dev-sideloaded.exe");
return new Promise((resolve, reject) => {
log.info("Enabling sideloaded apps");
const process = exec(exePath);
process.on("exit", code => {
if(code === 0){
resolve();
} else {
reject(new Error(`Failed to enable sideloaded apps, exit code: ${code}`));
}
});
process.on("error", err => {
log.error("Error while enabling sideloaded apps", err);
reject(err);
});
process.stdout.on("data", data => {
log.info(data.toString?.() ?? data);
});
})
}
}
export interface OculusLibrary {
+1 -1
View File
@@ -49,7 +49,7 @@ export class RequestService {
const response = await fetch(url, this.getInitWithOptions(options));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} ${url}`);
throw new Error(`HTTP error! status: ${response.status} response: ${await response.text().catch(() => "No response")} url: ${url}`);
}
return await response.json();
@@ -14,7 +14,7 @@ export class BeatSaverApiService {
private readonly request: RequestService;
private readonly bsaverApiUrl = "https://beatsaver.com/api";
private readonly bsaverApiUrl = "https://api.beatsaver.com";
private constructor() {
this.request = RequestService.getInstance();
@@ -0,0 +1,42 @@
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BSVersion } from "shared/bs-version.interface";
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
import { useState } from "react";
import { ConfigurationService } from "renderer/services/configuration.service";
import { useService } from "renderer/hooks/use-service.hook";
export const BSVersionOutdatedModal: ModalComponent<void, { outdated: BSVersion, recommended: BSVersion }> = ({ resolver, data : { outdated, recommended }}) => {
const t = useTranslationV2();
const config = useService(ConfigurationService);
const [remember, setRemember] = useState(false);
const handleContinue = () => {
if (remember) {
config.set("not-show-bs-version-outdated-modal", true);
}
resolver({ exitCode: ModalExitCode.COMPLETED });
};
return (
<form className="text-gray-800 dark:text-gray-200 max-w-sm">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t.text("misc.warning")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p>{t.element("modals.bs-version-outdated.body", { outdatedVersion: <b>{outdated.BSVersion}</b>, recommendedVersion: <b>{recommended.BSVersion}</b> })}</p>
<div className="flex items-center relative py-2 gap-1 mt-1">
<BsmCheckbox className="h-5 relative z-[1]" checked={remember} onChange={val => setRemember(() => val)} />
<span className="italic">{t.text("modals.misc.remember-my-choice")}</span>
</div>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-4 h-8">
<BsmButton typeColor="cancel" className="rounded-md flex justify-center items-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md flex justify-center items-center transition-all" onClick={handleContinue} withBar={false} text="misc.continue" />
</div>
</form>
);
};
@@ -34,7 +34,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
const [filter, setFilter] = useState<MapFilter>({});
const [query, setQuery] = useState("");
const [maps, setMaps] = useState<BsvMapDetail[]>([]);
const [sortOrder, setSortOrder] = useState<SearchOrder>(BSV_SORT_ORDER.at(0));
const [sortOrder, setSortOrder] = useState<SearchOrder>("Relevance");
const [ownedMapHashs, setOwnedMapHashs] = useState<string[]>(ownedMaps?.map(map => map.hash) ?? []);
const [loading, setLoading] = useState(false);
const isOnline = useObservable(() => os.isOnline$);
@@ -195,7 +195,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
handleSearch();
}}
/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} onChange={sort => handleSortChange(sort)} />
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} onChange={sort => handleSortChange(sort)} selected={sortOrder}/>
</div>
<ul className="w-full grow flex content-start flex-wrap gap-2 pt-1.5 px-2 overflow-y-scroll overflow-x-hidden scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-neutral-900 z-0">
{maps.length === 0 ? (
@@ -2,31 +2,26 @@ import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
import { useState } from "react";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
export const OriginalOculusVersionBackupModal: ModalComponent<boolean, void> = ({ resolver }) => {
export const EnableOculusSideloadedApps: ModalComponent<void, void> = ({ resolver }) => {
const t = useTranslation();
const [dontShowAgain, setDontShowAgain] = useState(false);
const { text: t } = useTranslationV2();
const submit = () => {
resolver({ exitCode: ModalExitCode.COMPLETED, data: dontShowAgain });
resolver({ exitCode: ModalExitCode.COMPLETED });
}
return (
<form className="max-w-[450px] text-gray-800 dark:text-gray-200">
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.original-version-backup-oculus.title")}</h1>
<BsmImage className="mx-auto h-20" image={BeatConflict} />
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.enable-oculus-sideloaded-apps.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p className="text-sm italic mb-4 font-bold">{t("modals.original-version-backup-oculus.body.must-be-installed-once")}</p>
<p className="mb-4">{t("modals.original-version-backup-oculus.body.will-backup")}</p>
<p className="mb-4">{t("modals.enable-oculus-sideloaded-apps.info-1")}</p>
<p className="mb-4">{t("modals.enable-oculus-sideloaded-apps.info-2")}</p>
<p className="mb-4">{t("modals.enable-oculus-sideloaded-apps.info-3")}</p>
<div className="flex flex-row justify-start items-center gap-1.5 my-4" >
<BsmCheckbox className="relative z-[1] w-6 aspect-square" checked={dontShowAgain} onChange={setDontShowAgain} />
<span>{t("modals.original-version-backup-oculus.not-remind-me")}</span>
</div>
<a className="underline mb-5 block" href="https://github.com/Zagrios/bs-manager/wiki/Activate-Oculus-sideloading" target="_blank">{t("modals.enable-oculus-sideloaded-apps.i-want-to-do-it-myself")}</a>
<div className="grid grid-flow-col grid-cols-2 gap-4">
<BsmButton
@@ -43,7 +38,7 @@ export const OriginalOculusVersionBackupModal: ModalComponent<boolean, void> = (
className="rounded-md transition-all h-10 flex items-center justify-center"
onClick={submit}
withBar={false}
text="modals.original-version-backup-oculus.understood"
text="modals.enable-oculus-sideloaded-apps.understood"
/>
</div>
</form>
@@ -3,9 +3,9 @@ import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmMod } from "shared/models/mods/mod.interface";
export const UninstallModModal: ModalComponent<void, Mod> = ({ resolver, data }) => {
export const UninstallModModal: ModalComponent<void, BbmMod> = ({ resolver, data }) => {
const mod = data;
const t = useTranslation();
@@ -35,7 +35,7 @@ export function SupportersView({ isVisible, setVisible }: Props) {
(
{isVisible && (
<motion.div className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-90 z-40 text-gray-200" transition={{ duration: 0.3 }} initial={{ opacity: 0, y: "-100%" }} animate={{ opacity: 1, y: "0%" }} exit={{ opacity: 0, y: "-100%" }}>
<BsmButton className="absolute right-10 top-10 !bg-transparent w-7 h-7" icon="cross" withBar={false} onClick={() => setVisible(false)} />
<BsmButton className="absolute right-10 top-10 !bg-transparent w-7 h-7" iconClassName="text-white size-full" icon="cross" withBar={false} onClick={() => setVisible(false)} />
{(!!sponsors.length || !!supporters.length) && <img className="absolute bottom-5 right-5 rotate-45 w-32 h-32" src={ManheraChanGif} />}
<div className="w-full h-full overflow-y-scroll">
{!!sponsors.length && <SupportersBlock className="mt-12" title="pages.settings.patreon.view.sponsors" supporters={sponsors} />}
@@ -60,8 +60,10 @@ import { ChevronTopIcon } from "./icons/chevron-top-icon.component";
import { EyeCrossIcon } from "./icons/eye-cross-icon.component";
import { ShortcutIcon } from "./icons/shortcut-icon.component";
import { BackupRestoreIcon } from "./icons/backup-restore-icon.component";
import { CancelIcon } from "./icons/cancel-icon.component";
import { WarningIcon } from "./icons/warning-icon.component";
export type BsmIconType = BsvMapCharacteristic | ("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" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
export type BsmIconType = BsvMapCharacteristic | ("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" | "cancel" | "warning" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
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
@@ -250,6 +252,15 @@ export const BsmIcon = memo(({ className, icon, style }: { className?: string; i
if (icon === "backup-restore") {
return <BackupRestoreIcon className={className} style={style} />;
}
if(icon === "cancel"){
return <CancelIcon className={className} style={style} />;
}
if(icon === "warning"){
return <WarningIcon className={className} style={style} />;
}
return <TrashIcon className={className} style={style} />;
};
@@ -0,0 +1,9 @@
import { createSvgIcon } from "../svg-icon.type";
export const CancelIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M480-421.37 592.17-309.2q11.72 11.72 29.32 11.6 17.6-.12 29.31-11.83 11.72-11.72 11.72-29.32t-11.72-29.32L538.63-480 650.8-592.17q11.72-11.72 11.72-29.32T650.8-650.8q-11.71-11.72-29.31-11.72-17.6 0-29.32 11.72L480-538.63 367.83-650.8q-11.72-11.72-29.2-11.72-17.48 0-29.2 11.72-11.71 11.71-11.71 29.31 0 17.6 11.71 29.32L421.37-480 309.2-367.83q-11.72 11.72-11.6 29.2.12 17.48 11.83 29.2 11.72 11.71 29.32 11.71t29.32-11.71L480-421.37Zm0 349.5q-84.91 0-159.35-32.1-74.43-32.1-129.51-87.17-55.07-55.08-87.17-129.51-32.1-74.44-32.1-159.35t32.1-159.35q32.1-74.43 87.17-129.51 55.08-55.07 129.51-87.17 74.44-32.1 159.35-32.1t159.35 32.1q74.43 32.1 129.51 87.17 55.07 55.08 87.17 129.51 32.1 74.44 32.1 159.35t-32.1 159.35q-32.1 74.43-87.17 129.51-55.08 55.07-129.51 87.17-74.44 32.1-159.35 32.1Zm0-91q133.04 0 225.09-92.04 92.04-92.05 92.04-225.09 0-133.04-92.04-225.09-92.05-92.04-225.09-92.04-133.04 0-225.09 92.04-92.04 92.05-92.04 225.09 0 133.04 92.04 225.09 92.05 92.04 225.09 92.04ZM480-480Z"/>
</svg>
)
})
@@ -0,0 +1,9 @@
import { createSvgIcon } from "../svg-icon.type";
export const WarningIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M80-78q-15 0-27-8t-19-19q-7-11-7.5-25t7.5-28l400-689q8-14 20-20t26-6q14 0 26 6t20 20l400 689q8 14 7.5 28t-7.5 25q-7 11-19 19t-27 8H80Zm401-135q22 0 37.5-15.5T534-266q0-22-15.5-37.5T481-319q-22 0-37.5 15.5T428-266q0 22 15.5 37.5T481-213Zm0-143q22 0 37.5-15.5T534-409v-98q0-22-15.5-37.5T481-560q-22 0-37.5 15.5T428-507v98q0 22 15.5 37.5T481-356Z"/>
</svg>
);
})
@@ -1,8 +1,7 @@
import { motion } from "framer-motion";
import { ChangeEvent, useEffect, useState } from "react";
import { ChangeEvent, useEffect, useMemo, useState } from "react";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
import { BSLauncherService, LaunchMods } from "renderer/services/bs-launcher.service";
import { ConfigurationService } from "renderer/services/configuration.service";
import { BSVersion } from "shared/bs-version.interface";
@@ -14,15 +13,21 @@ import { BsStore } from "shared/models/bs-store.enum";
import { lastValueFrom } from "rxjs";
import { BsDownloaderService } from "renderer/services/bs-version-download/bs-downloader.service";
import equal from "fast-deep-equal";
import { BSVersionManagerService } from "renderer/services/bs-version-manager.service";
import { safeLt } from "shared/helpers/semver.helpers";
import { WarningIcon } from "renderer/components/svgs/icons/warning-icon.component";
import Tippy from "@tippyjs/react";
import { motion } from "framer-motion"
type Props = { version: BSVersion };
export function LaunchSlide({ version }: Props) {
const t = useTranslation();
const { text: t, element: te } = useTranslationV2();
const configService = useService(ConfigurationService);
const bsLauncherService = useService(BSLauncherService);
const bsDownloader = useService(BsDownloaderService);
const versions = useService(BSVersionManagerService);
const [oculusMode, setOculusMode] = useState(!!configService.get<boolean>(LaunchMods.OCULUS_MOD));
const [desktopMode, setDesktopMode] = useState(!!configService.get<boolean>(LaunchMods.DESKTOP_MOD));
@@ -62,7 +67,7 @@ export function LaunchSlide({ version }: Props) {
.map(arg => arg.trim())
.filter(arg => arg.length > 0)
: undefined;
const launch$ = bsLauncherService.launch({
version,
oculus: version.oculus ? false : oculusMode,
@@ -70,15 +75,28 @@ export function LaunchSlide({ version }: Props) {
debug: debugMode,
additionalArgs
});
return lastValueFrom(launch$).catch(() => {});
};
const isOutdated = useMemo(() => {
return safeLt(version?.BSVersion, versions.getRecommendedVersion()?.BSVersion);
}, [version]);
return (
<div className="w-full shrink-0 items-center relative flex flex-col justify-start">
<div className="flex flex-col gap-3 justify-center items-center mb-5">
<BsmImage className="relative object-cover h-28" image={BSLogo} />
<h1 className="relative text-4xl font-bold italic -top-3">{version.name ? `${version.BSVersion} - ${version.name}` : version.BSVersion}</h1>
<h1 className="relative text-4xl font-bold italic -top-3">
{version.name ? `${version.BSVersion} - ${version.name}` : version.BSVersion}
{isOutdated && (
<Tippy theme="default" content={te("pages.version-viewer.launch-mods.outdated-tippy", { recommendedVersion: <b>{versions.getRecommendedVersion().BSVersion}</b> })}>
<span className="absolute bg-theme-1 size-7 rounded-full p-1.5 flex justify-center items-center -right-2 top-2 translate-x-full translate-y-px cursor-help">
<WarningIcon className="text-warning-400 animate-pulse"/>
</span>
</Tippy>
)}
</h1>
</div>
<div className="grid grid-flow-col gap-6">
{!(version.oculus || version.metadata?.store === BsStore.OCULUS) && <LaunchModToogle infoText="pages.version-viewer.launch-mods.oculus-description" icon="oculus" onClick={() => setMode(LaunchMods.OCULUS_MOD, !oculusMode)} active={oculusMode} text="pages.version-viewer.launch-mods.oculus" />}
@@ -100,10 +118,10 @@ export function LaunchSlide({ version }: Props) {
</motion.div>
</div>
<div className='grow flex justify-center items-center'>
<BsmButton
onClick={launch}
<BsmButton
onClick={launch}
active={JSON.stringify(version) === JSON.stringify(versionRunning)}
className='relative -translate-y-1/2 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 -translate-y-1/2 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)}
/>
@@ -111,3 +129,5 @@ export function LaunchSlide({ version }: Props) {
</div>
);
}
@@ -1,6 +1,5 @@
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
import { Mod } from "shared/models/mods/mod.interface";
import { CSSProperties, useRef } from "react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
@@ -9,8 +8,10 @@ import { PageStateService } from "renderer/services/page-state.service";
import useDoubleClick from "use-double-click";
import { gt } from "semver";
import { useService } from "renderer/hooks/use-service.hook";
import { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
import striptags from "striptags";
type Props = { className?: string; mod: Mod; installedVersion: string; isDependency?: boolean; isSelected?: boolean; onChange?: (val: boolean) => void; wantInfo?: boolean; onWantInfo?: (mod: Mod) => void };
type Props = { className?: string; mod: BbmFullMod; installedVersion: string; isDependency?: boolean; isSelected?: boolean; onChange?: (val: boolean) => void; wantInfo?: boolean; onWantInfo?: (mod: BbmFullMod) => void };
export function ModItem({ className, mod, installedVersion, isDependency, isSelected, onChange, wantInfo, onWantInfo }: Props) {
const modsManager = useService(BsModsManagerService);
@@ -28,7 +29,7 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
});
const wantInfoStyle: CSSProperties = wantInfo ? { borderColor: themeColor } : { borderColor: "transparent" };
const isOutDated = installedVersion ? gt(mod.version, installedVersion) : false;
const isOutDated = installedVersion ? gt(mod.version.modVersion, installedVersion) : false;
const uninstall = () => {
modsManager.uninstallMod(mod, pageState.getState());
@@ -43,24 +44,24 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
onChange(!isChecked);
};
const isChecked = isDependency || isSelected || mod.required;
const isChecked = isDependency || isSelected || mod.mod.category === BbmCategories.Core;
return (
<li ref={clickRef} className={`${className} group`}>
<div className="h-full aspect-square flex items-center justify-center p-[7px] rounded-l-md bg-inherit ml-3 border-2 border-r-0 z-[1] group-hover:brightness-90" style={wantInfoStyle}>
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={onChange} disabled={mod.required || isDependency} checked={isChecked} />
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={onChange} disabled={mod.mod.category === BbmCategories.Core || isDependency} checked={isChecked} />
</div>
<span className="bg-inherit py-2 pl-3 font-bold text-sm whitespace-nowrap border-t-2 border-b-2 blur-none group-hover:brightness-90" style={wantInfoStyle}>
{mod.name}
{mod.mod.name}
</span>
<span className={`min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90 ${installedVersion && isOutDated && "text-red-400 line-through"} ${installedVersion && !isOutDated && "text-green-400"}`} style={wantInfoStyle}>
{installedVersion || "-"}
</span>
<span className="min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.version}
{mod.version.modVersion}
</span>
<span title={mod.description} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.description}
<span title={striptags(mod.mod?.description ?? "", { tagReplacementText: " " })} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{striptags(mod.mod?.summary ?? "", { tagReplacementText: " " })}
</span>
<div className="h-full bg-inherit flex items-center justify-center mr-3 rounded-r-md pr-2 border-t-2 border-b-2 border-r-2 group-hover:brightness-90" style={wantInfoStyle}>
{installedVersion && (
@@ -5,14 +5,22 @@ import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-butto
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { PageStateService } from "renderer/services/page-state.service";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
import { ModItem } from "./mod-item.component";
import { useService } from "renderer/hooks/use-service.hook";
type Props = { modsMap: Map<string, Mod[]>; installed: Map<string, Mod[]>; modsSelected: Mod[]; onModChange: (selected: boolean, mod: Mod) => void; moreInfoMod?: Mod; onWantInfos: (mod: Mod) => void };
type Props = {
modsMap: Map<BbmCategories, BbmFullMod[]>;
installed: Map<BbmCategories, BbmFullMod[]>;
modsSelected: BbmFullMod[];
onModChange: (selected: boolean, mod: BbmFullMod) => void;
moreInfoMod?: BbmFullMod;
onWantInfos: (mod: BbmFullMod) => void
unselectAllMods?: () => void;
};
export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreInfoMod, onWantInfos, unselectAllMods }: Props) {
export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreInfoMod, onWantInfos }: Props) {
const pageState = useService(PageStateService);
const modsManager = useService(BsModsManagerService);
@@ -20,32 +28,29 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
const [filterEnabled, setFilterEnabled] = useState(false);
const t = useTranslation();
const installedModVersion = (key: string, mod: Mod): string => {
const installedModVersion = (key: BbmCategories, mod: BbmFullMod): string => {
if (!installed?.get(key)) {
return undefined;
}
const installedMod = installed.get(key).find(m => m.name === mod.name);
const installedMod = installed.get(key).find(m => m.mod.id === mod.mod.id);
if (!installedMod) {
return undefined;
}
return installedMod.version;
return installedMod.version.modVersion;
};
const isDependency = (mod: Mod): boolean => {
return modsSelected.some(m => {
const deps = m.dependencies.map(dep =>
Array.from(modsMap.values())
.flat()
.find(m => dep.name === m.name)
);
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
const getAvailableMods = (): BbmFullMod[] => {
return Array.from(modsMap.values()).flat();
}
const isDependency = (mod: BbmFullMod): boolean => {
const selectedModsDepsIds = modsSelected.flatMap(m => m.version.dependencies);
const modsDeps = getAvailableMods().filter(m => selectedModsDepsIds.includes(m.version.id));
const modsDepsDepsIds = modsDeps.flatMap(m => m.version.dependencies);
return selectedModsDepsIds.includes(mod.version.id) || modsDepsDepsIds.includes(mod.version.id);
};
const isSelected = (mod: Mod): boolean => modsSelected.some(m => m.name === mod.name);
const isSelected = (mod: BbmFullMod): boolean => modsSelected.some(m => m.mod.id === mod.mod.id);
const handleInput = (val: string) => setFilter(val.toLowerCase());
@@ -65,20 +70,23 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
<span className="z-10 sticky flex items-center justify-end top-0 bg-inherit border-b-2 border-main-color-1">
<BsmButton className="rounded-full h-6 w-6 p-[2px]" withBar={false} icon="search" onClick={handleToogleFilter} />
</span>
<span className="z-10 sticky top-0 flex items-center bg-inherit border-main-color-1 border-b-2 h-8 px-1 whitespace-nowrap">{filterEnabled ? <motion.input autoFocus className="bg-main-color-1 rounded-md h-6 px-2" initial={{ width: 0 }} animate={{ width: "250px" }} transition={{ ease: "easeInOut", duration: 0.15 }} onChange={e => handleInput(e.target.value)} /> : <span className="w-full text-center">{t("pages.version-viewer.mods.mods-grid.header-bar.name")}</span>}</span>
<span className="z-10 sticky top-0 flex items-center bg-inherit border-main-color-1 border-b-2 h-8 px-1 whitespace-nowrap">{filterEnabled ? <motion.input autoFocus className="bg-light-main-color-1 dark:bg-main-color-1 rounded-md h-6 px-2" initial={{ width: 0 }} animate={{ width: "250px" }} transition={{ ease: "easeInOut", duration: 0.15 }} onChange={e => handleInput(e.target.value)} /> : <span className="w-full text-center">{t("pages.version-viewer.mods.mods-grid.header-bar.name")}</span>}</span>
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8 px-2 whitespace-nowrap">{t("pages.version-viewer.mods.mods-grid.header-bar.installed")}</span>
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8 px-2 whitespace-nowrap">{t("pages.version-viewer.mods.mods-grid.header-bar.latest")}</span>
<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.uninstall-all", icon: "trash", onClick: handleUninstallAll }]} />
<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.unselect-all", icon: "cancel", onClick: () => unselectAllMods?.() },
{ text: "pages.version-viewer.mods.mods-grid.header-bar.dropdown.uninstall-all", icon: "trash", onClick: () => handleUninstallAll?.() }
]} />
</span>
{Array.from(modsMap.keys()).map(
key =>
modsMap.get(key).some(mod => mod.name.toLowerCase().includes(filter)) && (
modsMap.get(key).some(mod => mod.mod.name.toLowerCase().includes(filter)) && (
<ul key={key} className="contents">
<h2 className="col-span-full py-1 font-bold pl-3">{key}</h2>
{modsMap.get(key).map(mod => mod.name.toLowerCase().includes(filter) && <ModItem key={mod.name} className="contents bg-light-main-color-3 dark:bg-main-color-1 text-main-color-1 dark:text-light-main-color-1 hover:cursor-pointer" mod={mod} installedVersion={installedModVersion(key, mod)} isDependency={isDependency(mod)} isSelected={isSelected(mod)} onChange={val => onModChange(val, mod)} onWantInfo={onWantInfos} wantInfo={mod.name === moreInfoMod?.name} />)}
<h2 className="col-span-full py-1 font-bold pl-3 capitalize">{key}</h2>
{modsMap.get(key).map(mod => mod.mod.name.toLowerCase().includes(filter) && <ModItem key={mod.mod.id} className="contents bg-light-main-color-3 dark:bg-main-color-1 text-main-color-1 dark:text-light-main-color-1 hover:cursor-pointer" mod={mod} installedVersion={installedModVersion(key, mod)} isDependency={isDependency(mod)} isSelected={isSelected(mod)} onChange={val => onModChange(val, mod)} onWantInfo={onWantInfos} wantInfo={mod.mod.id === moreInfoMod?.mod.id} />)}
</ul>
)
)}
@@ -1,10 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmCategories, BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
import { ModsGrid } from "./mods-grid.component";
import { ConfigurationService } from "renderer/services/configuration.service";
import { DefaultConfigKey } from "renderer/config/default-configuration.config";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import BeatWaitingImg from "../../../../../../assets/images/apngs/beat-waiting.png";
import BeatConflictImg from "../../../../../../assets/images/apngs/beat-conflict.png";
@@ -19,6 +18,7 @@ import { ModsDisclaimerModal } from "renderer/components/modal/modal-types/mods-
import { OsDiagnosticService } from "renderer/services/os-diagnostic.service";
import { lt } from "semver";
import { useService } from "renderer/hooks/use-service.hook";
import { logRenderError } from "renderer";
export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; onDisclamerDecline: () => void }) {
const ACCEPTED_DISCLAIMER_KEY = "accepted-mods-disclaimer";
@@ -31,49 +31,46 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
const ref = useRef(null);
const isVisible = useInView(ref, { amount: 0.5 });
const [modsAvailable, setModsAvailable] = useState(null as Map<string, Mod[]>);
const [modsInstalled, setModsInstalled] = useState(null as Map<string, Mod[]>);
const [modsSelected, setModsSelected] = useState([] as Mod[]);
const [moreInfoMod, setMoreInfoMod] = useState(null as Mod);
const [modsAvailable, setModsAvailable] = useState(null as Map<BbmCategories, BbmFullMod[]>);
const [modsInstalled, setModsInstalled] = useState(null as Map<BbmCategories, BbmFullMod[]>);
const [modsSelected, setModsSelected] = useState([] as BbmFullMod[]);
const [moreInfoMod, setMoreInfoMod] = useState(null as BbmFullMod);
const isOnline = useObservable(() => os.isOnline$);
const installing = useObservable(() => modsManager.isInstalling$);
const downloadRef = useRef(null);
const [downloadWith, setDownloadWidth] = useState(0);
const modsToCategoryMap = (mods: Mod[]): Map<string, Mod[]> => {
const modsToCategoryMap = (mods: BbmFullMod[]): Map<BbmCategories, BbmFullMod[]> => {
if (!mods) {
return new Map<string, Mod[]>();
return new Map<BbmCategories, BbmFullMod[]>();
}
const map = new Map<string, Mod[]>();
mods.forEach(mod => map.set(mod.category, [...(map.get(mod.category) ?? []), mod]));
const map = new Map<BbmCategories, BbmFullMod[]>();
mods.forEach(mod => map.set(mod.mod.category, [...(map.get(mod.mod.category) ?? []), mod]));
return map;
};
const handleModChange = (selected: boolean, mod: Mod) => {
const handleModChange = (selected: boolean, mod: BbmFullMod) => {
if (selected) {
return setModsSelected([...modsSelected, mod]);
}
const mods = [...modsSelected];
mods.splice(
mods.findIndex(m => m.name === mod.name),
1
);
mods.splice(mods.findIndex(m => m.mod.id === mod.mod.id), 1);
setModsSelected(mods);
};
const handleMoreInfo = (mod: Mod) => {
if (mod.name === moreInfoMod?.name) {
const handleMoreInfo = (mod: BbmFullMod) => {
if (mod.mod.id === moreInfoMod?.mod.id) {
return setMoreInfoMod(null);
}
setMoreInfoMod(mod);
};
const handleOpenMoreInfo = () => {
if (!moreInfoMod?.link) {
if (!moreInfoMod?.mod?.gitUrl) {
return;
}
linkOpener.open(moreInfoMod.link);
linkOpener.open(moreInfoMod.mod.gitUrl);
};
const installMods = () => {
@@ -81,14 +78,14 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
return;
}
const modsToInstall = modsSelected.filter(mod => {
const corespondingMod = modsAvailable.get(mod.category).find(availabeMod => availabeMod._id === mod._id);
const installedMod = modsInstalled.get(mod.category)?.find(installedMod => installedMod.name === mod.name);
const corespondingMod = modsAvailable.get(mod.mod.category).find(availabeMod => availabeMod.mod.id === mod.mod.id);
const installedMod = modsInstalled.get(mod.mod.category)?.find(installedMod => installedMod.mod.id === mod.mod.id);
if (corespondingMod?.version && lt(corespondingMod.version, mod.version)) {
if (corespondingMod?.version && lt(corespondingMod.version.modVersion, mod.version.modVersion)) {
return false;
}
if(installedMod?.version && lt(mod.version, installedMod?.version)){
if(installedMod?.version && lt(mod.version.modVersion, installedMod?.version.modVersion)){
return false;
}
@@ -105,17 +102,31 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
return;
}
Promise.all([
lastValueFrom(modsManager.getAvailableMods(version)),
lastValueFrom(modsManager.getInstalledMods(version))
]).then(([available, installed]) => {
const defaultMods = installed?.length ? [] : configService.get<string[]>("default_mods" as DefaultConfigKey);
const promise = async (): Promise<[BbmFullMod[], BbmModVersion[]]> => {
const available = await lastValueFrom(modsManager.getAvailableMods(version));
const installed = await lastValueFrom(modsManager.getInstalledMods(version));
return [available, installed];
}
promise().then(([available, installed]) => {
const defaultMods = installed?.length ? [] : available.filter(m => m.mod.category === BbmCategories.Core || m.mod.category === BbmCategories.Essential);
setModsAvailable(modsToCategoryMap(available));
setModsSelected(available.filter(m => m.required || defaultMods.some(d => m.name.toLowerCase() === d.toLowerCase()) || installed.some(i => m.name === i.name)));
setModsInstalled(modsToCategoryMap(installed));
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));
}).catch(e => {
logRenderError("Error occur while loading mods", e);
});
};
const unselectAllMods = () => {
setModsSelected(() => []);
};
useEffect(() => {
const subs: Subscription[] = [];
@@ -180,8 +191,8 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
}
return (
<>
<div className="grow overflow-scroll w-full min-h-0 scrollbar-thin scrollbar-thumb-neutral-900 scrollbar-thumb-rounded-full">
<ModsGrid modsMap={modsAvailable} installed={modsInstalled} modsSelected={modsSelected} onModChange={handleModChange} moreInfoMod={moreInfoMod} onWantInfos={handleMoreInfo} />
<div className="grow overflow-y-scroll w-full min-h-0 scrollbar-default p-0 m-0">
<ModsGrid modsMap={modsAvailable} installed={modsInstalled} modsSelected={modsSelected} onModChange={handleModChange} moreInfoMod={moreInfoMod} onWantInfos={handleMoreInfo} unselectAllMods={unselectAllMods} />
</div>
<div className="h-10 shrink-0 flex items-center justify-between px-3">
<BsmButton className="text-center rounded-md px-2 py-[2px]" text="pages.version-viewer.mods.buttons.more-infos" typeColor="cancel" withBar={false} disabled={!moreInfoMod} onClick={handleOpenMoreInfo} style={{ width: downloadWith }} />
@@ -4,13 +4,12 @@ export const defaultConfiguration: { [key in DefaultConfigKey]: any } = {
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", "de-DE", "ru-RU", "zh-CN", "zh-TW", "ja-JP"],
default_mods: ["SongCore", "WhyIsThereNoLeaderboard", "BeatSaverDownloader", "BeatSaverVoting", "PlaylistManager"],
"default-shared-folders": [
window.electron.path.join("Beat Saber_Data", "CustomLevels"),
window.electron.path.join("Beat Saber_Data", "CustomWIPLevels"),
],
]
};
export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default_mods" | "default-shared-folders";
export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default-shared-folders";
export type ThemeConfig = "dark" | "light" | "os";
@@ -1,7 +1,12 @@
import { I18nService } from "renderer/services/i18n.service";
import { useService } from "./use-service.hook";
import { useObservable } from "./use-observable.hook";
import { useConstant } from "./use-constant.hook";
import { createElement, Fragment } from "react";
/**
* @deprecated Use useTranslationV2 instead
*/
export function useTranslation(): (translationKey: string, args?: Record<string, string>) => string {
const i18nService = useService(I18nService);
@@ -16,3 +21,35 @@ export function useTranslation(): (translationKey: string, args?: Record<string,
return tranlatables.map(key => i18nService.translate(key, args)).join(" ");
};
}
type TranslateFunctions = {
text: (translationKey: string, args?: Record<string, string>) => string;
element: (translationKey: string, args?: Record<string, JSX.Element>) => JSX.Element;
}
export function useTranslationV2(): TranslateFunctions {
const i18nService = useService(I18nService);
useObservable(() => i18nService.currentLanguage$);
const text = useConstant(() => {
return (key: string, args?: Record<string, string>) => {
if (!key) {
return key;
}
return i18nService.translate(key, args);
};
});
const element = useConstant(() => {
return (key: string, args?: Record<string, JSX.Element>) => {
if (!key) {
return createElement(Fragment, null, key);
}
return i18nService.translateElement(key, args);
};
});
return { text, element };
}
+25
View File
@@ -96,6 +96,13 @@
@apply border-r-neutral-900
}
.bg-theme-1 { @apply bg-light-main-color-1 dark:bg-main-color-1; }
.bg-theme-2 { @apply bg-light-main-color-2 dark:bg-main-color-2; }
.bg-theme-3 { @apply bg-light-main-color-3 dark:bg-main-color-3; }
.\!bg-theme-1 { @apply !bg-light-main-color-1 dark:!bg-main-color-1; }
.\!bg-theme-2 { @apply !bg-light-main-color-2 dark:!bg-main-color-2; }
.\!bg-theme-3 { @apply !bg-light-main-color-3 dark:!bg-main-color-3; }
@keyframes glowing {
0% {
background-position: 0 0;
@@ -125,3 +132,21 @@
background-position: 0 0%;
}
}
.scrollbar-default {
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgb(23 23 23 / var(--tw-bg-opacity));
border-radius: 999px;
}
}
@@ -11,6 +11,11 @@ import { useService } from "renderer/hooks/use-service.hook";
import { BSVersion } from "shared/bs-version.interface";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { BsDownloaderService } from "renderer/services/bs-version-download/bs-downloader.service";
import { logRenderError } from "renderer";
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
import { BSVersionOutdatedModal } from "renderer/components/modal/modal-types/bs-version-outdated-modal.component";
import { ConfigurationService } from "renderer/services/configuration.service";
import { safeLt } from "shared/helpers/semver.helpers";
export const AvailableVersionsContext = createContext<{ selectedVersion: BSVersion; setSelectedVersion: (version: BSVersion) => void }>(null);
@@ -18,6 +23,8 @@ export function AvailableVersionsList() {
const bsDownloader = useService(BsDownloaderService);
const versionManager = useService(BSVersionManagerService);
const modals = useService(ModalService);
const config = useService(ConfigurationService);
const [selectedVersion, setSelectedVersion] = useState<BSVersion>(null);
const contextValue = useMemo(() => ({ selectedVersion, setSelectedVersion }), [selectedVersion]);
@@ -25,10 +32,23 @@ export function AvailableVersionsList() {
const downloading = useObservable(() => bsDownloader.downloadingVersion$.pipe(map(v => !!v)));
const t = useTranslation();
const startDownload = async () => {
const startDownload = async (version: BSVersion) => {
const showOutdated = !config.get("not-show-bs-version-outdated-modal");
const recommendedVersion = versionManager.getRecommendedVersion();
return bsDownloader.downloadVersion(selectedVersion)
.catch(console.log)
const isVersionOutdated = safeLt(version.BSVersion, recommendedVersion?.BSVersion);
if (showOutdated && isVersionOutdated) {
const res = await modals.openModal(BSVersionOutdatedModal, { outdated: version, recommended: recommendedVersion });
if (res.exitCode !== ModalExitCode.COMPLETED) {
return;
}
}
return bsDownloader.downloadVersion(version)
.catch(logRenderError)
.finally(() => setSelectedVersion(null));
};
@@ -45,17 +65,17 @@ export function AvailableVersionsList() {
return (
<div className="relative h-full w-full flex items-center flex-col pt-2">
<Slideshow className="absolute w-full h-full top-0" />
<h1 className="text-gray-100 text-2xl mb-4 z-[1]">{t("pages.available-versions.title")}</h1>
<AvailableVersionsContext.Provider value={contextValue}>
<AvailableVersionsSlider />
</AvailableVersionsContext.Provider>
<AnimatePresence>
{selectedVersion && !downloading && (
<motion.div initial={{ y: "150%" }} animate={{ y: "0%" }} exit={{ y: "150%" }} className="absolute bottom-5" onClick={startDownload}>
<motion.div initial={{ y: "150%" }} animate={{ y: "0%" }} exit={{ y: "150%" }} className="absolute bottom-5" onClick={() => startDownload(selectedVersion)}>
<BsmButton text="misc.download" className="relative text-gray-800 dark:text-gray-100 rounded-md text-3xl font-bold italic tracking-wide px-3 pb-2 pt-1 shadow-md shadow-black" />
</motion.div>
)}
+22 -12
View File
@@ -273,7 +273,7 @@ export function SettingsPage() {
<span className="font-extrabold">{t("notifications.settings.additional-content.deep-link.select-all")}</span>
</div>
<div className="flex h-full gap-2">
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatSaverIcon}
@@ -283,7 +283,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="BeastSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeastSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 rounded-md cursor-pointer"
image={beastSaberIcon}
@@ -293,7 +293,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="ScoreSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="ScoreSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={scoreSaberIcon}
@@ -303,7 +303,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="BeatLeader" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeatLeader" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatleaderIcon}
@@ -313,7 +313,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="ModelSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="ModelSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={modelSaberIcon}
@@ -332,7 +332,7 @@ export function SettingsPage() {
<span className="font-extrabold">{t("misc.maps")}</span>
</div>
<div className="flex h-full gap-2">
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatSaverIcon}
@@ -342,7 +342,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="BeastSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeastSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 rounded-md cursor-pointer"
image={beastSaberIcon}
@@ -352,7 +352,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="ScoreSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="ScoreSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={scoreSaberIcon}
@@ -362,7 +362,7 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="BeatLeader" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="BeatLeader" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatleaderIcon}
@@ -379,8 +379,8 @@ export function SettingsPage() {
<BsmCheckbox className="relative z-[1] h-5 w-5" onChange={() => tooglePlaylistsDeepLinks()} checked={playlistsDeepLinkEnabled} />
<span className="font-extrabold">{t("misc.playlists")}</span>
</div>
<div className="flex h-full">
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<div className="flex h-full gap-2">
<Tippy content="BeatSaver" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatSaverIcon}
@@ -390,6 +390,16 @@ export function SettingsPage() {
}}
/>
</Tippy>
<Tippy content="BeatLeader" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={beatleaderIcon}
onClick={e => {
e.stopPropagation();
linkOpener.open("https://www.beatleader.xyz/");
}}
/>
</Tippy>
</div>
</li>
<li className="bg-light-main-color-1 dark:bg-main-color-1 rounded-md group-one flex justify-between items-center basis-0 py-2 px-3 cursor-pointer" onClick={() => toogleModelsDeepLinks()}>
@@ -398,7 +408,7 @@ export function SettingsPage() {
<span className="font-extrabold">{t("misc.models")}</span>
</div>
<div className="flex h-full">
<Tippy content="ModelSaber" placement="top" className="font-bold bg-main-color-3" arrow={false} duration={[200, 0]}>
<Tippy content="ModelSaber" placement="top" className="font-bold bg-main-color-3" theme="default" arrow={false} duration={[200, 0]}>
<BsmImage
className="h-8 cursor-pointer"
image={modelSaberIcon}
@@ -21,6 +21,9 @@ import { CreateLaunchShortcutModal } from "renderer/components/modal/modal-types
import { lastValueFrom } from "rxjs";
import { NotificationService } from "renderer/services/notification.service";
import { BsDownloaderService } from "renderer/services/bs-version-download/bs-downloader.service";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { safeLt } from "shared/helpers/semver.helpers";
import { ConfigurationService } from "renderer/services/configuration.service";
export function VersionViewer() {
@@ -31,11 +34,41 @@ export function VersionViewer() {
const ipcService = useService(IpcService);
const bsLauncher = useService(BSLauncherService);
const notification = useService(NotificationService);
const config = useService(ConfigurationService);
const { state } = useLocation() as { state: BSVersion };
const navigate = useNavigate();
const [currentTabIndex, setCurrentTabIndex] = useState(0);
useOnUpdate(() => {
checkIsVersionOutaded();
}, [state]);
const checkIsVersionOutaded = async () => {
if(config.get("not-show-bs-version-outdated-notification")){
return;
}
const recommended = bsVersionManagerService.getRecommendedVersion();
const isOutdated = safeLt(state.BSVersion, recommended?.BSVersion);
if(!isOutdated){
return;
}
const res = await notification.notifyWarning({ title: "notifications.bs-version-oudated.title", desc: "notifications.bs-version-oudated.msg", duration: 9000, actions: [
{ id: "0", title: "notifications.bs-version-oudated.actions.do-not-remind" },
{ id: "1", title: "notifications.bs-version-oudated.actions.ok", cancel: true }
] });
if(res === "0"){
config.set("not-show-bs-version-outdated-notification", true);
}
}
const navigateToVersion = (version?: BSVersion) => {
if (!version) {
return navigate("/available-versions");
+17 -5
View File
@@ -7,7 +7,7 @@ import { ConfigurationService } from "./configuration.service";
import { ThemeService } from "./theme.service";
import { BsStore } from "shared/models/bs-store.enum";
import { ModalExitCode, ModalService } from "./modale.service";
import { OriginalOculusVersionBackupModal } from "renderer/components/modal/modal-types/original-oculus-version-backup.modal";
import { EnableOculusSideloadedApps } from "renderer/components/modal/modal-types/enable-oculus-sideloaded-apps";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { sToMs } from "shared/helpers/time.helpers";
import { NeedLaunchAdminModal } from "renderer/components/modal/modal-types/need-launch-admin-modal.component";
@@ -82,6 +82,19 @@ export class BSLauncherService {
return true;
}
private async enableSideloadedAppsIfNeeded(): Promise<void> {
const isSideloadedAppsEnabled = await lastValueFrom(this.ipcService.sendV2("is-oculus-sideloaded-apps-enabled"));
if(isSideloadedAppsEnabled){ return; }
const modalRes = await this.modals.openModal(EnableOculusSideloadedApps);
if(modalRes.exitCode !== ModalExitCode.COMPLETED){
throw new Error("Enable sideloaded apps canceled");
}
await lastValueFrom(this.ipcService.sendV2("enable-oculus-sideloaded-apps"));
}
public doLaunch(launchOptions: LaunchOption): Observable<BSLaunchEventData>{
return this.ipcService.sendV2<BSLaunchEventData, LaunchOption>("bs-launch.launch", {args: launchOptions});
}
@@ -91,10 +104,9 @@ export class BSLauncherService {
return new Observable<BSLaunchEventData>(obs => {
(async () => {
if(launchOptions.version.metadata?.store === BsStore.OCULUS && !this.notRewindBackupOculus()){
const { exitCode, data: notRewind } = await this.modals.openModal(OriginalOculusVersionBackupModal);
if(exitCode !== ModalExitCode.COMPLETED){ return; }
this.setNotRewindBackupOculus(notRewind);
// If downgraded from oculus and its not the official version
if(launchOptions.version.metadata?.store === BsStore.OCULUS && !launchOptions.version.oculus){
await this.enableSideloadedAppsIfNeeded();
}
if(launchOptions.version.metadata?.store !== BsStore.OCULUS){
@@ -3,7 +3,7 @@ import { UninstallModModal } from "renderer/components/modal/modal-types/uninsta
import { Observable, BehaviorSubject } from "rxjs";
import { map } from "rxjs/operators";
import { BSVersion } from "shared/bs-version.interface";
import { InstallModsResult, UninstallModsResult, Mod, ModInstallProgression } from "shared/models/mods";
import { InstallModsResult, UninstallModsResult, ModInstallProgression } from "shared/models/mods";
import { ProgressionInterface } from "shared/models/progress-bar";
import { IpcService } from "./ipc.service";
import { ModalExitCode, ModalService } from "./modale.service";
@@ -11,6 +11,7 @@ import { NotificationType } from "../../shared/models/notification/notification.
import { OsDiagnosticService } from "./os-diagnostic.service";
import { ProgressBarService } from "./progress-bar.service";
import { NotificationService } from "./notification.service";
import { BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -41,15 +42,15 @@ export class BsModsManagerService {
this.os = OsDiagnosticService.getInstance();
}
public getAvailableMods(version: BSVersion): Observable<Mod[]> {
return this.ipcService.sendV2<Mod[], BSVersion>("get-available-mods", { args: version });
public getAvailableMods(version: BSVersion): Observable<BbmFullMod[]> {
return this.ipcService.sendV2<BbmFullMod[], BSVersion>("get-available-mods", { args: version });
}
public getInstalledMods(version: BSVersion): Observable<Mod[]> {
return this.ipcService.sendV2<Mod[], BSVersion>("get-installed-mods", { args: version });
public getInstalledMods(version: BSVersion): Observable<BbmModVersion[]> {
return this.ipcService.sendV2<BbmModVersion[], BSVersion>("get-installed-mods", { args: version });
}
public installMods(mods: Mod[], version: BSVersion): Promise<void> {
public installMods(mods: BbmFullMod[], version: BSVersion): Promise<void> {
if (this.os.isOffline) {
this.notifications.notifyError({
title: "notifications.shared.errors.titles.no-internet",
@@ -71,7 +72,7 @@ export class BsModsManagerService {
this.progressBar.show(progress$, true, { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" });
this.isInstalling$.next(true);
return this.ipcService.send<InstallModsResult, { mods: Mod[]; version: BSVersion }>("install-mods", { args: { mods, version } }).then(res => {
return this.ipcService.send<InstallModsResult, { mods: BbmFullMod[]; version: BSVersion }>("install-mods", { args: { mods, version } }).then(res => {
if (res.success && res.data) {
const isFullyInstalled = res.data.nbInstalledMods === res.data.nbModsToInstall;
const title = `notifications.mods.install-mods.titles.${isFullyInstalled ? "success" : "warning"}`;
@@ -85,12 +86,12 @@ export class BsModsManagerService {
this.progressBar.hide();
});
}
public async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
public async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
if (!this.progressBar.require()) {
return;
}
const modalRes = await this.modals.openModal(UninstallModModal, mod);
const modalRes = await this.modals.openModal(UninstallModModal, mod.mod);
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return;
@@ -195,4 +195,8 @@ export class BSVersionManagerService {
version
]))).values());
}
public getRecommendedVersion(): BSVersion | undefined {
return (this.availableVersions$.value ?? []).find(v => v.recommended);
}
}
+27
View File
@@ -4,6 +4,7 @@ import { Observable } from "rxjs";
import { ConfigurationService } from "./configuration.service";
import { getProperty } from "dot-prop";
import { i18n } from "dateformat";
import { createElement, Fragment } from "react";
export class I18nService {
private static instance: I18nService;
@@ -87,4 +88,30 @@ export class I18nService {
});
return translated;
}
public translateElement(translationKey: string, args?: Record<string, JSX.Element>): JSX.Element {
let translated = this.cache.get(translationKey);
if (!translated) {
translated = getProperty(this.dictionary, translationKey) ?? translationKey;
this.cache.set(translationKey, translated);
}
if (!args) {
return createElement(Fragment, null, translated);
}
// Sort keys by length in descending order to avoid partial matches within keys
const sortedKeys = Object.keys(args).sort((a, b) => b.length - a.length);
const pattern = sortedKeys.map(key => `\\{${key}\\}`).join('|');
const regex = new RegExp(`(${pattern})`, 'g');
const segments = translated.split(regex);
const children = segments.map((segment) => {
const key = segment.slice(1, -1); // Remove surrounding {}
return args[key] || segment;
});
return createElement(Fragment, null, ...children);
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ export function isOculusTokenValid(token: string, logger?: (...args: unknown[])
logger?.("Token contains %. Token most likely comes from an uri and won't work");
return false;
}
if(!token.startsWith("FRL")){
if(!token.startsWith("FRL") && !token.startsWith("OC")){
logger?.("Tokens must start with 'FRL'.");
return false;
}
@@ -33,4 +33,4 @@ export function isOculusTokenValid(token: string, logger?: (...args: unknown[])
return true;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { coerce, lt, valid } from "semver";
import { tryit } from "./error.helpers";
export function safeLt(a: string, b: string): boolean {
const { result } = tryit(() => lt(valid(coerce(a)), valid(coerce(b))));
return result ?? false;
}
+18
View File
@@ -0,0 +1,18 @@
const HashAlgorithmsLengths = {
sha1: 40,
} as const;
export function findHashInString(str: string, algorithm: keyof typeof HashAlgorithmsLengths = 'sha1'): string | undefined {
if(!str) { return undefined; }
const hashLength = HashAlgorithmsLengths[algorithm];
const regex = new RegExp(`[a-fA-F0-9]{${hashLength}}`, "g");
const match = regex.exec(str);
return match ? match[0] : undefined;
}
export function escapeRegExp(str: string): string {
// Regex taken from lodash escapeRegExp function
return str.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
}
@@ -15,6 +15,7 @@ export enum BSLaunchError{
BS_EXIT_ERROR = "EXIT",
OCULUS_LIB_NOT_FOUND = "OCULUS_LIB_NOT_FOUND",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
ORIGINAL_OCULUS_NOT_INSTALLED = "ORIGINAL_OCULUS_NOT_INSTALLED"
}
export enum BSLaunchEvent{
+96
View File
@@ -34,3 +34,99 @@ export interface FileHashes {
hash: string;
file: string;
}
// BBM MOD
export interface BbmFullMod {
mod: BbmMod;
version: BbmModVersion;
}
export interface BbmMod {
id: number;
name: string;
summary: string;
description: string;
gameName: "BeatSaber";
category: BbmCategories;
authors: BbmUserAPIResponse[];
status: BbmStatus;
iconFileName: string;
gitUrl: string;
lastApprovedById: number;
lastUpdatedById: number;
createdAt: Date;
updatedAt: Date;
}
export interface BbmModVersion {
id: number;
modId: number;
author: BbmUserAPIResponse;
modVersion: string;
platform: BbmPlatform;
zipHash: string;
status: BbmStatus;
dependencies: number[];
contentHashes: BbmContentHash[];
supportedGameVersions: BbmGameVersion[];
downloadCount: number;
lastApprovedById?: number;
lastUpdatedById?: number;
createdAt?: Date;
updatedAt?: Date;
}
export interface BbmContentHash {
path: string;
hash: string;
}
export enum BbmStatus {
Private = "private",
Removed = "removed",
Unverified = "unverified",
Verified = "verified",
}
export enum BbmPlatform {
SteamPC = `steampc`,
OculusPC = `oculuspc`,
UniversalPC = `universalpc`,
UniversalQuest = `universalquest`,
}
export interface BbmGameVersion {
readonly id: number;
gameName: "BeatSaber";
version: string; // semver
defaultVersion: boolean;
}
export enum BbmCategories {
Core = "core",
Essential = "essential",
Library = "library",
Cosmetic = "cosmetic",
PracticeTraining = "practice",
Gameplay = "gameplay",
StreamTools = "streamtools",
UIEnhancements = "ui",
Lighting = "lighting",
TweaksTools = "tweaks",
Multiplayer = "multiplayer",
TextChanges = "text",
Editor = "editor",
Other = "other",
}
export interface BbmUserAPIResponse {
id: number;
username: string;
githubId: string;
sponsorUrl: string;
displayName: string;
bio: string;
createdAt?: Date;
updatedAt?: Date;
}
+3 -2
View File
@@ -1,3 +1,4 @@
const { nextui } = require("@nextui-org/react");
const colors = require("tailwindcss/colors");
// Suppress deprecation warnings during build
@@ -9,9 +10,9 @@ delete colors.blueGray;
module.exports = {
darkMode: "class",
content: ["./src/renderer/**/*.{js,jsx,ts,tsx,ejs}"],
content: ["./src/renderer/**/*.{js,jsx,ts,tsx,ejs}", "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}"],
mode: "jit",
plugins: [require("tailwind-scrollbar-hide"), require("tailwind-scrollbar")({ nocompatible: true }), require("tailwindcss-scoped-groups")({ groups: ["one"] }), require("ps-scrollbar-tailwind")],
plugins: [require("tailwindcss-scoped-groups")({ groups: ["one"] }), nextui()],
theme: {
colors: {
...colors,