diff --git a/assets/images/gifs/yuruyuri-dance.gif b/assets/images/gifs/yuruyuri-dance.gif new file mode 100644 index 00000000..03524548 Binary files /dev/null and b/assets/images/gifs/yuruyuri-dance.gif differ diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 3f573cf1..1d612590 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -14,6 +14,24 @@ "desktop": "Desktop Mod", "debug": "Debug Mod" }, + "mods":{ + "loading-mods": "Loading mods...", + "buttons":{ + "more-infos": "More info", + "install-or-update": "Install or update" + }, + "mods-grid":{ + "header-bar":{ + "name": "Name", + "installed": "Installed", + "latest": "Latest", + "description":"Description", + "dropdown":{ + "uninstall-all":"Uninstall all" + } + } + } + }, "dropdown":{ "open-folder": "Open folder", "verify-files": "Verify files", @@ -81,6 +99,11 @@ "warning": "⚠️ Warning", "success": "🎉 Success" }, + "common":{ + "msg":{ + "error-occurred": "An error has occurred" + } + }, "shared":{ "errors":{ "titles":{ @@ -196,7 +219,44 @@ "CloningFinished": "Cloning complete 🎉" } } - } + }, + "mods":{ + "install-mods":{ + "titles":{ + "success": "Mods installed 🎉", + "warning": "Mods installed 🤔" + }, + "msg":{ + "success": "All mods have been installed.", + "warning": "One or more mods could not be installed.", + "errors":{ + "no-mods": "No mods to install.", + "cannot-install-bsipa": "BSIPA installation failed 😨" + } + } + }, + "uninstall-mod":{ + "titles":{ + "success": "Mod uninstalled 🎉" + }, + "msg":{ + "errors":{ + "no-mods": "This mod isn't installed 😑" + } + } + }, + "uninstall-all-mods":{ + "titles":{ + "success": "Mods uninstalled 🎉" + }, + "msg":{ + "success": "All mods have been uninstalled.", + "errors": { + "no-mods": "No mod is installed in this version 😑" + } + } + } + } }, "modals":{ "guard":{ @@ -256,7 +316,16 @@ "buttons":{ "submit": "Clone" } - } + }, + "uninstall-mod":{ + "title": "Uninstall", + "description": "Are you sure you want to uninstall {mod}? It could cause other installed mods to malfunction.", + "description-bsipa": "Are you sure you want to uninstall BSIPA? After that all installed mods will not work anymore." + }, + "uninstall-all-mods":{ + "title": "Uninstalling mods", + "description": "Are you sure you want to uninstall all the mods from version {version}? This operation cannot be undone." + } }, "auto-update":{ "checking": "Checking for updates", diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index 5ee28177..7843d4bc 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -14,6 +14,24 @@ "desktop": "Desktop Mod", "debug": "Debug Mod" }, + "mods":{ + "loading-mods": "Cargando mods...", + "buttons":{ + "more-infos": "Más información", + "install-or-update": "Instalar o actualizar" + }, + "mods-grid":{ + "header-bar":{ + "name": "Nombre", + "installed": "Instalado", + "latest": "Último", + "description":"Descripción", + "dropdown":{ + "uninstall-all":"Desinstalar todos" + } + } + } + }, "dropdown":{ "open-folder": "Abrir la carpeta", "verify-files": "Verificar los archivos", @@ -81,6 +99,11 @@ "warning": "⚠️ Advertencia", "success": "🎉 Éxito" }, + "common":{ + "msg":{ + "error-occurred": "Se ha producido un error" + } + }, "shared":{ "errors":{ "titles":{ @@ -196,7 +219,44 @@ "CloningFinished": "Clonación completa 🎉" } } - } + }, + "mods":{ + "install-mods":{ + "titles":{ + "success": "Mods instalados 🎉", + "warning": "Mods instalados 🤔" + }, + "msg":{ + "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.", + "cannot-install-bsipa": "La instalación de BSIPA falló 😨" + } + } + }, + "uninstall-mod":{ + "titles":{ + "success": "Mod desinstalado 🎉" + }, + "msg":{ + "errors":{ + "no-mods": "Este mod no está instalado 😑" + } + } + }, + "uninstall-all-mods":{ + "titles":{ + "success": "Mods desinstalados 🎉" + }, + "msg":{ + "success": "Todos los mods han sido desinstalados.", + "errors": { + "no-mods": "No se instala ningún mod en esta versión 😑" + } + } + } + } }, "modals":{ "guard":{ @@ -256,7 +316,16 @@ "buttons":{ "submit": "Clonar" } - } + }, + "uninstall-mod":{ + "title": "Desinstalar", + "description": "¿Estás seguro de que quieres desinstalar {mod}? Podría provocar el mal funcionamiento de otros mods instalados.", + "description-bsipa": "¿Estás seguro de que quieres desinstalar BSIPA? Después de eso, todos los mods instalados dejarán de funcionar." + }, + "uninstall-all-mods":{ + "title": "Desinstalación de mods", + "description": "¿Estás seguro de que quieres desinstalar todos los mods de la versión {version}? Esta operación no se puede deshacer." + } }, "auto-update":{ "checking": "Comprobar las actualizaciones", diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index 34377231..71d036d0 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -3,7 +3,7 @@ "download": "Télécharger", "verify": "Vérifier", "launch": "Lancer", - "mods": "Modes", + "mods": "Mods", "maps": "Maps", "cancel": "Annuler" }, @@ -14,6 +14,24 @@ "desktop": "Mode Bureau", "debug": "Mode Debug" }, + "mods":{ + "loading-mods": "Chargement des mods...", + "buttons":{ + "more-infos": "Plus d'infos", + "install-or-update": "Installer ou mettre à jour" + }, + "mods-grid":{ + "header-bar":{ + "name": "Nom", + "installed": "Installé", + "latest": "Récent", + "description":"Description", + "dropdown":{ + "uninstall-all":"Tout désinstaller" + } + } + } + }, "dropdown":{ "open-folder": "Ouvrir le dossier", "verify-files": "Vérifier les fichiers", @@ -81,6 +99,11 @@ "warning": "⚠️ Attention", "success": "🎉 Succès" }, + "common":{ + "msg":{ + "error-occurred": "Une erreur c'est produite" + } + }, "shared":{ "errors":{ "titles":{ @@ -196,7 +219,44 @@ "CloningFinished": "Clonage terminer 🎉" } } - } + }, + "mods":{ + "install-mods":{ + "titles":{ + "success": "Mods installés 🎉", + "warning": "Mods installés 🤔" + }, + "msg":{ + "success": "Tous les mods on été installés.", + "warning": "Un ou plusieurs mods n'ont pas pu être installés.", + "errors":{ + "no-mods": "Aucun mods à installer.", + "cannot-install-bsipa": "L'installation de BSIPA à échoué 😨" + } + } + }, + "uninstall-mod":{ + "titles":{ + "success": "Mod désintallé 🎉" + }, + "msg":{ + "errors":{ + "no-mods": "Ce mod n'est pas installé 😑" + } + } + }, + "uninstall-all-mods":{ + "titles":{ + "success": "Mods désintallés 🎉" + }, + "msg":{ + "success": "Tous les mods on été désintallés.", + "errors": { + "no-mods": "Aucun mod n'est installé dans dans cette version 😑" + } + } + } + } }, "modals":{ "guard":{ @@ -232,7 +292,7 @@ }, "bs-uninstall":{ "title": "Désinstaller", - "description": "Est tu sûr de vouloir désinstaller BeatSaber {version} ? Tu vas devoir la retélécharger pour y jouer.", + "description": "Est-tu sûr de vouloir désinstaller BeatSaber {version} ? Tu vas devoir la retélécharger pour y jouer.", "buttons":{ "submit": "Désinstaller" } @@ -256,7 +316,16 @@ "buttons":{ "submit": "Cloner" } - } + }, + "uninstall-mod":{ + "title": "Désinstaller", + "description": "Es-tu sûr de vouloir désinstaller {mod} ? Cela pourrait faire dysfonctionner d'autres mods installés.", + "description-bsipa": "Es-tu sûr de vouloir désinstaller BSIPA ? Après cela, tous les mods installés ne fonctionneront plus." + }, + "uninstall-all-mods":{ + "title": "Désinstaller les mods", + "description": "Es-tu sûr de vouloir désinstaller tous les mods de la version {version} ? Cette opération ne pourra pas être annulée." + } }, "auto-update":{ "checking": "Vérification des mises à jour", diff --git a/package-lock.json b/package-lock.json index 99017021..2bc7527d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9024,6 +9024,11 @@ "escape-string-regexp": "^4.0.0" } }, + "md5-file": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-5.0.0.tgz", + "integrity": "sha512-xbEFXCYVWrSx/gEKS1VPlg84h/4L20znVIulKw6kMfmBUAZNAnF00eczz9ICMl+/hjQGo5KSXRxbL/47X3rmMw==" + }, "mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", @@ -9389,6 +9394,11 @@ "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", "dev": true }, + "node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==" + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -10771,6 +10781,14 @@ } } }, + "react-visibility-sensor": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-visibility-sensor/-/react-visibility-sensor-5.1.1.tgz", + "integrity": "sha512-cTUHqIK+zDYpeK19rzW6zF9YfT4486TIgizZW53wEZ+/GPBbK7cNS0EHyJVyHYacwFEvvHLEKfgJndbemWhB/w==", + "requires": { + "prop-types": "^15.7.2" + } + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 89b7865b..90263965 100644 --- a/package.json +++ b/package.json @@ -232,12 +232,15 @@ "framer-motion": "^7.2.1", "history": "^5.3.0", "is-online": "^9.0.1", + "md5-file": "^5.0.0", + "node-stream-zip": "^1.15.0", "react": "^18.2.0", "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-fitty": "^1.0.1", "react-outside-click-handler": "^1.3.0", "react-router-dom": "^6.3.0", + "react-visibility-sensor": "^5.1.1", "regedit": "^5.1.1", "rxjs": "^7.5.6", "tailwind-scrollbar-hide": "^1.1.7", diff --git a/release/app/package.json b/release/app/package.json index 06d9fdc1..edbbdca7 100644 --- a/release/app/package.json +++ b/release/app/package.json @@ -1,5 +1,5 @@ { - "name": "BSManager", + "name": "bs-manager", "version": "0.1.0", "description": "A foundation for scalable desktop apps", "main": "./dist/main/main.js", diff --git a/src/main/ipcs/bs-launcher-ipcs.ts b/src/main/ipcs/bs-launcher-ipcs.ts index c269e603..53ff080a 100644 --- a/src/main/ipcs/bs-launcher-ipcs.ts +++ b/src/main/ipcs/bs-launcher-ipcs.ts @@ -10,7 +10,6 @@ ipcMain.on('bs-launch.launch', (event, request: IpcRequest) => { const utilsService = UtilsService.getInstance(); launcherService.launch(request.args).then(res => { - console.log("BEAT SABER LANCER"); utilsService.ipcSend(request.responceChannel, {success: true, data: res}); }).catch((err: BsmException) => { utilsService.ipcSend(request.responceChannel, {success: false, error: err}); diff --git a/src/main/ipcs/bs-mods-ipcs.ts b/src/main/ipcs/bs-mods-ipcs.ts new file mode 100644 index 00000000..fe4590ee --- /dev/null +++ b/src/main/ipcs/bs-mods-ipcs.ts @@ -0,0 +1,68 @@ +import { ipcMain } from "electron"; +import { BsModsManagerService } from "../services/mods/bs-mods-manager.service"; +import { UtilsService } from "../services/utils.service"; +import { BSVersion } from "shared/bs-version.interface"; +import { IpcRequest } from "shared/models/ipc"; +import { Mod } from "shared/models/mods/mod.interface"; +import { InstallModsResult } from "shared/models/mods"; +import log from "electron-log"; + +ipcMain.on("get-available-mods", (event, request: IpcRequest) => { + const utils = UtilsService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + modsManager.getAvailableMods(request.args).then(mods => { + utils.ipcSend(request.responceChannel, {success: true, data: mods}); + }).catch(err =>{ + utils.ipcSend(request.responceChannel, {success: false}); + log.error("ipc", "get-available-mods", err, request); + }); +}); + +ipcMain.on("get-installed-mods", (event, request: IpcRequest) => { + const utils = UtilsService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + modsManager.getInstalledMods(request.args).then(mods => { + utils.ipcSend(request.responceChannel, {success: true, data: mods}); + }).catch(err => { + utils.ipcSend(request.responceChannel, {success: false}); + log.error("ipc", "get-installed-mods", err, request); + }); +}); + +ipcMain.on("install-mods", (event, request: IpcRequest<{mods: Mod[], version: BSVersion}>) => { + const utils = UtilsService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + modsManager.installMods(request.args.mods, request.args.version).then(nbInstalled => { + utils.ipcSend(request.responceChannel, {success: true, data: nbInstalled}) + }).catch(err => { + utils.ipcSend(request.responceChannel, {success: false, error: err}); + log.error("ipc", "install-mods", err, request); + }) +}); + +ipcMain.on("uninstall-mods", (event, request: IpcRequest<{mods: Mod[], version: BSVersion}>) => { + const utils = UtilsService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + modsManager.uninstallMods(request.args.mods, request.args.version).then(nbInstalled => { + utils.ipcSend(request.responceChannel, {success: true, data: nbInstalled}) + }).catch(err => { + utils.ipcSend(request.responceChannel, {success: false, error: err}); + log.error("ipc", "uninstall-mods", err, request); + }) +}); + +ipcMain.on("uninstall-all-mods", (event, request: IpcRequest) => { + const utils = UtilsService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + modsManager.uninstallAllMods(request.args).then(nbInstalled => { + utils.ipcSend(request.responceChannel, {success: true, data: nbInstalled}) + }).catch(err => { + utils.ipcSend(request.responceChannel, {success: false, error: err}); + log.error("ipc", "uninstall-all-mods", err, request); + }) +}); \ No newline at end of file diff --git a/src/main/ipcs/index.ts b/src/main/ipcs/index.ts index 7d9b243f..397a23be 100644 --- a/src/main/ipcs/index.ts +++ b/src/main/ipcs/index.ts @@ -7,3 +7,4 @@ import './map-ipcs'; import './supporters-ipcs'; import './launcher-ipcs'; import './window-manager-ipcs'; +import './bs-mods-ipcs'; diff --git a/src/main/main.ts b/src/main/main.ts index 8b66731a..f61156ff 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -30,7 +30,6 @@ log.catchErrors(); const utilsService = UtilsService.getInstance(); utilsService.setAssetsPath(app.isPackaged ? path.join(process.resourcesPath, 'assets') : path.join(__dirname, '../../assets')); - if (process.env.NODE_ENV === 'production') { const sourceMapSupport = require('source-map-support'); sourceMapSupport.install(); diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index 73cb8b10..5b547789 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -90,11 +90,16 @@ export class BSLocalVersionService{ return seq.replace( /[<>:"\/\\|?*]+/g, '' ); } - public getVersionFolder(version: BSVersion){ - return version.name ? `${version.BSVersion}-${version.name}` : version.BSVersion; - } + public getVersionFolder(version: BSVersion){ + return version.name ? `${version.BSVersion}-${version.name}` : version.BSVersion; + } - public async getInstalledVersions(): Promise{ + public getVersionType(version: BSVersion): "steam"|"universal"{ + if(version.steam){ return "steam"; } + return "universal" + } + + public async getInstalledVersions(): Promise{ const versions: BSVersion[] = []; const steamBsFolder = await this.steamService.getGameFolder(BS_APP_ID, "Beat Saber") if(steamBsFolder && this.utilsService.pathExist(steamBsFolder)){ diff --git a/src/main/services/bs-version-lib.service.ts b/src/main/services/bs-version-lib.service.ts index 2a0599e4..ed06e7ed 100644 --- a/src/main/services/bs-version-lib.service.ts +++ b/src/main/services/bs-version-lib.service.ts @@ -3,6 +3,7 @@ import { UtilsService } from './utils.service'; import path from 'path'; import { writeFileSync } from 'fs'; import { BSVersion } from 'shared/bs-version.interface'; +import { RequestService } from "./request.service" import isOnline from 'is-online'; export class BSVersionLibService{ @@ -13,11 +14,13 @@ export class BSVersionLibService{ private static instance: BSVersionLibService; private utilsService: UtilsService; + private requestService: RequestService private bsVersions: BSVersion[] = []; private constructor(){ this.utilsService = UtilsService.getInstance(); + this.requestService = RequestService.getInstance(); } @@ -26,19 +29,9 @@ export class BSVersionLibService{ return BSVersionLibService.instance; } - private getRemoteVersions(): Promise{ - return new Promise((resolve, reject) => { - let body = '' - get(this.REMOTE_BS_VERSIONS_URL, (res) => { - res.on('data', chunk => body += chunk); - res.on('end', () => { - this.bsVersions = JSON.parse(body); - resolve(this.bsVersions); - }); - res.on('error', () => reject(null)) - }) - }) - } + private getRemoteVersions(): Promise{ + return this.requestService.get(this.REMOTE_BS_VERSIONS_URL); + } private async getLocalVersions(): Promise{ const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE); diff --git a/src/main/services/mods/beat-mods-api.service.ts b/src/main/services/mods/beat-mods-api.service.ts new file mode 100644 index 00000000..f1b447b3 --- /dev/null +++ b/src/main/services/mods/beat-mods-api.service.ts @@ -0,0 +1,88 @@ +import { BSVersion } from "shared/bs-version.interface"; +import { Mod } from "shared/models/mods/mod.interface"; +import { RequestService } from "../request.service"; + +export class BeatModsApiService { + + private static instance: BeatModsApiService; + + private readonly requestService: RequestService; + + private readonly BEAT_MODS_VERSIONS = "https://versions.beatmods.com/versions.json"; + private readonly BEAT_MODS_ALIAS = "https://alias.beatmods.com/aliases.json"; + + private readonly BEAT_MODS_API_URL = "https://beatmods.com/api/v1/"; + public readonly BEAT_MODS_URL = "https://beatmods.com"; + + private readonly aliasesCache = new Map(); + private readonly versionModsCache = new Map(); + + private allModsCache: Mod[]; + + public static getInstance(): BeatModsApiService{ + if(!BeatModsApiService.instance){ BeatModsApiService.instance = new BeatModsApiService(); } + return BeatModsApiService.instance; + } + + private constructor(){ + this.requestService = RequestService.getInstance(); + } + + private getVersionModsUrl(version: BSVersion): string{ + return `${this.BEAT_MODS_API_URL}mod?status=approved&gameVersion=${version.BSVersion}&sort=&sortDirection=1`; + } + + private getAllModsUrl(): string{ + return `${this.BEAT_MODS_API_URL}mod`; + } + + private async getVersionAlias(): Promise>{ + if(this.aliasesCache.size){ return this.aliasesCache; } + return this.requestService.get>(this.BEAT_MODS_ALIAS).then(rawAliases => { + Object.entries(rawAliases).forEach(([key, value]) => { + this.aliasesCache.set(key, value.map(s => ({BSVersion: s} as BSVersion))); + }); + return this.aliasesCache; + }) + } + + private async getAliasOfVersion(version: BSVersion): Promise{ + return this.getVersionAlias().then(aliases => { + if(Array.from(aliases.keys()).some(k => k === version.BSVersion)){ return version; } + const alias = Array.from(aliases.entries()).find(([key, value]) => value.find(v => v.BSVersion === version.BSVersion))[0]; + return {BSVersion: alias} as BSVersion + }); + } + + private asignDependencies(mod: Mod, mods: Mod[]): Mod{ + mod.dependencies = mod.dependencies.map(dep => mods.find(mod => mod.name === dep.name)); + return mod; + } + + public async getVersionMods(version: BSVersion): Promise{ + if(this.versionModsCache.has(version.BSVersion)){ return this.versionModsCache.get(version.BSVersion); } + + const alias = await this.getAliasOfVersion(version); + + return this.requestService.get(this.getVersionModsUrl(alias)).then(mods => { + mods = mods.map(mod => this.asignDependencies(mod, mods)); + this.versionModsCache.set(version.BSVersion, mods); + return mods; + }); + } + + public async getAllMods(): Promise{ + if(!!this.allModsCache){ return this.allModsCache; } + return this.requestService.get(this.getAllModsUrl()).then(mods => { + this.allModsCache = mods; + return this.allModsCache; + }); + } + + public loadAllMods(){ return this.getAllMods(); } + + + + + +} \ No newline at end of file diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts new file mode 100644 index 00000000..339cc331 --- /dev/null +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -0,0 +1,330 @@ +import { BSVersion } from "shared/bs-version.interface"; +import { DownloadLink, InstallModsResult, Mod, ModInstallProgression, UninstallModsResult } from "shared/models/mods"; +import { BeatModsApiService } from "./beat-mods-api.service"; +import { BSLocalVersionService } from "../bs-local-version.service" +import path from "path"; +import { UtilsService } from "../utils.service"; +import md5File from "md5-file"; +import fs from "fs" +import StreamZip from "node-stream-zip"; +import { RequestService } from "../request.service"; +import { spawn } from "child_process"; +import { BS_EXECUTABLE } from "../../constants"; +import log from "electron-log"; + +export class BsModsManagerService { + + private static instance: BsModsManagerService; + + private readonly beatModsApi: BeatModsApiService; + private readonly bsLocalService: BSLocalVersionService; + private readonly utilsService: UtilsService; + private readonly requestService: RequestService + + private manifestMatches: Mod[]; + + private nbModsToInstall = 0; + private nbInstalledMods = 0; + + private nbModsToUninstall = 0; + private nbUninstalledMods = 0; + + public static getInstance(): BsModsManagerService{ + if(!BsModsManagerService.instance){ BsModsManagerService.instance = new BsModsManagerService(); } + return BsModsManagerService.instance; + } + + private constructor(){ + this.beatModsApi = BeatModsApiService.getInstance(); + this.bsLocalService = BSLocalVersionService.getInstance(); + this.utilsService = UtilsService.getInstance(); + this.requestService = RequestService.getInstance(); + } + + private async getModFromHash(hash: string): Promise{ + const allMods = await this.beatModsApi.getAllMods(); + return allMods.find(mod => { + if(mod.name.toLowerCase() === "bsipa" || mod.status === "declined"){ return false; } + return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash)); + }) + } + + private async getIpaFromHash(hash: string): Promise{ + const allMods = await this.beatModsApi.getAllMods(); + return allMods.find(mod => { + if(mod.name.toLowerCase() !== "bsipa"){ return false; } + return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash)); + }) + } + + private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise{ + const bsPath = await this.bsLocalService.getVersionPath(version); + const modsPath = path.join(bsPath, modsDir); + if(!this.utilsService.pathExist(modsPath)){ return []; } + const files = fs.readdirSync(modsPath); + const promises = files.map(f => { + return (async() => { + const filePath = path.join(modsPath, f) + const ext = path.extname(f); + if(ext !== ".dll" && ext !== ".exe" && ext !== ".manifest"){ return undefined; } + const hash = await md5File(filePath); + const mod = await this.getModFromHash(hash); + if(!mod){ return undefined; } + if(ext === ".manifest"){ + this.manifestMatches.push(mod); + return undefined; + } + if(filePath.includes("Libs")){ + if(!this.manifestMatches.some(m => m.name === mod.name)){ return undefined; } + const modIndex = this.manifestMatches.indexOf(mod); + if(modIndex > -1){ this.manifestMatches.splice(modIndex, 1); } + } + return mod; + })() + }); + const mods = await Promise.all(promises) + return mods.filter(m => !!m); + } + + private async getBsipaInstalled(version: BSVersion): Promise{ + const bsPath = await this.bsLocalService.getVersionPath(version); + const injectorPath = path.join(bsPath, "Beat Saber_Data", "Managed", "IPA.Injector.dll"); + if(!this.utilsService.pathExist(injectorPath)){ return undefined; } + const injectorMd5 = await md5File(injectorPath); + return this.getIpaFromHash(injectorMd5); + } + + private async downloadZip(zipUrl: string): Promise<{zip: StreamZip.StreamZipAsync, zipPath: string}>{ + zipUrl = path.join(this.beatModsApi.BEAT_MODS_URL, zipUrl); + const fileName = path.basename(zipUrl); + const tempPath = this.utilsService.getTempPath(); + this.utilsService.createFolderIfNotExist(this.utilsService.getTempPath()); + const dest = path.join(tempPath, fileName); + + const zipPath = await this.requestService.downloadFile(zipUrl, dest); + const zip = new StreamZip.async({file : zipPath}); + + return {zip, zipPath}; + } + + private async executeBSIPA(version: BSVersion, args: string[]): Promise{ + const versionPath = await this.bsLocalService.getVersionPath(version); + const ipaPath = path.join(versionPath, "IPA.exe"); + const bsExePath = path.join(versionPath, BS_EXECUTABLE); + if(!this.utilsService.pathExist(ipaPath) || !this.utilsService.pathExist(bsExePath)){ return false; } + + return new Promise(resolve => { + const processIPA = spawn(`start /wait /min "" "${ipaPath}" ${args.join(" ")}`, {cwd: versionPath, detached: true, shell: true}); + processIPA.once("exit", code => { + if(code === 0){ return resolve(true); } + log.error("IPA PROCESS", "exit code", code); + resolve(false); + }); + + setTimeout(() => resolve(false), (1 * 60) * 1000); //timeout 1min + }); + } + + private getModDownload(mod: Mod, version: BSVersion): DownloadLink{ + return mod.downloads.find(download => { + const type = download.type.toLowerCase() + return type === "universal" || type === this.bsLocalService.getVersionType(version); + }); + } + + private async installMod(mod: Mod, version: BSVersion): Promise{ + + this.utilsService.ipcSend("mod-installed", {success: true, data: {name: mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100}}) + + const download = this.getModDownload(mod, version); + + if(!download){ return false; } + + const {zip, zipPath} = await this.downloadZip(download.url); + + if(!zip){ return false; } + + const crypto = require('crypto'); + const entries = await zip.entries(); + + const checkedEntries = (await Promise.all(Object.values(entries).map(async (entry) => { + if(!entry.isFile){ return undefined; } + const data = await zip.entryData(entry); + const entryMd5 = crypto.createHash('md5').update(data).digest('hex') + return download.hashMd5.some(md5 => md5.hash === entryMd5) ? entry : undefined; + }))).filter(entry => !!entry); + + if(checkedEntries.length != download.hashMd5.length){ return false; } + + const verionPath = await this.bsLocalService.getVersionPath(version); + const isBSIPA = mod.name.toLowerCase() === "bsipa"; + const destDir = isBSIPA ? verionPath : path.join(verionPath, ModsInstallFolder.PENDING); + + const extracted = await zip.extract(null, destDir).then(() => true).catch(err => {log.error(err); return false}); + + await zip.close(); + await this.utilsService.unlinkIfExist(zipPath); + + const res = isBSIPA ? (extracted && (await this.executeBSIPA(version, ["-n"]))) : extracted; + + res && this.nbInstalledMods++; + + return res; + } + + private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]){ + return selectedMods.some(m => { + const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name)); + if(deps.some(depMod => depMod.name === mod.name)){ return true; } + return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name)); + }); + } + + private async resolveDependencies(mods: Mod[], version: BSVersion): Promise{ + const availableMods = await this.beatModsApi.getVersionMods(version); + return Array.from(new Map(availableMods.reduce((res, mod) => { + if(this.isDependency(mod, mods, availableMods)){ + res.push([mod.name, mod]); + } + return res; + }, [])).values()); + } + + private async uninstallBSIPA(mod: Mod, version: BSVersion): Promise{ + const download = this.getModDownload(mod, version); + + const verionPath = await this.bsLocalService.getVersionPath(version); + const hasIPAExe = this.utilsService.pathExist(path.join(verionPath, "IPA.exe")); + const hasIPADir = this.utilsService.pathExist(path.join(verionPath, "IPA")); + + if(!hasIPADir || !hasIPAExe){ return; } + + await this.executeBSIPA(version, ["--revert", "-n"]); + + const promises = download.hashMd5.map(files => { + const file = files.file.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data"); + return this.utilsService.unlinkIfExist(path.join(verionPath, file)); + }) + + await Promise.all(promises); + } + + private async uninstallMod(mod: Mod, version: BSVersion): Promise{ + + this.nbUninstalledMods++; + this.utilsService.ipcSend("mod-uninstalled", {success: true, data: {name: mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100}}) + + if(mod.name.toLowerCase() === "bsipa"){ return this.uninstallBSIPA(mod, version); } + + const download = this.getModDownload(mod, version); + const versionPath = await this.bsLocalService.getVersionPath(version); + + const promises = download.hashMd5.map(async files => { + this.utilsService.unlinkIfExist(path.join(versionPath, files.file)); + this.utilsService.unlinkIfExist(path.join(versionPath, "IPA", "Pending", files.file)); + }); + + await Promise.all(promises); + } + + public getAvailableMods(version: BSVersion): Promise{ + return this.beatModsApi.getVersionMods(version); + } + + public async getInstalledMods(version: BSVersion): Promise{ + this.manifestMatches = []; + await this.beatModsApi.loadAllMods(); + const bsipa = await this.getBsipaInstalled(version); + return Promise.all([ + this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING), + this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING), + this.getModsInDir(version, ModsInstallFolder.PLUGINS), + this.getModsInDir(version, ModsInstallFolder.LIBS) + ]).then(dirMods => { + const res = []; + if(!!bsipa){ res.push(bsipa); } + const installedMods = Array.from(new Map(dirMods.flat().map(m => [m.name, m])).values()); + res.push(...installedMods); + return res; + }); + } + + public async installMods(mods: Mod[], version: BSVersion): Promise{ + if(!mods || !mods.length){ throw "no-mods"; } + + const deps = await this.resolveDependencies(mods, version); + mods.push(...deps); + + const bsipa = mods.find(mod => mod.name.toLowerCase() === "bsipa"); + if(bsipa){ mods = mods.filter(mod => mod.name.toLowerCase() !== "bsipa"); } + + this.nbModsToInstall = mods.length + (bsipa && 1); + this.nbInstalledMods = 0; + + if(bsipa){ + const installed = await this.installMod(bsipa, version).catch(err => {log.error("INSTALL BSIPA", err); return false; }) + if(!installed){ throw "cannot-install-bsipa"; } + } + + for(const mod of mods){ + await this.installMod(mod, version);; + } + + return { + nbModsToInstall: this.nbModsToInstall, + nbInstalledMods: this.nbInstalledMods + }; + } + + public async uninstallMods(mods: Mod[], version: BSVersion): Promise{ + if(!mods || !mods.length){ throw "no-mods"; } + + this.nbModsToUninstall = mods.length; + this.nbUninstalledMods = 0; + + for(const mod of mods){ + await this.uninstallMod(mod, version); + } + + return { + nbModsToUninstall: this.nbModsToUninstall, + nbUninstalledMods: this.nbUninstalledMods + }; + } + + public async uninstallAllMods(version: BSVersion): Promise{ + const mods = await this.getInstalledMods(version); + + if(!mods || !mods.length){ throw "no-mods"; } + + this.nbModsToUninstall = mods.length; + this.nbUninstalledMods = 0; + + for(const mod of mods){ + await this.uninstallMod(mod, version); + } + + const versionPath = await this.bsLocalService.getVersionPath(version); + + this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.PLUGINS)); + this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.LIBS)); + this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.IPA)); + + path.resolve + + return { + nbModsToUninstall: this.nbModsToUninstall, + nbUninstalledMods: this.nbUninstalledMods + }; + } + +} + +const enum ModsInstallFolder { + PLUGINS = "Plugins", + LIBS = "Libs", + IPA = "IPA", + PENDING = "IPA/Pending", + PLUGINS_PENDING = "IPA/Pending/Plugins", + LIBS_PENDING = "IPA/Pending/Libs" +} \ No newline at end of file diff --git a/src/main/services/request.service.ts b/src/main/services/request.service.ts new file mode 100644 index 00000000..03ce76fc --- /dev/null +++ b/src/main/services/request.service.ts @@ -0,0 +1,44 @@ +import { get } from "https"; +import { createWriteStream, unlink } from "fs"; + +export class RequestService { + + private static instance: RequestService; + + public static getInstance(): RequestService{ + if(!RequestService.instance){ RequestService.instance = new RequestService(); } + return RequestService.instance; + } + + private constructor(){} + + public get(url: string): Promise{ + return new Promise((resolve, reject) => { + let body = '' + get(url, (res) => { + res.on('data', chunk => body += chunk); + res.on('end', () => { + resolve(JSON.parse(body)); + }); + res.on('error', (err) => reject(err)) + }); + }); + } + + public downloadFile(url: string, dest: string): Promise{ + return new Promise((resolve, reject) => { + const file = createWriteStream(dest); + get(url, res => { + res.pipe(file); + file.on("close", () => { + file.close(() => resolve(dest)) + }).on("error", err => { + unlink(dest, () => reject(err)); + }); + }).on("error", err => { + unlink(dest, () => reject(err)); + }); + }); + } + +} \ No newline at end of file diff --git a/src/main/services/utils.service.ts b/src/main/services/utils.service.ts index dbbc3791..328a9ac7 100644 --- a/src/main/services/utils.service.ts +++ b/src/main/services/utils.service.ts @@ -1,9 +1,9 @@ -import { existsSync, mkdirSync, readdirSync, readFile } from "fs"; +import { existsSync, mkdirSync, readdirSync, readFile, unlinkSync } from "fs"; import { spawnSync } from "child_process"; import { homedir } from "os"; import path from "path"; -import { BrowserWindow } from "electron"; -import { rm } from "fs/promises"; +import { app, BrowserWindow } from "electron"; +import { rm, unlink } from "fs/promises"; import { IpcResponse } from "shared/models/ipc"; import log from "electron-log"; @@ -27,6 +27,7 @@ export class UtilsService{ public getAssetsScriptsPath(): string { return this.getAssetsPath("scripts") } public getAssestsJsonsPath(): string { return this.getAssetsPath("jsons"); } + public getTempPath(): string{ return path.join(app.getPath("temp"), app.getName()) } public setMainWindow(win: BrowserWindow){ this.mainWindow = win; } public getMainWindow(){ return this.mainWindow; } @@ -37,6 +38,16 @@ export class UtilsService{ if(!this.pathExist(path)){ mkdirSync(path, {recursive: true}); } } + public async unlinkIfExist(pathToFile: string): Promise{ + if(!this.pathExist(pathToFile)){ return } + return unlink(pathToFile); + } + + public rmDirIfExist(path: string): Promise{ + if(!this.pathExist(path)){ return; } + return rm(path, {recursive: true, force: true}); + } + public taskRunning(task: string): boolean{ const tasks = spawnSync('tasklist').stdout.toString(); return tasks.includes(task); @@ -67,7 +78,7 @@ export class UtilsService{ return rm(folderPath, {recursive: true}); } - public ipcSend(channel: string, response: IpcResponse): void{ + public ipcSend(channel: string, response: IpcResponse): void{ try { this.mainWindow.webContents.send(channel, response); } catch (error) { @@ -75,4 +86,6 @@ export class UtilsService{ } } + + } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 28733c6e..e9826256 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,6 +1,6 @@ import { NavBar } from "./components/nav-bar/nav-bar.component"; import TitleBar from "./components/title-bar/title-bar.component"; -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useLocation } from "react-router-dom"; import { AvailableVersionsList } from "./pages/available-versions-list.components"; import { VersionViewer } from "./pages/version-viewer.component"; import { Modal } from "./components/modal/modal.component"; @@ -9,17 +9,23 @@ import { BsmProgressBar } from "./components/progress-bar/bsm-progress-bar.compo import { useEffect } from "react"; import { ThemeService } from "./services/theme.service"; import { NotificationOverlay } from "./components/notification/notification-overlay.component"; +import { PageStateService } from "./services/page-state.service"; export default function App() { - const themeService = ThemeService.getInstance(); + const themeService = ThemeService.getInstance(); + const pageState = PageStateService.getInstance(); - useEffect(() => { - themeService.theme$.subscribe(() => { - if(themeService.isDark || (themeService.isOS && window.matchMedia('(prefers-color-scheme: dark)').matches)){ document.documentElement.classList.add('dark'); } - else { document.documentElement.classList.remove('dark'); } - }); - }, []); + const location = useLocation(); + + pageState.setState(location.state); + + useEffect(() => { + themeService.theme$.subscribe(() => { + if(themeService.isDark || (themeService.isOS && window.matchMedia('(prefers-color-scheme: dark)').matches)){ return document.documentElement.classList.add('dark'); } + document.documentElement.classList.remove('dark'); + }); + }, []); return ( diff --git a/src/renderer/components/available-versions/available-version-item.component.css b/src/renderer/components/available-versions/available-version-item.component.css index 16135455..e69de29b 100644 --- a/src/renderer/components/available-versions/available-version-item.component.css +++ b/src/renderer/components/available-versions/available-version-item.component.css @@ -1,22 +0,0 @@ -.glow-on-hover { - border-radius: 10px; - background: linear-gradient(45deg, #ff0000, #ff7300, #fffb00, #48ff00, #00ffd5, #002bff, #7a00ff, #ff00c8, #ff0000); - position: absolute; - top: -4px; - left:-4px; - background-size: 400%; - z-index: -1; - filter: blur(3px); - width: calc(100% + 8px); - height: calc(100% + 8px); - animation: glowing 20s linear infinite; - opacity: 0; - transition: opacity .2s ease-in-out; - border-radius: 10px; -} - -@keyframes glowing { - 0% { background-position: 0 0; } - 50% { background-position: 400% 0; } - 100% { background-position: 0 0; } -} \ No newline at end of file diff --git a/src/renderer/components/available-versions/available-version-item.component.tsx b/src/renderer/components/available-versions/available-version-item.component.tsx index 15ef6173..6f0c5d19 100644 --- a/src/renderer/components/available-versions/available-version-item.component.tsx +++ b/src/renderer/components/available-versions/available-version-item.component.tsx @@ -40,7 +40,7 @@ export const AvailableVersionItem = memo(function AvailableVersionItem(props: {v return (
-
+
diff --git a/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx new file mode 100644 index 00000000..cede04ca --- /dev/null +++ b/src/renderer/components/modal/modal-types/uninstall-all-mods-modal.component.tsx @@ -0,0 +1,27 @@ +import { ModalExitCode, ModalResponse, ModalService } from "../../../services/modale.service"; +import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png" +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { BSVersion } from "shared/bs-version.interface"; + + +export function UninstallAllModsModal({resolver}: {resolver: (x: ModalResponse) => void}) { + + const version = ModalService.getInsance().getModalData(); + const t = useTranslation(); + + const desc = "modals.uninstall-all-mods.description"; + + return ( +
{e.preventDefault(); resolver({exitCode: ModalExitCode.COMPLETED})}}> +

{t("modals.uninstall-all-mods.title")}

+ +

{t(desc, {version: version.name ?? version.BSVersion})}

+
+ {resolver({exitCode: ModalExitCode.CANCELED})}} withBar={false} text="misc.cancel"/> + +
+ + ) +} diff --git a/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx new file mode 100644 index 00000000..4e913e24 --- /dev/null +++ b/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx @@ -0,0 +1,27 @@ +import { ModalExitCode, ModalResponse, ModalService } from "../../../services/modale.service"; +import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png" +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { Mod } from "shared/models/mods/mod.interface"; + + +export function UninstallModModal({resolver}: {resolver: (x: ModalResponse) => void}) { + + const mod = ModalService.getInsance().getModalData(); + const t = useTranslation(); + + const desc = mod.name.toLowerCase() === "bsipa" ? "modals.uninstall-mod.description-bsipa" : "modals.uninstall-mod.description"; + + return ( +
{e.preventDefault(); resolver({exitCode: ModalExitCode.COMPLETED})}}> +

{t("modals.uninstall-mod.title")}

+ +

{t(desc, {mod: mod.name})}

+
+ {resolver({exitCode: ModalExitCode.CANCELED})}} withBar={false} text="misc.cancel"/> + +
+ + ) +} diff --git a/src/renderer/components/modal/modal.component.tsx b/src/renderer/components/modal/modal.component.tsx index 47245a30..60d8f80d 100644 --- a/src/renderer/components/modal/modal.component.tsx +++ b/src/renderer/components/modal/modal.component.tsx @@ -7,6 +7,8 @@ import { InstallationFolderModal } from "./modal-types/installation-folder-modal import { EditVersionModal } from "./modal-types/edit-version-modal.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { UninstallModModal } from "./modal-types/uninstall-mod-modal.component"; +import { UninstallAllModsModal } from "./modal-types/uninstall-all-mods-modal.component"; export function Modal() { @@ -29,6 +31,8 @@ export function Modal() { {modalType === ModalType.INSTALLATION_FOLDER && } {modalType === ModalType.EDIT_VERSION && } {modalType === ModalType.CLONE_VERSION && } + {modalType === ModalType.UNINSTALL_MOD && } + {modalType === ModalType.UNINSTALL_ALL_MODS && }
diff --git a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx index cc94cffa..7040138d 100644 --- a/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx +++ b/src/renderer/components/progress-bar/bsm-progress-bar.component.tsx @@ -9,27 +9,34 @@ export function BsmProgressBar() { const progressBarService = ProgressBarService.getInstance(); - const progress = useObservable(progressBarService.progression$); + const progressData = useObservable(progressBarService.progressData$); const visible = useObservable(progressBarService.visible$); + const style = useObservable(progressBarService.style$); + + const progressLabel = (() => { + if(!progressData){ return ""; } + if(progressData?.label){ return progressData.label; } + return `${Math.floor(progressData.progression)}%` + })(); + const progressValue: number = progressData?.progression; + const {firstColor, secondColor} = useThemeColor(); - return ( - { visible && - -
- { !!progress && ( - <> -
-
- -
- {`${Math.floor(progress)}%`} + return ( + { visible && + +
+ { !!progressValue && ( +
+
+ +
+ {progressLabel} +
+ )} + { !progressValue && }
- - )} - { !progress && } -
- - } + + } ) } diff --git a/src/renderer/components/shared/UwU/spoiler-click.component.tsx b/src/renderer/components/shared/UwU/spoiler-click.component.tsx new file mode 100644 index 00000000..a9f6e1a6 --- /dev/null +++ b/src/renderer/components/shared/UwU/spoiler-click.component.tsx @@ -0,0 +1,21 @@ +import {motion, useCycle} from "framer-motion" +import { useState } from "react" + +type Props = {className?: string, nbClickToShow?: number, children?: JSX.Element} + +export function SpoilerClick({className, children, nbClickToShow = 5}: Props) { + + const [remainingClicks, setRemainingClicks] = useState(nbClickToShow); + const [translateY, setTranslateY] = useState("100%"); + + const handleClicks = () => { + if(remainingClicks - 1 > 0){ setRemainingClicks(nb => nb - 1); } + setTranslateY(`${(((remainingClicks - 1) / nbClickToShow) * 100)}%`) + } + + return ( + + {children} + + ) +} diff --git a/src/renderer/components/shared/bsm-button.component.tsx b/src/renderer/components/shared/bsm-button.component.tsx index 4589d684..539984a3 100644 --- a/src/renderer/components/shared/bsm-button.component.tsx +++ b/src/renderer/components/shared/bsm-button.component.tsx @@ -1,9 +1,10 @@ import { BsmIcon, BsmIconType } from "../svgs/bsm-icon.component" import OutsideClickHandler from 'react-outside-click-handler'; -import React from "react"; +import React, { MutableRefObject, Ref } from "react"; import { BsmImage } from "./bsm-image.component"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { getCorrectTextColor } from "renderer/helpers/correct-text-color"; type BsmButtonType = "primary"|"success"|"cancel"|"error"; @@ -32,19 +33,10 @@ export function BsmButton({className, style, imgClassName, iconClassName, icon, const primaryColor = typeColor === "primary" ? useThemeColor("first-color") : null; - const textColor = !primaryColor ? null : (() => { - const hex = primaryColor.replaceAll("#", ""); - const uicolors = [parseInt(hex.substring(0, 2), 16) / 255, parseInt(hex.substring(0, 2), 16) / 255, parseInt(hex.substring(0, 2), 16) / 255]; - const c = uicolors.map(col => { - if (col <= 0.03928) { return col / 12.92; } - return Math.pow((col + 0.055) / 1.055, 2.4); - }); - const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]); - return (L > 0.179) ? "#000" : "fff"; - })(); + const textColor = !primaryColor ? null : getCorrectTextColor(primaryColor); const renderTypeColor = (() => { - if(typeColor === "primary"){ return null; } + if(typeColor === "primary"){ return ""; } if(!typeColor){ return `bg-light-main-color-2 dark:bg-main-color-2 ${!withBar && "hover:bg-light-main-color-3 dark:hover:bg-main-color-3"}`; } if(typeColor === "cancel"){ return "bg-gray-500"; } if(typeColor === "error"){ return "bg-red-500"; } @@ -54,10 +46,10 @@ export function BsmButton({className, style, imgClassName, iconClassName, icon, return ( onClickOutside && onClickOutside(e)}> -
onClick && onClick(e)} className={`${className} overflow-hidden cursor-pointer group ${!withBar && (!!typeColor || !!color) && "hover:brightness-[1.15]"} ${disabled && "brightness-75 cursor-not-allowed"} ${renderTypeColor}`} style={{...style, ...((!!primaryColor || !!color) && {backgroundColor: primaryColor ?? color})}}> +
{ image && } { icon && } - {text && (type === "submit" ? : {t(text)})} + {text && (type === "submit" ? : {t(text)})} { withBar && (
diff --git a/src/renderer/components/shared/bsm-checkbox.component.tsx b/src/renderer/components/shared/bsm-checkbox.component.tsx new file mode 100644 index 00000000..560a076e --- /dev/null +++ b/src/renderer/components/shared/bsm-checkbox.component.tsx @@ -0,0 +1,29 @@ +import { BsmIcon } from "../svgs/bsm-icon.component" +import { motion } from "framer-motion" +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { getCorrectTextColor } from "renderer/helpers/correct-text-color" + +type Props = {className?:string, checked?: boolean, onChange?: (val: boolean) => void, disabled?: boolean} + +export function BsmCheckbox({className, checked, onChange, disabled} : Props) { + + const checkedColor = useThemeColor("first-color"); + const iconColor = getCorrectTextColor(checkedColor); + + const handleClick = () => { + if(disabled){ return; } + onChange && onChange(!checked); + } + + return ( +
+ {!disabled &&
} + +
{e.stopPropagation(); handleClick()}}> + + + +
+
+ ) +} diff --git a/src/renderer/components/shared/bsm-dropdown-button.component.tsx b/src/renderer/components/shared/bsm-dropdown-button.component.tsx index 1a43937d..a7cd79b7 100644 --- a/src/renderer/components/shared/bsm-dropdown-button.component.tsx +++ b/src/renderer/components/shared/bsm-dropdown-button.component.tsx @@ -6,17 +6,21 @@ import { useTranslation } from "renderer/hooks/use-translation.hook" export interface DropDownItem {text: string, icon?: BsmIconType, onClick?: () => void} -export function BsmDropdownButton({className, items, align}: {className?: string, items?: DropDownItem[], align?: "left"|"right"}) { +type Props = {className?: string, items?: DropDownItem[], align?: "left"|"right", withBar?: boolean, icon?: BsmIconType, buttonClassName?: string, menuTranslationY?: string|number} + +export function BsmDropdownButton({className, items, align, withBar = true, icon = "settings", buttonClassName, menuTranslationY}: Props) { const [expanded, setExpanded] = useState(false) - const t = useTranslation() + const t = useTranslation(); + + const defaultButtonClassName = "relative z-[1] p-1 rounded-md text-inherit w-full h-full shadow-md shadow-black" return (
- setExpanded(!expanded)} className='relative z-[1] p-1 rounded-md text-inherit w-full h-full shadow-md shadow-black' icon="settings" active={expanded} onClickOutside={() => {setExpanded(false)}}/> -
+ setExpanded(!expanded)} className={buttonClassName ?? defaultButtonClassName} icon={icon} active={expanded} onClickOutside={() => {setExpanded(false)}} withBar={withBar}/> +
{ items?.map((i, index) => !!i &&( -
+
{i.icon && } {t(i.text)}
diff --git a/src/renderer/components/svgs/bsm-icon.component.tsx b/src/renderer/components/svgs/bsm-icon.component.tsx index ced7ac0e..46281e2a 100644 --- a/src/renderer/components/svgs/bsm-icon.component.tsx +++ b/src/renderer/components/svgs/bsm-icon.component.tsx @@ -19,11 +19,14 @@ import { CSSProperties, memo } from "react"; import EditIcon from "./icons/edit-icon.component"; import { ExportIcon } from "./icons/export-icon.component"; import PatreonIcon from "./icons/patreon-icon.component"; +import { SearchIcon } from "./icons/search-icon.component"; +import { CheckIcon } from "./icons/check-icon.component"; +import { ThreeDotsIcon } from "./icons/three-dots-icon.component"; export type BsmIconType = ( - "settings"|"trash"|"favorite"|"folder"|"bsNote"| + "settings"|"trash"|"favorite"|"folder"|"bsNote"|"check"|"three-dots"| "terminal"|"desktop"|"oculus"|"add"|"cross"|"task"| - "copy"|"steam"|"edit"|"export"|"patreon"| + "copy"|"steam"|"edit"|"export"|"patreon"|"search"| "fr-FR-flag"|"es-ES-flag"|"en-US-flag"|"en-EN-flag" ); @@ -50,6 +53,9 @@ export const BsmIcon = memo(({className, icon, style}: {className?: string, icon if(icon === "edit"){ return } if(icon === "export"){ return } if(icon === "patreon"){ return } + if(icon === "search"){ return } + if(icon === "check"){ return } + if(icon === "three-dots"){ return } return } diff --git a/src/renderer/components/svgs/icons/check-icon.component.tsx b/src/renderer/components/svgs/icons/check-icon.component.tsx new file mode 100644 index 00000000..08143498 --- /dev/null +++ b/src/renderer/components/svgs/icons/check-icon.component.tsx @@ -0,0 +1,9 @@ +import { CSSProperties } from "react"; + +export function CheckIcon(props: {className?: string, style?: CSSProperties}) { + return ( + + + + ) +} diff --git a/src/renderer/components/svgs/icons/search-icon.component.tsx b/src/renderer/components/svgs/icons/search-icon.component.tsx new file mode 100644 index 00000000..b619113f --- /dev/null +++ b/src/renderer/components/svgs/icons/search-icon.component.tsx @@ -0,0 +1,9 @@ +import { CSSProperties } from "react"; + +export function SearchIcon(props: {className?: string, style?: CSSProperties}) { + return ( + + + + ) +} diff --git a/src/renderer/components/svgs/icons/three-dots-icon.component.tsx b/src/renderer/components/svgs/icons/three-dots-icon.component.tsx new file mode 100644 index 00000000..0d90b106 --- /dev/null +++ b/src/renderer/components/svgs/icons/three-dots-icon.component.tsx @@ -0,0 +1,9 @@ +import { CSSProperties } from "react"; + +export function ThreeDotsIcon(props: {className?: string, style?: CSSProperties}) { + return ( + + + + ) +} diff --git a/src/renderer/components/title-bar/title-bar.component.tsx b/src/renderer/components/title-bar/title-bar.component.tsx index f5d7670d..f46ee96d 100644 --- a/src/renderer/components/title-bar/title-bar.component.tsx +++ b/src/renderer/components/title-bar/title-bar.component.tsx @@ -31,7 +31,7 @@ export default function TitleBar({template = "main"} : {template?: "update"|"mai } return ( -
+
{template !== "update" && (
BSManager diff --git a/src/renderer/components/version-viewer/slides/launch/launch-mod-toogle.component.tsx b/src/renderer/components/version-viewer/slides/launch/launch-mod-toogle.component.tsx new file mode 100644 index 00000000..4b8637ca --- /dev/null +++ b/src/renderer/components/version-viewer/slides/launch/launch-mod-toogle.component.tsx @@ -0,0 +1,19 @@ +import { BsmIcon, BsmIconType } from "renderer/components/svgs/bsm-icon.component" +import { useTranslation } from "renderer/hooks/use-translation.hook"; + +type props = {onClick: (active: boolean) => void, active: boolean, text: string, icon: BsmIconType} + +export function LaunchModToogle({onClick, active, text, icon}: props) { + + const t = useTranslation(); + + return ( +
onClick(!active)}> +
+
+ + {t(text)} +
+
+ ) +} diff --git a/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx b/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx new file mode 100644 index 00000000..137de37f --- /dev/null +++ b/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { BSLauncherService, LaunchMods } from "renderer/services/bs-launcher.service"; +import { ConfigurationService } from "renderer/services/configuration.service"; +import { BSVersion } from "shared/bs-version.interface" +import { LaunchModToogle } from "./launch-mod-toogle.component"; + +type props = {version: BSVersion}; + +export function LaunchSlide({version}: props) { + + const configService = ConfigurationService.getInstance(); + const bsLauncherService = BSLauncherService.getInstance(); + + const [oculusMode, setOculusMode] = useState(!!configService.get(LaunchMods.OCULUS_MOD)); + const [desktopMode, setDesktopMode] = useState(!!configService.get(LaunchMods.DESKTOP_MOD)); + const [debugMode, setDebugMode] = useState(!!configService.get(LaunchMods.DEBUG_MOD)); + + const launchState = useObservable(bsLauncherService.launchState$); + + const setMode = (mode: LaunchMods, value: boolean) => { + if(mode === LaunchMods.DEBUG_MOD){ setDebugMode(value); } + else if(mode === LaunchMods.OCULUS_MOD){ + setOculusMode(value); + setDesktopMode(false); + configService.set(LaunchMods.DESKTOP_MOD, false); + } + else if(mode === LaunchMods.DESKTOP_MOD){ + setDesktopMode(value); + setOculusMode(false); + configService.set(LaunchMods.OCULUS_MOD, false); + } + configService.set(mode, value); + } + + const launch = () => bsLauncherService.launch(version, oculusMode, desktopMode, debugMode) + + return ( +
+
+ setMode(LaunchMods.OCULUS_MOD, !oculusMode)} active={oculusMode} text="pages.version-viewer.launch-mods.oculus"/> + setMode(LaunchMods.DESKTOP_MOD, !desktopMode)} active={desktopMode} text="pages.version-viewer.launch-mods.desktop"/> + setMode(LaunchMods.DEBUG_MOD, !debugMode)} active={debugMode} text="pages.version-viewer.launch-mods.debug"/> +
+
+ +
+
+ ) +} diff --git a/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx b/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx new file mode 100644 index 00000000..fe19688a --- /dev/null +++ b/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx @@ -0,0 +1,41 @@ +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component"; +import { Mod } from "shared/models/mods/mod.interface"; +import { CSSProperties } from "react" +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { BsModsManagerService } from "renderer/services/bs-mods-manager.service"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { PageStateService } from "renderer/services/page-state.service"; + +type Props = {mod: Mod, installedVersion: string, isDependency?: boolean, isSelected?: boolean, onChange?: (val: boolean) => void, wantInfo?: boolean} + +export function ModItem({mod, installedVersion, isDependency, isSelected, onChange, wantInfo}: Props) { + + const modsManager = BsModsManagerService.getInstance(); + const pageState = PageStateService.getInstance(); + + const themeColor = useThemeColor("second-color"); + const uninstalling = useObservable(modsManager.isUninstalling$); + + const wantInfoStyle: CSSProperties = wantInfo ? {borderColor: themeColor} : {borderColor: "transparent"}; + const isOutDated = installedVersion < mod.version; + + const uninstall = () => { + modsManager.uninstallMod(mod, pageState.getState()); + } + + return ( +
+
+ +
+ {mod.name} + {installedVersion || "-"} + {mod.version} + {mod.description} +
+ {installedVersion && {e.stopPropagation(); uninstall()}}/>} +
+
+ ) +} diff --git a/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx b/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx new file mode 100644 index 00000000..0c779ed2 --- /dev/null +++ b/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx @@ -0,0 +1,85 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { BsModsManagerService } from "renderer/services/bs-mods-manager.service"; +import { PageStateService } from "renderer/services/page-state.service"; +import { Mod } from "shared/models/mods/mod.interface" +import { ModItem } from "./mod-item.component" + +type Props = {modsMap: Map, installed: Map, modsSelected: Mod[], onModChange: (selected: boolean, mod: Mod) => void, moreInfoMod?: Mod, onWantInfos: (mod: Mod) => void} + +export function ModsGrid({modsMap, installed, modsSelected, onModChange, moreInfoMod, onWantInfos}: Props) { + + const pageState = PageStateService.getInstance(); + const modsManager = BsModsManagerService.getInstance(); + + const [filter, setFilter] = useState(""); + const [filterEnabled, setFilterEnabled] = useState(false); + const t = useTranslation(); + + const installedModVersion = (key: string, mod: Mod): string => { + if(!installed || !installed.get(key)){ return undefined; } + const installedMod = installed.get(key).find(m => m.name === mod.name); + if(!installedMod){ return undefined } + return installedMod.version + } + + const isDependency = (mod: Mod): boolean => { + return modsSelected.some(m => { + const deps = m.dependencies.map(dep => Array.from(modsMap.values()).flat().find(m => dep.name === m.name)); + if(deps.some(depMod => depMod.name === mod.name)){ return true; } + return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name)); + }); + } + + const isSelected = (mod: Mod): boolean => modsSelected.some(m => m.name === mod.name); + + const handleInput = (val: string) => setFilter(val.toLowerCase()); + + const handleToogleFilter = () => { + setFilter(""); + setFilterEnabled(b => !b); + } + + const handleUninstallAll = () => { + modsManager.uninstallAllMods(pageState.getState()) + } + + return modsMap && ( +
+ + + + + + {(filterEnabled ? ( + handleInput(e.target.value)}/> + ):( + {t("pages.version-viewer.mods.mods-grid.header-bar.name")} + ))} + + {t("pages.version-viewer.mods.mods-grid.header-bar.installed")} + {t("pages.version-viewer.mods.mods-grid.header-bar.latest")} + {t("pages.version-viewer.mods.mods-grid.header-bar.description")} + + + + { + Array.from(modsMap.keys()).map(key => modsMap.get(key).some(mod => mod.name.toLowerCase().includes(filter)) && ( +
    +

    {key}

    + {modsMap.get(key).map(mod => mod.name.toLowerCase().includes(filter) && ( +
  • onWantInfos(mod)} key={mod.name}> + onModChange(val, mod)} wantInfo={mod.name === moreInfoMod?.name}/> +
  • + ))} +
+ )) + } +
+ ) +} diff --git a/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx b/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx new file mode 100644 index 00000000..c1c07b17 --- /dev/null +++ b/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx @@ -0,0 +1,137 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { BsModsManagerService } from "renderer/services/bs-mods-manager.service"; +import { BSVersion } from "shared/bs-version.interface"; +import VisibilitySensor from 'react-visibility-sensor'; +import { Mod } from "shared/models/mods/mod.interface"; +import { ModsGrid } from "./mods-grid.component"; +import { ConfigurationService } from "renderer/services/configuration.service"; +import { DefaultConfigKey } from "renderer/config/default-configuration.config"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { IpcService } from "renderer/services/ipc.service"; +import BeatWaitingImg from "../../../../../../assets/images/apngs/beat-waiting.png" +import { SpoilerClick } from "renderer/components/shared/UwU/spoiler-click.component"; +import YuruYuriDance from "../../../../../../assets/images/gifs/yuruyuri-dance.gif" +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { skip, filter } from "rxjs/operators"; +import { Subscription } from "rxjs"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; + +export function ModsSlide({version}: {version: BSVersion}) { + + const modsManager = BsModsManagerService.getInstance(); + const configService = ConfigurationService.getInstance(); + const ipcServive = IpcService.getInstance(); + + const [isVisible, setIsVisible] = useState(false); + const [modsAvailable, setModsAvailable] = useState(null as Map); + const [modsInstalled, setModsInstalled] = useState(null as Map); + const [modsSelected, setModsSelected] = useState([] as Mod[]); + const [moreInfoMod, setMoreInfoMod] = useState(null as Mod); + const installing = useObservable(modsManager.isInstalling$); + const t = useTranslation(); + + const downloadRef = useRef(null); + const [downloadWith, setDownloadWidth] = useState(0); + + const modsToCategoryMap = (mods: Mod[]): Map => { + if(!mods){ return new Map(); } + const map = new Map(); + mods.forEach(mod => map.set(mod.category, [...map.get(mod.category) ?? [], mod])); + return map; + } + + const handleModChange = (selected: boolean, mod: Mod) => { + if(selected){ return setModsSelected([...modsSelected, mod]); } + const mods = [...modsSelected]; + mods.splice(mods.findIndex(m => m.name === mod.name), 1); + setModsSelected(mods); + } + + const handleMoreInfo = (mod: Mod) => { + if(mod.name === moreInfoMod?.name){ return setMoreInfoMod(null); } + setMoreInfoMod(mod); + } + + const handleOpenMoreInfo = () => { + if(!moreInfoMod || !moreInfoMod.link){ return; } + ipcServive.sendLazy("new-window", {args: moreInfoMod.link}); + } + + const installMods = () => { + if(installing){ return; } + modsManager.installMods(modsSelected, version).then(() => { + loadMods(); + }); + } + + const loadMods = () => { + Promise.all([ + modsManager.getAvailableMods(version), + modsManager.getInstalledMods(version) + ]).then(([available, installed]) => { + const defaultMods = configService.get("default_mods" as DefaultConfigKey); + setModsAvailable(modsToCategoryMap(available)); + setModsSelected(available.filter(m => m.required || defaultMods.some(d => m.name.toLowerCase() === d.toLowerCase()) || installed.some(i => m.name === i.name))); + setModsInstalled(modsToCategoryMap(installed)) + }); + } + + useEffect(() => { + + const subs: Subscription[] = []; + + if(isVisible){ + loadMods(); + subs.push(modsManager.isUninstalling$.pipe(skip(1), filter(uninstalling => !uninstalling)).subscribe(() => { + loadMods(); + })) + } + + return () => { + setMoreInfoMod(null); + setModsAvailable(null); + setModsInstalled(null); + subs.forEach(s => s.unsubscribe()); + } + + }, [isVisible, version]); + + + useLayoutEffect(() => { + if(modsAvailable){ + setDownloadWidth(downloadRef?.current?.offsetWidth) + } + }, [modsAvailable]) + + + return ( + +
+
+ {modsAvailable ? ( + <> +
+ +
+
+ +
+ +
+
+ + ) : ( +
+ + {t("pages.version-viewer.mods.loading-mods")} + + + +
+ )} +
+
+
+ + ) +} diff --git a/src/renderer/config/default-configuration.config.ts b/src/renderer/config/default-configuration.config.ts index 8034f70c..5aac4dfb 100644 --- a/src/renderer/config/default-configuration.config.ts +++ b/src/renderer/config/default-configuration.config.ts @@ -4,8 +4,9 @@ export const defaultConfiguration: {[key in DefaultConfigKey]: any} = { "theme": "os", "language": window.navigator.language.length <= 2 ? `${window.navigator.language}-${window.navigator.language.toLocaleUpperCase()}` : window.navigator.language, "supported_languages": ["en-US", "en-EN", "fr-FR", "es-ES"], + "default_mods": ["SongCore", "ScoreSaber", "BeatSaverDownloader", "BeatSaverVoting", "PlaylistManager", "ModelDownloader"] } -export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages"; +export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default_mods"; export type ThemeConfig = "dark" | "light" | "os" \ No newline at end of file diff --git a/src/renderer/helpers/correct-text-color.ts b/src/renderer/helpers/correct-text-color.ts new file mode 100644 index 00000000..dac68b0b --- /dev/null +++ b/src/renderer/helpers/correct-text-color.ts @@ -0,0 +1,15 @@ +/** + * Return text color for best constrast with background color + * @param {string} bgHex + * @returns {string} + */ +export function getCorrectTextColor(bgHex: string): string{ + const hex = bgHex.replaceAll("#", ""); + const uicolors = [parseInt(hex.substring(0, 2), 16) / 255, parseInt(hex.substring(0, 2), 16) / 255, parseInt(hex.substring(0, 2), 16) / 255]; + const c = uicolors.map(col => { + if (col <= 0.03928) { return col / 12.92; } + return (col + 0.055) / 1.055 ** 2.4; + }); + const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]); + return (L > 0.128) ? "#000" : "fff"; +} \ No newline at end of file diff --git a/src/renderer/index.css b/src/renderer/index.css index cbad61b2..71078041 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -33,6 +33,29 @@ animation: spin-loading 2s cubic-bezier(.46,-0.55,.49,1.52) 0s forwards infinite; } +.glow-on-hover { + border-radius: 10px; + background: linear-gradient(45deg, #ff0000, #ff7300, #fffb00, #48ff00, #00ffd5, #002bff, #7a00ff, #ff00c8, #ff0000); + position: absolute; + top: -4px; + left:-4px; + background-size: 400%; + z-index: -1; + filter: blur(3px); + width: calc(100% + 8px); + height: calc(100% + 8px); + animation: glowing 20s linear infinite; + opacity: 0; + transition: opacity .2s ease-in-out; + border-radius: 10px; +} + +@keyframes glowing { + 0% { background-position: 0 0; } + 50% { background-position: 400% 0; } + 100% { background-position: 0 0; } +} + @keyframes spin-loading{ 0%{ transform: rotate(-360deg); } 50%{ transform: rotate(-360deg); } diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index 216ef94b..800015b5 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -1,100 +1,56 @@ import { BSVersion } from 'shared/bs-version.interface'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import BSLogo from '../../../assets/images/apngs/bs-logo.png'; -import { BSLauncherService, LaunchMods } from '../services/bs-launcher.service'; import { TabNavBar } from 'renderer/components/shared/tab-nav-bar.component'; import wipGif from "../../../assets/images/gifs/wip.gif" -import { ConfigurationService } from 'renderer/services/configuration.service'; import { BsmDropdownButton } from 'renderer/components/shared/bsm-dropdown-button.component'; -import { BsmIcon, BsmIconType } from 'renderer/components/svgs/bsm-icon.component'; import { BsmImage } from 'renderer/components/shared/bsm-image.component'; -import { BsmButton } from 'renderer/components/shared/bsm-button.component'; import { BSUninstallerService } from '../services/bs-uninstaller.service'; import { BSVersionManagerService } from '../services/bs-version-manager.service'; import { ModalExitCode, ModalService, ModalType } from '../services/modale.service'; import DefautVersionImage from "../../../assets/images/default-version-img.jpg"; import { BsDownloaderService } from 'renderer/services/bs-downloader.service'; -import { useTranslation } from 'renderer/hooks/use-translation.hook'; import { IpcService } from 'renderer/services/ipc.service'; -import { useObservable } from 'renderer/hooks/use-observable.hook'; import { MapService } from 'renderer/services/maps.service'; +import { LaunchSlide } from 'renderer/components/version-viewer/slides/launch/launch-slide.component'; +import { ModsSlide } from 'renderer/components/version-viewer/slides/mods/mods-slide.component'; export function VersionViewer() { - const configService = ConfigurationService.getInstance(); - const bsUninstallerService = BSUninstallerService.getInstance(); - const bsVersionManagerService = BSVersionManagerService.getInstance(); - const modalService = ModalService.getInsance(); - const bsDownloaderService = BsDownloaderService.getInstance(); - const ipcService = IpcService.getInstance(); - const mapService = MapService.getInstance(); + const bsUninstallerService = BSUninstallerService.getInstance(); + const bsVersionManagerService = BSVersionManagerService.getInstance(); + const modalService = ModalService.getInsance(); + const bsDownloaderService = BsDownloaderService.getInstance(); + const ipcService = IpcService.getInstance(); + const mapService = MapService.getInstance(); - const {state} = useLocation() as {state: BSVersion}; - const navigate = useNavigate(); - const bsLauncherService = BSLauncherService.getInstance(); + const {state} = useLocation() as {state: BSVersion}; + const navigate = useNavigate(); + const [currentTabIndex, setCurrentTabIndex] = useState(0); - const [oculusMode, setOculusMode] = useState(false); - const [desktopMode, setDesktopMode] = useState(false); - const [debugMode, setDebugMode] = useState(false); - const [currentTabIndex, setCurrentTabIndex] = useState(0); - const launchState = useObservable(bsLauncherService.launchState$); + const navigateToVersion = (version: BSVersion) => navigate(`/bs-version/${version.BSVersion}`, {state: version}); + const openFolder = () => ipcService.sendLazy("bs-version.open-folder", {args: state}); + const exportMaps = () => mapService.exportVersionMaps(state); + const verifyFiles = () => bsDownloaderService.download(state, true); - useEffect(() => { - setOculusMode(!!configService.get(LaunchMods.OCULUS_MOD)); - setDesktopMode(!!configService.get(LaunchMods.DESKTOP_MOD)); - setDebugMode(!!configService.get(LaunchMods.DEBUG_MOD)); - }, []); - - const navigateToVersion = (version: BSVersion) => { - navigate(`/bs-version/${version.BSVersion}`, {state: version}); - } - - const setMode = (mode: LaunchMods, value: boolean) => { - if(mode === LaunchMods.DEBUG_MOD){ setDebugMode(value); } - else if(mode === LaunchMods.OCULUS_MOD){ - setOculusMode(value); - setDesktopMode(false); - configService.set(LaunchMods.DESKTOP_MOD, false); + const uninstall = async () => { + const modalCompleted = await modalService.openModal(ModalType.UNINSTALL, state) + if(modalCompleted.exitCode === ModalExitCode.COMPLETED){ + bsUninstallerService.uninstall(state).then(() => { + bsVersionManagerService.askInstalledVersions(); + const newVersionPage = bsVersionManagerService.getInstalledVersions()[0]; + navigateToVersion(newVersionPage); + }) + } } - else if(mode === LaunchMods.DESKTOP_MOD){ - setDesktopMode(value); - setOculusMode(false); - configService.set(LaunchMods.OCULUS_MOD, false); + + const edit = () => { + bsVersionManagerService.editVersion(state).then(newVersion => { + if(!newVersion){ return; } + navigateToVersion(newVersion); + }); } - configService.set(mode, value); - } - - const openFolder = () => { - ipcService.sendLazy("bs-version.open-folder", {args: state}); - } - - const uninstall = async () => { - const modalCompleted = await modalService.openModal(ModalType.UNINSTALL, state) - if(modalCompleted.exitCode === ModalExitCode.COMPLETED){ - bsUninstallerService.uninstall(state) - .then(() => { - bsVersionManagerService.askInstalledVersions(); - const newVersionPage = bsVersionManagerService.getInstalledVersions()[0]; - navigateToVersion(newVersionPage); - }) - } - } - - const verifyFiles = () => { - bsDownloaderService.download(state, true); - } - - const launchBs = () => { - bsLauncherService.launch(state, oculusMode, desktopMode, debugMode); - } - - const edit = () => { - bsVersionManagerService.editVersion(state).then(newVersion => { - if(!newVersion){ return; } - navigateToVersion(newVersion); - }); - } const clone = () => { bsVersionManagerService.cloneVersion(state).then(newVersion => { @@ -103,9 +59,6 @@ export function VersionViewer() { }); } - const exportMaps = () => { - mapService.exportVersionMaps(state); - } return ( <> @@ -114,32 +67,18 @@ export function VersionViewer() {

{state.name ? `${state.BSVersion} - ${state.name}` : state.BSVersion}

setCurrentTabIndex(i)}/> -
-
-
- setMode(LaunchMods.OCULUS_MOD, !oculusMode)} active={oculusMode} text="pages.version-viewer.launch-mods.oculus"/> - setMode(LaunchMods.DESKTOP_MOD, !desktopMode)} active={desktopMode} text="pages.version-viewer.launch-mods.desktop"/> - setMode(LaunchMods.DEBUG_MOD, !debugMode)} active={debugMode} text="pages.version-viewer.launch-mods.debug"/> -
-
- -
-
-
-
- - Work In Progress -
-
+
+
Work In Progress
+
- ) -} - -function ToogleLunchMod(props: {onClick: () => void, active: boolean, text: string, icon: BsmIconType}) { - - const t = useTranslation(); - - return ( -
props.onClick()}> -
-
- - {t(props.text)} -
-
- ); -} +} \ No newline at end of file diff --git a/src/renderer/services/bs-downloader.service.ts b/src/renderer/services/bs-downloader.service.ts index 6d28cff6..ee21f72f 100644 --- a/src/renderer/services/bs-downloader.service.ts +++ b/src/renderer/services/bs-downloader.service.ts @@ -114,7 +114,7 @@ export class BsDownloaderService{ 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 && isFirstCall){ console.log("aaaa"); this.notificationService.notifyError({title: `notifications.types.error`, desc: `notifications.bs-download.errors.msg.${res.data}`, duration: 3000}); } + else if(res.data && isFirstCall){ this.notificationService.notifyError({title: `notifications.types.error`, desc: `notifications.bs-download.errors.msg.${res.data}`, duration: 3000}); } return res; } diff --git a/src/renderer/services/bs-mods-manager.service.ts b/src/renderer/services/bs-mods-manager.service.ts new file mode 100644 index 00000000..f87db7b6 --- /dev/null +++ b/src/renderer/services/bs-mods-manager.service.ts @@ -0,0 +1,125 @@ +import { Observable } from "rxjs"; +import { BehaviorSubject } from "rxjs"; +import { map } from "rxjs/operators"; +import { BSVersion } from "shared/bs-version.interface"; +import { InstallModsResult, UninstallModsResult } from "shared/models/mods"; +import { Mod, ModInstallProgression } from "shared/models/mods"; +import { ProgressionInterface } from "shared/models/progress-bar"; +import { IpcService } from "./ipc.service"; +import { ModalExitCode, ModalService, ModalType } from "./modale.service"; +import { NotificationService, NotificationType } from "./notification.service"; +import { ProgressBarService } from "./progress-bar.service"; + +export class BsModsManagerService { + + private static instance: BsModsManagerService; + + private readonly NOTIFICATION_DURATION = 3000; + + private readonly ipcService: IpcService; + private readonly progressBar: ProgressBarService; + private readonly modals: ModalService; + private readonly notifications: NotificationService; + + public readonly isInstalling$: BehaviorSubject = new BehaviorSubject(false); + public readonly isUninstalling$: BehaviorSubject = new BehaviorSubject(false); + + public static getInstance(): BsModsManagerService{ + if(!BsModsManagerService.instance){ BsModsManagerService.instance = new BsModsManagerService(); } + return BsModsManagerService.instance; + } + + private constructor(){ + this.ipcService = IpcService.getInstance(); + this.progressBar = ProgressBarService.getInstance(); + this.modals = ModalService.getInsance(); + this.notifications = NotificationService.getInstance(); + } + + public getAvailableMods(version: BSVersion): Promise{ + return this.ipcService.send("get-available-mods", {args: version}).then(res => res.data); + } + + public getInstalledMods(version: BSVersion): Promise{ + return this.ipcService.send("get-installed-mods", {args: version}).then(res => res.data); + } + + public installMods(mods: Mod[], version: BSVersion): Promise{ + if(!this.progressBar.require()){ return undefined; } + + const progress$: Observable = this.ipcService.watch("mod-installed").pipe(map(res => { + return {progression: res.data.progression, label: res.data.name} as ProgressionInterface + })); + + this.progressBar.show(progress$, true, {paddingLeft: "190px", paddingRight: "190px", bottom: "20px"}); + + this.isInstalling$.next(true); + return this.ipcService.send("install-mods", {args: {mods, version}}).then(res => { + + if(res.success && res.data){ + const isFullyInstalled = res.data.nbInstalledMods === res.data.nbModsToInstall; + const title = `notifications.mods.install-mods.titles.${isFullyInstalled ? "success" : "warning"}`; + const desc = `notifications.mods.install-mods.msg.${isFullyInstalled ? "success" : "warning"}`; + + this.notifications.notify({type: isFullyInstalled ? NotificationType.SUCCESS : NotificationType.WARNING, title, desc, duration: this.NOTIFICATION_DURATION}); + } + else{ + this.notifications.notifyError({title: "notifications.types.error", desc: `notifications.mods.install-mods.msg.errors.${res.error}`, duration: this.NOTIFICATION_DURATION}) + } + this.isInstalling$.next(false); + this.progressBar.hide(); + }); + } + public async uninstallMod(mod: Mod, version: BSVersion): Promise{ + + if(!this.progressBar.require()){ return; } + + const modalRes = await this.modals.openModal(ModalType.UNINSTALL_MOD, mod); + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const progress$ = this.ipcService.watch("mod-uninstalled").pipe(map(res => res.data.progression)); + this.progressBar.show(progress$, true, {paddingLeft: "190px", paddingRight: "190px", bottom: "20px"}); + + this.isUninstalling$.next(true); + return this.ipcService.send("uninstall-mods", {args: {mods: [mod], version}}).then(res => { + + if(res.success){ + this.notifications.notifySuccess({title: "notifications.mods.uninstall-mod.titles.success", duration: this.NOTIFICATION_DURATION}) + } + else{ + this.notifications.notifyError({title: "notifications.types.error", desc: `notifications.mods.uninstall-mod.msg.errors.${res.error}`, duration: this.NOTIFICATION_DURATION}) + } + + this.isUninstalling$.next(false); + this.progressBar.hide(); + }); + } + + public async uninstallAllMods(version: BSVersion){ + + if(!this.progressBar.require()){ return; } + + const modalRes = await this.modals.openModal(ModalType.UNINSTALL_ALL_MODS, version); + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const progress$ = this.ipcService.watch("mod-uninstalled").pipe(map(res => res.data.progression)); + this.progressBar.show(progress$, true, {paddingLeft: "190px", paddingRight: "190px", bottom: "20px"}); + + this.isUninstalling$.next(true); + return this.ipcService.send("uninstall-all-mods", {args: version}).then(res => { + + if(res.success){ + this.notifications.notifySuccess({title: "notifications.mods.uninstall-all-mods.titles.success", desc: "notifications.mods.uninstall-all-mods.msg.success", duration: this.NOTIFICATION_DURATION}) + } + else{ + this.notifications.notifyError({title: "notifications.types.error", desc: `notifications.mods.uninstall-all-mods.msg.errors.${res.error}`, duration: this.NOTIFICATION_DURATION}) + } + + this.isUninstalling$.next(false); + this.progressBar.hide(); + }); + + } +} \ No newline at end of file diff --git a/src/renderer/services/i18n.service.ts b/src/renderer/services/i18n.service.ts index 58428316..c187d4c5 100644 --- a/src/renderer/services/i18n.service.ts +++ b/src/renderer/services/i18n.service.ts @@ -53,11 +53,10 @@ export class I18nService { public translate(translationKey: string, args?: Record): string{ let translated = this.cache.get(translationKey); if(!translated){ - console.log(translationKey); translated = getProperty(this.dictionary, translationKey); translated ? this.cache.set(translated, translationKey) : translated = translationKey; } - args && Object.keys(args).forEach(key => translated = translated.replaceAll(`{${key}}`, args[key])); + args && Object.keys(args).forEach(key => {translated = translated.replaceAll(`{${key}}`, args[key])}); return translated; } diff --git a/src/renderer/services/modale.service.ts b/src/renderer/services/modale.service.ts index 5f659e2b..8e5a3674 100644 --- a/src/renderer/services/modale.service.ts +++ b/src/renderer/services/modale.service.ts @@ -50,23 +50,25 @@ export class ModalService{ } -export enum ModalType { +export const enum ModalType { STEAM_LOGIN = "STEAM_LOGIN", GUARD_CODE = "GUARD_CODE", UNINSTALL = "UNINSTALL", INSTALLATION_FOLDER = "INSTALLATION_FOLDER", EDIT_VERSION = "EDIT_VERSION", - CLONE_VERSION = "CLONE_VERSION" + CLONE_VERSION = "CLONE_VERSION", + UNINSTALL_MOD = "UNINSTALL_MOD", + UNINSTALL_ALL_MODS = "UNINSTALL_ALL_MODS", } -export enum ModalExitCode { +export const enum ModalExitCode { NO_CHOICE = -1, COMPLETED = 0, CLOSED = 1, CANCELED = 2, } -export interface ModalResponse { +export interface ModalResponse { exitCode: ModalExitCode, data?: T } diff --git a/src/renderer/services/page-state.service.ts b/src/renderer/services/page-state.service.ts new file mode 100644 index 00000000..cea5c66f --- /dev/null +++ b/src/renderer/services/page-state.service.ts @@ -0,0 +1,28 @@ +import { BehaviorSubject } from "rxjs"; + +export class PageStateService { + + private static instance: PageStateService; + + private readonly _state$: BehaviorSubject = new BehaviorSubject(undefined); + + public static getInstance(): PageStateService{ + if(!PageStateService.instance){ PageStateService.instance = new PageStateService(); } + return PageStateService.instance; + } + + private constructor(){} + + public setState(state: unknown){ + this._state$.next(state); + } + + public getState(): T{ + return this._state$.value; + } + + public get state$(){ + return this._state$.asObservable(); + } + +} \ No newline at end of file diff --git a/src/renderer/services/progress-bar.service.ts b/src/renderer/services/progress-bar.service.ts index c4f7442b..8d3546c8 100644 --- a/src/renderer/services/progress-bar.service.ts +++ b/src/renderer/services/progress-bar.service.ts @@ -2,6 +2,8 @@ import { distinctUntilChanged, map } from "rxjs/operators"; import { BehaviorSubject, Observable, Subscription, timer } from "rxjs"; import { IpcService } from "./ipc.service"; import { NotificationService } from "./notification.service"; +import { CSSProperties } from "react"; +import { ProgressionInterface } from "shared/models/progress-bar"; export class ProgressBarService{ @@ -10,8 +12,9 @@ export class ProgressBarService{ private readonly ipcService: IpcService; private notificationService: NotificationService; - private readonly _progression$: BehaviorSubject; + private readonly _progression$: BehaviorSubject; private readonly _visible$: BehaviorSubject; + private readonly _style$: BehaviorSubject; private subscription: Subscription; @@ -24,51 +27,55 @@ export class ProgressBarService{ this.ipcService = IpcService.getInstance(); this.notificationService = NotificationService.getInstance(); - this._progression$ = new BehaviorSubject(0); + this._progression$ = new BehaviorSubject({progression: 0}); this._visible$ = new BehaviorSubject(false); + this._style$ = new BehaviorSubject(undefined); - this.progression$.subscribe(progression => this.setSystemProgression(progression)); + this.progress$.subscribe(value => this.setSystemProgression(value)); } private setSystemProgression(progression: number){ this.ipcService.sendLazy("window.progression", {args: progression}); } - public subscribreTo(obs: Observable){ + public subscribreTo(obs: Observable){ if(this.subscription){ this.unsubscribe(); } this.subscription = obs.pipe(distinctUntilChanged()).subscribe(value => { - this.progression$.next(value); + if(typeof value === "number"){ return this._progression$.next({progression: value}); } + this._progression$.next(value); }); } public unsubscribe(){ - this._progression$.next(0); + this._progression$.next({progression: 0}); this.subscription && this.subscription.unsubscribe(); this.subscription = null; } - public show(obs?: Observable, unsubscribe? : boolean){ + public show(obs?: Observable, unsubscribe? : boolean, style?: CSSProperties){ if(unsubscribe){ this.unsubscribe(); } if(obs){ this.subscribreTo(obs); } - this.visible$.next(true); + this._visible$.next(true); + this._style$.next(style); } - public showFake(speed: number): void{ - const obs = timer(1000, 100).pipe(map(val => { - if(this.progression$.value >= 100){ return 100; } + public showFake(speed: number, style?: CSSProperties): void{ + const obs: Observable = timer(1000, 100).pipe(map(val => { + if(this._progression$.value.progression >= 100){ return {progression: 100}; } const currentProgress = speed * (val + 1); - return Math.round(Math.atan(currentProgress) / (Math.PI / 2) * 100 * 1000) / 1000; + const progress = Math.round(Math.atan(currentProgress) / (Math.PI / 2) * 100 * 1000) / 1000; + return {progression: progress} as ProgressionInterface; })); - this.show(obs, true); + this.show(obs, true, style); } public complete(): void{ - this.progression$.next(100); + this._progression$.next({progression: 100}); } - public hide(unsubscribe: boolean){ + public hide(unsubscribe: boolean = true){ if(unsubscribe){ this.unsubscribe(); } - this.visible$.next(false); + this._visible$.next(false); } public require(): boolean{ @@ -79,9 +86,13 @@ export class ProgressBarService{ return true; } - public get progression$(): BehaviorSubject{ return this._progression$; } - public get visible$(): BehaviorSubject{ return this._visible$; } + public get progressData$(): Observable{ return this._progression$.asObservable(); } + public get progress$(): Observable{ return this._progression$.pipe(map(data => data.progression)); } + public get progressLabel(): Observable{ return this._progression$.pipe(map(data => data?.label)); } + + public get visible$(): Observable{ return this._visible$.asObservable(); } public get isVisible(): boolean{ return this._visible$.value; } + public get style$(): Observable{ return this._style$.asObservable(); } diff --git a/src/shared/models/mods/index.ts b/src/shared/models/mods/index.ts new file mode 100644 index 00000000..f8b92ee4 --- /dev/null +++ b/src/shared/models/mods/index.ts @@ -0,0 +1,2 @@ +export { ModInstallProgression, InstallModsResult, UninstallModsResult } from "./mod-ipc.model" +export { Mod, DownloadLink, DownloadLinkType, FileHashes, ModAuthor } from "./mod.interface" \ No newline at end of file diff --git a/src/shared/models/mods/mod-ipc.model.ts b/src/shared/models/mods/mod-ipc.model.ts new file mode 100644 index 00000000..a9a38ff1 --- /dev/null +++ b/src/shared/models/mods/mod-ipc.model.ts @@ -0,0 +1,14 @@ +export interface ModInstallProgression{ + name: string, + progression: number +} + +export interface InstallModsResult { + nbModsToInstall: number, + nbInstalledMods: number +} + +export interface UninstallModsResult { + nbModsToUninstall: number, + nbUninstalledMods: number +} \ No newline at end of file diff --git a/src/shared/models/mods/mod.interface.ts b/src/shared/models/mods/mod.interface.ts new file mode 100644 index 00000000..8f325812 --- /dev/null +++ b/src/shared/models/mods/mod.interface.ts @@ -0,0 +1,37 @@ +export interface Mod { + _id: string, + name: string, + version: string, + gameVersion: string, + authorId: string, + uploadedDate: string, + updatedDate: string, + author: ModAuthor, + description: string, + link: string, + category: string, + downloads: DownloadLink[], + required: boolean, + dependencies: Mod[], + status: string + +} + +export interface ModAuthor { + _id: string, + username: string, + lastLogin: string, +} + +export interface DownloadLink { + type: DownloadLinkType, + url: string, + hashMd5: FileHashes[] +} + +export type DownloadLinkType = "universal"|"steam"|"oculus"; + +export interface FileHashes { + hash: string, + file: string +} \ No newline at end of file diff --git a/src/shared/models/progress-bar/index.ts b/src/shared/models/progress-bar/index.ts new file mode 100644 index 00000000..4d701bbf --- /dev/null +++ b/src/shared/models/progress-bar/index.ts @@ -0,0 +1 @@ +export { ProgressionInterface } from "./progress-bar.model" \ No newline at end of file diff --git a/src/shared/models/progress-bar/progress-bar.model.ts b/src/shared/models/progress-bar/progress-bar.model.ts new file mode 100644 index 00000000..aa9c4ddf --- /dev/null +++ b/src/shared/models/progress-bar/progress-bar.model.ts @@ -0,0 +1,4 @@ +export interface ProgressionInterface { + progression: number, + label?: string +}; \ No newline at end of file