mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
Compare commits
93 Commits
v1.3.0-alpha.3
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d61f59c714 | |||
| 64e3652b60 | |||
| 6f018e6a32 | |||
| da3b2b13a8 | |||
| 381daa80a4 | |||
| bdfe8ce535 | |||
| 7f71d3006c | |||
| 120470de5e | |||
| 8efe5be63f | |||
| 7378b234eb | |||
| d256f2989f | |||
| 85d43639af | |||
| f849b0d570 | |||
| 34936f6666 | |||
| d8a4889298 | |||
| 21c7f9d225 | |||
| 1048ca6b9b | |||
| b2b7002a8e | |||
| 1776f5f769 | |||
| f17c8a27cc | |||
| 1b8e009a62 | |||
| 3a44779ade | |||
| c407997dc2 | |||
| d61c1a519f | |||
| 46eb8d065b | |||
| 9ee036527c | |||
| 825b35d164 | |||
| 9de0a4a821 | |||
| e01fe02c08 | |||
| 2e376b03fc | |||
| 6bdf47678e | |||
| c9bcdc8da6 | |||
| 7d21ba5bca | |||
| 7fb19f46d0 | |||
| 195bc1ad64 | |||
| ccc133cf6d | |||
| 549d4473ab | |||
| 23d7f8ad64 | |||
| 3edb3f583b | |||
| 71a2a01aca | |||
| e3468fac0a | |||
| 8d2075012b | |||
| f3ed2a8477 | |||
| 5355f84bc6 | |||
| 6758e5937e | |||
| de2d38bd45 | |||
| 7b8c98e809 | |||
| bda7c6719a | |||
| 254047e99e | |||
| 578f2ec335 | |||
| 8cc5449818 | |||
| 323e666d5a | |||
| cbe6cb06c0 | |||
| 3e36321ba5 | |||
| c9677b1224 | |||
| e6387b45c5 | |||
| 8b4a5f15e7 | |||
| 4b741ead15 | |||
| de03044ff9 | |||
| b3a3955f69 | |||
| f5c83ee9ca | |||
| e1546d0975 | |||
| d69ecac496 | |||
| 8f1a2015bd | |||
| 3e4afe5a07 | |||
| 288c219417 | |||
| 31db079b08 | |||
| 5e1eff7143 | |||
| 301d2fdfc4 | |||
| 9638c65407 | |||
| efd69e4bb3 | |||
| 90cb0d5038 | |||
| 73e8e0f15a | |||
| b169b04637 | |||
| 6296a78cbc | |||
| 1305756cac | |||
| 1f2601427b | |||
| 1e7ec798e6 | |||
| d56262b56d | |||
| 4a3528229e | |||
| f61d44b14a | |||
| 1f99ce1ec1 | |||
| 0d75b56d19 | |||
| 35aa1af377 | |||
| 4ee4d0f565 | |||
| 30275a920e | |||
| e51e63af8e | |||
| 1be3b7af31 | |||
| a92c65be53 | |||
| 922aad7867 | |||
| 82bca18f3f | |||
| d0aff9d27b | |||
| b308d66c2c |
@@ -46,6 +46,7 @@ module.exports = {
|
||||
"react/prop-types": "off",
|
||||
"import/no-cycle": "off",
|
||||
"prefer-promise-reject-errors": "off",
|
||||
"react/jsx-no-target-blank": "off"
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
@@ -565,5 +565,13 @@
|
||||
"ReleaseImg": "https://clan.cloudflare.steamstatic.com/images/32055887/28993aecc56a75c52295fa07620f58cb0e4b09e8.png",
|
||||
"ReleaseDate": "1688050646",
|
||||
"year": "2023"
|
||||
},
|
||||
{
|
||||
"BSVersion": "1.31.1",
|
||||
"BSManifest": "5239818041644201119",
|
||||
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/3664295677895178184",
|
||||
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/58621b3cb8bf42fcfbf3b97fe47aa91b290eea04.png",
|
||||
"ReleaseDate": "1693824747",
|
||||
"year": "2023"
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{"username": "Shidorien"},
|
||||
{"username": "iPixelGalaxy", "type": "diamond", "link": "https://twitch.tv/ipixelgalaxy"},
|
||||
{"username": "iPixelGalaxy"},
|
||||
{"username": "Emyte", "type": "gold"},
|
||||
{"username": "GoodOldNervy"},
|
||||
{"username": "Naysy"},
|
||||
@@ -11,5 +11,11 @@
|
||||
{"username": "Anonymously42", "type": "diamond", "link": "https://www.twitch.tv/anonymously42tv"},
|
||||
{"username": "En4Yuz"},
|
||||
{"username": "AndyIsHereBoi", "type": "gold"},
|
||||
{"username": "grtroadrunner"}
|
||||
{"username": "grtroadrunner"},
|
||||
{"username": "Ilnahro"},
|
||||
{"username": "T3chLog"},
|
||||
{"username": "Phil", "type": "gold"},
|
||||
{"username": "Protocrush", "type": "gold"},
|
||||
{"username": "ServerMensch", "type": "gold"},
|
||||
{"username": "policepigou"}
|
||||
]
|
||||
|
||||
@@ -231,8 +231,7 @@
|
||||
"401": "Steam scheint uns BeatSaber nicht herunterladen zu lassen 😢",
|
||||
"404": "Steam-Server können nicht kontaktiert werden.",
|
||||
"Password": "Passwort ist ungültig.",
|
||||
"InvalidCredentials": "Deine E-Mail oder Passwort ist ungültig 😴",
|
||||
"InvalidPassword": "Dein Passwort ist ungültig 😕",
|
||||
"InvalidCredentials": "Ungültige Anmeldedaten, nicht genehmigte Verbindung oder zu viele Anmeldeversuche.",
|
||||
"NoManifest": "Es wurde keine Manifest gefunden",
|
||||
"DirectoryCreate": "Die erforderlichen Ordner können nicht installiert werden.",
|
||||
"NotAvailableApp": "Versuchst du, Beat Saber herunterzuladen, wenn Du es nicht hast? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "Wenn dieser Fehler auftritt, melde den Fehler auf Github mit Protokollen, bitte.",
|
||||
"ConnectionError": "Kann nach 10 Versuchen keine Verbindung zu Steam herstellen 🤯",
|
||||
"LicenceError": "Die Liste der Lizenzen kann nicht abgerufen werden.",
|
||||
"verification-failed": "Verifikation fehlgeschlagen, versuche es später erneut.",
|
||||
"RateLimitExceeded": "Du hast es zu oft versucht, warte eine Weile und versuche es später erneut.",
|
||||
"AlreadyDownloading": "Ein Download ist bereits im Gange",
|
||||
"no-internet": "Keine Internet Verbindung",
|
||||
"TokenRejected": "Ihr Anmeldetoken wurde abgelehnt 😕 Bitte versuchen Sie es erneut.",
|
||||
"AccessDenied": "Der Zugang zu Steam wurde verweigert.",
|
||||
"dotnet-required": ".NET 6 Runtime muss installiert sein, um eine Version von BeatSaber herunterzuladen. Lade es herunter, indem Du auf die Schaltfläche unten klickst."
|
||||
},
|
||||
"actions": {
|
||||
@@ -482,26 +480,39 @@
|
||||
}
|
||||
},
|
||||
"steam-login": {
|
||||
"title": "Steam Login",
|
||||
"title": "Steam Anmeldung",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Nutzername",
|
||||
"placeholder": "Gebe deinen Nutzernamen ein"
|
||||
"label": "Mit Accountnamen anmelden",
|
||||
"placeholder": "Geben Sie Ihren Accountnamen ein"
|
||||
},
|
||||
"password": {
|
||||
"label": "Passwort",
|
||||
"placeholder": "Gebe dein Passwort ein",
|
||||
"max-length-warning": "Das Passwort überschreitet 64 Zeichen! Wenn das Passwort ungültig ist, versuchen Sie nur die ersten 64 Zeichen einzugeben."
|
||||
"placeholder": "Geben Sie Ihr Passwort ein",
|
||||
"max-length-warning": "Das Passwort überschreitet 64 Zeichen! Wenn das Passwort ungültig ist, versuchen Sie nur seine ersten 64 Zeichen einzugeben."
|
||||
},
|
||||
"stay": {
|
||||
"label": "Verbunden bleiben"
|
||||
}
|
||||
"qr":{
|
||||
"label": "Oder mit einem QR-Code",
|
||||
"note":{
|
||||
"use-the":"Anmelden Sie sich über die ",
|
||||
"steam-mobile-app": "Steam-Mobile-App",
|
||||
"to-connect-with-qr": " per QR-Code an."
|
||||
}
|
||||
},
|
||||
"stay": "Angemeldet bleiben"
|
||||
},
|
||||
"why-credentials": "Warum braucht ihr meine Anmeldeinformationen?",
|
||||
"why-credentials": "Warum sind meine Anmeldedaten notwendig?",
|
||||
"need-help-to-connect": "Ich brauche Hilfe, um auf mein Konto zuzugreifen!",
|
||||
"buttons": {
|
||||
"submit": "Anmelden"
|
||||
}
|
||||
},
|
||||
"steam-auth-approve":{
|
||||
"title": "Warten auf Bestätigung",
|
||||
"protected-by-mobile-auth": "Konto durch mobile Authentifizierung geschützt.",
|
||||
"use-steam-app-to-approve": "Verwenden Sie die Steam Mobile-App, um die Verbindung zu bestätigen...",
|
||||
"not-access-to-steam-app": "Ich habe keinen Zugang zur Steam Mobile-App"
|
||||
},
|
||||
"steam-credentials": {
|
||||
"title": "Steam Anmeldeinformationen",
|
||||
"p-1": "Die Anmeldeinformationen werden nur zum Herunterladen des Spiels verwendet, da Steam überprüfen muss, ob Du das Spiel bezahlt hast, um es herunterladen zu dürfen. Sie werden nicht gespeichert und direkt an DepotDownloader weitergegeben. Wenn Du deine Anmeldeinformationen nicht eingeben möchtest, kannst Du diesem Tutorial folgen:",
|
||||
|
||||
@@ -231,8 +231,7 @@
|
||||
"401": "Steam doesn't seem to want to let us download Beat Saber 😢",
|
||||
"404": "Unable to contact Steam servers.",
|
||||
"Password": "Password is invalid.",
|
||||
"InvalidCredentials": "Your e-mail or password is invalid 😴",
|
||||
"InvalidPassword": "Your password is invalid 😕",
|
||||
"InvalidCredentials": "Invalid login credentials, unapproved connection, or too many login attempts.",
|
||||
"NoManifest": "No manifest was found",
|
||||
"DirectoryCreate": "Unable to install the necessary folders.",
|
||||
"NotAvailableApp": "Are you trying to download Beat Saber when you don't have it? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "If you have this error, report the bug on GitHub with the logs pls.",
|
||||
"ConnectionError": "Unable to connect to Steam after 10 tries 🤯",
|
||||
"LicenceError": "Unable to get the list of licenses.",
|
||||
"verification-failed": "Verification failed, try again later.",
|
||||
"RateLimitExceeded": "You've tried too many times, wait a while and try again later.",
|
||||
"AlreadyDownloading": "A download is already in progress",
|
||||
"no-internet": "No internet connection.",
|
||||
"TokenRejected": "Your login token has been rejected 😕 Please try again.",
|
||||
"AccessDenied": "Access to Steam has been denied.",
|
||||
"dotnet-required": ".NET 6 Runtime must be installed in order to download a version of Beat Saber. Download it by clicking the button below."
|
||||
},
|
||||
"actions": {
|
||||
@@ -485,23 +483,36 @@
|
||||
"title": "Steam Login",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Username",
|
||||
"placeholder": "Enter your username"
|
||||
"label": "Sign in with account name",
|
||||
"placeholder": "Enter your account name"
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"placeholder": "Enter your password",
|
||||
"max-length-warning": "The password exceeds 64 characters! If the password is invalid, try to put its first 64 characters."
|
||||
"max-length-warning": "The password exceeds 64 characters! If the password is invalid, try to enter only its first 64 characters."
|
||||
},
|
||||
"stay": {
|
||||
"label": "Stay connected"
|
||||
}
|
||||
"qr":{
|
||||
"label": "Or with a QR code",
|
||||
"note":{
|
||||
"use-the":"Use the ",
|
||||
"steam-mobile-app": "Steam Mobile App",
|
||||
"to-connect-with-qr": "to sign in via QR code."
|
||||
}
|
||||
},
|
||||
"stay": "Remember me"
|
||||
},
|
||||
"why-credentials": "Why do you need my credentials?",
|
||||
"why-credentials": "Why are my credentials necessary?",
|
||||
"need-help-to-connect": "I need help to access my account!",
|
||||
"buttons": {
|
||||
"submit": "Login"
|
||||
"submit": "Sign in"
|
||||
}
|
||||
},
|
||||
"steam-auth-approve":{
|
||||
"title": "Waiting for confirmation",
|
||||
"protected-by-mobile-auth": "Account protected by mobile authenticator.",
|
||||
"use-steam-app-to-approve": "Use the Steam mobile app to confirm the connection...",
|
||||
"not-access-to-steam-app": "I don't have access to the Steam mobile app"
|
||||
},
|
||||
"steam-credentials": {
|
||||
"title": "Steam Credentials",
|
||||
"p-1": "The credentials are only used to download the game because steam need to verify that you paid the game in order to be allowed to download it. They aren't saved and directly passed to DepotDownloader. If you don't want to enter your credentials you can follow this tutorial :",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"download": "Descargar",
|
||||
"add": "Añadir",
|
||||
"verify": "Verificar",
|
||||
"launch": "Lanzar",
|
||||
"launch": "Iniciar",
|
||||
"mods": "Mods",
|
||||
"maps": "Mapas",
|
||||
"playlists": "Playlists",
|
||||
@@ -30,20 +30,20 @@
|
||||
},
|
||||
"version-viewer": {
|
||||
"launch-mods": {
|
||||
"oculus": "Oculus Mod",
|
||||
"oculus": "Modo Oculus",
|
||||
"oculus-description": "Si estás usando Beat Saber a través de Steam, te permite usar el compositor VR de Oculus sin pasar por SteamVR, para ganar prestaciones. Esto no es necesario para usar auriculares Oculus.",
|
||||
"desktop": "FPFC Mod",
|
||||
"desktop-description": "Esto 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 el auricular!",
|
||||
"debug": "Debug Mod",
|
||||
"desktop": "Modo FPFC",
|
||||
"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.",
|
||||
"advanced-launch": {
|
||||
"button": "Lanzamiento avanzado",
|
||||
"button": "Opciones avanzadas",
|
||||
"placeholder": "Argumentos adicionales ej: --revert; --no-wait"
|
||||
}
|
||||
},
|
||||
"maps": {
|
||||
"search-bar": {
|
||||
"search-placeholder": "Buscar en un mapa",
|
||||
"search-placeholder": "Buscar un mapa",
|
||||
"filters-btn": "Filtros",
|
||||
"dropdown": {
|
||||
"export-maps": "Exportar mapas",
|
||||
@@ -101,8 +101,8 @@
|
||||
}
|
||||
},
|
||||
"available-versions": {
|
||||
"title": "Seleccione una versión",
|
||||
"steam-release": "Publicación Página",
|
||||
"title": "Selecciona una versión",
|
||||
"steam-release": "Anuncio en Steam",
|
||||
"dropdown": {
|
||||
"refresh": "Actualizar la lista",
|
||||
"import-version": "Importar una versión"
|
||||
@@ -111,12 +111,12 @@
|
||||
"settings": {
|
||||
"steam": {
|
||||
"title": "Steam",
|
||||
"description": "Si sales de Steam, tendrás que volver a entrar para descargar una nueva versión de Beat Saber.",
|
||||
"logout": "Desconectar"
|
||||
"description": "Si desconectas tu cuenta de Steam, tendrás que volver a conectarla para descargar una nueva versión de Beat Saber.",
|
||||
"logout": "Cerrar sesión"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apariencia",
|
||||
"description": "Elija los dos colores principales de BSManager.",
|
||||
"description": "Elige los dos colores principales de BSManager.",
|
||||
"reset": "Reiniciar",
|
||||
"sub-title": "Tema",
|
||||
"themes": {
|
||||
@@ -127,12 +127,12 @@
|
||||
},
|
||||
"installation-folder": {
|
||||
"title": "Carpeta de instalación",
|
||||
"description": "Cambia la carpeta por defecto para las versiones de Beat Saber y otras características próximas.",
|
||||
"choose-folder": "Elija la carpeta"
|
||||
"description": "Cambia la carpeta por defecto para las versiones de Beat Saber y próximas funciones.",
|
||||
"choose-folder": "Elige la carpeta"
|
||||
},
|
||||
"additional-content": {
|
||||
"title": "Contenido adicional",
|
||||
"description": "¡El contenido adicional te permite personalizar BeatSaber!",
|
||||
"description": "¡Contenido adicional que te permite personalizar Beat Saber!",
|
||||
"deep-links": {
|
||||
"sub-title": "Instalaciones OneClick"
|
||||
}
|
||||
@@ -158,23 +158,23 @@
|
||||
}
|
||||
},
|
||||
"patreon": {
|
||||
"title": "Apoyo BSManager 💖",
|
||||
"description": "Apoye el proyecto y ayúdenos a proporcionar mejoras continuas a BSManager.",
|
||||
"title": "Apoya BSManager 💖",
|
||||
"description": "Apoya el proyecto y ayúdanos a proporcionar mejoras continuas a BSManager.",
|
||||
"buttons": {
|
||||
"support": "Apoyo BSManager 🥰",
|
||||
"supporters": "Apoyos 👀"
|
||||
"support": "Apoya BSManager 🥰",
|
||||
"supporters": "Donadores 👀"
|
||||
},
|
||||
"view": {
|
||||
"no-supporters": "Todavía no hay apoyos",
|
||||
"no-supporters": "Todavía no hay donadores",
|
||||
"sponsors": "Patrocinadores",
|
||||
"supporters": "Apoyos"
|
||||
"supporters": "Donadores"
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"description": "¡Únete a la comunidad de BSManager siguiéndonos en nuestras redes sociales!"
|
||||
},
|
||||
"contribution": {
|
||||
"description": "¡Sugiera nuevas características o reporte un error para ayudar a mejorar BSManager!",
|
||||
"description": "¡Sugiere nuevas características o reporta un error para ayudar a mejorar BSManager!",
|
||||
"buttons": {
|
||||
"request-features": "Proponer una funcionalidad",
|
||||
"report-bug": "Informar de un error",
|
||||
@@ -201,7 +201,7 @@
|
||||
"no-internet": "Sin internet"
|
||||
},
|
||||
"msg": {
|
||||
"operation-running": "Espere a que termine la operación actual y vuelva a empezar.",
|
||||
"operation-running": "Espera a que termine la operación actual y vuelve a empezar.",
|
||||
"no-internet": "Comprueba tu conexión a Internet e inténtalo de nuevo."
|
||||
}
|
||||
}
|
||||
@@ -209,16 +209,16 @@
|
||||
"bs-download": {
|
||||
"success": {
|
||||
"titles": {
|
||||
"download-success": "Descargar completo",
|
||||
"verification-finished": "Verificación completa"
|
||||
"download-success": "Descarga completada",
|
||||
"verification-finished": "Verificación completada"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"msg": {
|
||||
"ManifestChecksum": "El manifiesto descargado anteriormente no coincide con el nuevo 🤔",
|
||||
"ConnectionTimeout": "Su conexión a Internet parece inestable 🥶",
|
||||
"ConnectionLost": "Se ha perdido la conexión, inténtelo de nuevo...",
|
||||
"ConnectionError": "No se puede conectar a Steam, reintentar...",
|
||||
"ConnectionTimeout": "Tu conexión a Internet parece inestable 🥶",
|
||||
"ConnectionLost": "Se ha perdido la conexión, inténtalo de nuevo...",
|
||||
"ConnectionError": "No se puede conectar a Steam, reintentando...",
|
||||
"Unknown": "Algo extraño ha ocurrido 🤔 Tu conexión es probablemente inestable."
|
||||
}
|
||||
},
|
||||
@@ -227,31 +227,29 @@
|
||||
"dotnet-required": ".NET 6 Requerido"
|
||||
},
|
||||
"msg": {
|
||||
"401": "Parece que Steam no quiere dejarnos descargar BeatSaber 😢",
|
||||
"401": "Parece que Steam no quiere dejarnos descargar Beat Saber 😢",
|
||||
"404": "No se puede contactar con los servidores de Steam",
|
||||
"Password": "La contraseña es inválida.",
|
||||
"InvalidCredentials": "Su correo electrónico o contraseña no son válidos 😴",
|
||||
"InvalidPassword": "Su contraseña no es válida 😕",
|
||||
"InvalidCredentials": "Credenciales de inicio de sesión inválidas, conexión no aprobada o demasiados intentos de inicio de sesión.",
|
||||
"NoManifest": "No se ha encontrado el manifiesto",
|
||||
"DirectoryCreate": "No se pueden instalar las carpetas necesarias.",
|
||||
"NotAvailableApp": "¿Intentas descargar BeatSaber cuando no lo tienes? 🤣",
|
||||
"DepotNotFound": "No se puede descargar BeatSaber 😥 inténtalo más tarde 😕",
|
||||
"NotAvailableApp": "¿Intentas descargar Beat Saber cuando no lo tienes? 🤣",
|
||||
"DepotNotFound": "No se puede descargar Beat Saber 😥 inténtalo más tarde 😕",
|
||||
"NotCompleted": "La descarga no pudo completarse ¯\\_(ツ)_/¯",
|
||||
"InvalidManifest": "No se puede descargar BeatSaber 😥 inténtalo más tarde 😕",
|
||||
"NoValidKey": "No se puede descargar BeatSaber 😥 inténtalo más tarde 😕",
|
||||
"NoManifestCode": "No se puede descargar BeatSaber 😥 inténtalo más tarde 😕",
|
||||
"InvalidManifest": "No se puede descargar Beat Saber 😥 inténtalo más tarde 😕",
|
||||
"NoValidKey": "No se puede descargar Beat Saber 😥 inténtalo más tarde 😕",
|
||||
"NoManifestCode": "No se puede descargar Beat Saber 😥 inténtalo más tarde 😕",
|
||||
"Unknown": "Se ha producido un error desconocido ¯\\_(ツ)_/¯",
|
||||
"NoServer": "No se puede contactar con los servidores de Steam.",
|
||||
"NotAllowed": "Aparentemente no tienes permiso para descargar BeatSaber 🥱",
|
||||
"NotAllowed": "Aparentemente no tienes permiso para descargar Beat Saber 🥱",
|
||||
"ConnectionTimeout": "No se puede conectar a Steam 😕",
|
||||
"SteamLib": "Si tienes este error, reporta el bug en GitHub con los logs por favor.",
|
||||
"SteamLib": "Si tienes este error, reporta el bug en GitHub con los logs, por favor.",
|
||||
"ConnectionError": "No se puede conectar a Steam después de 10 intentos 🤯",
|
||||
"LicenceError": "No se puede obtener la lista de licencias.",
|
||||
"verification-failed": "Verificación fallida, inténtelo más tarde.",
|
||||
"RateLimitExceeded": "Lo has intentado demasiadas veces, espera un poco y vuelve a intentarlo más tarde.",
|
||||
"AlreadyDownloading": "La descarga ya está en curso",
|
||||
"no-internet": "Sin conexión a Internet.",
|
||||
"dotnet-required": "Se debe instalar el tiempo de ejecución de .NET 6 para descargar una versión de BeatSaber. Descárguelo haciendo clic en el botón de abajo."
|
||||
"TokenRejected": "Tu token de inicio de sesión ha sido rechazada 😕 Por favor, inténtalo de nuevo.",
|
||||
"AccessDenied": "El acceso a Steam ha sido denegado.",
|
||||
"dotnet-required": "Se debe instalar el tiempo de ejecución de .NET 6 para descargar una versión de Beat Saber. Descárgalo haciendo clic en el botón de abajo."
|
||||
},
|
||||
"actions": {
|
||||
"download-dotnet": "Descargar .NET 6"
|
||||
@@ -262,7 +260,7 @@
|
||||
"success": {
|
||||
"start-import": {
|
||||
"title": "Importación en curso 👌",
|
||||
"desc": "La importación puede tardar varios minutos dependiendo de su configuración."
|
||||
"desc": "La importación puede tardar varios minutos dependiendo de tu configuración."
|
||||
},
|
||||
"imported": {
|
||||
"title": "Versión importada 🎉"
|
||||
@@ -270,7 +268,7 @@
|
||||
},
|
||||
"errors": {
|
||||
"import-error": {
|
||||
"desc": "Compruebe que la carpeta seleccionada es una instalación de BeatSaber."
|
||||
"desc": "Comprueba que la carpeta seleccionada es una instalación de Beat Saber."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -279,10 +277,10 @@
|
||||
"success": {
|
||||
"titles": {
|
||||
"transfer-started": "Transferencia en curso",
|
||||
"transfer-finished": "Transferencia completa"
|
||||
"transfer-finished": "Transferencia completada"
|
||||
},
|
||||
"descs": {
|
||||
"transfer-started": "La transferencia ha comenzado y puede tomar varios minutos dependiendo de su configuración."
|
||||
"transfer-started": "La transferencia ha comenzado y puede tomar varios minutos dependiendo de tu configuración."
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -291,7 +289,7 @@
|
||||
},
|
||||
"descs": {
|
||||
"COPY_TO_SUBPATH": "La carpeta de destino no puede ser una subcarpeta de la carpeta de origen.",
|
||||
"restore-linked-folders": "Se produjo un error al restaurar las carpetas compartidas. Aún puede restaurarlas manualmente a través del menú 'Carpetas compartidas' en la página de versiones."
|
||||
"restore-linked-folders": "Se produjo un error al restaurar las carpetas compartidas. Aún puedes restaurarlas manualmente a través del menú 'Carpetas compartidas' en la página de versiones."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -320,12 +318,12 @@
|
||||
"description": "Las instalaciones OneClick han sido desactivadas."
|
||||
},
|
||||
"error": {
|
||||
"description": "Ocurrió un error desconocido."
|
||||
"description": "Ha ocurrido un error desconocido."
|
||||
}
|
||||
},
|
||||
"check-all-enabled": {
|
||||
"title": "OneClick deshabilitado",
|
||||
"description": "Una o más instalaciones de OneClick están deshabilitadas. Vaya a la configuración para habilitarlas.",
|
||||
"description": "Una o más instalaciones de OneClick están deshabilitadas. Ve a la configuración para habilitarlas.",
|
||||
"actions": {
|
||||
"settings": "Ajustes",
|
||||
"not-remind": "No volver a recordarme"
|
||||
@@ -341,26 +339,26 @@
|
||||
"STEAM_LAUNCHING": "¡Steam se está iniciando!"
|
||||
},
|
||||
"msg": {
|
||||
"BS_LAUNCHING": "No olvides calentarte 😉",
|
||||
"STEAM_LAUNCHING": "Beat Saber se lanzará automáticamente después de Steam."
|
||||
"BS_LAUNCHING": "No olvides calentar 😉",
|
||||
"STEAM_LAUNCHING": "Beat Saber se iniciará automáticamente después de Steam."
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"titles": {
|
||||
"UNKNOWN_ERROR": "No se puede lanzar",
|
||||
"UNKNOWN_ERROR": "No se puede iniciar",
|
||||
"STEAM_NOT_RUNNING": "Steam no funciona",
|
||||
"OCULUS_NOT_RUNNING": "Oculus no funciona",
|
||||
"BS_ALREADY_RUNNING": "BeatSaber ya está en marcha",
|
||||
"EXE_NOT_FINDED": "Archivos perdidos",
|
||||
"BS_ALREADY_RUNNING": "Beat Saber ya está en marcha",
|
||||
"EXE_NOT_FINDED": "Archivos no encontrados",
|
||||
"EXIT": "Parada abrupta"
|
||||
},
|
||||
"msg": {
|
||||
"UNKNOWN_ERROR": "Se ha producido un error desconocido.",
|
||||
"STEAM_NOT_RUNNING": "Para iniciar BeatSaber es necesario que Steam esté en funcionamiento.",
|
||||
"OCULUS_NOT_RUNNING": "Oculus debe estar funcionando para lanzar BeatSaber.",
|
||||
"BS_ALREADY_RUNNING": "Cierra BeatSaber antes de volver a lanzarlo.",
|
||||
"EXE_NOT_FINDED": "Parece que faltan algunos archivos, intente verificar los archivos.",
|
||||
"EXIT": "BeatSaber se detuvo abruptamente, trata de revisar los archivos."
|
||||
"STEAM_NOT_RUNNING": "Para iniciar Beat Saber es necesario que Steam esté en funcionamiento.",
|
||||
"OCULUS_NOT_RUNNING": "Oculus debe estar funcionando para iniciar Beat Saber.",
|
||||
"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."
|
||||
},
|
||||
"actions": {
|
||||
"STEAM_NOT_RUNNING": "Iniciar Steam"
|
||||
@@ -370,14 +368,14 @@
|
||||
"steam": {
|
||||
"steam-launching": {
|
||||
"title": "¡Steam se está iniciando!",
|
||||
"description": "Beat Saber se lanzará automáticamente después de Steam."
|
||||
"description": "Beat Saber se iniciará automáticamente después de Steam."
|
||||
}
|
||||
},
|
||||
"custom-version": {
|
||||
"errors": {
|
||||
"titles": {
|
||||
"CantEditSteam": "Imposible editar",
|
||||
"CantRename": "El renombramiento es imposible",
|
||||
"CantRename": "Cambio de nombre imposible",
|
||||
"VersionAlreadExist": "Esta versión ya existe",
|
||||
"CantClone": "Clonación imposible"
|
||||
},
|
||||
@@ -401,7 +399,7 @@
|
||||
"success": "Todos los mods han sido instalados.",
|
||||
"warning": "No se han podido instalar uno o más mods.",
|
||||
"errors": {
|
||||
"no-mods": "No hay que instalar mods.",
|
||||
"no-mods": "No hay mods para instalar.",
|
||||
"cannot-install-bsipa": "La instalación de BSIPA falló 😨"
|
||||
}
|
||||
}
|
||||
@@ -423,7 +421,7 @@
|
||||
"msg": {
|
||||
"success": "Todos los mods han sido desinstalados.",
|
||||
"errors": {
|
||||
"no-mods": "No se instala ningún mod en esta versión 😑"
|
||||
"no-mods": "No hay mods instalados en esta versión 😑"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -450,7 +448,7 @@
|
||||
"info": {
|
||||
"userdata-backup-created": {
|
||||
"title": "Backup creado",
|
||||
"msg": "Compartir la carpeta 'UserData' puede generar errores, en caso de problemas desvincular la carpeta para restaurar la copia de seguridad"
|
||||
"msg": "Compartir la carpeta 'UserData' puede generar errores, en caso de problemas desvincula la carpeta para restaurar la copia de seguridad"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -472,50 +470,63 @@
|
||||
"title": "Steam Guard",
|
||||
"inputs": {
|
||||
"guard-code": {
|
||||
"label": "Guard Código",
|
||||
"label": "Código Guard",
|
||||
"placeholder": "Introduzca su código Guard"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"submit": "Conéctate"
|
||||
"submit": "Conectar"
|
||||
}
|
||||
},
|
||||
"steam-login": {
|
||||
"title": "Steam Conectar",
|
||||
"title": "Inicio de sesión Steam",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Nombre de usuario",
|
||||
"placeholder": "Introduzca su nombre de usuario"
|
||||
"label": "Inicia sesión con un nombre de cuenta",
|
||||
"placeholder": "Introduce tu nombre de cuenta"
|
||||
},
|
||||
"password": {
|
||||
"label": "Contraseña",
|
||||
"placeholder": "Introduzca su contraseña",
|
||||
"placeholder": "Introduce tu contraseña",
|
||||
"max-length-warning": "¡La contraseña supera los 64 caracteres! Si la contraseña es inválida, intenta ingresar solo sus primeros 64 caracteres."
|
||||
},
|
||||
"stay": {
|
||||
"label": "Manténgase conectado"
|
||||
}
|
||||
"qr":{
|
||||
"label": "O con un código QR",
|
||||
"note":{
|
||||
"use-the":"Usa la ",
|
||||
"steam-mobile-app": "aplicación Steam Mobile",
|
||||
"to-connect-with-qr": "para conectarte con un código QR."
|
||||
}
|
||||
},
|
||||
"stay": "Recuérdame"
|
||||
},
|
||||
"why-credentials": "¿Por qué necesitas mis credenciales?",
|
||||
"why-credentials": "¿Por qué son necesarias mis credenciales?",
|
||||
"need-help-to-connect": "¡Necesito ayuda para acceder a mi cuenta!",
|
||||
"buttons": {
|
||||
"submit": "Inicio de sesión"
|
||||
"submit": "Iniciar sesión"
|
||||
}
|
||||
},
|
||||
"steam-auth-approve":{
|
||||
"title": "Esperando confirmación",
|
||||
"protected-by-mobile-auth": "Cuenta protegida por el autenticador móvil.",
|
||||
"use-steam-app-to-approve": "Use la aplicación móvil de Steam para confirmar la conexión...",
|
||||
"not-access-to-steam-app": "No tengo acceso a la aplicación móvil de Steam"
|
||||
},
|
||||
"steam-credentials": {
|
||||
"title": "Steam Credentials",
|
||||
"p-1": "Las credenciales sólo se utilizan para descargar el juego porque steam necesita verificar que has pagado el juego para que se te permita descargarlo. No se guardan y se pasan directamente a DepotDownloader. Si no quieres introducir tus credenciales puedes seguir este tutorial :",
|
||||
"p-1": "Las credenciales sólo se utilizan para descargar el juego porque steam necesita verificar que has pagado el juego para permitirte descargarlo. No se guardan y se pasan directamente a DepotDownloader. Si no quieres introducir tus credenciales puedes seguir este tutorial :",
|
||||
"p-2": "y, a continuación, haga clic en el icono de engranaje en la esquina superior derecha y seleccione \"Importar una versión\", seleccione la carpeta donde se ha descargado beat saber (Si usted sigue el tutorial anterior debe tener la ubicación correcta)"
|
||||
},
|
||||
"bs-import-version": {
|
||||
"title": "Importar una versión",
|
||||
"description": "Importa una versión de BeatSaber para usar con BSManager. La importación copiará la carpeta de instalación seleccionada de BeatSaber en la carpeta de versiones de BSManager.",
|
||||
"description": "Importa una versión de Beat Saber para usar con BSManager. La importación copiará la carpeta de instalación seleccionada de Beat Saber en la carpeta de versiones de BSManager.",
|
||||
"buttons": {
|
||||
"submit": "Importar una versión"
|
||||
}
|
||||
},
|
||||
"bs-uninstall": {
|
||||
"title": "Desinstalar",
|
||||
"description": "¿Estás seguro de que quieres desinstalar BeatSaber {version}? Tendrás que descargarlo de nuevo para poder jugar.",
|
||||
"description": "¿Estás seguro de que quieres desinstalar Beat Saber {version}? Tendrás que descargarlo de nuevo para poder jugar.",
|
||||
"buttons": {
|
||||
"submit": "Desinstalar"
|
||||
}
|
||||
@@ -535,7 +546,7 @@
|
||||
},
|
||||
"clone-version": {
|
||||
"title": "Versión clonada",
|
||||
"description": "La clonación de la versión le permite separar el contenido adicional de BeatSaber entre dos versiones.",
|
||||
"description": "La clonación de la versión te permite separar el contenido adicional de Beat Saber entre dos versiones.",
|
||||
"inputs": {
|
||||
"name": {
|
||||
"label": "Nombre",
|
||||
@@ -591,7 +602,7 @@
|
||||
},
|
||||
"unlink-maps": {
|
||||
"title": "Desvincular mapas",
|
||||
"desc": "Tenga en cuenta que desvincular mapas no permitirá el uso de mapas compartidos para esta versión.",
|
||||
"desc": "Ten en cuenta que desvincular mapas no permitirá el uso de mapas compartidos para esta versión.",
|
||||
"keep-maps": {
|
||||
"label": "Mantener mapas",
|
||||
"title": "Mantener los mapas creará una copia de los mapas compartidos para la versión actual. De lo contrario, no se mantendrá ningún mapa para esta versión."
|
||||
@@ -600,7 +611,7 @@
|
||||
}
|
||||
},
|
||||
"download-maps": {
|
||||
"search-btn": "buscar",
|
||||
"search-btn": "Buscar",
|
||||
"loading-maps": "Cargando mapas...",
|
||||
"no-maps-found": "No se encontraron mapas",
|
||||
"no-internet": "Sin internet"
|
||||
@@ -615,7 +626,7 @@
|
||||
},
|
||||
"shared-folders": {
|
||||
"title": "Carpetas Compartidas",
|
||||
"description": "Enlace las carpetas de BeatSaber para sincronizar su contenido con las carpetas compartidas en otras versiones. Tenga en cuenta que la eliminación de contenido también se compartirá.",
|
||||
"description": "Enlaza las carpetas de Beat Saber para sincronizar su contenido con las carpetas compartidas en otras versiones. Tenga en cuenta que la eliminación de contenido también se compartirá.",
|
||||
"buttons": {
|
||||
"add-folder": "Añadir Carpeta",
|
||||
"link-folder": "Enlazar Carpeta",
|
||||
@@ -645,7 +656,7 @@
|
||||
"dancestyle": "baile",
|
||||
"fitness": "fitness",
|
||||
"speed": "speed",
|
||||
"tech": "tec"
|
||||
"tech": "tech"
|
||||
},
|
||||
"map-styles": {
|
||||
"dance": "baile",
|
||||
@@ -802,14 +813,14 @@
|
||||
"valid-btn": "Vincular plataformas"
|
||||
},
|
||||
"bloq": {
|
||||
"title": "Vincular bloqs",
|
||||
"desc": "La vinculación de bloqs permite compartir bloqs entre todas las versiones. Una vez vinculada, esta versión se beneficiará de los bloqs compartidos",
|
||||
"info": "La adición y eliminación de bloqs también se compartirá",
|
||||
"title": "Vincular bloques",
|
||||
"desc": "La vinculación de bloques permite compartir bloques entre todas las versiones. Una vez vinculada, esta versión se beneficiará de los bloques compartidos",
|
||||
"info": "La adición y eliminación de bloques también se compartirá",
|
||||
"keep-models": {
|
||||
"label": "Conservar los bloqs",
|
||||
"title": "Conservar los bloqs moverá los bloqs de la versión actual a la carpeta de bloqs compartidos. De lo contrario, se perderán"
|
||||
"label": "Conservar los bloques",
|
||||
"title": "Conservar los bloques moverá los bloques de la versión actual a la carpeta de bloques compartidos. De lo contrario, se perderán"
|
||||
},
|
||||
"valid-btn": "Vincular bloqs"
|
||||
"valid-btn": "Vincular bloques"
|
||||
}
|
||||
},
|
||||
"unlink-models": {
|
||||
@@ -841,26 +852,26 @@
|
||||
"valid-btn": "Desvincular plataformas"
|
||||
},
|
||||
"bloq": {
|
||||
"title": "Desvincular bloqs",
|
||||
"desc": "Atención, desvincular los bloqs ya no permitirá el uso de bloqs compartidos para esta versión.",
|
||||
"title": "Desvincular bloques",
|
||||
"desc": "Atención, desvincular los bloques ya no permitirá el uso de bloques compartidos para esta versión.",
|
||||
"keep-models": {
|
||||
"label": "Conservar los bloqs",
|
||||
"title": "Conservar los bloqs creará una copia de los bloqs compartidos para la versión actual. En caso contrario, ningún bloq se conservará para esta versión."
|
||||
"label": "Conservar los bloques",
|
||||
"title": "Conservar los bloques creará una copia de los bloques compartidos para la versión actual. En caso contrario, ningún bloques se conservará para esta versión."
|
||||
},
|
||||
"valid-btn": "Desvincular bloqs"
|
||||
"valid-btn": "Desvincular bloques"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"prevent-for-mods": {
|
||||
"title": "Mods necesarios",
|
||||
"desc": "Asegúrate de haber instalado los mods necesarios para usar modelos en BeatSaber",
|
||||
"desc": "Asegúrate de haber instalado los mods necesarios para usar modelos en Beat Saber",
|
||||
"go-to-mods": "Ir a mods",
|
||||
"not-remind": "No volver a recordarme"
|
||||
},
|
||||
"prevent-for-models-breaks": {
|
||||
"title": "Modelos rotos",
|
||||
"desc": "Desde la actualización del motor de BeatSaber, los modelos ya no son funcionales para esta versión."
|
||||
"desc": "Desde la actualización del motor de Beat Saber, los modelos ya no son funcionales para esta versión."
|
||||
},
|
||||
"export-success": {
|
||||
"title": "Exportación completada 🎉"
|
||||
@@ -876,8 +887,8 @@
|
||||
}
|
||||
},
|
||||
"auto-update": {
|
||||
"checking": "Comprobar las actualizaciones",
|
||||
"downloading": "Descargar actualizaciones"
|
||||
"checking": "Comprobando actualizaciones",
|
||||
"downloading": "Descargando actualizaciones"
|
||||
},
|
||||
"bs-shortcut-launch": {
|
||||
"beat-saber-launching": "Iniciando Beat Saber",
|
||||
|
||||
@@ -215,10 +215,7 @@
|
||||
},
|
||||
"warnings": {
|
||||
"msg": {
|
||||
"ManifestChecksum": "Le manifest téléchargé précédemment ne correspond pas au nouveau 🤔",
|
||||
"ConnectionTimeout": "Ta connexion internet semble instable 🥶",
|
||||
"ConnectionLost": "La connexion a été perdue, nouvelle tentative...",
|
||||
"ConnectionError": "Impossible de se connecter à Steam, nouvelle tentative...",
|
||||
"Unknown": "Quelque chose d'étrange s'est produit 🤔 Votre connexion est surement instable."
|
||||
}
|
||||
},
|
||||
@@ -230,8 +227,7 @@
|
||||
"401": "Steam ne semble pas vouloir nous laisser télécharger Beat Saber 😢",
|
||||
"404": "Impossible de contacter les serveurs de Steam.",
|
||||
"Password": "Le mot de passe est invalide.",
|
||||
"InvalidCredentials": "Ton e-mail ou mot de passe est invalide 😴",
|
||||
"InvalidPassword": "Ton mot de passe est invalide 😕",
|
||||
"InvalidCredentials": "Identifiants de connexion invalides, connexion non approuvée, ou trop de tentatives de connexion.",
|
||||
"NoManifest": "Aucun manifest n'a été trouvé.",
|
||||
"DirectoryCreate": "Impossible d'installer les dossiers nécessaires.",
|
||||
"NotAvailableApp": "Tu essayes de télécharger Beat Saber alors que tu ne l'as pas ? 🤣",
|
||||
@@ -247,10 +243,9 @@
|
||||
"SteamLib": "Si tu as cette erreur, signale le bug sur GitHub avec les logs stp.",
|
||||
"ConnectionError": "Impossible de se connecter à Steam après 10 essais 🤯",
|
||||
"LicenceError": "Impossible d'obtenir la liste des licences.",
|
||||
"verification-failed": "La vérification a échoué, réessaye plus tard.",
|
||||
"RateLimitExceeded": "Tu as essayé trop de fois attends un peu et recommence plus tard.",
|
||||
"AlreadyDownloading": "Un téléchargement est déjà en cours",
|
||||
"no-internet": "Pas de connexion internet.",
|
||||
"TokenRejected": "Votre token de connexion a été rejeté 😕 Veuillez réessayer.",
|
||||
"AccessDenied": "L'accès à Steam a été refusé.",
|
||||
"dotnet-required": ".NET 6 Runtime doit être installé pour pouvoir télécharger une version de Beat Saber. Télécharge-le en cliquant sur le bouton ci-dessous."
|
||||
},
|
||||
"actions": {
|
||||
@@ -484,23 +479,36 @@
|
||||
"title": "Connexion Steam",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Nom d'utilisateur",
|
||||
"placeholder": "Entre ton nom d'utilisateur"
|
||||
"label": "Se connecter avec un nom de compte",
|
||||
"placeholder": "Entre ton nom de compte"
|
||||
},
|
||||
"password": {
|
||||
"label": "Mot de passe",
|
||||
"placeholder": "Entre ton mot de passe",
|
||||
"max-length-warning": "Le mot de passe dépasse 64 caractères ! Si le mot de passe est invalide, tente d'entrer seulement ses 64 premiers caractères."
|
||||
},
|
||||
"stay": {
|
||||
"label": "Rester connecté"
|
||||
}
|
||||
"qr":{
|
||||
"label": "Ou avec un QR code",
|
||||
"note":{
|
||||
"use-the":"Utilisez l'",
|
||||
"steam-mobile-app": "application mobile Steam",
|
||||
"to-connect-with-qr": "pour vous connecter avec un QR code."
|
||||
}
|
||||
},
|
||||
"stay": "Se souvenir de moi"
|
||||
},
|
||||
"why-credentials": "Pourquoi mes identifiants sont nécessaires ?",
|
||||
"need-help-to-connect": "J'ai besoin d'aide pour accéder à mon compte !",
|
||||
"buttons": {
|
||||
"submit": "Se connecter"
|
||||
}
|
||||
},
|
||||
"steam-auth-approve":{
|
||||
"title": "Attente de confirmation",
|
||||
"protected-by-mobile-auth": "Compte protégé par l'authentificateur mobile.",
|
||||
"use-steam-app-to-approve": "Utilisez l'application mobile Steam pour confirmer la connexion...",
|
||||
"not-access-to-steam-app": "Je n'ai pas accès à l'application mobile Steam"
|
||||
},
|
||||
"steam-credentials": {
|
||||
"title": "Steam Credentials",
|
||||
"p-1": "Les informations d'identification ne sont utilisées que pour télécharger le jeu, car Steam doit vérifier que vous avez payé le jeu afin d'être autorisé à le télécharger. Ils ne sont pas sauvegardés et transmis directement à DepotDownloader. Si vous ne voulez pas entrer vos informations d'identification, vous pouvez suivre ce tutoriel :",
|
||||
|
||||
@@ -231,8 +231,7 @@
|
||||
"401": "Похоже Steam не хочет, чтобы мы скачали Beat Saber 😢",
|
||||
"404": "Нет связи с серверами Steam.",
|
||||
"Password": "Неверный пароль.",
|
||||
"InvalidCredentials": "Ваша почта или пароль неверны 😴",
|
||||
"InvalidPassword": "Ваш пароль неверен 😕",
|
||||
"InvalidCredentials": "Неверные учетные данные для входа, неразрешенное соединение или слишком много попыток входа.",
|
||||
"NoManifest": "Файл манифеста не найден",
|
||||
"DirectoryCreate": "Не смогли создать нужные папки.",
|
||||
"NotAvailableApp": "Вы пытаетесь скачать Beat Saber, не купив её? 🤣",
|
||||
@@ -248,10 +247,9 @@
|
||||
"SteamLib": "Если вы это видите, пожалуйста, сообщите о баге на GitHub с файлом логов.",
|
||||
"ConnectionError": "Не удалось подключиться к Steam за 10 попыток 🤯",
|
||||
"LicenceError": "Не удалось проверить наличие лицензии.",
|
||||
"verification-failed": "Верификация провалена, повторите попытку позже.",
|
||||
"RateLimitExceeded": "Слишком много попыток, вернитесь позже и попробуйте снова.",
|
||||
"AlreadyDownloading": "Скачивание уже в процессе",
|
||||
"no-internet": "Нет интернета.",
|
||||
"TokenRejected": "Ваш токен для входа был отклонен 😕 Пожалуйста, попробуйте снова.",
|
||||
"AccessDenied": "Доступ к Steam был отклонен.",
|
||||
"dotnet-required": "Среда запуска .NET 6 должна быть установлена, чтобы скачать Beat Saber. Скачайте её с помощью кнопки ниже."
|
||||
},
|
||||
"actions": {
|
||||
@@ -485,23 +483,36 @@
|
||||
"title": "Вход в Steam",
|
||||
"inputs": {
|
||||
"username": {
|
||||
"label": "Имя пользователя",
|
||||
"placeholder": "Введите имя пользователя"
|
||||
"label": "Войти с именем пользователя",
|
||||
"placeholder": "Введите ваше имя пользователя"
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль",
|
||||
"placeholder": "Введите пароль",
|
||||
"max-length-warning": "Пароль больше 64-х символов! Если пароль неверен, введите первые 64 символа."
|
||||
"placeholder": "Введите ваш пароль",
|
||||
"max-length-warning": "Пароль превышает 64 символа! Если пароль недействителен, попробуйте ввести только его первые 64 символа."
|
||||
},
|
||||
"stay": {
|
||||
"label": "Оставаться в аккаунте"
|
||||
}
|
||||
"qr":{
|
||||
"label": "Или с QR-кодом",
|
||||
"note":{
|
||||
"use-the":"Используйте ",
|
||||
"steam-mobile-app": "мобильное приложение Steam",
|
||||
"to-connect-with-qr": "для входа с QR-кодом."
|
||||
}
|
||||
},
|
||||
"stay": "Запомнить меня"
|
||||
},
|
||||
"why-credentials": "Почему нужны мои данные для входа?",
|
||||
"why-credentials": "Зачем мне нужны учетные данные?",
|
||||
"need-help-to-connect": "Мне нужна помощь, чтобы войти в свой аккаунт!",
|
||||
"buttons": {
|
||||
"submit": "Войти"
|
||||
}
|
||||
},
|
||||
"steam-auth-approve":{
|
||||
"title": "Ожидание подтверждения",
|
||||
"protected-by-mobile-auth": "Учетная запись защищена мобильным аутентификатором.",
|
||||
"use-steam-app-to-approve": "Используйте мобильное приложение Steam для подтверждения подключения...",
|
||||
"not-access-to-steam-app": "У меня нет доступа к мобильному приложению Steam"
|
||||
},
|
||||
"steam-credentials": {
|
||||
"title": "Данные входа Steam",
|
||||
"p-1": "Данные входа используются только при скачивании, чтобы проверить наличие у вас Beat Saber, это нужно Steam, чтобы разрешить скачивание. Они не сохраняются и напрямую передаются в DepotDownloader. Если вы не хотите вводить данные для входа, воспользуйтесь руководством(Оно на английском языке) :",
|
||||
|
||||
@@ -1,121 +1,166 @@
|
||||
{
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v6.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v6.0": {
|
||||
"DepotDownloader/2.4.6": {
|
||||
"dependencies": {
|
||||
"SteamKit2": "2.4.1",
|
||||
"protobuf-net": "3.0.101"
|
||||
},
|
||||
"runtime": {
|
||||
"DepotDownloader.dll": {}
|
||||
}
|
||||
},
|
||||
"Microsoft.NETCore.Platforms/5.0.0": {},
|
||||
"Microsoft.Win32.Registry/5.0.0": {
|
||||
"dependencies": {
|
||||
"System.Security.AccessControl": "5.0.0",
|
||||
"System.Security.Principal.Windows": "5.0.0"
|
||||
}
|
||||
},
|
||||
"protobuf-net/3.0.101": {
|
||||
"dependencies": {
|
||||
"protobuf-net.Core": "3.0.101"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net5.0/protobuf-net.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.0.101.59408"
|
||||
}
|
||||
}
|
||||
},
|
||||
"protobuf-net.Core/3.0.101": {
|
||||
"runtime": {
|
||||
"lib/net5.0/protobuf-net.Core.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.0.101.59408"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SteamKit2/2.4.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "5.0.0",
|
||||
"protobuf-net": "3.0.101"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/SteamKit2.dll": {
|
||||
"assemblyVersion": "2.4.1.0",
|
||||
"fileVersion": "2.4.1.10"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Security.AccessControl/5.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.Platforms": "5.0.0",
|
||||
"System.Security.Principal.Windows": "5.0.0"
|
||||
}
|
||||
},
|
||||
"System.Security.Principal.Windows/5.0.0": {}
|
||||
"runtimeTarget": {
|
||||
"name": ".NETCoreApp,Version=v6.0",
|
||||
"signature": ""
|
||||
},
|
||||
"compilationOptions": {},
|
||||
"targets": {
|
||||
".NETCoreApp,Version=v6.0": {
|
||||
"DepotDownloader/2.5.0": {
|
||||
"dependencies": {
|
||||
"QRCoder": "1.4.3",
|
||||
"SteamKit2": "2.5.0-Beta.1",
|
||||
"protobuf-net": "3.2.16"
|
||||
},
|
||||
"runtime": {
|
||||
"DepotDownloader.dll": {}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"DepotDownloader/2.4.6": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Microsoft.NETCore.Platforms/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==",
|
||||
"path": "microsoft.netcore.platforms/5.0.0",
|
||||
"hashPath": "microsoft.netcore.platforms.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Win32.Registry/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==",
|
||||
"path": "microsoft.win32.registry/5.0.0",
|
||||
"hashPath": "microsoft.win32.registry.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"protobuf-net/3.0.101": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-mLm8VGfGylp9q+rbJDp3uw0BOeNzfGENecVz/EYGlzq3ss7tfajGXHCMoZm/GxJ9vSn9pcK22UXWZnQ5GJQMjA==",
|
||||
"path": "protobuf-net/3.0.101",
|
||||
"hashPath": "protobuf-net.3.0.101.nupkg.sha512"
|
||||
},
|
||||
"protobuf-net.Core/3.0.101": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-gf9QIF0RlctfjmOjbE6eOyMulkNEsH7tHncjKj/4BTu9tkuTyrH3fv5I+35GnolJqKJ6fh9kYaezvNRL3t75+Q==",
|
||||
"path": "protobuf-net.core/3.0.101",
|
||||
"hashPath": "protobuf-net.core.3.0.101.nupkg.sha512"
|
||||
},
|
||||
"SteamKit2/2.4.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-t9yptysn/2YY6KZaI123vOOnumS+ZCFcC6MhFEkTiQ04mdpZ/EWhk5RVY7jU36IQHyTQUXotmn7taKFCXMKp4g==",
|
||||
"path": "steamkit2/2.4.1",
|
||||
"hashPath": "steamkit2.2.4.1.nupkg.sha512"
|
||||
},
|
||||
"System.Security.AccessControl/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==",
|
||||
"path": "system.security.accesscontrol/5.0.0",
|
||||
"hashPath": "system.security.accesscontrol.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Security.Principal.Windows/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==",
|
||||
"path": "system.security.principal.windows/5.0.0",
|
||||
"hashPath": "system.security.principal.windows.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.NETCore.Platforms/5.0.0": {},
|
||||
"Microsoft.Win32.Registry/5.0.0": {
|
||||
"dependencies": {
|
||||
"System.Security.AccessControl": "5.0.0",
|
||||
"System.Security.Principal.Windows": "5.0.0"
|
||||
}
|
||||
},
|
||||
"protobuf-net/3.2.16": {
|
||||
"dependencies": {
|
||||
"protobuf-net.Core": "3.2.16"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/protobuf-net.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.2.16.54102"
|
||||
}
|
||||
}
|
||||
},
|
||||
"protobuf-net.Core/3.2.16": {
|
||||
"dependencies": {
|
||||
"System.Collections.Immutable": "7.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/protobuf-net.Core.dll": {
|
||||
"assemblyVersion": "3.0.0.0",
|
||||
"fileVersion": "3.2.16.54102"
|
||||
}
|
||||
}
|
||||
},
|
||||
"QRCoder/1.4.3": {
|
||||
"runtime": {
|
||||
"lib/net6.0/QRCoder.dll": {
|
||||
"assemblyVersion": "1.4.3.0",
|
||||
"fileVersion": "1.4.3.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SteamKit2/2.5.0-Beta.1": {
|
||||
"dependencies": {
|
||||
"Microsoft.Win32.Registry": "5.0.0",
|
||||
"protobuf-net": "3.2.16"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/SteamKit2.dll": {
|
||||
"assemblyVersion": "2.5.0.0",
|
||||
"fileVersion": "2.5.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Collections.Immutable/7.0.0": {
|
||||
"dependencies": {
|
||||
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
|
||||
},
|
||||
"runtime": {
|
||||
"lib/net6.0/System.Collections.Immutable.dll": {
|
||||
"assemblyVersion": "7.0.0.0",
|
||||
"fileVersion": "7.0.22.51805"
|
||||
}
|
||||
}
|
||||
},
|
||||
"System.Runtime.CompilerServices.Unsafe/6.0.0": {},
|
||||
"System.Security.AccessControl/5.0.0": {
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.Platforms": "5.0.0",
|
||||
"System.Security.Principal.Windows": "5.0.0"
|
||||
}
|
||||
},
|
||||
"System.Security.Principal.Windows/5.0.0": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraries": {
|
||||
"DepotDownloader/2.5.0": {
|
||||
"type": "project",
|
||||
"serviceable": false,
|
||||
"sha512": ""
|
||||
},
|
||||
"Microsoft.NETCore.Platforms/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ==",
|
||||
"path": "microsoft.netcore.platforms/5.0.0",
|
||||
"hashPath": "microsoft.netcore.platforms.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"Microsoft.Win32.Registry/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==",
|
||||
"path": "microsoft.win32.registry/5.0.0",
|
||||
"hashPath": "microsoft.win32.registry.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"protobuf-net/3.2.16": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-W6RM2B2f5O7SigBGfkXFYzI/a7Mks+rCHtuGlE/b5O3NMupDon7I0akjCLhrfcnqIfdWDkDafseav/MnIzUSHA==",
|
||||
"path": "protobuf-net/3.2.16",
|
||||
"hashPath": "protobuf-net.3.2.16.nupkg.sha512"
|
||||
},
|
||||
"protobuf-net.Core/3.2.16": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-ZFoY0lfMUWGJ4RoyI0oNj2o2l8t08n3k27YyDD0kYWQ+vXj0hTbWEwYWOpGAfCWvyUPkq9Jcc/4uR74GICZL2g==",
|
||||
"path": "protobuf-net.core/3.2.16",
|
||||
"hashPath": "protobuf-net.core.3.2.16.nupkg.sha512"
|
||||
},
|
||||
"QRCoder/1.4.3": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-fWuFqjm8GTlEb2GqBl3Hi8HZZeZQwBSHxvRPtPjyNbT82H0ff0JwavKRBmMaXCno1Av6McPC8aJzri0Mj2w9Jw==",
|
||||
"path": "qrcoder/1.4.3",
|
||||
"hashPath": "qrcoder.1.4.3.nupkg.sha512"
|
||||
},
|
||||
"SteamKit2/2.5.0-Beta.1": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-fwwdF0cZZEIdNpcUc1+B9zbP5Q48A3DFOAclPhBh2dMTU03aROaIDktFW+Ww/NN1Z/Emp/LwVqHB+hCM8uxS4Q==",
|
||||
"path": "steamkit2/2.5.0-beta.1",
|
||||
"hashPath": "steamkit2.2.5.0-beta.1.nupkg.sha512"
|
||||
},
|
||||
"System.Collections.Immutable/7.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dQPcs0U1IKnBdRDBkrCTi1FoajSTBzLcVTpjO4MBCMC7f4pDOIPzgBoX8JjG7X6uZRJ8EBxsi8+DR1JuwjnzOQ==",
|
||||
"path": "system.collections.immutable/7.0.0",
|
||||
"hashPath": "system.collections.immutable.7.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Runtime.CompilerServices.Unsafe/6.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==",
|
||||
"path": "system.runtime.compilerservices.unsafe/6.0.0",
|
||||
"hashPath": "system.runtime.compilerservices.unsafe.6.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Security.AccessControl/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==",
|
||||
"path": "system.security.accesscontrol/5.0.0",
|
||||
"hashPath": "system.security.accesscontrol.5.0.0.nupkg.sha512"
|
||||
},
|
||||
"System.Security.Principal.Windows/5.0.0": {
|
||||
"type": "package",
|
||||
"serviceable": true,
|
||||
"sha512": "sha512-t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==",
|
||||
"path": "system.security.principal.windows/5.0.0",
|
||||
"hashPath": "system.security.principal.windows.5.0.0.nupkg.sha512"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net6.0",
|
||||
"rollForward": "LatestMajor",
|
||||
"framework": {
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "6.0.0"
|
||||
},
|
||||
"configProperties": {
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false
|
||||
}
|
||||
"runtimeOptions": {
|
||||
"tfm": "net6.0",
|
||||
"rollForward": "LatestMajor",
|
||||
"framework": {
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "6.0.0"
|
||||
},
|
||||
"configProperties": {
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+370
-332
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -14,7 +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",
|
||||
"publish": "npm run build && electron-builder --publish always --win --x64"
|
||||
"publish": "npm run build && electron-builder -c.win.certificateSha1=842a817a51e2a1d360fcd62f54bf5f9193e919e1 --publish always --win --x64"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
@@ -76,8 +76,7 @@
|
||||
"nsis",
|
||||
"nsis-web"
|
||||
],
|
||||
"icon": "assets/favicon.ico",
|
||||
"certificateSha1": "f3363a89b084f8f6b616babea3c91f9d79e15000"
|
||||
"icon": "assets/favicon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
@@ -179,8 +178,8 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^4.1.0",
|
||||
"detect-port": "^1.3.0",
|
||||
"electron": "^25.3.0",
|
||||
"electron-builder": "^24.4.0",
|
||||
"electron": "^26.2.1",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-notarize": "^1.2.1",
|
||||
"electronmon": "^2.0.2",
|
||||
@@ -231,23 +230,25 @@
|
||||
"dependencies": {
|
||||
"@node-steam/vdf": "^2.2.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"archiver": "^5.3.1",
|
||||
"archiver": "^6.0.1",
|
||||
"color": "^4.2.3",
|
||||
"dateformat": "^5.0.3",
|
||||
"dot-prop": "^8.0.0",
|
||||
"dot-prop": "^8.0.2",
|
||||
"electron-debug": "^3.2.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-store": "^8.1.0",
|
||||
"electron-updater": "^6.1.1",
|
||||
"electron-updater": "^6.1.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"framer-motion": "^10.12.16",
|
||||
"framer-motion": "^10.16.4",
|
||||
"fs-extra": "^11.1.1",
|
||||
"history": "^5.3.0",
|
||||
"jszip": "^3.10.1",
|
||||
"md5-file": "^5.0.0",
|
||||
"node-abi": "^3.47.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"ps-list": "^7.2.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -260,7 +261,7 @@
|
||||
"rfdc": "^1.3.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"semver": "^7.5.1",
|
||||
"semver": "^7.5.4",
|
||||
"striptags": "^4.0.0-alpha.4",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"tailwindcss-scoped-groups": "^2.0.0",
|
||||
|
||||
Generated
+855
-246
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.3.0-alpha.3",
|
||||
"version": "1.3.1",
|
||||
"description": "BSManager",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
@@ -14,8 +14,8 @@
|
||||
"postinstall": "npm run electron-rebuild && npm run link-modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@resvg/resvg-js": "^2.4.1",
|
||||
"ps-list": "^7.2.0"
|
||||
"ps-list": "^7.2.0",
|
||||
"sharp": "^0.32.6"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
@@ -1,60 +1,18 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { BSInstallerService, DownloadEventType, DownloadInfo } from "../services/bs-installer.service";
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { BSInstallerService, DownloadInfo } from "../services/bs-installer.service";
|
||||
import { InstallationLocationService } from "../services/installation-location.service";
|
||||
import { UtilsService } from "../services/utils.service";
|
||||
import { LocalMapsManagerService } from "../services/additional-content/local-maps-manager.service";
|
||||
import { IpcService } from "../services/ipc.service";
|
||||
import { from } from "rxjs";
|
||||
|
||||
export interface InitDownloadInfoInterface {
|
||||
cwd: string;
|
||||
folder: string;
|
||||
app: string;
|
||||
depot: string;
|
||||
manifest: string;
|
||||
username: string;
|
||||
stay: boolean;
|
||||
}
|
||||
import { from, of } from "rxjs";
|
||||
|
||||
const ipc = IpcService.getInstance();
|
||||
|
||||
ipcMain.on("is-dotnet-6-installed", async (event, request: IpcRequest<void>) => {
|
||||
ipc.on("is-dotnet-6-installed", (_, reply) => {
|
||||
const installer = BSInstallerService.getInstance();
|
||||
const utils = UtilsService.getInstance();
|
||||
installer.isDotNet6Installed().then(installed => {
|
||||
utils.ipcSend(request.responceChannel, { success: true, data: installed });
|
||||
});
|
||||
reply(from(installer.isDotNet6Installed()));
|
||||
});
|
||||
|
||||
ipcMain.on("bs-download.start", async (event, request: IpcRequest<DownloadInfo>) => {
|
||||
BSInstallerService.getInstance()
|
||||
.downloadBsVersion(request.args)
|
||||
.then(async res => {
|
||||
await LocalMapsManagerService.getInstance()
|
||||
.linkVersionMaps(request.args.bsVersion, true)
|
||||
.catch(e => {});
|
||||
UtilsService.getInstance().ipcSend(request.responceChannel, { success: true, data: res });
|
||||
})
|
||||
.catch(e => {
|
||||
UtilsService.getInstance().ipcSend(request.responceChannel, { success: false, data: e });
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.on(`bs-download.${"[2FA]" as DownloadEventType}`, async (event, args: IpcRequest<string>) => {
|
||||
BSInstallerService.getInstance().sendInputProcess(args.args);
|
||||
});
|
||||
|
||||
ipcMain.on("bs-download.kill", async (event, request: IpcRequest<void>) => {
|
||||
const res = await BSInstallerService.getInstance().killDownloadProcess();
|
||||
if (request.responceChannel) {
|
||||
UtilsService.getInstance().ipcSend(request.responceChannel, { success: res });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("bs-download.installation-folder", async (event, request: IpcRequest<void>) => {
|
||||
const installationFolder = InstallationLocationService.getInstance().installationDirectory;
|
||||
UtilsService.getInstance().ipcSend(request.responceChannel, { success: true, data: installationFolder });
|
||||
ipc.on("bs-download.installation-folder", (_, reply) => {
|
||||
const installLocation = InstallationLocationService.getInstance();
|
||||
reply(from(installLocation.installationDirectory()));
|
||||
});
|
||||
|
||||
ipc.on<string>("bs-download.set-installation-folder", (req, reply) => {
|
||||
@@ -62,15 +20,32 @@ ipc.on<string>("bs-download.set-installation-folder", (req, reply) => {
|
||||
reply(from(installerService.setInstallationDirectory(req.args)));
|
||||
});
|
||||
|
||||
ipcMain.on("bs-download.import-version", (event, request: IpcRequest<string>) => {
|
||||
const utils = UtilsService.getInstance();
|
||||
ipc.on<string>("bs-download.import-version", (req, reply) => {
|
||||
const installer = BSInstallerService.getInstance();
|
||||
installer
|
||||
.importVersion(request.args)
|
||||
.catch(e => {
|
||||
utils.ipcSend(request.responceChannel, { success: false, error: e });
|
||||
})
|
||||
.then(() => {
|
||||
utils.ipcSend(request.responceChannel, { success: true });
|
||||
});
|
||||
reply(from(installer.importVersion(req.args)));
|
||||
});
|
||||
|
||||
ipc.on<DownloadInfo>("auto-download-bs-version", (req, reply) => {
|
||||
const bsInstaller = BSInstallerService.getInstance();
|
||||
reply(bsInstaller.autoDownloadBsVersion(req.args));
|
||||
});
|
||||
|
||||
ipc.on<DownloadInfo>("download-bs-version", (req, reply) => {
|
||||
const bsInstaller = BSInstallerService.getInstance();
|
||||
reply(bsInstaller.downloadBsVersion(req.args))
|
||||
});
|
||||
|
||||
ipc.on<DownloadInfo>("download-bs-version-qr", (req, reply) => {
|
||||
const bsInstaller = BSInstallerService.getInstance();
|
||||
reply(bsInstaller.downloadBsVersionWithQRCode(req.args))
|
||||
});
|
||||
|
||||
ipc.on("stop-download-bs-version", (_, reply) => {
|
||||
const bsInstaller = BSInstallerService.getInstance();
|
||||
reply(of(bsInstaller.stopDownload()));
|
||||
});
|
||||
|
||||
ipc.on<string>("send-input-bs-download", (req, reply) => {
|
||||
const bsInstaller = BSInstallerService.getInstance();
|
||||
reply(of(bsInstaller.sendInput(req.args)));
|
||||
});
|
||||
|
||||
@@ -15,3 +15,9 @@ ipc.on<LaunchOption>("create-launch-shortcut", (req, reply) => {
|
||||
const bsLauncher = BSLauncherService.getInstance();
|
||||
reply(from(bsLauncher.createLaunchShortcut(req.args)));
|
||||
});
|
||||
|
||||
|
||||
ipc.on<void>("bs-launch.restore-steamvr", (_, reply) => {
|
||||
const bsLauncher = BSLauncherService.getInstance();
|
||||
reply(from(bsLauncher.restoreSteamVR()));
|
||||
});
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import { LocalModelsManagerService } from "./services/additional-content/local-m
|
||||
import { APP_NAME } from "./constants";
|
||||
import { BSLauncherService } from "./services/bs-launcher.service";
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { LivShortcut } from "./services/liv/liv-shortcut.service";
|
||||
|
||||
const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true";
|
||||
|
||||
@@ -63,7 +64,7 @@ const initServicesMustBeInitialized = () => {
|
||||
LocalMapsManagerService.getInstance();
|
||||
LocalPlaylistsManagerService.getInstance();
|
||||
LocalModelsManagerService.getInstance();
|
||||
|
||||
LivShortcut.getInstance();
|
||||
BSLauncherService.getInstance();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, spawn } from "child_process";
|
||||
import { Observable, ReplaySubject, Subscriber, filter, map, share } from "rxjs";
|
||||
import { DepotDownloaderArgsOptions, DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderEventTypes, DepotDownloaderInfoEvent, DepotDownloaderWarningEvent } from "../../shared/models/depot-downloader.model";
|
||||
|
||||
export class DepotDownloader {
|
||||
|
||||
private process: ChildProcessWithoutNullStreams;
|
||||
private processOut$: Observable<string>;
|
||||
private subscriber: Subscriber<string>;
|
||||
|
||||
public constructor(
|
||||
options: {
|
||||
command: string, args?: string[], options?: SpawnOptionsWithoutStdio, echoStartData?: unknown
|
||||
},
|
||||
logger?: Logger
|
||||
){
|
||||
this.processOut$ = new Observable<string>(subscriber => {
|
||||
|
||||
this.subscriber = subscriber;
|
||||
|
||||
this.process = spawn(options.command, options.args ?? [], options.options);
|
||||
|
||||
subscriber.next(`[Info]|[Start]|${JSON.stringify(options.echoStartData) ?? ""}`);
|
||||
|
||||
this.process.stdout.on("data", data => {
|
||||
const stringData: string = data.toString();
|
||||
|
||||
if(!stringData.includes(DepotDownloaderInfoEvent.Progress) && !stringData.includes(DepotDownloaderInfoEvent.Validated)){
|
||||
logger?.info("DepotDownloader stdout:", stringData);
|
||||
}
|
||||
|
||||
const lines: string[] = stringData.split("\n");
|
||||
lines.forEach(line => subscriber.next(line));
|
||||
});
|
||||
|
||||
this.process.on("error", error => subscriber.error(error));
|
||||
this.process.stderr.on("error", error => subscriber.error(error));
|
||||
this.process.on("exit", () => subscriber.complete());
|
||||
|
||||
return () => {
|
||||
this.process.kill();
|
||||
logger?.info("DepotDownloader process end with code", this.process.exitCode);
|
||||
this.process = null;
|
||||
}
|
||||
|
||||
}).pipe(share({connector: () => new ReplaySubject(1)}));
|
||||
}
|
||||
|
||||
public $events(): Observable<DepotDownloaderEvent<unknown>>{
|
||||
|
||||
const eventTypesArr = Object.values(DepotDownloaderEventType);
|
||||
|
||||
const DepotDownloaderSubTypeOfEventType: {[key in DepotDownloaderEventType]: DepotDownloaderEventTypes[]} = {
|
||||
[DepotDownloaderEventType.Error]: Object.values(DepotDownloaderErrorEvent),
|
||||
[DepotDownloaderEventType.Warning]: Object.values(DepotDownloaderWarningEvent),
|
||||
[DepotDownloaderEventType.Info]: Object.values(DepotDownloaderInfoEvent),
|
||||
}
|
||||
|
||||
return this.processOut$.pipe(map(line => {
|
||||
|
||||
const matched = (line.toString() as string).match(/(?:\[(.*?)\])\|(?:\[(.*?)\]\|)?(.*?)(?=$|\[)/gm)?.[0] ?? null;
|
||||
|
||||
if(!matched){ return null; }
|
||||
|
||||
const splitedLine = matched.split("|").map(str => str.trim().replaceAll("[", "").replaceAll("]", "")) as [DepotDownloaderEventType, DepotDownloaderEventTypes, unknown];
|
||||
|
||||
if(!eventTypesArr.includes(splitedLine[0]) || !DepotDownloaderSubTypeOfEventType[splitedLine[0]].includes(splitedLine[1])){
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: splitedLine[0],
|
||||
subType: splitedLine[1],
|
||||
data: splitedLine[2],
|
||||
}
|
||||
|
||||
}),
|
||||
filter(Boolean));
|
||||
}
|
||||
|
||||
public sendInput(input: string): boolean{
|
||||
if(!this.process.stdin.writable){ throw new Error("DepotDownloader stdin is not writable"); }
|
||||
return this.process.stdin.write(`${input}\n`);
|
||||
}
|
||||
|
||||
public stop(){
|
||||
this.subscriber.complete();
|
||||
}
|
||||
|
||||
public get running(){ return !!this.process }
|
||||
|
||||
public static buildArgs(depotDownloaderArgs: DepotDownloaderArgsOptions): string[]{
|
||||
const args: string[] = [];
|
||||
|
||||
for(const [key, value] of Object.entries(depotDownloaderArgs)){
|
||||
|
||||
if(value === true){
|
||||
args.push(`-${key}`);
|
||||
}
|
||||
else if(value){
|
||||
args.push(`-${key}`);
|
||||
args.push(`${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface Logger {
|
||||
info: (...args: unknown[]) => void,
|
||||
warn: (...args: unknown[]) => void,
|
||||
error: (...args: unknown[]) => void,
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { BSLocalVersionService } from "../bs-local-version.service";
|
||||
import { InstallationLocationService } from "../installation-location.service";
|
||||
import { UtilsService } from "../utils.service";
|
||||
import crypto from "crypto";
|
||||
import { lstatSync, unlinkSync } from "fs";
|
||||
import { copySync, createReadStream } from "fs-extra";
|
||||
import { lstatSync } from "fs";
|
||||
import { copy, createReadStream, ensureDir, unlink } from "fs-extra";
|
||||
import StreamZip from "node-stream-zip";
|
||||
import { RequestService } from "../request.service";
|
||||
import sanitize from "sanitize-filename";
|
||||
@@ -75,7 +75,7 @@ export class LocalMapsManagerService {
|
||||
if (version) {
|
||||
return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
|
||||
}
|
||||
const sharedMapsPath = path.join(this.installLocation.sharedContentPath, LocalMapsManagerService.SHARED_MAPS_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
|
||||
const sharedMapsPath = path.join(await this.installLocation.sharedContentPath(), LocalMapsManagerService.SHARED_MAPS_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
|
||||
if (!(await pathExist(sharedMapsPath))) {
|
||||
await ensureFolderExist(sharedMapsPath);
|
||||
}
|
||||
@@ -252,20 +252,19 @@ export class LocalMapsManagerService {
|
||||
|
||||
const { zip, zipPath } = await this.downloadMapZip(zipUrl);
|
||||
|
||||
const mapFolderName = sanitize(`${map.id}-${map.name}`);
|
||||
|
||||
const mapPath = path.join(mapsFolder, mapFolderName);
|
||||
|
||||
if (!zip) {
|
||||
throw `Cannot download ${zipUrl}`;
|
||||
}
|
||||
|
||||
const mapFolderName = sanitize(`${map.id}-${map.name}`);
|
||||
|
||||
const mapPath = path.join(mapsFolder, mapFolderName);
|
||||
|
||||
await ensureFolderExist(mapPath);
|
||||
|
||||
await zip.extract(null, mapPath);
|
||||
await zip.close();
|
||||
|
||||
unlinkSync(zipPath);
|
||||
await unlink(zipPath);
|
||||
|
||||
const localMap = await this.loadMapInfoFromPath(mapPath);
|
||||
localMap.bsaverInfo = map;
|
||||
@@ -288,20 +287,14 @@ export class LocalMapsManagerService {
|
||||
}
|
||||
|
||||
public async oneClickDownloadMap(map: BsvMapDetail): Promise<void> {
|
||||
const downloadedMap = await this.downloadMap(map);
|
||||
|
||||
const versions = await this.localVersion.getInstalledVersions();
|
||||
const downloadedMap = await this.downloadMap(map, versions.pop());
|
||||
|
||||
for (const version of versions) {
|
||||
if (await this.versionIsLinked(version)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const versionMapsPath = await this.getMapsFolderPath(version);
|
||||
|
||||
await ensureFolderExist(versionMapsPath);
|
||||
|
||||
copySync(downloadedMap.path, path.join(versionMapsPath, path.basename(downloadedMap.path)), { overwrite: true });
|
||||
await ensureDir(versionMapsPath);
|
||||
await copy(downloadedMap.path, path.join(versionMapsPath, path.basename(downloadedMap.path)), { overwrite: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +307,6 @@ export class LocalMapsManagerService {
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean {
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistered(link));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export class LocalModelsManagerService {
|
||||
}
|
||||
|
||||
private async getModelFolderPath(type: MSModelType, version?: BSVersion): Promise<string> {
|
||||
const rootPath = version ? await this.localVersion.getVersionPath(version) : this.installPaths.sharedContentPath;
|
||||
const rootPath = await (version ? this.localVersion.getVersionPath(version) : this.installPaths.sharedContentPath());
|
||||
const modelFolderPath = path.join(rootPath, MODEL_TYPE_FOLDERS[type]);
|
||||
|
||||
await ensureFolderExist(modelFolderPath);
|
||||
@@ -119,23 +119,13 @@ export class LocalModelsManagerService {
|
||||
}
|
||||
|
||||
public async oneClickDownloadModel(model: MSModel): Promise<void> {
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
if (!model) { return; }
|
||||
|
||||
const versions = await this.localVersion.getInstalledVersions();
|
||||
|
||||
if (versions?.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fisrtVersion = versions.shift();
|
||||
|
||||
const downloaded = await lastValueFrom(this.downloadModel(model, fisrtVersion));
|
||||
const downloaded = await lastValueFrom(this.downloadModel(model, versions.pop()));
|
||||
|
||||
for (const version of versions) {
|
||||
const modelDest = path.join(await this.getModelFolderPath(model.type, version), path.basename(downloaded.data.path));
|
||||
|
||||
copyFileSync(downloaded.data.path, modelDest);
|
||||
}
|
||||
}
|
||||
@@ -242,7 +232,7 @@ export class LocalModelsManagerService {
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean {
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistered(link));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,20 +178,11 @@ export class LocalPlaylistsManagerService {
|
||||
public async oneClickInstallPlaylist(bpListUrl: string): Promise<void> {
|
||||
const versions = await this.versions.getInstalledVersions();
|
||||
|
||||
const firstVersion = versions.shift();
|
||||
const fistVersionLinked = await this.maps.versionIsLinked(firstVersion);
|
||||
|
||||
const { bpListPath, mapsPath } = await this.downloadPlaylist(bpListUrl, firstVersion).toPromise();
|
||||
const { bpListPath, mapsPath } = await lastValueFrom(this.downloadPlaylist(bpListUrl, versions.pop()));
|
||||
|
||||
for (const version of versions) {
|
||||
await this.installBPListFile(bpListPath, version);
|
||||
|
||||
const versionIsLinked = await this.maps.versionIsLinked(version);
|
||||
|
||||
if (fistVersionLinked && versionIsLinked) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const mapPath of mapsPath) {
|
||||
const versionMapsFolder = await this.maps.getMapsFolderPath(version);
|
||||
const mapDest = path.join(versionMapsFolder, path.basename(mapPath));
|
||||
@@ -210,6 +201,6 @@ export class LocalPlaylistsManagerService {
|
||||
}
|
||||
|
||||
public isDeepLinksEnabled(): boolean {
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
|
||||
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistered(link));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ import { BS_APP_ID, BS_DEPOT } from "../constants";
|
||||
import path from "path";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { UtilsService } from "./utils.service";
|
||||
import { ChildProcessWithoutNullStreams, spawn, spawnSync } from "child_process";
|
||||
import { spawnSync } from "child_process";
|
||||
import log from "electron-log";
|
||||
import { InstallationLocationService } from "./installation-location.service";
|
||||
import { BSLocalVersionService } from "./bs-local-version.service";
|
||||
import { WindowManagerService } from "./window-manager.service";
|
||||
import { copy } from "fs-extra";
|
||||
import { ensureFolderExist, pathExist } from "../helpers/fs.helpers";
|
||||
import { net } from "electron";
|
||||
import { copy, ensureDir } from "fs-extra";
|
||||
import { pathExist } from "../helpers/fs.helpers";
|
||||
import { Observable, map } from "rxjs";
|
||||
import { DepotDownloaderArgsOptions, DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderInfoEvent } from "../../shared/models/depot-downloader.model";
|
||||
import { DepotDownloader } from "../models/depot-downloader.class";
|
||||
|
||||
export class BSInstallerService {
|
||||
private static instance: BSInstallerService;
|
||||
@@ -19,7 +21,7 @@ export class BSInstallerService {
|
||||
private readonly localVersionService: BSLocalVersionService;
|
||||
private readonly windows: WindowManagerService;
|
||||
|
||||
private downloadProcess: ChildProcessWithoutNullStreams;
|
||||
private depotDownloader: DepotDownloader;
|
||||
|
||||
private constructor() {
|
||||
this.utils = UtilsService.getInstance();
|
||||
@@ -28,7 +30,7 @@ export class BSInstallerService {
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
|
||||
this.windows.getWindow("index.html")?.on("close", () => {
|
||||
this.killDownloadProcess();
|
||||
this.depotDownloader?.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,35 +42,16 @@ export class BSInstallerService {
|
||||
}
|
||||
|
||||
private getDepotDownloaderExePath(): string {
|
||||
return path.join(this.utils.getAssetsScriptsPath(), "depot-downloader", "DepotDownloader.exe");
|
||||
}
|
||||
|
||||
private removeSpecialSchar(txt: string): string {
|
||||
return txt.replaceAll(/[\[\]]/g, "");
|
||||
}
|
||||
|
||||
private sendDownloadEvent(event: DownloadEventType, data?: string | number, success = true): void {
|
||||
if (typeof data === "string") {
|
||||
data = this.removeSpecialSchar(data);
|
||||
}
|
||||
this.utils.ipcSend(`bs-download.${event}`, { success, data });
|
||||
}
|
||||
|
||||
public sendInputProcess(input: string) {
|
||||
if (this.downloadProcess.stdin.writable) {
|
||||
this.downloadProcess.stdin.write(`${input}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
public killDownloadProcess(): boolean {
|
||||
return this.downloadProcess.kill();
|
||||
return path.join(this.utils.getAssetsScriptsPath(), "depot-downloader", `DepotDownloader.${process.platform === 'linux' ? 'dll' : 'exe'}`);
|
||||
}
|
||||
|
||||
public async isDotNet6Installed(): Promise<boolean> {
|
||||
try {
|
||||
const process = spawnSync(this.getDepotDownloaderExePath());
|
||||
if (process.stderr.toString()) {
|
||||
log.error("no dotnet", process.stderr.toString());
|
||||
const proc = process.platform === 'linux'
|
||||
? spawnSync('dotnet', [this.getDepotDownloaderExePath()])
|
||||
: spawnSync(this.getDepotDownloaderExePath());
|
||||
if (proc.stderr?.toString()) {
|
||||
log.error("no dotnet", proc.stderr.toString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -78,95 +61,95 @@ export class BSInstallerService {
|
||||
}
|
||||
}
|
||||
|
||||
public async downloadBsVersion(downloadInfos: DownloadInfo): Promise<DownloadEvent> {
|
||||
// TODO : Can be a lot improved by using ipcV2 and Observable -- This will be reworked for qrCode login
|
||||
|
||||
if (this.downloadProcess?.connected) {
|
||||
throw "AlreadyDownloading";
|
||||
}
|
||||
const { bsVersion } = downloadInfos;
|
||||
if (!bsVersion) {
|
||||
return { type: "[Error]" };
|
||||
}
|
||||
if (!net.isOnline()) {
|
||||
throw "no-internet";
|
||||
}
|
||||
|
||||
await ensureFolderExist(this.installLocationService.versionsDirectory);
|
||||
|
||||
const versionPath = await this.localVersionService.getVersionPath(bsVersion);
|
||||
|
||||
const dest = !downloadInfos.isVerification ? await this.getPathNotAleardyExist(versionPath) : versionPath;
|
||||
private async buildDepotDownloaderInstance(downloadInfos: DownloadInfo, qr?: boolean): Promise<DepotDownloader> {
|
||||
|
||||
const versionPath = await this.localVersionService.getVersionPath(downloadInfos.bsVersion);
|
||||
const dest = downloadInfos.isVerification ? versionPath : await this.getPathNotAleardyExist(versionPath);
|
||||
const downloadVersion: BSVersion = { ...downloadInfos.bsVersion, ...(path.basename(dest) !== downloadInfos.bsVersion.BSVersion && { name: path.basename(dest) }) };
|
||||
|
||||
this.downloadProcess = spawn(this.getDepotDownloaderExePath(), ["-app", BS_APP_ID, "-depot", BS_DEPOT, "-manifest", bsVersion.BSManifest, "-username", downloadInfos.username, "-dir", this.localVersionService.getVersionFolder(downloadVersion)], { cwd: this.installLocationService.versionsDirectory });
|
||||
const depotDownloaderOptions: DepotDownloaderArgsOptions = {
|
||||
app: BS_APP_ID,
|
||||
depot: BS_DEPOT,
|
||||
dir: this.localVersionService.getVersionFolder(downloadVersion),
|
||||
manifest: downloadInfos.bsVersion.BSManifest,
|
||||
username: downloadInfos.username,
|
||||
password: downloadInfos.password,
|
||||
"remember-password": downloadInfos.stay,
|
||||
validate: downloadInfos.isVerification,
|
||||
qr
|
||||
}
|
||||
|
||||
this.utils.ipcSend("start-download-version", { success: true, data: downloadVersion });
|
||||
await ensureDir(await this.installLocationService.versionsDirectory());
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.downloadProcess.stdout.on("data", data => {
|
||||
const matched = (data.toString() as string).match(/(?:\[(.*?)\])\|(?:\[(.*?)\]\|)?(.*?)(?=$|\[)/gm) ?? [];
|
||||
matched.forEach(match => {
|
||||
const out = match.split("|");
|
||||
const isLinux = process.platform === 'linux';
|
||||
const exePath = this.getDepotDownloaderExePath();
|
||||
const args = DepotDownloader.buildArgs(depotDownloaderOptions);
|
||||
|
||||
if (out[0] !== "[Progress]" && out[0] !== "[Validated]") {
|
||||
log.info(data.toString());
|
||||
return new DepotDownloader({
|
||||
command: isLinux ? 'dotnet' : exePath,
|
||||
args: isLinux ? [exePath, ...args] : args,
|
||||
options: { cwd: await this.installLocationService.versionsDirectory() },
|
||||
echoStartData: downloadVersion
|
||||
}, log);
|
||||
}
|
||||
|
||||
private buildDepotDownloaderObservable(downloadInfos: DownloadInfo, qr?: boolean): Observable<DepotDownloaderEvent> {
|
||||
return new Observable(sub => {
|
||||
|
||||
const depotDownloaderBuildPromise = this.buildDepotDownloaderInstance(downloadInfos, qr);
|
||||
|
||||
depotDownloaderBuildPromise.then(depotDownloader => {
|
||||
|
||||
if(this.depotDownloader?.running){
|
||||
this.depotDownloader.stop();
|
||||
}
|
||||
|
||||
this.depotDownloader = depotDownloader;
|
||||
|
||||
depotDownloader.$events().pipe(map(event => {
|
||||
if(event.type === DepotDownloaderEventType.Error){
|
||||
throw event;
|
||||
}
|
||||
return event;
|
||||
})).subscribe(sub);
|
||||
|
||||
if (out[0] === ("[Password]" as DownloadEventType) && downloadInfos.password) {
|
||||
this.sendInputProcess(downloadInfos.password);
|
||||
} else if (out[0] === ("[Password]" as DownloadEventType)) {
|
||||
this.killDownloadProcess();
|
||||
resolve({ type: "[Password]", data: bsVersion });
|
||||
} else if (out[0] === ("[2FA]" as DownloadEventType) || out[0] === ("[Guard]" as DownloadEventType)) {
|
||||
this.sendDownloadEvent("[2FA]");
|
||||
} else if (out[0] === ("[Progress]" as DownloadEventType) || (out[0] === ("[Validated]" as DownloadEventType) && parseFloat(out[1]) < 100)) {
|
||||
this.sendDownloadEvent("[Progress]", parseFloat(out[1].replaceAll(",", ".")));
|
||||
} else if (out[0] === ("[Finished]" as DownloadEventType) || (out[0] === ("[Validated]" as DownloadEventType) && parseFloat(out[1]) === 100)) {
|
||||
resolve({ type: "[Finished]" });
|
||||
this.killDownloadProcess();
|
||||
} else if (out[0] === ("[SteamID]" as DownloadEventType)) {
|
||||
this.sendDownloadEvent("[SteamID]", out[1]);
|
||||
} else if (out[0] === ("[Warning]" as DownloadEventType)) {
|
||||
this.sendDownloadEvent("[Warning]", out[1]);
|
||||
} else if (out[0] === ("[Error]" as DownloadEventType)) {
|
||||
reject(this.removeSpecialSchar(out[1]));
|
||||
this.killDownloadProcess();
|
||||
log.error("Download Event, Error", data.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch(err => sub.error({
|
||||
type: DepotDownloaderEventType.Error,
|
||||
subType: DepotDownloaderErrorEvent.Unknown,
|
||||
data: err
|
||||
} as DepotDownloaderEvent));
|
||||
|
||||
this.downloadProcess.stdout.on("error", err => {
|
||||
log.error("BS-DOWNLOAD ERROR", err.toString());
|
||||
this.killDownloadProcess();
|
||||
if (err.toString().includes(".NET") || err.toString().includes("dotnet") || err.toString().includes(".dll")) {
|
||||
reject("dotnet");
|
||||
}
|
||||
reject();
|
||||
});
|
||||
this.downloadProcess.stderr.on("data", err => {
|
||||
log.error("BS-DOWNLOAD ERROR", err.toString());
|
||||
this.killDownloadProcess();
|
||||
if (err.toString().includes(".NET") || err.toString().includes("dotnet") || err.toString().includes(".dll")) {
|
||||
reject("dotnet");
|
||||
}
|
||||
reject();
|
||||
});
|
||||
this.downloadProcess.stderr.on("error", err => {
|
||||
log.error("BS-DOWNLOAD ERROR", err.toString());
|
||||
this.killDownloadProcess();
|
||||
if (err.toString().includes(".NET") || err.toString().includes("dotnet") || err.toString().includes(".dll")) {
|
||||
reject("dotnet");
|
||||
}
|
||||
reject();
|
||||
});
|
||||
|
||||
this.downloadProcess.on("close", () => reject());
|
||||
return () => {
|
||||
depotDownloaderBuildPromise.then(depotDownloader => depotDownloader.stop());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public downloadBsVersion(downloadInfos: DownloadInfo): Observable<DepotDownloaderEvent> {
|
||||
return this.buildDepotDownloaderObservable(downloadInfos);
|
||||
}
|
||||
|
||||
public autoDownloadBsVersion(downloadInfos: DownloadInfo): Observable<DepotDownloaderEvent> {
|
||||
return this.buildDepotDownloaderObservable({...downloadInfos, password: null, stay: true}).pipe(map(event => {
|
||||
if(event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.Password){
|
||||
throw new Error("Ask for password while auto download");
|
||||
}
|
||||
return event;
|
||||
}));
|
||||
}
|
||||
|
||||
public downloadBsVersionWithQRCode(downloadInfos: DownloadInfo): Observable<DepotDownloaderEvent> {
|
||||
return this.buildDepotDownloaderObservable(downloadInfos, true);
|
||||
}
|
||||
|
||||
public sendInput(input: string): boolean {
|
||||
return this.depotDownloader?.sendInput(input);
|
||||
}
|
||||
|
||||
public stopDownload(): void {
|
||||
this.depotDownloader?.stop();
|
||||
}
|
||||
|
||||
private async getPathNotAleardyExist(path: string): Promise<string> {
|
||||
let destPath = path;
|
||||
let folderExist = await pathExist(destPath);
|
||||
@@ -198,10 +181,10 @@ export class BSInstallerService {
|
||||
|
||||
export interface DownloadInfo {
|
||||
bsVersion: BSVersion;
|
||||
username: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
stay?: boolean;
|
||||
isVerification: boolean;
|
||||
isVerification?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadEvent {
|
||||
|
||||
@@ -8,10 +8,9 @@ import { BSLocalVersionService } from "./bs-local-version.service";
|
||||
import { pathExist } from "../helpers/fs.helpers";
|
||||
import { rename } from "fs/promises";
|
||||
import log from "electron-log";
|
||||
import { Observable, lastValueFrom, of, timer } from "rxjs";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { BsmProtocolService } from "./bsm-protocol.service";
|
||||
import { app, shell} from "electron";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import Color from "color";
|
||||
import { ensureDir, writeFile } from "fs-extra";
|
||||
import toIco from "to-ico";
|
||||
@@ -20,6 +19,7 @@ import { WindowManagerService } from "./window-manager.service";
|
||||
import { IpcService } from "./ipc.service";
|
||||
import { BSVersionLibService } from "./bs-version-lib.service";
|
||||
import { execOnOs } from "../helpers/env.helpers";
|
||||
import sharp from "sharp";
|
||||
|
||||
export class BSLauncherService {
|
||||
private static instance: BSLauncherService;
|
||||
@@ -50,8 +50,7 @@ export class BSLauncherService {
|
||||
|
||||
this.bsmProtocolService.on("launch", link => {
|
||||
log.info("Launch from bsm protocol", link.toString());
|
||||
const shortcutParams = objectFromEntries(link.searchParams.entries()) as ShortcutParams;
|
||||
this.openShortcutLaunchWindow(shortcutParams).catch(log.error);
|
||||
this.openShortcutLaunchWindow(this.shortcutLinkToShortcutParams(link)).catch(log.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ export class BSLauncherService {
|
||||
}
|
||||
|
||||
private buildBsLaunchArgs(launchOptions: LaunchOption): string[]{
|
||||
const launchArgs = [];
|
||||
const launchArgs = ["--no-yeet"];
|
||||
|
||||
if (launchOptions.oculus) {
|
||||
launchArgs.push("-vrmode");
|
||||
@@ -144,7 +143,6 @@ export class BSLauncherService {
|
||||
await this.backupSteamVR().catch(() => {
|
||||
return this.restoreSteamVR();
|
||||
});
|
||||
await lastValueFrom(timer(2_000));
|
||||
} else if(!launchOptions.version.oculus){
|
||||
await this.restoreSteamVR();
|
||||
}
|
||||
@@ -163,13 +161,14 @@ export class BSLauncherService {
|
||||
bsProcess.removeAllListeners("error");
|
||||
bsProcess.removeAllListeners("exit");
|
||||
resolve(-1);
|
||||
}, 4000);
|
||||
}, 30_000);
|
||||
|
||||
}).then(exitCode => {
|
||||
log.info("BS process exit code", exitCode);
|
||||
}).catch(err => {
|
||||
log.error(err);
|
||||
obs.error({type: BSLaunchError.BS_EXIT_ERROR, data: err} as BSLaunchErrorData);
|
||||
}).finally(() => {
|
||||
this.restoreSteamVR().catch(log.error);
|
||||
});
|
||||
|
||||
})().then(() => {
|
||||
@@ -179,6 +178,18 @@ export class BSLauncherService {
|
||||
})});
|
||||
}
|
||||
|
||||
public shortcutLinkToShortcutParams(shortcutLink: string|URL): ShortcutParams{
|
||||
if(typeof shortcutLink === "string"){
|
||||
shortcutLink = new URL(shortcutLink);
|
||||
}
|
||||
|
||||
return objectFromEntries(shortcutLink.searchParams.entries()) as ShortcutParams;
|
||||
}
|
||||
|
||||
public shortcutLinkToLaunchOptions(shortcutLink: string|URL): LaunchOption{
|
||||
return this.shortcutParamsToLaunchOption(this.shortcutLinkToShortcutParams(shortcutLink));
|
||||
}
|
||||
|
||||
private shortcutParamsToLaunchOption(params: ShortcutParams): LaunchOption{
|
||||
const res: LaunchOption = {
|
||||
version: {
|
||||
@@ -213,19 +224,16 @@ export class BSLauncherService {
|
||||
return res;
|
||||
}
|
||||
|
||||
private createShortcutPngBuffer(color: Color): Buffer{
|
||||
private createShortcutPngBuffer(color: Color): Promise<Buffer>{
|
||||
|
||||
const svgIcon = `
|
||||
const svgBuffer = Buffer.from(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406.4 406.4" height="406.4" width="406.4">
|
||||
<rect rx="69.453" height="406.4" width="406.4" fill="${color.hex()}"/>
|
||||
<path d="M65.467 60.6H336.4v33.867L200.933 162.2 65.467 94.467z" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return new Resvg(svgIcon, {
|
||||
fitTo: { mode: "width", value: 256 }
|
||||
}).render().asPng();
|
||||
`);
|
||||
|
||||
return sharp(svgBuffer).resize(256).png().toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,7 +259,7 @@ export class BSLauncherService {
|
||||
* @returns {Promise<string>} Path of the icon
|
||||
*/
|
||||
private async createShortcutIco(color: Color): Promise<string>{
|
||||
const pngBuffer = this.createShortcutPngBuffer(color);
|
||||
const pngBuffer = await this.createShortcutPngBuffer(color);
|
||||
const icoBuffer = await toIco([pngBuffer]);
|
||||
|
||||
await ensureDir(IMAGE_CACHE_PATH);
|
||||
@@ -263,9 +271,13 @@ export class BSLauncherService {
|
||||
return iconPath;
|
||||
}
|
||||
|
||||
public async createLaunchShortcut(launchOptions: LaunchOption): Promise<boolean>{
|
||||
public createLaunchLink(launchOptions: LaunchOption): string{
|
||||
const shortcutParams = this.launchOptionToShortcutParams(launchOptions);
|
||||
const shortcutUrl = this.bsmProtocolService.buildLink("launch", shortcutParams).toString();
|
||||
return this.bsmProtocolService.buildLink("launch", shortcutParams).toString();
|
||||
}
|
||||
|
||||
public async createLaunchShortcut(launchOptions: LaunchOption): Promise<boolean>{
|
||||
const shortcutUrl = this.createLaunchLink(launchOptions);
|
||||
|
||||
const shortcutName = ["Beat Saber", launchOptions.version.BSVersion, launchOptions.version.name].join(" ");
|
||||
const shortcutIconColor = new Color(launchOptions.version.color, "hex");
|
||||
|
||||
@@ -15,6 +15,7 @@ import { copyDirectoryWithJunctions, deleteFolder, getFoldersInFolder, pathExist
|
||||
import { FolderLinkerService } from "./folder-linker.service";
|
||||
import { ReadStream, createReadStream } from "fs-extra";
|
||||
import readline from "readline";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
export class BSLocalVersionService {
|
||||
private static instance: BSLocalVersionService;
|
||||
@@ -27,6 +28,7 @@ export class BSLocalVersionService {
|
||||
private readonly remoteVersionService: BSVersionLibService;
|
||||
private readonly configService: ConfigurationService;
|
||||
private readonly linker: FolderLinkerService;
|
||||
private readonly _loadedVersions$: Subject<BSVersion[]>;
|
||||
|
||||
public static getInstance(): BSLocalVersionService {
|
||||
if (!BSLocalVersionService.instance) {
|
||||
@@ -42,6 +44,8 @@ export class BSLocalVersionService {
|
||||
this.remoteVersionService = BSVersionLibService.getInstance();
|
||||
this.configService = ConfigurationService.getInstance();
|
||||
this.linker = FolderLinkerService.getInstance();
|
||||
|
||||
this._loadedVersions$ = new Subject<BSVersion[]>();
|
||||
}
|
||||
|
||||
private async getVersionFromGlobalGameManagerFile(versionFilePath: string): Promise<BSVersion> {
|
||||
@@ -142,7 +146,7 @@ export class BSLocalVersionService {
|
||||
if(version.oculus){ return this.oculusService.getGameFolder(OCULUS_BS_DIR); }
|
||||
|
||||
return path.join(
|
||||
this.installLocationService.versionsDirectory,
|
||||
await this.installLocationService.versionsDirectory(),
|
||||
this.getVersionFolder(version)
|
||||
);
|
||||
}
|
||||
@@ -156,7 +160,7 @@ export class BSLocalVersionService {
|
||||
const versionPath = await this.getVersionPath(version);
|
||||
if(await pathExist(versionPath)){ return versionPath; }
|
||||
|
||||
const versionFolders = await getFoldersInFolder(this.installLocationService.versionsDirectory);
|
||||
const versionFolders = await getFoldersInFolder(await this.installLocationService.versionsDirectory());
|
||||
|
||||
for(const folder of versionFolders){
|
||||
const stats = await lstat(folder);
|
||||
@@ -215,11 +219,11 @@ export class BSLocalVersionService {
|
||||
versions.push(oculusVersion);
|
||||
}
|
||||
|
||||
if (!(await pathExist(this.installLocationService.versionsDirectory))) {
|
||||
if (!(await pathExist(await this.installLocationService.versionsDirectory()))) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
const folderInInstallation = await getFoldersInFolder(this.installLocationService.versionsDirectory);
|
||||
const folderInInstallation = await getFoldersInFolder(await this.installLocationService.versionsDirectory());
|
||||
|
||||
for (const f of folderInInstallation) {
|
||||
log.info("try get version from folder", f);
|
||||
@@ -233,6 +237,8 @@ export class BSLocalVersionService {
|
||||
|
||||
this.setCustomVersions(versions.filter(v => !!v.color));
|
||||
|
||||
this._loadedVersions$.next(versions);
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
@@ -310,4 +316,8 @@ export class BSLocalVersionService {
|
||||
|
||||
return (await linkedFolder).filter(folder => folder);
|
||||
}
|
||||
|
||||
public get loadedVersions$(): Observable<BSVersion[]>{
|
||||
return this._loadedVersions$.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Subject, Subscription, filter } from "rxjs";
|
||||
import { DeepLinkService } from "./deep-link.service";
|
||||
import { URL } from "url";
|
||||
import { buildUrl, isValidUrl } from "../../shared/helpers/url.helpers";
|
||||
|
||||
export class BsmProtocolService {
|
||||
@@ -30,7 +29,7 @@ export class BsmProtocolService {
|
||||
}
|
||||
|
||||
private registerBsmProtocol(): boolean{
|
||||
if(this.deepLink.isDeepLinkRegistred(this.BSM_PROTOCOL)){ return true; }
|
||||
if(this.deepLink.isDeepLinkRegistered(this.BSM_PROTOCOL)){ return true; }
|
||||
return this.deepLink.registerDeepLink(this.BSM_PROTOCOL);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ export class ConfigurationService {
|
||||
this.locations = InstallationLocationService.getInstance();
|
||||
this.initStore();
|
||||
|
||||
this.locations.onInstallLocationUpdate(() => this.initStore());
|
||||
this.locations.onInstallLocationUpdate(() => { this.initStore() });
|
||||
}
|
||||
|
||||
private initStore() {
|
||||
const contentPath = this.locations.installationDirectory;
|
||||
private async initStore() {
|
||||
const contentPath = await this.locations.installationDirectory();
|
||||
this.store = new ElectronStore({
|
||||
cwd: contentPath,
|
||||
name: "config",
|
||||
|
||||
@@ -35,7 +35,7 @@ export class DeepLinkService {
|
||||
return app.removeAsDefaultProtocolClient(protocol);
|
||||
}
|
||||
|
||||
public isDeepLinkRegistred(protocol: string): boolean {
|
||||
public isDeepLinkRegistered(protocol: string): boolean {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
return app.isDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ export class FolderLinkerService {
|
||||
this.installLocationService = InstallationLocationService.getInstance();
|
||||
}
|
||||
|
||||
private get sharedFolder(): string {
|
||||
return this.installLocationService.sharedContentPath;
|
||||
private async sharedFolder(): Promise<string> {
|
||||
return this.installLocationService.sharedContentPath();
|
||||
}
|
||||
|
||||
private getSharedFolder(folderPath: string, intermediateFolder?: string): string {
|
||||
return path.join(this.sharedFolder, intermediateFolder ?? "", path.basename(folderPath));
|
||||
private async getSharedFolder(folderPath: string, intermediateFolder?: string): Promise<string> {
|
||||
return path.join(await this.sharedFolder(), intermediateFolder ?? "", path.basename(folderPath));
|
||||
}
|
||||
|
||||
private getBackupFolder(folderPath: string): string {
|
||||
@@ -50,7 +50,7 @@ export class FolderLinkerService {
|
||||
}
|
||||
|
||||
public async linkFolder(folderPath: string, options?: LinkOptions): Promise<void> {
|
||||
const sharedPath = this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
const sharedPath = await this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
|
||||
if (await this.isFolderSymlink(folderPath)) {
|
||||
const isTargetedToSharedPath = await readlink(folderPath)
|
||||
@@ -86,7 +86,7 @@ export class FolderLinkerService {
|
||||
}
|
||||
await unlinkPath(folderPath);
|
||||
|
||||
const sharedPath = this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
const sharedPath = await this.getSharedFolder(folderPath, options?.intermediateFolder);
|
||||
|
||||
await ensureFolderExist(folderPath);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path";
|
||||
import { app } from "electron";
|
||||
import ElectronStore from "electron-store";
|
||||
import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist } from "../helpers/fs.helpers";
|
||||
import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist, pathExist } from "../helpers/fs.helpers";
|
||||
|
||||
export class InstallationLocationService {
|
||||
private static instance: InstallationLocationService;
|
||||
@@ -27,51 +27,65 @@ export class InstallationLocationService {
|
||||
|
||||
private constructor() {
|
||||
this.installPathConfig = new ElectronStore({ watch: true });
|
||||
this.initInstallationLocation();
|
||||
|
||||
this.installPathConfig.onDidChange(this.STORE_INSTALLATION_PATH_KEY, () => {
|
||||
this.triggerListeners();
|
||||
});
|
||||
}
|
||||
|
||||
private initInstallationLocation(): void {
|
||||
this._installationDirectory = (this.installPathConfig.get<string>(this.STORE_INSTALLATION_PATH_KEY) as string) || app.getPath("documents");
|
||||
}
|
||||
|
||||
private triggerListeners(): void {
|
||||
this.updateListeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
public async setInstallationDirectory(newDir: string): Promise<string> {
|
||||
const oldDir = this.installationDirectory;
|
||||
const newDest = path.join(newDir, this.INSTALLATION_FOLDER);
|
||||
newDir = path.basename(newDir) === this.INSTALLATION_FOLDER ? path.join(newDir, "..") : newDir;
|
||||
const oldDir = await this.installationDirectory();
|
||||
|
||||
await ensureFolderExist(oldDir);
|
||||
|
||||
await copyDirectoryWithJunctions(oldDir, newDest, { overwrite: true });
|
||||
await copyDirectoryWithJunctions(oldDir, path.join(newDir, this.INSTALLATION_FOLDER), { overwrite: true });
|
||||
|
||||
this._installationDirectory = newDir;
|
||||
this.installPathConfig.set(this.STORE_INSTALLATION_PATH_KEY, newDir);
|
||||
|
||||
deleteFolder(oldDir);
|
||||
|
||||
return this.installationDirectory;
|
||||
return this.installationDirectory();
|
||||
}
|
||||
|
||||
public onInstallLocationUpdate(fn: Listener) {
|
||||
this.updateListeners.add(fn);
|
||||
}
|
||||
|
||||
public get installationDirectory(): string {
|
||||
public async installationDirectory(): Promise<string> {
|
||||
|
||||
const installParentPath = async () => {
|
||||
if(this._installationDirectory) {
|
||||
return this._installationDirectory;
|
||||
}
|
||||
|
||||
if(this.installPathConfig.has(this.STORE_INSTALLATION_PATH_KEY)) {
|
||||
return this.installPathConfig.get(this.STORE_INSTALLATION_PATH_KEY) as string;
|
||||
}
|
||||
|
||||
const oldPath = path.join(app.getPath("documents"), this.INSTALLATION_FOLDER);
|
||||
if(await pathExist(oldPath)){
|
||||
return app.getPath("documents");
|
||||
}
|
||||
|
||||
return app.getPath("home");
|
||||
};
|
||||
|
||||
this._installationDirectory = await installParentPath();
|
||||
|
||||
return path.join(this._installationDirectory, this.INSTALLATION_FOLDER);
|
||||
}
|
||||
|
||||
public get versionsDirectory(): string {
|
||||
return path.join(this.installationDirectory, this.VERSIONS_FOLDER);
|
||||
public async versionsDirectory(): Promise<string> {
|
||||
return path.join(await this.installationDirectory(), this.VERSIONS_FOLDER);
|
||||
}
|
||||
|
||||
public get sharedContentPath(): string {
|
||||
return path.join(this.installationDirectory, this.SHARED_CONTENT_FOLDER);
|
||||
public async sharedContentPath(): Promise<string> {
|
||||
return path.join(await this.installationDirectory(), this.SHARED_CONTENT_FOLDER);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { ipcMain } from "electron";
|
||||
import { Observable } from "rxjs";
|
||||
import { IpcCompleteChannel, IpcErrorChannel } from "shared/models/ipc/ipc-response.interface";
|
||||
import { IpcCompleteChannel, IpcErrorChannel, IpcTearDownChannel } from "shared/models/ipc/ipc-response.interface";
|
||||
import { AppWindow } from "shared/models/window-manager/app-window.model";
|
||||
import { WindowManagerService } from "./window-manager.service";
|
||||
import { IpcReplier } from "shared/models/ipc/ipc-request.interface";
|
||||
@@ -31,6 +31,10 @@ export class IpcService {
|
||||
return `${channel}_complete`;
|
||||
}
|
||||
|
||||
private getTearDownChannel(channel: string): IpcTearDownChannel {
|
||||
return `${channel}_teardown`;
|
||||
}
|
||||
|
||||
private buildProxyListener<T>(listener: IpcListener<T>) {
|
||||
return (event: Electron.IpcMainEvent, req: IpcRequest<T>) => {
|
||||
const window = this.windows.getAppWindowFromWebContents(event.sender);
|
||||
@@ -44,18 +48,21 @@ export class IpcService {
|
||||
}
|
||||
|
||||
private connectStream(channel: string, window: AppWindow, observable: Observable<unknown>): void {
|
||||
observable.subscribe(
|
||||
data => {
|
||||
this.send(channel, window, data);
|
||||
},
|
||||
error => {
|
||||
|
||||
const sub = observable.subscribe({
|
||||
next: data => this.send(channel, window, data),
|
||||
error: error => {
|
||||
log.error(error);
|
||||
this.send(this.getErrorChannel(channel), window, error);
|
||||
},
|
||||
() => {
|
||||
this.send(this.getCompleteChannel(channel), window);
|
||||
}
|
||||
);
|
||||
complete: () => this.send(this.getCompleteChannel(channel), window)
|
||||
})
|
||||
|
||||
ipcMain.once(this.getTearDownChannel(channel), () => sub.unsubscribe());
|
||||
|
||||
sub.add(() => {
|
||||
ipcMain.removeAllListeners(this.getTearDownChannel(channel));
|
||||
});
|
||||
}
|
||||
|
||||
public on<T>(channel: string, listener: IpcListener<T>): void {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BSLauncherService } from "../bs-launcher.service";
|
||||
import { LivEntry, LivService } from "./liv.service";
|
||||
import { LaunchOption } from "shared/models/bs-launch";
|
||||
import { app } from "electron";
|
||||
import { BSLocalVersionService } from "../bs-local-version.service";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { debounceTime, distinctUntilChanged, map } from "rxjs";
|
||||
import equal from "fast-deep-equal";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { tryit } from "../../../shared/helpers/error.helpers";
|
||||
import log from "electron-log"
|
||||
|
||||
export class LivShortcut {
|
||||
|
||||
private static instance: LivShortcut;
|
||||
|
||||
public static getInstance(): LivShortcut {
|
||||
if (!LivShortcut.instance) {
|
||||
LivShortcut.instance = new LivShortcut();
|
||||
}
|
||||
return LivShortcut.instance;
|
||||
}
|
||||
|
||||
private readonly LIV_ID_PREFIX = "BSManager";
|
||||
|
||||
private readonly liv: LivService;
|
||||
private readonly launcher: BSLauncherService;
|
||||
private readonly versionManager: BSLocalVersionService;
|
||||
|
||||
private constructor() {
|
||||
this.liv = LivService.getInstance();
|
||||
this.launcher = BSLauncherService.getInstance();
|
||||
this.versionManager = BSLocalVersionService.getInstance();
|
||||
|
||||
this.liv.isLivInstalled().then(installed => {
|
||||
if(!installed){ return; }
|
||||
|
||||
this.versionManager.loadedVersions$.pipe(debounceTime(1000), distinctUntilChanged((prev, curr) => equal(prev, curr)), map(versions => versions.filter(v => !v.steam))).subscribe(versions => {
|
||||
(async () => {
|
||||
const clearedLaunchOptions = (await this.clearLivShortcuts().catch(() => [] as LivEntry[]))?.map(entry => {
|
||||
return tryit(() => this.launcher.shortcutLinkToLaunchOptions(entry.arguments)).result;
|
||||
});
|
||||
|
||||
versions.forEach(version => this.createLivShortcut({
|
||||
...(clearedLaunchOptions?.find(launchOpt => launchOpt.version.ino === version.ino) ?? {}),
|
||||
version,
|
||||
skipAlreadyRunning: true
|
||||
}));
|
||||
})().catch(err => log.error("Error while creating LIV shortcuts", err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private buildShortcutId(version: BSVersion): string {
|
||||
const name = (() => {
|
||||
if(version.steam){ return "Steam"; }
|
||||
if(version.oculus){ return "Oculus"; }
|
||||
return version.name;
|
||||
})();
|
||||
return sanitize([this.LIV_ID_PREFIX, name, version.BSVersion].filter(Boolean).join("-").replaceAll(".", "-").replaceAll(" ", "-"));
|
||||
}
|
||||
|
||||
private buildShortcutName(version: BSVersion): string {
|
||||
const name = (() => {
|
||||
if(version.steam){ return "Steam"; }
|
||||
if(version.oculus){ return "Oculus"; }
|
||||
return version.name;
|
||||
})();
|
||||
return ["Beat Saber", name, `v${version.BSVersion}`].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
private async buildLivEntry(launchOptions: LaunchOption): Promise<LivEntry> {
|
||||
return {
|
||||
id: this.buildShortcutId(launchOptions.version),
|
||||
name: this.buildShortcutName(launchOptions.version),
|
||||
installPath: await this.versionManager.getInstalledVersionPath(launchOptions.version),
|
||||
executable: app.getPath("exe"),
|
||||
arguments: this.launcher.createLaunchLink(launchOptions)
|
||||
};
|
||||
}
|
||||
|
||||
private createLivShortcut(launchOptions: LaunchOption): Promise<void>{
|
||||
return this.buildLivEntry(launchOptions).then(entry => this.liv.createLivShortcut(entry));
|
||||
}
|
||||
|
||||
private async clearLivShortcuts(): Promise<LivEntry[]>{
|
||||
const bsmShortcuts = (await this.liv.getLivShortcuts()).reduce((acc, shortcut) => {
|
||||
if(shortcut.id?.startsWith(this.LIV_ID_PREFIX)){
|
||||
acc.push(shortcut);
|
||||
}
|
||||
return acc;
|
||||
}, [] as LivEntry[]);
|
||||
|
||||
return this.liv.deleteLivShortcuts(bsmShortcuts.map(shortcut => shortcut.id)).then(() => bsmShortcuts);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { execOnOs } from "../../helpers/env.helpers";
|
||||
import path from "path";
|
||||
import regedit from "regedit";
|
||||
|
||||
export class LivService {
|
||||
|
||||
private static instance: LivService;
|
||||
|
||||
public static getInstance(): LivService {
|
||||
if (!LivService.instance) {
|
||||
LivService.instance = new LivService();
|
||||
}
|
||||
return LivService.instance;
|
||||
}
|
||||
|
||||
private readonly livRegeditKey: string = path.join("HKCU", "SOFTWARE", "LIV.App");
|
||||
private readonly livExternalAppsRegeditKey: string = path.join(this.livRegeditKey, "ExternalApplications");
|
||||
|
||||
private constructor() {
|
||||
|
||||
}
|
||||
|
||||
public async isLivInstalled(): Promise<boolean> {
|
||||
return execOnOs({
|
||||
win32: async () => {
|
||||
const regRes = await regedit.promisified.list([this.livRegeditKey]).then(res => res[this.livRegeditKey]);
|
||||
return regRes?.exists;
|
||||
},
|
||||
}, true);
|
||||
}
|
||||
|
||||
public async createLivShortcut(entry: LivEntry): Promise<void> {
|
||||
return execOnOs({
|
||||
win32: async () => {
|
||||
const livExternalAppRegeditKey = path.join(this.livExternalAppsRegeditKey, entry.id);
|
||||
await regedit.promisified.createKey([livExternalAppRegeditKey]);
|
||||
await regedit.promisified.putValue({
|
||||
[livExternalAppRegeditKey]: {
|
||||
"InstallPath": {
|
||||
value: entry.installPath,
|
||||
type: "REG_SZ"
|
||||
},
|
||||
"Executable": {
|
||||
value: entry.executable,
|
||||
type: "REG_SZ"
|
||||
},
|
||||
"Arguments": {
|
||||
value: entry.arguments,
|
||||
type: "REG_SZ"
|
||||
},
|
||||
"Name": {
|
||||
value: entry.name,
|
||||
type: "REG_SZ"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteLivShortcuts(ids: string[]): Promise<void> {
|
||||
return execOnOs({
|
||||
win32: async () => {
|
||||
const shotcutsKeys = ids.map(id => path.join(this.livExternalAppsRegeditKey, id));
|
||||
return regedit.promisified.deleteKey(shotcutsKeys);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getLivShortcuts(): Promise<LivEntry[]> {
|
||||
return execOnOs({
|
||||
win32: async () => {
|
||||
const regRes = await regedit.promisified.list([this.livExternalAppsRegeditKey]).then(res => res[this.livExternalAppsRegeditKey]);
|
||||
|
||||
if(!regRes.exists){
|
||||
return [];
|
||||
}
|
||||
|
||||
const promises = regRes.keys.map(async key => {
|
||||
const shortcutKey = path.join(this.livExternalAppsRegeditKey, key);
|
||||
const entries = await regedit.promisified.list([shortcutKey]).then(res => res[shortcutKey]);
|
||||
|
||||
if(!entries.exists){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
name: entries.values.Name.value,
|
||||
installPath: entries.values.InstallPath.value,
|
||||
executable: entries.values.Executable.value,
|
||||
arguments: entries.values.Arguments.value
|
||||
} as LivEntry;
|
||||
});
|
||||
|
||||
return Promise.all(promises).then(entries => entries.filter(Boolean));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export interface LivEntry {
|
||||
id: string,
|
||||
name: string,
|
||||
installPath: string,
|
||||
executable: string,
|
||||
arguments: string
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
|
||||
import { app, BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron";
|
||||
import { resolveHtmlPath } from "../util";
|
||||
import { UtilsService } from "./utils.service";
|
||||
import { AppWindow } from "shared/models/window-manager/app-window.model";
|
||||
import path from "path";
|
||||
import { APP_NAME } from "../constants";
|
||||
import { isValidUrl } from "../../shared/helpers/url.helpers";
|
||||
|
||||
export class WindowManagerService {
|
||||
private static instance: WindowManagerService;
|
||||
@@ -44,6 +45,14 @@ export class WindowManagerService {
|
||||
public openWindow(windowType: AppWindow, options?: BrowserWindowConstructorOptions): Promise<BrowserWindow> {
|
||||
const window = new BrowserWindow({ ...this.appWindowsOptions[windowType], ...this.baseWindowOption, ...options });
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if(isValidUrl(url)){
|
||||
shell.openExternal(url);
|
||||
}
|
||||
|
||||
return { action: "deny"}
|
||||
})
|
||||
|
||||
const promise = window.loadURL(resolveHtmlPath(windowType));
|
||||
window.removeMenu();
|
||||
window.setMenu(null);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { useState, memo } from "react";
|
||||
import { BsDownloaderService } from "renderer/services/bs-downloader.service";
|
||||
import { distinctUntilChanged, map } from "rxjs/operators";
|
||||
import { useState, memo, ComponentProps } from "react";
|
||||
import defaultImage from "../../../../assets/images/default-version-img.jpg";
|
||||
import dateFormat from "dateformat";
|
||||
import { BsmImage } from "../shared/bsm-image.component";
|
||||
@@ -11,51 +9,38 @@ import { LinkOpenerService } from "renderer/services/link-opener.service";
|
||||
import { motion } from "framer-motion";
|
||||
import { GlowEffect } from "../shared/glow-effect.component";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export const AvailableVersionItem = memo(function AvailableVersionItem(props: { version: BSVersion }) {
|
||||
const bsDownloaderService = useService(BsDownloaderService)
|
||||
type Props = {
|
||||
version: BSVersion;
|
||||
selected: boolean;
|
||||
onClick: ComponentProps<"li">["onClick"];
|
||||
}
|
||||
|
||||
export const AvailableVersionItem = memo(function AvailableVersionItem({version, selected, onClick}: Props) {
|
||||
const linkOpener = useService(LinkOpenerService)
|
||||
|
||||
const selected = useObservable(
|
||||
bsDownloaderService.selectedBsVersion$.pipe(distinctUntilChanged(), map(version => version?.BSVersion === props.version.BSVersion)),
|
||||
false
|
||||
);
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const t = useTranslation();
|
||||
|
||||
const formatedDate = (() => {
|
||||
return dateFormat(+props.version.ReleaseDate * 1000, "ddd. d mmm yyyy");
|
||||
})();
|
||||
|
||||
const toggleSelect = () => {
|
||||
if (bsDownloaderService.isDownloading) {
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
bsDownloaderService.selectedBsVersion$.next(null);
|
||||
} else {
|
||||
bsDownloaderService.selectedBsVersion$.next(props.version);
|
||||
}
|
||||
};
|
||||
|
||||
const openReleasePage = () => {
|
||||
linkOpener.open(props.version.ReleaseURL);
|
||||
linkOpener.open(version.ReleaseURL);
|
||||
};
|
||||
|
||||
const formatedDate = (() => dateFormat(+version.ReleaseDate * 1000, "ddd. d mmm yyyy"))();
|
||||
|
||||
return (
|
||||
<motion.li className="group relative w-72 h-60 transition-transform active:scale-[.98]" onClick={toggleSelect} onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)}>
|
||||
<motion.li className="group relative w-72 h-60 transition-transform active:scale-[.98]" onClick={onClick} onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)}>
|
||||
<GlowEffect visible={hovered || selected} className="absolute" />
|
||||
<div className={`relative flex flex-col overflow-hidden rounded-md w-72 h-60 cursor-pointer group-hover:shadow-none duration-300 bg-light-main-color-2 dark:bg-main-color-2 ${!selected && "shadow-lg shadow-gray-900"}`}>
|
||||
<BsmImage image={props.version.ReleaseImg ? props.version.ReleaseImg : defaultImage} errorImage={defaultImage} placeholder={defaultImage} className="absolute top-0 right-0 w-full h-full opacity-40 blur-xl object-cover" loading="lazy" />
|
||||
<BsmImage image={props.version.ReleaseImg ? props.version.ReleaseImg : defaultImage} errorImage={defaultImage} placeholder={defaultImage} className="bg-black w-full h-3/4 object-cover" loading="lazy" />
|
||||
<BsmImage image={version.ReleaseImg ? version.ReleaseImg : defaultImage} errorImage={defaultImage} placeholder={defaultImage} className="absolute top-0 right-0 w-full h-full opacity-40 blur-xl object-cover" loading="lazy" />
|
||||
<BsmImage image={version.ReleaseImg ? version.ReleaseImg : defaultImage} errorImage={defaultImage} placeholder={defaultImage} className="bg-black w-full h-3/4 object-cover" loading="lazy" />
|
||||
<div className="z-[1] p-2 w-full flex items-center justify-between grow">
|
||||
<div>
|
||||
<h2 className="block text-xl font-bold text-white tracking-wider">{props.version.BSVersion}</h2>
|
||||
<h2 className="block text-xl font-bold text-white tracking-wider">{version.BSVersion}</h2>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-400">{formatedDate}</span>
|
||||
</div>
|
||||
{props.version.ReleaseURL && (
|
||||
{version.ReleaseURL && (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid -- link will be reworked
|
||||
<a
|
||||
onClickCapture={e => {
|
||||
@@ -72,4 +57,4 @@ export const AvailableVersionItem = memo(function AvailableVersionItem(props: {
|
||||
</div>
|
||||
</motion.li>
|
||||
);
|
||||
});
|
||||
}, equal);
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { BSVersionManagerService } from "renderer/services/bs-version-manager.service";
|
||||
import { filter, map } from "rxjs/operators";
|
||||
import { useContext } from "react";
|
||||
import { AvailableVersionItem } from "./available-version-item.component";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { AvailableVersionsContext } from "renderer/pages/available-versions-list.components";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export function AvailableVersionsSlide(props: { year: string }) {
|
||||
|
||||
const versionsService = useService(BSVersionManagerService);
|
||||
|
||||
const availableVersions = useObservable(
|
||||
versionsService.availableVersions$.pipe(filter(versions => !!versions?.length), map(() => versionsService.getAvaibleVersionsOfYear(props.year))),
|
||||
[]
|
||||
);
|
||||
type Props = {
|
||||
versions: BSVersion[]
|
||||
}
|
||||
|
||||
export function AvailableVersionsSlide({ versions }: Props) {
|
||||
|
||||
const context = useContext(AvailableVersionsContext);
|
||||
|
||||
const setSelectedVersion = (version: BSVersion) => {
|
||||
if(equal(version, context.selectedVersion)){
|
||||
return context.setSelectedVersion(null);
|
||||
}
|
||||
context.setSelectedVersion(version);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="w-full flex items-start justify-center gap-6 shrink-0 content-start flex-wrap p-4 overflow-x-hidden overflow-y-scroll scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-neutral-900">
|
||||
{availableVersions.map(version => (
|
||||
<AvailableVersionItem key={version.BSManifest} version={version} />
|
||||
{versions.map(version => (
|
||||
<AvailableVersionItem key={version.BSManifest} version={version} selected={equal(version, context.selectedVersion)} onClick={() => setSelectedVersion(version)}/>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { BSVersionManagerService } from "../../services/bs-version-manager.service";
|
||||
import { AvailableVersionsSlide } from "./available-versions-slide.component";
|
||||
import { filter, map } from "rxjs/operators";
|
||||
import { TabNavBar } from "../shared/tab-nav-bar.component";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
@@ -9,22 +8,28 @@ import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
export function AvailableVersionsSlider() {
|
||||
const versionManagerService = useService(BSVersionManagerService);
|
||||
|
||||
const availableVersions = useObservable(versionManagerService.availableVersions$);
|
||||
const [yearIndex, setYearIndex] = useState(0);
|
||||
const availableYears = useObservable(
|
||||
versionManagerService.availableVersions$.pipe(filter(versions => !!versions?.length), map(() => versionManagerService.getAvailableYears())),
|
||||
[]
|
||||
);
|
||||
|
||||
const availableYears = (() => {
|
||||
if(!availableVersions?.length) { return []; }
|
||||
return [...new Set(availableVersions.map(v => v.year))].sort((a, b) => b.localeCompare(a))
|
||||
})();
|
||||
|
||||
const setSelectedYear = (index: number) => {
|
||||
setYearIndex(index);
|
||||
};
|
||||
|
||||
const getVersionOfYear = (year: string) => {
|
||||
return availableVersions.filter(v => v.year === year).sort((a, b) => +b.ReleaseDate - +a.ReleaseDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-fit max-h-full flex flex-col items-center grow min-h-0 gap-3">
|
||||
<TabNavBar tabIndex={yearIndex} tabsText={availableYears} onTabChange={setSelectedYear} />
|
||||
<ol className="w-full min-h-0 flex transition-transform duration-300" style={{ transform: `translate(${-(yearIndex * 100)}%, 0)` }}>
|
||||
{availableYears.map(year => (
|
||||
<AvailableVersionsSlide key={year} year={year} />
|
||||
<AvailableVersionsSlide key={year} versions={getVersionOfYear(year)} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,7 @@ export const LinkButton = motion(
|
||||
!props.disabled && props.onClick?.();
|
||||
}}
|
||||
style={{ pointerEvents: props.disabled ? "none" : "auto" }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="absolute top-0 left-0 h-full w-full rounded-full brightness-50 opacity-75 dark:opacity-20 dark:filter-none" style={{ backgroundColor: linkedColor }} />
|
||||
<BsmIcon className="p-1 absolute top-0 left-0 h-full w-full !bg-transparent -rotate-45 brightness-150" icon={props.linked ? "link" : "unlink"} style={{ color: linkedColor }} />
|
||||
|
||||
@@ -12,7 +12,6 @@ import { AudioPlayerService } from "renderer/services/audio-player.service";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { map } from "rxjs/operators";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import { getMapZipUrlFromHash } from "renderer/helpers/maps-utils";
|
||||
import { BsmBasicSpinner } from "../shared/bsm-basic-spinner/bsm-basic-spinner.component";
|
||||
import defaultImage from "../../../../assets/images/default-version-img.jpg";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
@@ -73,8 +72,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
|
||||
|
||||
const songPlaying = useObservable(audioPlayer.playing$.pipe(map(playing => playing && audioPlayer.src === songUrl)));
|
||||
|
||||
const zipUrl = getMapZipUrlFromHash(hash);
|
||||
const previewUrl = mapId ? `https://skystudioapps.com/bs-viewer/?url=${zipUrl}` : null;
|
||||
const previewUrl = mapId ? `https://allpoland.github.io/ArcViewer/?id=${mapId}` : null;
|
||||
const mapUrl = mapId ? `https://beatsaver.com/maps/${mapId}` : null;
|
||||
const authorUrl = autorId ? `https://beatsaver.com/profile/${autorId}` : null;
|
||||
const createdDate = createdAt ? dateFormat(createdAt, "d mmm yyyy") : null;
|
||||
|
||||
@@ -121,6 +121,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
e.stopPropagation();
|
||||
onClickAdd(index);
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="absolute top-0 left-0 h-full w-full brightness-50 opacity-75 dark:opacity-20 dark:filter-none" style={{ backgroundColor: "currentcolor" }} />
|
||||
<motion.div className="h-full p-0.5" variants={variants}>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
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 { ModalComponent, ModalExitCode, ModalService } from "renderer/services/modale.service";
|
||||
import BeatImpatient from "../../../../../assets/images/apngs/beat-impatient.png";
|
||||
import { WhyCredentialsModal } from "./why-credentials-modal.component";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { Observable } from "rxjs";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
|
||||
import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bsm-basic-spinner.component";
|
||||
|
||||
export const LoginModal: ModalComponent<{ username: string; password: string; stay: boolean }> = ({ resolver }) => {
|
||||
export const LoginModal: ModalComponent<
|
||||
{ username: string; password: string; stay: boolean, method: "form"|"qr" },
|
||||
{ qrCode$: Observable<string>, logged$: Observable<string> }
|
||||
> = ({ resolver, data }) => {
|
||||
|
||||
const modal = useService(ModalService);
|
||||
|
||||
@@ -15,13 +21,29 @@ export const LoginModal: ModalComponent<{ username: string; password: string; st
|
||||
const [password, setPassword] = useState("");
|
||||
const [stay, setStay] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const qrCodeUrl = useObservable(data.qrCode$);
|
||||
const t = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const sub = data.logged$.subscribe({
|
||||
next: logged => {
|
||||
if(!logged) return;
|
||||
// If the logged event is emitted, it means that the user is logged in by the QR code
|
||||
resolver({ exitCode: ModalExitCode.COMPLETED, data: { username, password, stay, method: "qr" } });
|
||||
},
|
||||
error: () => resolver({ exitCode: ModalExitCode.NO_CHOICE}),
|
||||
complete: () => resolver({ exitCode: ModalExitCode.NO_CHOICE})
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const loggin = () => {
|
||||
if (!username || !password) {
|
||||
return;
|
||||
}
|
||||
resolver({ exitCode: ModalExitCode.COMPLETED, data: { username, password, stay } });
|
||||
resolver({ exitCode: ModalExitCode.COMPLETED, data: { username, password, stay, method: "form" } });
|
||||
};
|
||||
|
||||
const whyCredentials = () => {
|
||||
@@ -29,48 +51,48 @@ export const LoginModal: ModalComponent<{ username: string; password: string; st
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="max-w-xs"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
loggin();
|
||||
}}
|
||||
>
|
||||
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.steam-login.title")}</h1>
|
||||
<BsmImage className="mx-auto h-20" image={BeatImpatient} />
|
||||
|
||||
<div className="mb-2">
|
||||
<label className="block font-bold cursor-pointer tracking-wide text-gray-800 dark:text-gray-200" htmlFor="username">
|
||||
{t("modals.steam-login.inputs.username.label")}
|
||||
</label>
|
||||
<input className="w-full bg-light-main-color-1 dark:bg-main-color-1 px-1 py-[2px] rounded-md outline-none h-8" onChange={e => setUsername(e.target.value)} value={username} type="text" name="username" id="username" placeholder={t("modals.steam-login.inputs.username.placeholder")} />
|
||||
</div>
|
||||
<div className="mb-2 flex flex-col w-full">
|
||||
<label className="block font-bold cursor-pointer tracking-wide text-gray-800 dark:text-gray-200" htmlFor="password">
|
||||
{t("modals.steam-login.inputs.password.label")}
|
||||
</label>
|
||||
<div className="bg-light-main-color-1 dark:bg-main-color-1 rounded-md flex box-border h-8">
|
||||
<input className="grow px-1 py-[2px] outline-none bg-transparent" onChange={e => setPassword(e.target.value)} value={password} type={showPassword ? "text" : "password"} name="password" id="password" placeholder={t("modals.steam-login.inputs.password.placeholder")} />
|
||||
<BsmButton className="shrink-0 m-1 rounded-md p-0.5 !bg-light-main-color-3 dark:!bg-main-color-3" icon={showPassword ? "eye-cross" : "eye"} withBar={false} onClick={() => setShowPassword(prev => !prev)} />
|
||||
<form className="w-[40rem] text-gray-800 dark:text-gray-200" onSubmit={e => {e.preventDefault(); loggin()}}>
|
||||
<h1 className="text-3xl uppercase tracking-wide w-full text-center mb-5">{t("modals.steam-login.title")}</h1>
|
||||
<div className="flex justify-center items-stretch gap-5 min-w-0">
|
||||
<div className="grow flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="block font-bold cursor-pointer tracking-wide" htmlFor="username">
|
||||
{t("modals.steam-login.inputs.username.label")}
|
||||
</label>
|
||||
<input className="w-full bg-light-main-color-1 dark:bg-main-color-1 px-1 py-[2px] rounded-md outline-none h-9" onChange={e => setUsername(e.target.value)} value={username} type="text" name="username" id="username" placeholder={t("modals.steam-login.inputs.username.placeholder")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-bold cursor-pointer tracking-wide" htmlFor="password">
|
||||
{t("modals.steam-login.inputs.password.label")}
|
||||
</label>
|
||||
<div className="bg-light-main-color-1 dark:bg-main-color-1 rounded-md flex box-border h-9">
|
||||
<input className="grow px-1 py-[2px] outline-none bg-transparent" onChange={e => setPassword(e.target.value)} value={password} type={showPassword ? "text" : "password"} name="password" id="password" placeholder={t("modals.steam-login.inputs.password.placeholder")} />
|
||||
<BsmButton className="shrink-0 m-1 rounded-md p-0.5 !bg-light-main-color-3 dark:!bg-main-color-3" icon={showPassword ? "eye-cross" : "eye"} withBar={false} onClick={() => setShowPassword(prev => !prev)} />
|
||||
</div>
|
||||
{password?.length > 64 && <span className="text-orange-700 dark:text-orange-400 text-xs whitespace-normal min-w-0">{t("modals.steam-login.inputs.password.max-length-warning")}</span>}
|
||||
</div>
|
||||
<div className="flex flex-row justify-start items-center gap-1.5 py-3">
|
||||
<BsmCheckbox className="relative z-[1] w-6 aspect-square" checked={stay} onChange={enable => setStay(() => enable)}/>
|
||||
<span>{t("modals.steam-login.inputs.stay")}</span>
|
||||
</div>
|
||||
<BsmButton typeColor="primary" className="rounded-md text-center transition-all h-10" type="submit" withBar={false} text="modals.steam-login.buttons.submit" />
|
||||
<div className="grow flex justify-center items-center gap-1 flex-col">
|
||||
<a className="flex justify-center items-center text-[.85rem] underline" href="https://help.steampowered.com/wizard/HelpWithLogin" target="_blank">{t("modals.steam-login.need-help-to-connect")}</a>
|
||||
<span className="flex justify-center items-center text-[.85rem] underline cursor-pointer" onClick={whyCredentials}>{t("modals.steam-login.why-credentials")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col max-w-[13rem]">
|
||||
<span className="block font-bold cursor-pointer tracking-wide ">{t("modals.steam-login.inputs.qr.label")}</span>
|
||||
<div className="w-52 h-52 p-3 bg-light-main-color-1 dark:bg-white rounded-md max-w-xs mb-1 flex items-center justify-center">
|
||||
{(qrCodeUrl ?
|
||||
<QRCodeSVG className="w-full h-full text-light-main-color-1 dark:text-white" value={qrCodeUrl} bgColor="currentColor" level="H"/> :
|
||||
<BsmBasicSpinner className="w-full h-full p-11 text-neutral-300" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-center whitespace-break-spaces text-[.85rem]">
|
||||
{t("modals.steam-login.inputs.qr.note.use-the")}<a className="underline" href="https://store.steampowered.com/mobile" target="_blank">{t("modals.steam-login.inputs.qr.note.steam-mobile-app")}</a> {t("modals.steam-login.inputs.qr.note.to-connect-with-qr")}
|
||||
</span>
|
||||
</div>
|
||||
{password?.length > 64 && <span className="text-orange-700 dark:text-orange-400 text-xs whitespace-normal max-w-full w-full overflow-hidden">{t("modals.steam-login.inputs.password.max-length-warning")}</span>}
|
||||
</div>
|
||||
|
||||
<span onClick={whyCredentials} className="underline my-4 block cursor-pointer">
|
||||
{t("modals.steam-login.why-credentials")}
|
||||
</span>
|
||||
|
||||
<div className="grid grid-flow-col grid-cols-2 gap-4">
|
||||
<BsmButton
|
||||
typeColor="cancel"
|
||||
className="rounded-md text-center transition-all"
|
||||
onClick={() => {
|
||||
resolver({ exitCode: ModalExitCode.CANCELED });
|
||||
}}
|
||||
withBar={false}
|
||||
text="misc.cancel"
|
||||
/>
|
||||
<BsmButton typeColor="primary" className="rounded-md text-center transition-all" type="submit" withBar={false} text="modals.steam-login.buttons.submit" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -25,11 +25,9 @@ export const ShareFoldersModal: ModalComponent<void, BSVersion> = ({ data }) =>
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>(Array.from(new Set([...config.get<string[]>(SHARED_FOLDERS_KEY)]).values()));
|
||||
|
||||
useEffect(() => {
|
||||
setFolders(prev => Array.from(new Set([...prev, ...config.get<string[]>(SHARED_FOLDERS_KEY)]).values()));
|
||||
|
||||
linker
|
||||
.getLinkedFolders(data, { relative: true })
|
||||
.toPromise()
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ModalComponent, ModalExitCode } from "../../../services/modale.service";
|
||||
import BeatWaiting from "../../../../../assets/images/apngs/beat-waiting.png";
|
||||
import LoginMobileAuthImage from "../../../../../assets/images/steam/login_mobile_auth.png";
|
||||
import { BsmImage } from "renderer/components/shared/bsm-image.component";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { Observable } from "rxjs";
|
||||
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
|
||||
|
||||
export const SteamMobileApproveModal: ModalComponent<void, { logged$: Observable<unknown> }> = ({ resolver, data }) => {
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
useOnUpdate(() => {
|
||||
const sub = data.logged$.subscribe({
|
||||
next: () => resolver({ exitCode: ModalExitCode.COMPLETED }),
|
||||
error: () => resolver({ exitCode: ModalExitCode.NO_CHOICE }),
|
||||
complete: () => resolver({ exitCode: ModalExitCode.NO_CHOICE }),
|
||||
});
|
||||
|
||||
return () => sub.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form className="max-w-md flex flex-col items-center">
|
||||
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200 m-0">{t("modals.steam-auth-approve.title")}</h1>
|
||||
|
||||
<p className="font-bold my-3">{t("modals.steam-auth-approve.protected-by-mobile-auth")}</p>
|
||||
|
||||
<div className="relative flex w-full bg-light-main-color-1 dark:bg-main-color-1 rounded-md gap-5">
|
||||
<BsmImage className="w-fit ml-7 mt-5" image={LoginMobileAuthImage}/>
|
||||
<div className="flex flex-col justify-center gap-5 pr-2">
|
||||
<p className="text-lg">{t("modals.steam-auth-approve.use-steam-app-to-approve")}</p>
|
||||
</div>
|
||||
<BsmImage className="absolute bottom-1 right-1 w-10 h-10 spin-loading" image={BeatWaiting}/>
|
||||
</div>
|
||||
<a className="underline text-sm mt-2.5" href="https://help.steampowered.com/wizard/HelpWithLoginInfo?lost=8&issueid=402" target="_blank">{t("modals.steam-auth-approve.not-access-to-steam-app")}</a>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -112,7 +112,7 @@ function ModelItemElement<T = unknown>(props: Props<T>) {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.li className={`relative flex-grow min-w-[16rem] h-64 cursor-pointer ${props.className ?? ""}`} onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)}>
|
||||
<motion.li className={`relative flex-grow min-w-[16rem] h-64 cursor-pointer text-gray-200 ${props.className ?? ""}`} onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)}>
|
||||
<GlowEffect visible={props.selected || hovered} />
|
||||
<div className="absolute top-0 left-0 w-full h-full rounded-lg overflow-hidden blur-none bg-black shadow-sm shadow-black">
|
||||
<div ref={ref} className="contents">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { BsDownloaderService } from "renderer/services/bs-downloader.service";
|
||||
import { useEffect, useState } from "react";
|
||||
import { combineLatest, Subscription } from "rxjs";
|
||||
import { useState } from "react";
|
||||
import { distinctUntilChanged, map, of, Subscription, switchMap } from "rxjs";
|
||||
import { BSLauncherService, LaunchMods } from "renderer/services/bs-launcher.service";
|
||||
import { ConfigurationService } from "renderer/services/configuration.service";
|
||||
import { BSUninstallerService } from "renderer/services/bs-uninstaller.service";
|
||||
@@ -13,6 +13,8 @@ import { NavBarItem } from "./nav-bar-item.component";
|
||||
import useFitText from "use-fit-text";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
|
||||
|
||||
export function BsVersionItem(props: { version: BSVersion }) {
|
||||
const downloaderService = useService(BsDownloaderService);
|
||||
@@ -24,10 +26,28 @@ export function BsVersionItem(props: { version: BSVersion }) {
|
||||
const { state } = useLocation() as { state: BSVersion };
|
||||
const { fontSize, ref } = useFitText();
|
||||
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadPercent, setDownloadPercent] = useState(0);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const secondColor = useThemeColor("second-color");
|
||||
|
||||
useOnUpdate(() => {
|
||||
const subs: Subscription[] = []
|
||||
|
||||
subs.push(downloaderService.currentBsVersionDownload$.pipe(map(download => equal(download, props.version)), distinctUntilChanged()).subscribe(isDownloading => {
|
||||
setIsDownloading(() => isDownloading);
|
||||
}));
|
||||
|
||||
subs.push(downloaderService.currentBsVersionDownload$.pipe(
|
||||
map(download => equal(download, props.version)),
|
||||
distinctUntilChanged(),
|
||||
switchMap(isDownloading => isDownloading ? downloaderService.downloadProgress$ : of(0)),
|
||||
).subscribe(progress => {
|
||||
setDownloadProgress(() => progress);
|
||||
}));
|
||||
|
||||
return () => subs.forEach(sub => sub.unsubscribe());
|
||||
}, [props.version]);
|
||||
|
||||
const isActive = (): boolean => {
|
||||
return props.version?.BSVersion === state?.BSVersion && props?.version.steam === state?.steam && props?.version.oculus === state?.oculus && props?.version.name === state?.name;
|
||||
};
|
||||
@@ -44,33 +64,12 @@ export function BsVersionItem(props: { version: BSVersion }) {
|
||||
const cancel = () => {
|
||||
const versionDownload = downloaderService.currentBsVersionDownload$.value;
|
||||
const wasVerification = downloaderService.isVerification;
|
||||
downloaderService.cancelDownload().then(async res => {
|
||||
if (!res.success) {
|
||||
return;
|
||||
}
|
||||
if (!wasVerification) {
|
||||
bsUninstallerService.uninstall(versionDownload).then(res => res && verionManagerService.askInstalledVersions());
|
||||
}
|
||||
downloaderService.stopDownload().then(() => {
|
||||
if(wasVerification){ return; }
|
||||
bsUninstallerService.uninstall(versionDownload).then(res => res && verionManagerService.askInstalledVersions());
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const subs: Subscription[] = [];
|
||||
const downloadSub = combineLatest([downloaderService.currentBsVersionDownload$, downloaderService.downloadProgress$]).subscribe(vals => {
|
||||
if (vals[0]?.BSVersion === props.version.BSVersion && vals[0]?.steam === props.version.steam && vals[0].oculus === props.version.oculus && vals[0]?.name === props.version.name) {
|
||||
setDownloading(true);
|
||||
setDownloadPercent(vals[1]);
|
||||
} else {
|
||||
setDownloading(false);
|
||||
setDownloadPercent(0);
|
||||
}
|
||||
});
|
||||
subs.push(downloadSub);
|
||||
return () => {
|
||||
subs.forEach(s => s.unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderIcon = () => {
|
||||
const classes = "w-[19px] h-[19px] mr-[5px] shrink-0";
|
||||
if (props.version.steam) {
|
||||
@@ -100,7 +99,7 @@ export function BsVersionItem(props: { version: BSVersion }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NavBarItem onCancel={cancel} progress={downloading ? downloadPercent : 0} isActive={isActive() && !downloading} isDownloading={downloading}>
|
||||
<NavBarItem onCancel={cancel} progress={downloadProgress} isActive={isActive() && !isDownloading} isDownloading={isDownloading}>
|
||||
{props.version.name ? (
|
||||
<Tippy content={props.version.BSVersion} placement="right-end" arrow={false} className="font-bold !bg-neutral-900" duration={[100, 0]} animation="shift-away-subtle">
|
||||
<Link onDoubleClick={handleDoubleClick} to={`/bs-version/${props.version.BSVersion}`} state={props.version} className="w-full flex items-center justify-start content-center max-w-full">
|
||||
|
||||
@@ -11,21 +11,37 @@ import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { BsDownloaderService } from "renderer/services/bs-downloader.service";
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import equal from "fast-deep-equal";
|
||||
|
||||
export function NavBar() {
|
||||
const bsVersionServoce = useService(BSVersionManagerService);
|
||||
const versionManager = useService(BSVersionManagerService);
|
||||
const versionDownloader = useService(BsDownloaderService);
|
||||
|
||||
const downloadingVersion = useObservable(versionDownloader.currentBsVersionDownload$.pipe(distinctUntilChanged(equal)));
|
||||
const installedVersions = useObservable(versionManager.installedVersions$);
|
||||
|
||||
const installedVersions = useObservable(bsVersionServoce.installedVersions$);
|
||||
const color = useThemeColor("first-color");
|
||||
const t = useTranslation();
|
||||
|
||||
function listVersions(){
|
||||
const versions = Array.isArray(installedVersions) ? [...installedVersions] : [];
|
||||
|
||||
if (downloadingVersion){ versions.push(downloadingVersion); }
|
||||
|
||||
const sorted = BSVersionManagerService.sortVersions(versions);
|
||||
|
||||
return BSVersionManagerService.removeDuplicateVersions(sorted);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav id="nav-bar" className="z-10 flex flex-col h-full max-h-full items-center p-1">
|
||||
<BsManagerIcon className="relative aspect-square w-16 h-16 mb-3" />
|
||||
<ol id="versions" className="w-fit max-w-[120px] relative left-[2px] grow overflow-y-hidden scrollbar-track-transparent scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-neutral-900 hover:overflow-y-scroll">
|
||||
<SharedNavBarItem />
|
||||
<NavBarSpliter />
|
||||
{installedVersions?.map(version => (
|
||||
{listVersions().map(version => (
|
||||
<BsVersionItem key={JSON.stringify(version)} version={version} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function BsmButton({ className, style, imgClassName, iconClassName, icon,
|
||||
const handleClick = (e: MouseEvent<HTMLDivElement>) => !disabled && onClick?.(e);
|
||||
|
||||
return (
|
||||
<div ref={ref} onClick={handleClick} title={t(title)} className={`${className} overflow-hidden cursor-pointer group ${!withBar && !disabled && (!!typeColor || !!color) && "hover:brightness-[1.15]"} ${disabled && "brightness-75 cursor-not-allowed"} ${renderTypeColor}`} style={{ ...style, backgroundColor: primaryColor || color }}>
|
||||
<div ref={ref} onClick={handleClick} title={t(title)} className={`${className} overflow-hidden group ${!withBar && !disabled && (!!typeColor || !!color) && "hover:brightness-[1.15]"} ${disabled ? "brightness-75 cursor-not-allowed" : "cursor-pointer"} ${renderTypeColor}`} style={{ ...style, backgroundColor: primaryColor || color }}>
|
||||
{image && <BsmImage image={image} className={imgClassName} />}
|
||||
{icon && <BsmIcon icon={icon} className={iconClassName ?? "h-full w-full text-gray-800 dark:text-white"} style={{ color: iconColor || textColor }} />}
|
||||
{text &&
|
||||
|
||||
@@ -18,7 +18,7 @@ export function BsmIframeView() {
|
||||
<div className="top-0 absolute w-screen h-screen flex content-center items-center justify-center z-[150]">
|
||||
<motion.span className="absolute w-full h-full bg-black top-0 right-0" initial={{ opacity: 0 }} animate={{ opacity: 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} />
|
||||
<motion.div ref={ref} className="z-[1] w-[calc(100vw-250px)] h-[calc(100vh-250px)] shadow-black shadow-md rounded-md overflow-hidden bg-black" initial={{ y: "100vh" }} animate={{ y: 0 }} exit={{ y: "100vh" }}>
|
||||
<iframe className="w-full h-full" src={iframeUrl} />
|
||||
<iframe className="w-full h-full" src={iframeUrl} allowFullScreen/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function TitleBar({ template = "index.html" }: { template: AppWin
|
||||
return setPreviewVersion("BETA");
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ export function LaunchSlide({ version }: Props) {
|
||||
configService.set("additionnal-args", additionalArgsString);
|
||||
}, [additionalArgsString]);
|
||||
|
||||
useEffect(() => {
|
||||
if(desktopMode){ return; }
|
||||
bsLauncherService.restoreSteamVR();
|
||||
}, [desktopMode]);
|
||||
|
||||
const setMode = (mode: LaunchMods, value: boolean) => {
|
||||
if (mode === LaunchMods.DEBUG_MOD) {
|
||||
setDebugMode(value);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { BsvMapDetail } from "shared/models/maps";
|
||||
|
||||
export function getMapZipUrlFromMapDetails(map: BsvMapDetail) {
|
||||
const { hash } = map.versions.at(0);
|
||||
return getMapZipUrlFromHash(hash);
|
||||
}
|
||||
|
||||
export function getMapZipUrlFromHash(hash: string) {
|
||||
return `https://r2cdn.beatsaver.com/${hash}.zip`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AvailableVersionsSlider } from "../components/available-versions/available-versions-slider.component";
|
||||
import { BsDownloaderService } from "../services/bs-downloader.service";
|
||||
import { Slideshow } from "renderer/components/slideshow/slideshow.component";
|
||||
import { useState } from "react";
|
||||
import { createContext, useMemo, useState } from "react";
|
||||
import { BsmButton } from "renderer/components/shared/bsm-button.component";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
@@ -12,28 +12,32 @@ import { ModalExitCode, ModalService } from "renderer/services/modale.service";
|
||||
import { ImportVersionModal } from "renderer/components/modal/modal-types/import-version-modal.component";
|
||||
import { IpcService } from "renderer/services/ipc.service";
|
||||
import { NotificationService } from "renderer/services/notification.service";
|
||||
import { timer } from "rxjs";
|
||||
import { lastValueFrom, map } from "rxjs";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
|
||||
export const AvailableVersionsContext = createContext<{ selectedVersion: BSVersion; setSelectedVersion: (version: BSVersion) => void }>(null);
|
||||
|
||||
export function AvailableVersionsList() {
|
||||
const bsDownloaderService = useService(BsDownloaderService);
|
||||
const versionManagerService = useService(BSVersionManagerService);
|
||||
const bsDownloader = useService(BsDownloaderService);
|
||||
const versionManager = useService(BSVersionManagerService);
|
||||
const progressBar = useService(ProgressBarService);
|
||||
const modal = useService(ModalService);
|
||||
const ipc = useService(IpcService);
|
||||
const installer = useService(BsDownloaderService);
|
||||
const notification = useService(NotificationService);
|
||||
|
||||
const versionSelected = useObservable(bsDownloaderService.selectedBsVersion$);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [selectedVersion, setSelectedVersion] = useState<BSVersion>(null);
|
||||
const contextValue = useMemo(() => ({ selectedVersion, setSelectedVersion }), [selectedVersion]);
|
||||
|
||||
const downloading = useObservable(bsDownloader.currentBsVersionDownload$.pipe(map(v => !!v)));
|
||||
const t = useTranslation();
|
||||
|
||||
const startDownload = () => {
|
||||
setDownloading(() => true);
|
||||
bsDownloaderService.download(versionSelected).finally(() => {
|
||||
setDownloading(() => false);
|
||||
});
|
||||
bsDownloader.downloadBsVersion(selectedVersion)
|
||||
.catch(() => {})
|
||||
.finally(() => setSelectedVersion(null));
|
||||
};
|
||||
|
||||
const importVersion = async () => {
|
||||
@@ -47,7 +51,7 @@ export function AvailableVersionsList() {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderRes = await ipc.sendV2<{ canceled: boolean; filePaths: string[] }>("choose-folder").toPromise();
|
||||
const folderRes = await lastValueFrom(ipc.sendV2<{ canceled: boolean; filePaths: string[] }>("choose-folder"))
|
||||
|
||||
if (!folderRes || folderRes.canceled || !folderRes.filePaths?.length) {
|
||||
return;
|
||||
@@ -62,10 +66,9 @@ export function AvailableVersionsList() {
|
||||
const imported = await installer.importVersion(toImport);
|
||||
|
||||
if (imported) {
|
||||
versionManagerService.askInstalledVersions();
|
||||
versionManager.askInstalledVersions();
|
||||
notification.notifySuccess({ title: "notifications.bs-import-version.success.imported.title", duration: 3_000 });
|
||||
progressBar.complete();
|
||||
await timer(400).toPromise();
|
||||
} else {
|
||||
notification.notifyError({ title: "notifications.types.error", desc: "notifications.bs-import-version.errors.import-error.desc" });
|
||||
}
|
||||
@@ -75,13 +78,16 @@ 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>
|
||||
|
||||
<AvailableVersionsSlider />
|
||||
|
||||
<AvailableVersionsContext.Provider value={contextValue}>
|
||||
<AvailableVersionsSlider />
|
||||
</AvailableVersionsContext.Provider>
|
||||
|
||||
<AnimatePresence>
|
||||
{versionSelected && !downloading && (
|
||||
{selectedVersion && !downloading && (
|
||||
<motion.div initial={{ y: "150%" }} animate={{ y: "0%" }} exit={{ y: "150%" }} className="absolute bottom-5" onClick={startDownload}>
|
||||
<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>
|
||||
@@ -92,7 +98,7 @@ export function AvailableVersionsList() {
|
||||
className="absolute top-5 right-5 h-9 w-9 bg-light-main-color-2 dark:bg-main-color-2 rounded-md"
|
||||
icon="settings"
|
||||
items={[
|
||||
{ icon: "sync", text: "pages.available-versions.dropdown.refresh", onClick: () => versionManagerService.askAvailableVersions() },
|
||||
{ icon: "sync", text: "pages.available-versions.dropdown.refresh", onClick: () => versionManager.askAvailableVersions() },
|
||||
{ icon: "download", text: "pages.available-versions.dropdown.import-version", onClick: importVersion },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,8 @@ import { VersionFolderLinkerService } from "renderer/services/version-folder-lin
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
import { BsmException } from "shared/models/bsm-exception.model";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
import { AuthUserService } from "renderer/services/auth-user.service";
|
||||
|
||||
export function SettingsPage() {
|
||||
|
||||
@@ -50,6 +52,7 @@ export function SettingsPage() {
|
||||
const playlistsManager = useService(PlaylistsManagerService);
|
||||
const modelsManager = useService(ModelsManagerService);
|
||||
const versionLinker = useService(VersionFolderLinkerService);
|
||||
const authService = useService(AuthUserService);
|
||||
|
||||
const { firstColor, secondColor } = useThemeColor();
|
||||
|
||||
@@ -66,6 +69,9 @@ export function SettingsPage() {
|
||||
})
|
||||
.sort((a, b) => a.text.localeCompare(b.text));
|
||||
|
||||
const nav = useNavigate();
|
||||
const t = useTranslation();
|
||||
|
||||
const [themeIdSelected, setThemeIdSelected] = useState(themeItem.find(e => e.value === themeService.getTheme()).id);
|
||||
const [languageSelected, setLanguageSelected] = useState(languagesItems.find(e => e.value === i18nService.currentLanguage).id);
|
||||
const [installationFolder, setInstallationFolder] = useState(null);
|
||||
@@ -73,13 +79,11 @@ export function SettingsPage() {
|
||||
const [mapDeepLinksEnabled, setMapDeepLinksEnabled] = useState(false);
|
||||
const [playlistsDeepLinkEnabled, setPlaylistsDeepLinkEnabled] = useState(false);
|
||||
const [modelsDeepLinkEnabled, setModelsDeepLinkEnabled] = useState(false);
|
||||
const [appVersion, setAppVersion] = useState("");
|
||||
const nav = useNavigate();
|
||||
const t = useTranslation();
|
||||
const appVersion = useObservable(ipcService.sendV2<string>("current-version"));
|
||||
const steamSessionExist = useObservable(authService.sessionExist$);
|
||||
|
||||
useEffect(() => {
|
||||
loadInstallationFolder();
|
||||
lastValueFrom(ipcService.sendV2<string>("current-version")).then(res => setAppVersion(res));
|
||||
mapsManager.isDeepLinksEnabled().then(enabled => setMapDeepLinksEnabled(() => enabled));
|
||||
playlistsManager.isDeepLinksEnabled().then(enabled => setPlaylistsDeepLinkEnabled(() => enabled));
|
||||
modelsManager.isDeepLinksEnabled().then(enabled => setModelsDeepLinkEnabled(() => enabled));
|
||||
@@ -203,6 +207,10 @@ export function SettingsPage() {
|
||||
<BsmButton className="inline-block grow-0 bg-transparent sticky h-full w-full top-20 right-20 !m-0 rounded-full p-1" onClick={() => nav(-1)} icon="close" withBar={false} />
|
||||
</div>
|
||||
|
||||
<SettingContainer title="pages.settings.steam.title" description="pages.settings.steam.description">
|
||||
<BsmButton onClick={() => authService.deleteSteamSession()} className="w-fit px-3 py-[2px] text-white rounded-md" withBar={false} text="pages.settings.steam.logout" typeColor="error" disabled={!steamSessionExist}/>
|
||||
</SettingContainer>
|
||||
|
||||
<SettingContainer title="pages.settings.appearance.title" description="pages.settings.appearance.description">
|
||||
<div className="relative w-full h-8 bg-light-main-color-1 dark:bg-main-color-1 flex justify-center rounded-md py-1">
|
||||
<SettingColorChooser color={firstColor} onChange={setFirstColorSetting} />
|
||||
|
||||
@@ -43,7 +43,7 @@ export function VersionViewer() {
|
||||
navigate(`/bs-version/${version.BSVersion}`, { state: version });
|
||||
};
|
||||
const openFolder = () => ipcService.sendLazy("bs-version.open-folder", { args: state });
|
||||
const verifyFiles = () => bsDownloaderService.download(state, true);
|
||||
const verifyFiles = () => bsDownloaderService.verifyBsVersionFiles(state);
|
||||
|
||||
const uninstall = async () => {
|
||||
const modalCompleted = await modalService.openModal(UninstallModal, state);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { DownloadEvent, DownloadInfo } from "main/services/bs-installer.service";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { distinctUntilChanged, filter, throttleTime } from "rxjs/operators";
|
||||
import { IpcResponse } from "shared/models/ipc";
|
||||
import { DownloadInfo } from "main/services/bs-installer.service";
|
||||
import { BehaviorSubject, Observable, ReplaySubject, Subscription, lastValueFrom, throwError } from "rxjs";
|
||||
import { filter, map, share, take, tap, throttleTime } from "rxjs/operators";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { AuthUserService } from "./auth-user.service";
|
||||
import { BSVersionManagerService } from "./bs-version-manager.service";
|
||||
@@ -12,6 +11,8 @@ import { ProgressBarService } from "./progress-bar.service";
|
||||
import { LoginModal } from "renderer/components/modal/modal-types/login-modal.component";
|
||||
import { GuardModal } from "renderer/components/modal/modal-types/guard-modal.component";
|
||||
import { LinkOpenerService } from "./link-opener.service";
|
||||
import { DepotDownloaderErrorEvent, DepotDownloaderEvent, DepotDownloaderEventType, DepotDownloaderInfoEvent, DepotDownloaderWarningEvent } from "../../shared/models/depot-downloader.model";
|
||||
import { SteamMobileApproveModal } from "renderer/components/modal/modal-types/steam-mobile-approve-modal.component";
|
||||
|
||||
export class BsDownloaderService {
|
||||
private static instance: BsDownloaderService;
|
||||
@@ -24,11 +25,9 @@ export class BsDownloaderService {
|
||||
private readonly notificationService: NotificationService;
|
||||
private readonly linkOpener: LinkOpenerService;
|
||||
|
||||
private _isVerification: boolean = false;
|
||||
|
||||
public readonly currentBsVersionDownload$: BehaviorSubject<BSVersion> = new BehaviorSubject(null);
|
||||
public readonly downloadProgress$: BehaviorSubject<number> = new BehaviorSubject(0);
|
||||
public readonly selectedBsVersion$: BehaviorSubject<BSVersion> = new BehaviorSubject(null);
|
||||
private readonly isVerification$ = new BehaviorSubject(false);
|
||||
public readonly currentBsVersionDownload$ = new BehaviorSubject<BSVersion>(null);
|
||||
public readonly downloadProgress$ = new BehaviorSubject(0);
|
||||
|
||||
public static getInstance(): BsDownloaderService {
|
||||
if (!BsDownloaderService.instance) {
|
||||
@@ -45,81 +44,10 @@ export class BsDownloaderService {
|
||||
this.progressBarService = ProgressBarService.getInstance();
|
||||
this.notificationService = NotificationService.getInstance();
|
||||
this.linkOpener = LinkOpenerService.getInstance();
|
||||
this.asignListerners();
|
||||
}
|
||||
|
||||
private asignListerners(): void {
|
||||
this.ipcService
|
||||
.watch<number>("bs-download.[Progress]")
|
||||
.pipe(distinctUntilChanged())
|
||||
.subscribe(response => this.downloadProgress$.next(response.data));
|
||||
|
||||
this.ipcService
|
||||
.watch<string>("bs-download.[SteamID]")
|
||||
.pipe(filter(r => r.success && !!r.data))
|
||||
.subscribe(response => this.authService.setSteamID(response.data));
|
||||
|
||||
this.ipcService
|
||||
.watch<string>("bs-download.[Warning]")
|
||||
.pipe(
|
||||
filter(v => !!v && !!v.data),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(10_000)
|
||||
)
|
||||
.subscribe(warning => {
|
||||
this.notificationService.notifyWarning({ title: "notifications.types.warning", desc: `notifications.bs-download.warnings.msg.${warning.data}` });
|
||||
});
|
||||
|
||||
this.ipcService
|
||||
.watch<string>("bs-download.[Error]")
|
||||
.pipe(
|
||||
filter(v => !!v && !!v.data),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000)
|
||||
)
|
||||
.subscribe(err => {
|
||||
this.notificationService.notifyError({ title: "notifications.types.error", desc: `notifications.bs-download.errors.msg.${err.data}` });
|
||||
});
|
||||
|
||||
this.ipcService.watch<void>("bs-download.[2FA]").subscribe(async response => {
|
||||
if (!response.success) {
|
||||
this.ipcService.sendLazy("bs-download.kill");
|
||||
return;
|
||||
}
|
||||
const res = await this.modalService.openModal(GuardModal);
|
||||
if (res.exitCode !== ModalExitCode.COMPLETED) {
|
||||
this.progressBarService.hide(true);
|
||||
this.ipcService.sendLazy("bs-download.kill");
|
||||
return;
|
||||
}
|
||||
this.ipcService.sendLazy("bs-download.[2FA]", { args: res.data });
|
||||
});
|
||||
|
||||
this.ipcService.watch<BSVersion>("start-download-version").subscribe(res => {
|
||||
this.currentBsVersionDownload$.next(res.data);
|
||||
});
|
||||
|
||||
this.currentBsVersionDownload$.subscribe(version => {
|
||||
if (version) {
|
||||
this.bsVersionManager.setInstalledVersions([...this.bsVersionManager.installedVersions$.value, version]);
|
||||
} else {
|
||||
this.bsVersionManager.askInstalledVersions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetDownload(): void {
|
||||
this.currentBsVersionDownload$.next(null);
|
||||
this.downloadProgress$.next(0);
|
||||
this.selectedBsVersion$.next(null);
|
||||
}
|
||||
|
||||
public cancelDownload(): Promise<IpcResponse<boolean>> {
|
||||
return this.ipcService.send<boolean>("bs-download.kill");
|
||||
}
|
||||
|
||||
public isDotNet6Installed(): Promise<boolean> {
|
||||
return this.ipcService.send<boolean>("is-dotnet-6-installed").then(res => res.success && res.data);
|
||||
return lastValueFrom(this.ipcService.sendV2<boolean>("is-dotnet-6-installed"));
|
||||
}
|
||||
|
||||
private async showDotNetNotInstalledError(): Promise<void> {
|
||||
@@ -135,66 +63,16 @@ export class BsDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
public async download(bsVersion: BSVersion, isVerification?: boolean, isFirstCall = true): Promise<IpcResponse<DownloadEvent>> {
|
||||
// TODO : to remake cause we don't need recursion anymore (will be rework with qr code)
|
||||
|
||||
if (isFirstCall && !this.progressBarService.require()) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (isFirstCall && !(await this.isDotNet6Installed())) {
|
||||
await this.showDotNetNotInstalledError();
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
this.progressBarService.show(this.downloadProgress$);
|
||||
this._isVerification = isVerification;
|
||||
|
||||
let promise;
|
||||
if (!this.authService.sessionExist()) {
|
||||
const res = await this.modalService.openModal(LoginModal);
|
||||
if (res.exitCode !== ModalExitCode.COMPLETED) {
|
||||
this.progressBarService.hide(true);
|
||||
return { success: false };
|
||||
}
|
||||
this.authService.setSteamSession(res.data.username, res.data.stay);
|
||||
promise = this.ipcService.send<DownloadEvent, DownloadInfo>("bs-download.start", { args: { bsVersion, username: res.data.username, password: res.data.password, stay: res.data.stay, isVerification } });
|
||||
} else {
|
||||
promise = this.ipcService.send<DownloadEvent, DownloadInfo>("bs-download.start", { args: { bsVersion, username: this.authService.getSteamUsername(), isVerification } });
|
||||
}
|
||||
|
||||
let res = await promise;
|
||||
|
||||
if (res.data?.type === "[Password]") {
|
||||
this.authService.deleteSteamSession();
|
||||
res = await this.download(bsVersion, isVerification, false);
|
||||
}
|
||||
|
||||
this.progressBarService.hide(true);
|
||||
this.resetDownload();
|
||||
if (res.success && isFirstCall) {
|
||||
this.notificationService.notifySuccess({ title: `notifications.bs-download.success.titles.${isVerification ? "verification-finished" : "download-success"}`, duration: 3000 });
|
||||
} else if (res.data === "dotnet" && isFirstCall) {
|
||||
await this.showDotNetNotInstalledError();
|
||||
return res;
|
||||
} else if (res.data && isFirstCall) {
|
||||
this.notificationService.notifyError({ title: `notifications.types.error`, desc: `notifications.bs-download.errors.msg.${res.data}`, duration: 3000 });
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public get isDownloading(): boolean {
|
||||
return !!this.currentBsVersionDownload$.value;
|
||||
}
|
||||
|
||||
public async getInstallationFolder(): Promise<string> {
|
||||
const res = await this.ipcService.send<string>("bs-download.installation-folder");
|
||||
return res.success ? res.data : "";
|
||||
return lastValueFrom(this.ipcService.sendV2<string>("bs-download.installation-folder"));
|
||||
}
|
||||
|
||||
public get isVerification(): boolean {
|
||||
return this._isVerification;
|
||||
return this.isVerification$.value;
|
||||
}
|
||||
|
||||
public setInstallationFolder(path: string): Observable<string> {
|
||||
@@ -202,7 +80,219 @@ export class BsDownloaderService {
|
||||
}
|
||||
|
||||
public async importVersion(pathToImport: string): Promise<boolean> {
|
||||
const res = await this.ipcService.send<void>("bs-download.import-version", { args: pathToImport });
|
||||
return res.success;
|
||||
return lastValueFrom(this.ipcService.sendV2<void>("bs-download.import-version", { args: pathToImport }))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
// ### Downloading
|
||||
|
||||
private handleInfoEvents(events$: Observable<DepotDownloaderEvent>): Subscription[] {
|
||||
const subs: Subscription[] = [];
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.Start),
|
||||
take(1),
|
||||
map(event => event.data as string),
|
||||
).subscribe(startData => {
|
||||
const downloadVersion = JSON.parse(startData) as BSVersion;
|
||||
if(typeof downloadVersion === "object" && downloadVersion?.BSVersion){
|
||||
this.currentBsVersionDownload$.next(downloadVersion);
|
||||
}
|
||||
}));
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.Progress || event.subType === DepotDownloaderInfoEvent.Validated),
|
||||
map(event => event.data as string),
|
||||
).subscribe(progress => {
|
||||
this.downloadProgress$.next(parseFloat(progress.replaceAll(",", ".")));
|
||||
}));
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.MobileApp),
|
||||
take(1),
|
||||
).subscribe(async () => {
|
||||
const logged$ = events$.pipe(filter(event => event.subType === DepotDownloaderInfoEvent.SteamID), take(1));
|
||||
const res = await this.modalService.openModal(SteamMobileApproveModal, { logged$ });
|
||||
if(res.exitCode !== ModalExitCode.COMPLETED){
|
||||
return this.stopDownload();
|
||||
}
|
||||
}));
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.TwoFA || event.subType === DepotDownloaderInfoEvent.Guard),
|
||||
take(1),
|
||||
map(event => event.subType),
|
||||
).subscribe(async () => {
|
||||
const res = await this.modalService.openModal(GuardModal);
|
||||
if(res.exitCode !== ModalExitCode.COMPLETED){
|
||||
return this.stopDownload();
|
||||
}
|
||||
this.sendInput(res.data);
|
||||
}));
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => event.subType === DepotDownloaderInfoEvent.Finished),
|
||||
take(1),
|
||||
).subscribe(() => {
|
||||
if(this.isVerification){
|
||||
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.verification-finished"});
|
||||
}
|
||||
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.download-success"});
|
||||
}));
|
||||
|
||||
return subs;
|
||||
}
|
||||
|
||||
private handleWarningEvents(events$: Observable<DepotDownloaderEvent>): Subscription[] {
|
||||
const subs: Subscription[] = [];
|
||||
|
||||
const handledWarnings = Object.values(DepotDownloaderWarningEvent);
|
||||
|
||||
subs.push(events$.pipe(
|
||||
filter(event => handledWarnings.includes(event.subType as DepotDownloaderWarningEvent)),
|
||||
).subscribe(event => {
|
||||
this.notificationService.notifyWarning({title: "notifications.types.warning", desc: `notifications.bs-download.warnings.msg.${event.subType}`});
|
||||
}));
|
||||
|
||||
return subs;
|
||||
}
|
||||
|
||||
private hanndleErrorEvent(errorEvent: DepotDownloaderEvent) {
|
||||
const handledErrors = Object.values(DepotDownloaderErrorEvent);
|
||||
|
||||
if(handledErrors.includes(errorEvent?.subType as DepotDownloaderErrorEvent)){
|
||||
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.errors.msg.${errorEvent.subType}`, duration: 10_000});
|
||||
}
|
||||
|
||||
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.errors.msg.${DepotDownloaderErrorEvent.Unknown}`, duration: 10_000});
|
||||
}
|
||||
|
||||
private wrapDownload(download$: Observable<DepotDownloaderEvent>, silent?: boolean): Observable<DepotDownloaderEvent> {
|
||||
|
||||
return new Observable<DepotDownloaderEvent>(sub => {
|
||||
|
||||
const downloadSub = download$.subscribe({next: n => sub.next(n), error: e => sub.error(e), complete: () => sub.complete()});
|
||||
|
||||
const subs = [
|
||||
...this.handleInfoEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Info))),
|
||||
...this.handleWarningEvents(download$.pipe(filter(event => event.type === DepotDownloaderEventType.Warning), throttleTime(10_000))),
|
||||
];
|
||||
|
||||
return () => {
|
||||
downloadSub.unsubscribe();
|
||||
subs.forEach(sub => sub.unsubscribe());
|
||||
}
|
||||
|
||||
}).pipe(
|
||||
tap({
|
||||
error: (e) => {
|
||||
this.authService.deleteSteamSession();
|
||||
!silent && this.hanndleErrorEvent(e)
|
||||
}
|
||||
}),
|
||||
share({connector: () => new ReplaySubject(1)})
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
private tryAutoDownloadBsVersion(downloadInfo: DownloadInfo){
|
||||
|
||||
if(!this.authService.sessionExist()){
|
||||
return throwError(() => new Error("No session"));
|
||||
}
|
||||
|
||||
const infos: DownloadInfo = {...downloadInfo, username: this.authService.getSteamUsername()}
|
||||
return this.wrapDownload(
|
||||
this.ipcService.sendV2<DepotDownloaderEvent>("auto-download-bs-version", { args: infos }),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private startDownload(downloadInfo: DownloadInfo){
|
||||
return this.wrapDownload(
|
||||
this.ipcService.sendV2<DepotDownloaderEvent>("download-bs-version", { args: downloadInfo })
|
||||
);
|
||||
}
|
||||
|
||||
private startQrCodeDownload(downloadInfo: DownloadInfo){
|
||||
return this.wrapDownload(
|
||||
this.ipcService.sendV2<DepotDownloaderEvent>("download-bs-version-qr", { args: downloadInfo })
|
||||
);
|
||||
}
|
||||
|
||||
private doDownloadBsVersion(bsVersion: BSVersion, isVerification?: boolean): Promise<void>{
|
||||
|
||||
if(!this.progressBarService.require()){
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.progressBarService.show(this.downloadProgress$, true);
|
||||
this.isVerification$.next(isVerification);
|
||||
|
||||
const downloadPromise = (async () => {
|
||||
|
||||
const haveDotNet = await this.isDotNet6Installed().catch(() => false);
|
||||
if(!haveDotNet){
|
||||
this.showDotNetNotInstalledError();
|
||||
return Promise.reject(new Error("DotNet not installed"));
|
||||
}
|
||||
|
||||
const downloadInfo: DownloadInfo = {bsVersion, isVerification}
|
||||
|
||||
const autoDownload = await lastValueFrom(this.tryAutoDownloadBsVersion(downloadInfo)).then(() => true).catch(() => false);
|
||||
|
||||
if(autoDownload){ return Promise.resolve(); }
|
||||
|
||||
const qrCodeDownload$ = this.startQrCodeDownload(downloadInfo);
|
||||
const qrCode$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.QRCode), map(event => event.data as string));
|
||||
const logged$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.SteamID), map(event => event.data as string), take(1));
|
||||
|
||||
const loginRes = await this.modalService.openModal(LoginModal, { qrCode$, logged$ });
|
||||
|
||||
if(loginRes.exitCode !== ModalExitCode.COMPLETED){
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if(loginRes.data.stay){
|
||||
this.authService.setSteamSession(loginRes.data.username);
|
||||
}
|
||||
|
||||
const download$ = loginRes.data.method === "qr" ? qrCodeDownload$ : this.startDownload({...downloadInfo, username: loginRes.data.username, password: loginRes.data.password, stay: loginRes.data.stay});
|
||||
|
||||
return lastValueFrom(download$);
|
||||
|
||||
})();
|
||||
|
||||
// *** TEST EXPIRATION MOBILE APP APROVAL ***
|
||||
|
||||
return downloadPromise.then(() => {}).finally(() => {
|
||||
this.downloadProgress$.next(0);
|
||||
this.currentBsVersionDownload$.next(null);
|
||||
this.progressBarService.hide(true);
|
||||
this.isVerification$.next(false);
|
||||
this.bsVersionManager.askInstalledVersions();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private sendInput(input: string){
|
||||
return lastValueFrom(this.ipcService.sendV2<void>("send-input-bs-download", { args: input }));
|
||||
}
|
||||
|
||||
public downloadBsVersion(version: BSVersion): Promise<BSVersion> {
|
||||
return this.doDownloadBsVersion(version).then(() => version);
|
||||
}
|
||||
|
||||
public verifyBsVersionFiles(version: BSVersion): Promise<BSVersion> {
|
||||
return this.doDownloadBsVersion(version, true).then(() => version);
|
||||
}
|
||||
|
||||
public verifyBsVersion(version: BSVersion): Promise<BSVersion> {
|
||||
return this.doDownloadBsVersion(version, true).then(() => version);
|
||||
}
|
||||
|
||||
public stopDownload(): Promise<void>{
|
||||
return lastValueFrom(this.ipcService.sendV2<void>("stop-download-bs-version"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LaunchOption, BSLaunchEvent, BSLaunchWarning, BSLaunchEventData, BSLaun
|
||||
import { BSVersion } from 'shared/bs-version.interface';
|
||||
import { IpcService } from "./ipc.service";
|
||||
import { NotificationService } from "./notification.service";
|
||||
import { BehaviorSubject, Observable, filter } from "rxjs";
|
||||
import { BehaviorSubject, Observable, filter, lastValueFrom } from "rxjs";
|
||||
import { ConfigurationService } from "./configuration.service";
|
||||
import { ThemeService } from "./theme.service";
|
||||
|
||||
@@ -37,7 +37,7 @@ export class BSLauncherService {
|
||||
additionalArgs: (this.config.get<string>("additionnal-args") || "").split(";").map(arg => arg.trim()).filter(arg => arg.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public doLaunch(launchOptions: LaunchOption): Observable<BSLaunchEventData>{
|
||||
return this.ipcService.sendV2<BSLaunchEventData, LaunchOption>("bs-launch.launch", {args: launchOptions});
|
||||
}
|
||||
@@ -73,6 +73,9 @@ export class BSLauncherService {
|
||||
return this.ipcService.sendV2<void, LaunchOption>("create-launch-shortcut", {args: options});
|
||||
}
|
||||
|
||||
public restoreSteamVR(): Promise<void>{
|
||||
return lastValueFrom(this.ipcService.sendV2<void, void>("bs-launch.restore-steamvr"));
|
||||
}
|
||||
}
|
||||
|
||||
export enum LaunchMods {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ModalExitCode, ModalService } from "./modale.service";
|
||||
import { NotificationService } from "./notification.service";
|
||||
import { ProgressBarService } from "./progress-bar.service";
|
||||
import { EditVersionModal } from "renderer/components/modal/modal-types/edit-version-modal.component";
|
||||
import { popElement } from "shared/helpers/array.helpers";
|
||||
|
||||
export class BSVersionManagerService {
|
||||
private static instance: BSVersionManagerService;
|
||||
@@ -33,17 +34,8 @@ export class BSVersionManagerService {
|
||||
}
|
||||
|
||||
public setInstalledVersions(versions: BSVersion[]) {
|
||||
const sorted: BSVersion[] = [...versions].sort((a, b) => +b.ReleaseDate - +a.ReleaseDate);
|
||||
const steamIndex = sorted.findIndex(v => v.steam);
|
||||
const oculusIndex = sorted.findIndex(v => v.oculus);
|
||||
if (steamIndex > 0) {
|
||||
[sorted[0], sorted[steamIndex]] = [sorted[steamIndex], sorted[0]];
|
||||
}
|
||||
if (oculusIndex > 0) {
|
||||
[sorted[steamIndex > 0 ? 1 : 0], sorted[steamIndex]] = [sorted[steamIndex], sorted[steamIndex > 0 ? 1 : 0]];
|
||||
}
|
||||
const cleanedSort = [...new Map(sorted.map(version => [`${version.BSVersion}-${version.name}-${version.steam}-${version.oculus}`, version])).values()];
|
||||
this.installedVersions$.next(cleanedSort);
|
||||
const sorted = BSVersionManagerService.sortVersions(versions);
|
||||
this.installedVersions$.next(BSVersionManagerService.removeDuplicateVersions(sorted));
|
||||
}
|
||||
|
||||
public getInstalledVersions(): BSVersion[] {
|
||||
@@ -64,18 +56,10 @@ export class BSVersionManagerService {
|
||||
});
|
||||
}
|
||||
|
||||
public getAvailableYears(): string[] {
|
||||
return [...new Set(this.availableVersions$.value.map(v => v.year))].sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
|
||||
public isVersionInstalled(version: BSVersion): boolean {
|
||||
return !!this.getInstalledVersions().find(v => v.BSVersion === version.BSVersion && v.steam === version.steam && v.oculus === version.oculus);
|
||||
}
|
||||
|
||||
public getAvaibleVersionsOfYear(year: string): BSVersion[] {
|
||||
return this.availableVersions$.value.filter(v => v.year === year).sort((a, b) => +b.ReleaseDate - +a.ReleaseDate);
|
||||
}
|
||||
|
||||
public async editVersion(version: BSVersion): Promise<BSVersion> {
|
||||
const modalRes = await this.modalService.openModal(EditVersionModal, { version, clone: false });
|
||||
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
|
||||
@@ -127,4 +111,30 @@ export class BSVersionManagerService {
|
||||
public getVersionPath(version: BSVersion): Observable<string> {
|
||||
return this.ipcService.sendV2("get-version-full-path", { args: version });
|
||||
}
|
||||
|
||||
public static sortVersions(versions: BSVersion[]): BSVersion[] {
|
||||
const steamVersion = popElement(v => v.steam, versions);
|
||||
const oculusVersion = popElement(v => v.oculus, versions);
|
||||
|
||||
const compare = (a: BSVersion, b: BSVersion) => ( +b.ReleaseDate - +a.ReleaseDate )
|
||||
|
||||
const sorted: BSVersion[] = versions.sort(compare);
|
||||
|
||||
sorted.unshift(...[steamVersion, oculusVersion].filter(Boolean).sort(compare));
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
public static removeDuplicateVersions(versions: BSVersion[]): BSVersion[] {
|
||||
return Array.from(new Map(versions.map(version => ([
|
||||
JSON.stringify([
|
||||
version.BSVersion,
|
||||
version.BSManifest,
|
||||
version.name,
|
||||
version.steam,
|
||||
version.oculus
|
||||
]),
|
||||
version
|
||||
]))).values());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defaultIfEmpty, shareReplay } from "rxjs/operators";
|
||||
import { Observable, identity } from "rxjs";
|
||||
import { defaultIfEmpty, share } from "rxjs/operators";
|
||||
import { Observable, ReplaySubject, identity } from "rxjs";
|
||||
import { IpcRequest, IpcResponse } from "shared/models/ipc";
|
||||
import { IpcCompleteChannel, IpcErrorChannel, IpcTearDownChannel } from "shared/models/ipc/ipc-response.interface";
|
||||
|
||||
export class IpcService {
|
||||
private static instance: IpcService;
|
||||
@@ -65,26 +66,29 @@ export class IpcService {
|
||||
if (!request) {
|
||||
request = { args: null, responceChannel: null };
|
||||
}
|
||||
|
||||
if (!request.responceChannel) {
|
||||
request.responceChannel = `${channel}_responce_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const completeChannel = `${request.responceChannel}_complete`;
|
||||
const errorChannel = `${request.responceChannel}_error`;
|
||||
const completeChannel: IpcCompleteChannel = `${request.responceChannel}_complete`;
|
||||
const errorChannel: IpcErrorChannel = `${request.responceChannel}_error`;
|
||||
const teardownChannel: IpcTearDownChannel = `${request.responceChannel}_teardown`;
|
||||
|
||||
const obs = new Observable<T>(observer => {
|
||||
window.electron.ipcRenderer.on(request.responceChannel, (res: T) => observer.next(res));
|
||||
window.electron.ipcRenderer.on(errorChannel, (err: Error) => observer.error(err));
|
||||
window.electron.ipcRenderer.on(completeChannel, () => observer.complete());
|
||||
}).pipe(defaultValue ? defaultIfEmpty(defaultValue) : identity, shareReplay(1));
|
||||
|
||||
window.electron.ipcRenderer.once(completeChannel, () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(request.responceChannel);
|
||||
window.electron.ipcRenderer.removeAllListeners(errorChannel);
|
||||
window.electron.ipcRenderer.removeAllListeners(completeChannel);
|
||||
});
|
||||
window.electron.ipcRenderer.sendMessage(channel, request);
|
||||
|
||||
window.electron.ipcRenderer.sendMessage(channel, request);
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(request.responceChannel);
|
||||
window.electron.ipcRenderer.removeAllListeners(errorChannel);
|
||||
window.electron.ipcRenderer.removeAllListeners(completeChannel);
|
||||
window.electron.ipcRenderer.sendMessage(teardownChannel, null);
|
||||
};
|
||||
}).pipe(defaultValue ? defaultIfEmpty(defaultValue) : identity, share({connector: () => new ReplaySubject(1)}));
|
||||
|
||||
return obs;
|
||||
}
|
||||
|
||||
@@ -5,3 +5,16 @@ export function splitIntoChunk<T = unknown>(arr: T[], chunkSize: number): T[][]
|
||||
}
|
||||
return resArr;
|
||||
}
|
||||
|
||||
export function swapElements<T = unknown>(from: number, to: number, arr: T[]): T[] {
|
||||
[arr[from], arr[to]] = [arr[to], arr[from]];
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function popElement<T = unknown>(func: (element: T) => boolean, arr: T[]): T {
|
||||
const index = arr.findIndex(func);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
return arr.splice(index, 1)[0];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function tryit<Return>(func: () => Return): {error: Error, result: Return} {
|
||||
try {
|
||||
return { error: null, result: func() };
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err : new Error(`${err}`), result: null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export enum DepotDownloaderEventType {
|
||||
Error = "Error",
|
||||
Warning = "Warning",
|
||||
Info = "Info",
|
||||
|
||||
}
|
||||
|
||||
export interface DepotDownloaderEvent<T = unknown> {
|
||||
type: DepotDownloaderEventType;
|
||||
subType: DepotDownloaderEventTypes;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export type DepotDownloaderEventTypes = DepotDownloaderErrorEvent | DepotDownloaderWarningEvent | DepotDownloaderInfoEvent;
|
||||
|
||||
export enum DepotDownloaderInfoEvent {
|
||||
Start = "Start",
|
||||
Password = "Password",
|
||||
Guard = "Guard",
|
||||
TwoFA = "2FA",
|
||||
Progress = "Progress",
|
||||
Validated = "Validated",
|
||||
Finished = "Finished",
|
||||
SteamID = "SteamID",
|
||||
QRCode = "QRCode",
|
||||
MobileApp = "MobileApp",
|
||||
}
|
||||
|
||||
export enum DepotDownloaderErrorEvent {
|
||||
Password = "Password",
|
||||
InvalidCredentials = "InvalidCredentials",
|
||||
NoManifest = "NoManifest",
|
||||
DirectoryCreate = "DirectoryCreate",
|
||||
NotAvailableApp = "NotAvailableApp",
|
||||
DepotNotFound = "DepotNotFound",
|
||||
NotCompleted = "NotCompleted",
|
||||
InvalidManifest = "InvalidManifest",
|
||||
NoValidKeys = "NoValidKey",
|
||||
NoManifestCode = "NoManifestCode",
|
||||
_401 = "401",
|
||||
_404 = "404",
|
||||
NoServer = "NoServer",
|
||||
SteamLib = "SteamLib",
|
||||
NotAllowed = "NotAllowed",
|
||||
ConnectionTimeout = "ConnectionTimeout",
|
||||
ConnectionError = "ConnectionError",
|
||||
TokenRejected = "TokenRejected",
|
||||
LicenceError = "LicenceError",
|
||||
AccessDenied = "AccessDenied",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export enum DepotDownloaderWarningEvent {
|
||||
ManifestChecksum = "ManifestChecksum",
|
||||
ConnectionTimeout = "ConnectionTimeout",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export interface DepotDownloaderArgsOptions {
|
||||
app: number|string,
|
||||
depot: number|string,
|
||||
manifest: number|string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
"remember-password"?: boolean,
|
||||
dir: string,
|
||||
validate?: boolean,
|
||||
qr?: boolean,
|
||||
}
|
||||
@@ -6,5 +6,6 @@ export interface IpcResponse<T> {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export type IpcErrorChannel = string & `${string}_error`;
|
||||
export type IpcCompleteChannel = string & `${string}_complete`;
|
||||
export type IpcErrorChannel = `${string}_error`;
|
||||
export type IpcCompleteChannel = `${string}_complete`;
|
||||
export type IpcTearDownChannel = `${string}_teardown`;
|
||||
|
||||
Reference in New Issue
Block a user