Merge branch 'mods-management'

This commit is contained in:
MathieuG-P
2022-10-09 21:01:00 +02:00
53 changed files with 1656 additions and 241 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

+71 -2
View File
@@ -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",
+71 -2
View File
@@ -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",
+73 -4
View File
@@ -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",
+18
View File
@@ -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",
+3
View File
@@ -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",
+1 -1
View File
@@ -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",
-1
View File
@@ -10,7 +10,6 @@ ipcMain.on('bs-launch.launch', (event, request: IpcRequest<LauchOption>) => {
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});
+68
View File
@@ -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<BSVersion>) => {
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<BSVersion>) => {
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<InstallModsResult>(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<BSVersion>) => {
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);
})
});
+1
View File
@@ -7,3 +7,4 @@ import './map-ipcs';
import './supporters-ipcs';
import './launcher-ipcs';
import './window-manager-ipcs';
import './bs-mods-ipcs';
-1
View File
@@ -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();
@@ -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<BSVersion[]>{
public getVersionType(version: BSVersion): "steam"|"universal"{
if(version.steam){ return "steam"; }
return "universal"
}
public async getInstalledVersions(): Promise<BSVersion[]>{
const versions: BSVersion[] = [];
const steamBsFolder = await this.steamService.getGameFolder(BS_APP_ID, "Beat Saber")
if(steamBsFolder && this.utilsService.pathExist(steamBsFolder)){
+6 -13
View File
@@ -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<BSVersion[]>{
return new Promise<BSVersion[]>((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<BSVersion[]>{
return this.requestService.get<BSVersion[]>(this.REMOTE_BS_VERSIONS_URL);
}
private async getLocalVersions(): Promise<BSVersion[]>{
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
@@ -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<string, BSVersion[]>();
private readonly versionModsCache = new Map<string, Mod[]>();
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<Map<string, BSVersion[]>>{
if(this.aliasesCache.size){ return this.aliasesCache; }
return this.requestService.get<Record<string, string[]>>(this.BEAT_MODS_ALIAS).then(rawAliases => {
Object.entries(rawAliases).forEach(([key, value]) => {
this.aliasesCache.set(key, value.map(s => ({BSVersion: s} as BSVersion)));
});
return this.aliasesCache;
})
}
private async getAliasOfVersion(version: BSVersion): Promise<BSVersion>{
return this.getVersionAlias().then(aliases => {
if(Array.from(aliases.keys()).some(k => k === version.BSVersion)){ return version; }
const alias = Array.from(aliases.entries()).find(([key, value]) => value.find(v => v.BSVersion === version.BSVersion))[0];
return {BSVersion: alias} as BSVersion
});
}
private asignDependencies(mod: Mod, mods: Mod[]): Mod{
mod.dependencies = mod.dependencies.map(dep => mods.find(mod => mod.name === dep.name));
return mod;
}
public async getVersionMods(version: BSVersion): Promise<Mod[]>{
if(this.versionModsCache.has(version.BSVersion)){ return this.versionModsCache.get(version.BSVersion); }
const alias = await this.getAliasOfVersion(version);
return this.requestService.get<Mod[]>(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<Mod[]>{
if(!!this.allModsCache){ return this.allModsCache; }
return this.requestService.get<Mod[]>(this.getAllModsUrl()).then(mods => {
this.allModsCache = mods;
return this.allModsCache;
});
}
public loadAllMods(){ return this.getAllMods(); }
}
@@ -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<Mod>{
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<Mod>{
const allMods = await this.beatModsApi.getAllMods();
return allMods.find(mod => {
if(mod.name.toLowerCase() !== "bsipa"){ return false; }
return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash));
})
}
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<Mod[]>{
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<Mod>{
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<boolean>{
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<boolean>(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<boolean>{
this.utilsService.ipcSend<ModInstallProgression>("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<Mod[]>{
const availableMods = await this.beatModsApi.getVersionMods(version);
return Array.from(new Map<string, Mod>(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<void>{
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<void>{
this.nbUninstalledMods++;
this.utilsService.ipcSend<ModInstallProgression>("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<Mod[]>{
return this.beatModsApi.getVersionMods(version);
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]>{
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<string, Mod>(dirMods.flat().map(m => [m.name, m])).values());
res.push(...installedMods);
return res;
});
}
public async installMods(mods: Mod[], version: BSVersion): Promise<InstallModsResult>{
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<UninstallModsResult>{
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<UninstallModsResult>{
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"
}
+44
View File
@@ -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<T = any>(url: string): Promise<T>{
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<string>{
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));
});
});
}
}
+17 -4
View File
@@ -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<void>{
if(!this.pathExist(pathToFile)){ return }
return unlink(pathToFile);
}
public rmDirIfExist(path: string): Promise<void>{
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<any>): void{
public ipcSend<T = any>(channel: string, response: IpcResponse<T>): void{
try {
this.mainWindow.webContents.send(channel, response);
} catch (error) {
@@ -75,4 +86,6 @@ export class UtilsService{
}
}
}
+14 -8
View File
@@ -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 (
@@ -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; }
}
@@ -40,7 +40,7 @@ export const AvailableVersionItem = memo(function AvailableVersionItem(props: {v
return (
<div className="group m-3 relative w-72 h-60 transition-transform active:scale-[.98]" onClick={toggleSelect}>
<div className={`absolute glow-on-hover group-hover:opacity-100 ${selected && "opacity-100"}`}></div>
<div className={`absolute glow-on-hover group-hover:opacity-100 ${selected && "!opacity-100"}`}/>
<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"/>
@@ -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<BSVersion>();
const t = useTranslation();
const desc = "modals.uninstall-all-mods.description";
return (
<form onSubmit={(e) => {e.preventDefault(); resolver({exitCode: ModalExitCode.COMPLETED})}}>
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.uninstall-all-mods.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict}/>
<p className="max-w-sm text-gray-800 dark:text-gray-200">{t(desc, {version: version.name ?? version.BSVersion})}</p>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-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.bs-uninstall.buttons.submit"/>
</div>
</form>
)
}
@@ -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<Mod>();
const t = useTranslation();
const desc = mod.name.toLowerCase() === "bsipa" ? "modals.uninstall-mod.description-bsipa" : "modals.uninstall-mod.description";
return (
<form onSubmit={(e) => {e.preventDefault(); resolver({exitCode: ModalExitCode.COMPLETED})}}>
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.uninstall-mod.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict}/>
<p className="max-w-sm text-gray-800 dark:text-gray-200">{t(desc, {mod: mod.name})}</p>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-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.bs-uninstall.buttons.submit"/>
</div>
</form>
)
}
@@ -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 && <InstallationFolderModal resolver={modalSevice.getResolver()}/>}
{modalType === ModalType.EDIT_VERSION && <EditVersionModal resolver={modalSevice.getResolver()}/>}
{modalType === ModalType.CLONE_VERSION && <EditVersionModal resolver={modalSevice.getResolver()} clone/>}
{modalType === ModalType.UNINSTALL_MOD && <UninstallModModal resolver={modalSevice.getResolver()}/>}
{modalType === ModalType.UNINSTALL_ALL_MODS && <UninstallAllModsModal resolver={modalSevice.getResolver()}/>}
</div>
</motion.div>
</div>
@@ -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 (
<AnimatePresence> { visible &&
<motion.div initial={{y: "120%"}} animate={{y:"0%"}} exit={{y:"120%"}} className="w-full absolute h-14 flex justify-center items-center bottom-2">
<div className={`flex items-center content-center justify-center bottom-9 z-10 rounded-lg bg-light-main-color-2 dark:bg-main-color-2 shadow-center shadow-black cursor-pointer transition-all duration-300 ${!progress && "h-14 w-14 rounded-full"} ${!!progress && "h-5 w-3/4 rounded-full p-[6px]"}`}>
{ !!progress && (
<>
<div className="relative flex items-center h-full w-full rounded-full bg-black">
<div className="w-0 h-full relative rounded-full download-progress flex items-center transition-all" style={{width: `${progress}%`, background: `linear-gradient(90deg, ${firstColor}, ${secondColor}, ${firstColor}, ${secondColor})`}}>
<img className="h-[70px] w-[70px] min-w-fit absolute z-[1] -translate-y-1 -right-8 transition-all" src={BeatRunningImg} />
</div>
<span className="absolute w-full text-center text-white -top-[3px] left-0 text-[10px]">{`${Math.floor(progress)}%`}</span>
return (
<AnimatePresence> { visible &&
<motion.div initial={{y: "120%"}} animate={{y:"0%"}} exit={{y:"120%"}} className="w-full absolute h-14 flex justify-center items-center bottom-2 pointer-events-none" style={style}>
<div className={`flex items-center content-center justify-center bottom-9 z-10 rounded-lg bg-light-main-color-2 dark:bg-main-color-2 shadow-center shadow-black cursor-pointer transition-all duration-300 ${!progressValue && "h-14 w-14 !rounded-full"} ${!!progressValue && "h-5 w-3/4 !rounded-full p-[6px]"}`}>
{ !!progressValue && (
<div className="relative flex items-center h-full w-full rounded-full bg-black">
<div className="w-0 h-full relative rounded-full download-progress flex items-center transition-all" style={{width: `${progressValue}%`, background: `linear-gradient(90deg, ${firstColor}, ${secondColor}, ${firstColor}, ${secondColor})`}}>
<img className="h-[70px] w-[70px] min-w-fit absolute z-[1] -translate-y-1 -right-8 transition-all" src={BeatRunningImg} />
</div>
<span className="absolute w-full text-center text-white -top-[3px] left-0 text-[10px]">{progressLabel}</span>
</div>
)}
{ !progressValue && <img className="w-12 h-12 spin-loading" src={BeatWaitingImg}></img> }
</div>
</>
)}
{ !progress && <img className="w-12 h-12 spin-loading" src={BeatWaitingImg}></img> }
</div>
</motion.div>
} </AnimatePresence>
</motion.div>
} </AnimatePresence>
)
}
@@ -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 (
<motion.div className={className} initial={{y: translateY}} animate={{y: translateY}} transition={{type: "spring", stiffness: 300, mass: 1 }} onClick={handleClicks}>
{children}
</motion.div>
)
}
@@ -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 (
<OutsideClickHandler onOutsideClick={e => onClickOutside && onClickOutside(e)}>
<div onClick={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})}}>
<div onClick={onClick} className={`${className} overflow-hidden cursor-pointer group ${(!withBar && !disabled && (!!typeColor || !!color)) && "hover:brightness-[1.15]"} ${disabled && "brightness-75 cursor-not-allowed"} ${renderTypeColor}`} style={{...style, ...((!!primaryColor || !!color) && {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"}/> }
{text && (type === "submit" ? <button className="w-full h-full" style={{...(!!textColor && {color: textColor})}}>{t(text)}</button> : <span style={{...(!!textColor && {color: textColor})}} >{t(text)}</span>)}
{text && (type === "submit" ? <button type="submit" className="w-full h-full" style={{...(!!textColor && {color: textColor})}}>{t(text)}</button> : <span style={{...(!!textColor && {color: textColor})}} >{t(text)}</span>)}
{ withBar && (
<div className="absolute bottom-0 left-0 w-full h-1 bg-current" style={{color: secondColor}}>
<div className="absolute top-0 left-0 h-full w-full bg-current brightness-50"/>
@@ -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 (
<div className={`group ${className}`}>
{!disabled && <div className="glow-on-hover !w-[calc(100%+6px)] !h-[calc(100%+6px)] !-top-[3px] !-left-[3px] group-hover:opacity-100"/>}
<div className={`w-full h-full bg-inherit border-2 border-current rounded-md overflow-hidden flex items-center justify-center ${disabled ? "brightness-50 cursor-not-allowed" : "cursor-pointer"}`} onClickCapture={e => {e.stopPropagation(); handleClick()}}>
<motion.span className="w-full h-full flex items-center justify-center" style={{backgroundColor: checkedColor}} initial={checked ? {opacity: 1} : {opacity: 0}} animate={checked ? {opacity: 1} : {opacity: 0}} transition={{duration: .15}}>
<BsmIcon icon="check" style={{color: iconColor}}/>
</motion.span>
</div>
</div>
)
}
@@ -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 (
<div className={`${className}`}>
<BsmButton onClick={() => 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)}}/>
<div className={`pt-1 pb-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md overflow-hidden bg-light-main-color-2 dark:bg-main-color-2 text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-transform ease-in-out ${align === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"}`} style={{transform: expanded ? "scale(1)" : "scale(0)"}}>
<BsmButton onClick={() => setExpanded(!expanded)} className={buttonClassName ?? defaultButtonClassName} icon={icon} active={expanded} onClickOutside={() => {setExpanded(false)}} withBar={withBar}/>
<div className={`pt-1 pb-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md overflow-hidden bg-inherit text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-[scale] ease-in-out ${align === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"}`} style={{scale: expanded ? "1" : "0", translate: `0 ${menuTranslationY}`}}>
{ items?.map((i, index) => !!i &&(
<div key={index} onClick={!!i.onClick ? i.onClick : undefined} className="flex justify-start items-center w-full pr-3 pl-3 pt-2 pb-2 hover:bg-light-main-color-3 dark:hover:bg-main-color-3">
<div key={index} onClick={!!i.onClick ? i.onClick : undefined} className="flex justify-start items-center w-full pr-3 pl-3 pt-2 pb-2 hover:backdrop-brightness-150">
{i.icon && <BsmIcon icon={i.icon} className="h-5 w-5 mr-1 text-gray-800 dark:text-white"></BsmIcon>}
<span className="w-max">{t(i.text)}</span>
</div>
@@ -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 <EditIcon className={className} style={style}/> }
if(icon === "export"){ return <ExportIcon className={className} style={style}/> }
if(icon === "patreon"){ return <PatreonIcon className={className} style={style}/> }
if(icon === "search"){ return <SearchIcon className={className} style={style}/> }
if(icon === "check"){ return <CheckIcon className={className} style={style}/> }
if(icon === "three-dots"){ return <ThreeDotsIcon className={className} style={style}/> }
return <TrashIcon className={className} style={style}/>
}
@@ -0,0 +1,9 @@
import { CSSProperties } from "react";
export function CheckIcon(props: {className?: string, style?: CSSProperties}) {
return (
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" height="40" width="40">
<path fill="currentColor" d="M15.792 29.458q-.292 0-.563-.104-.271-.104-.521-.354l-7.416-7.417q-.459-.458-.459-1.145 0-.688.459-1.146.458-.459 1.104-.459.646 0 1.146.459l6.25 6.291 14.666-14.666q.459-.459 1.125-.459.667 0 1.125.459.459.458.459 1.145 0 .688-.459 1.146L16.917 29q-.25.25-.521.354-.271.104-.604.104Z"/>
</svg>
)
}
@@ -0,0 +1,9 @@
import { CSSProperties } from "react";
export function SearchIcon(props: {className?: string, style?: CSSProperties}) {
return (
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" height="40" width="40">
<path fill="currentColor" d="m31.833 34.167-9.708-9.75q-1.25 1.041-2.896 1.625-1.646.583-3.521.583-4.583 0-7.77-3.208-3.188-3.209-3.188-7.709 0-4.541 3.188-7.729 3.187-3.187 7.729-3.187 4.541 0 7.729 3.187 3.187 3.188 3.187 7.729 0 1.792-.583 3.438-.583 1.646-1.625 3.021l9.792 9.75q.458.458.458 1.104 0 .646-.5 1.146-.458.458-1.146.458-.687 0-1.146-.458ZM15.667 23.458q3.25 0 5.5-2.27 2.25-2.271 2.25-5.48 0-3.25-2.25-5.5t-5.5-2.25q-3.209 0-5.479 2.25-2.271 2.25-2.271 5.5 0 3.209 2.25 5.48 2.25 2.27 5.5 2.27Z"/>
</svg>
)
}
@@ -0,0 +1,9 @@
import { CSSProperties } from "react";
export function ThreeDotsIcon(props: {className?: string, style?: CSSProperties}) {
return (
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="24" width="24">
<path fill="currentColor" d="M12.025 21.15q-1 0-1.7-.7t-.7-1.675q0-1 .7-1.7t1.675-.7q1 0 1.688.7.687.7.687 1.7 0 .975-.687 1.675-.688.7-1.663.7Zm0-6.775q-1 0-1.7-.7T9.625 12q0-1 .7-1.688.7-.687 1.675-.687 1 0 1.688.687.687.688.687 1.663 0 1-.687 1.7-.688.7-1.663.7Zm0-6.75q-1 0-1.7-.713-.7-.712-.7-1.687 0-1 .7-1.688.7-.687 1.675-.687 1 0 1.688.687.687.688.687 1.688 0 .975-.687 1.687-.688.713-1.663.713Z"/>
</svg>
)
}
@@ -31,7 +31,7 @@ export default function TitleBar({template = "main"} : {template?: "update"|"mai
}
return (
<header id="titlebar" className="min-h-[22px] bg-light-main-color-1 dark:bg-main-color-1 w-screen h-[22px] flex content-center items-center justify-start z-10">
<header id="titlebar" className="min-h-[22px] bg-light-main-color-1 shrink-0 dark:bg-main-color-1 w-screen h-[22px] flex content-center items-center justify-start z-10">
<div id="drag-region" className='grow basis-0 h-full'>
{template !== "update" && (<div id="window-title" className='pl-1'>
<span className='text-gray-800 dark:text-gray-100 font-bold text-xs italic'>BSManager</span>
@@ -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 (
<div className={`relative rounded-full cursor-pointer group active:scale-95 transition-transform ${!active && "shadow-md shadow-black"}`} onClick={() => onClick(!active)}>
<div className={`absolute glow-on-hover !rounded-full ${active && "!opacity-100 blur-[2px]"}`}/>
<div className='w-full h-full pl-6 pr-6 flex justify-center items-center bg-light-main-color-2 dark:bg-main-color-2 p-3 rounded-full text-gray-800 dark:text-white group-hover:bg-light-main-color-1 dark:group-hover:bg-main-color-1'>
<BsmIcon icon={icon} className='mr-1 h-7 text-gray-800 dark:text-white'/>
<span className='w-fit min-w-fit h-full text-lg font-bold uppercase tracking-wide italic'>{t(text)}</span>
</div>
</div>
)
}
@@ -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<boolean>(LaunchMods.OCULUS_MOD));
const [desktopMode, setDesktopMode] = useState(!!configService.get<boolean>(LaunchMods.DESKTOP_MOD));
const [debugMode, setDebugMode] = useState(!!configService.get<boolean>(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 (
<div className="w-full shrink-0 items-center relative flex flex-col justify-start">
<div className='grid grid-flow-col grid-cols-3 gap-6'>
<LaunchModToogle icon='oculus' onClick={() => setMode(LaunchMods.OCULUS_MOD, !oculusMode)} active={oculusMode} text="pages.version-viewer.launch-mods.oculus"/>
<LaunchModToogle icon='desktop' onClick={() => setMode(LaunchMods.DESKTOP_MOD, !desktopMode)} active={desktopMode} text="pages.version-viewer.launch-mods.desktop"/>
<LaunchModToogle icon='terminal' onClick={() => setMode(LaunchMods.DEBUG_MOD, !debugMode)} active={debugMode} text="pages.version-viewer.launch-mods.debug"/>
</div>
<div className='grow flex justify-center items-center'>
<BsmButton onClick={launch} active={JSON.stringify(version) === JSON.stringify(launchState)} className='relative text-5xl text-gray-800 dark:text-gray-200 font-bold tracking-wide pt-1 pb-3 px-7 rounded-lg shadow-md italic shadow-black active:scale-90 transition-transform' text="misc.launch"/>
</div>
</div>
)
}
@@ -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 (
<div className="contents bg-light-main-color-3 dark:bg-main-color-1 text-main-color-1 dark:text-light-main-color-1">
<div className="h-full aspect-square flex items-center justify-center p-[7px] rounded-l-md bg-inherit ml-3 border-t-2 border-b-2 border-l-2" style={wantInfoStyle}>
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={onChange} disabled={mod.required || isDependency} checked={isDependency || isSelected || mod.required}/>
</div>
<span className="bg-inherit py-2 pl-3 font-bold text-sm whitespace-nowrap border-t-2 border-b-2" style={wantInfoStyle}>{mod.name}</span>
<span className={`min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 ${(installedVersion && isOutDated) && "text-red-400 line-through"} ${installedVersion && !isOutDated && "text-green-400"}`} style={wantInfoStyle}>{installedVersion || "-"}</span>
<span className="min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2" style={wantInfoStyle}>{mod.version}</span>
<span title={mod.description} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2" style={wantInfoStyle}>{mod.description}</span>
<div className="h-full bg-inherit flex items-center justify-center mr-3 rounded-r-md pr-2 border-t-2 border-b-2 border-r-2" style={wantInfoStyle}>
{installedVersion && <BsmButton className="z-[1] h-7 w-7 p-[5px] rounded-full" icon="trash" disabled={uninstalling} withBar={false} onClick={e => {e.stopPropagation(); uninstall()}}/>}
</div>
</div>
)
}
@@ -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<string, Mod[]>, installed: Map<string, Mod[]>, 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 && (
<div className="grid gap-y-1 grid-cols-[40px_min-content_min-content_min-content_1fr_min-content] bg-light-main-color-2 dark:bg-main-color-2 text-main-color-1 dark:text-light-main-color-1">
<span className="absolute z-10 top-0 w-full h-8 bg-inherit"/>
<span className="z-10 sticky flex items-center justify-end top-0 bg-inherit border-b-2 border-main-color-1">
<BsmButton className="rounded-full h-6 w-6 p-[2px]" withBar={false} icon="search" onClick={handleToogleFilter}/>
</span>
<span className="z-10 sticky top-0 flex items-center bg-inherit border-main-color-1 border-b-2 h-8 px-1">
{(filterEnabled ? (
<motion.input autoFocus className="bg-main-color-1 rounded-md h-6 px-2" initial={{width: 0}} animate={{width: "250px"}} transition={{ease:"easeInOut", duration:.15}} onChange={e => handleInput(e.target.value)}/>
):(
<span className="w-full text-center">{t("pages.version-viewer.mods.mods-grid.header-bar.name")}</span>
))}
</span>
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8 px-2">{t("pages.version-viewer.mods.mods-grid.header-bar.installed")}</span>
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8 px-2">{t("pages.version-viewer.mods.mods-grid.header-bar.latest")}</span>
<span className="z-10 sticky flex items-center justify-center top-0 bg-inherit border-b-2 border-main-color-1 h-8">{t("pages.version-viewer.mods.mods-grid.header-bar.description")}</span>
<span className="z-10 sticky top-0 bg-inherit border-b-2 border-main-color-1 h-8 flex justify-start items-center py-1 pl-[3px] min-w-[50px]">
<BsmDropdownButton className="h-full aspect-square relative rounded-full bg-light-main-color-1 dark:bg-main-color-3" withBar={false} icon="three-dots" buttonClassName="!rounded-full !p-[2px] !bg-light-main-color-2 dark:!bg-main-color-2 hover:!bg-light-main-color-1 dark:hover:!bg-main-color-3" menuTranslationY="5px" items={[
({text: "pages.version-viewer.mods.mods-grid.header-bar.dropdown.uninstall-all", icon: "trash", onClick: handleUninstallAll}),
]}/>
</span>
{
Array.from(modsMap.keys()).map(key => modsMap.get(key).some(mod => mod.name.toLowerCase().includes(filter)) && (
<ul key={key} className="contents">
<h2 className="col-span-full py-1 font-bold pl-3">{key}</h2>
{modsMap.get(key).map(mod => mod.name.toLowerCase().includes(filter) && (
<li className="contents cursor-pointer" onClick={() => onWantInfos(mod)} key={mod.name}>
<ModItem mod={mod} installedVersion={installedModVersion(key, mod)} isDependency={isDependency(mod)} isSelected={isSelected(mod)} onChange={(val) => onModChange(val, mod)} wantInfo={mod.name === moreInfoMod?.name}/>
</li>
))}
</ul>
))
}
</div>
)
}
@@ -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<string, Mod[]>);
const [modsInstalled, setModsInstalled] = useState(null as Map<string, Mod[]>);
const [modsSelected, setModsSelected] = useState([] as Mod[]);
const [moreInfoMod, setMoreInfoMod] = useState(null as Mod);
const installing = useObservable(modsManager.isInstalling$);
const t = useTranslation();
const downloadRef = useRef(null);
const [downloadWith, setDownloadWidth] = useState(0);
const modsToCategoryMap = (mods: Mod[]): Map<string, Mod[]> => {
if(!mods){ return new Map<string, Mod[]>(); }
const map = new Map<string, Mod[]>();
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<string[]>("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 (
<VisibilitySensor onChange={setIsVisible}>
<div className='shrink-0 w-full h-full px-8 pb-7 flex justify-center'>
<div className='relative flex flex-col grow-0 bg-light-main-color-2 dark:bg-main-color-2 h-full w-full rounded-md shadow-black shadow-center overflow-hidden'>
{modsAvailable ? (
<>
<div className="overflow-scroll w-full min-h-0 scrollbar-thin scrollbar-thumb-neutral-900 scrollbar-thumb-rounded-full">
<ModsGrid modsMap={modsAvailable} installed={modsInstalled} modsSelected={modsSelected} onModChange={handleModChange} moreInfoMod={moreInfoMod} onWantInfos={handleMoreInfo}/>
</div>
<div className="h-10 shrink-0 flex items-center justify-between px-3">
<BsmButton className="text-center rounded-md px-2 py-[2px]" text="pages.version-viewer.mods.buttons.more-infos" typeColor="cancel" withBar={false} disabled={!moreInfoMod} onClick={handleOpenMoreInfo} style={{width: downloadWith}}/>
<div ref={downloadRef}>
<BsmButton className="text-center rounded-md px-2 py-[2px]" text="pages.version-viewer.mods.buttons.install-or-update" withBar={false} disabled={installing} typeColor="primary" onClick={installMods}/>
</div>
</div>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center relative">
<img className="w-32 h-32 spin-loading" src={BeatWaitingImg}></img>
<span className="text-xl mt-3 h-0 italic">{t("pages.version-viewer.mods.loading-mods")}</span>
<SpoilerClick className="absolute right-5 w-20 h-[120px] bottom-5 cursor-pointer">
<img className="relative w-full" src={YuruYuriDance}/>
</SpoilerClick>
</div>
)}
</div>
</div>
</VisibilitySensor>
)
}
@@ -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"
@@ -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";
}
+23
View File
@@ -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); }
+36 -112
View File
@@ -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<boolean>(LaunchMods.OCULUS_MOD));
setDesktopMode(!!configService.get<boolean>(LaunchMods.DESKTOP_MOD));
setDebugMode(!!configService.get<boolean>(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() {
<BsmImage className='relative object-cover h-28' image={BSLogo}/>
<h1 className='relative text-4xl font-bold italic -top-3'>{state.name ? `${state.BSVersion} - ${state.name}` : state.BSVersion}</h1>
<TabNavBar className='mt-3' tabsText={["misc.launch", "misc.maps", "misc.mods"]} onTabChange={(i : number) => setCurrentTabIndex(i)}/>
<div className='mt-2 w-full grow flex transition-transform duration-300 pt-5' style={{transform: `translate(${-(currentTabIndex * 100)}%, 0)`}}>
<div className='w-full shrink-0 items-center relative flex flex-col justify-start -top-2'>
<div className='grid grid-flow-col grid-cols-3 gap-6'>
<ToogleLunchMod icon='oculus' onClick={() => setMode(LaunchMods.OCULUS_MOD, !oculusMode)} active={oculusMode} text="pages.version-viewer.launch-mods.oculus"/>
<ToogleLunchMod icon='desktop' onClick={() => setMode(LaunchMods.DESKTOP_MOD, !desktopMode)} active={desktopMode} text="pages.version-viewer.launch-mods.desktop"/>
<ToogleLunchMod icon='terminal' onClick={() => setMode(LaunchMods.DEBUG_MOD, !debugMode)} active={debugMode} text="pages.version-viewer.launch-mods.debug"/>
</div>
<div className='grow flex justify-center items-center'>
<BsmButton onClick={launchBs} active={JSON.stringify(state) === JSON.stringify(launchState)} className='relative text-5xl text-gray-800 dark:text-gray-200 font-bold tracking-wide pt-1 pb-3 px-7 rounded-lg shadow-md italic shadow-black active:scale-90 transition-transform' text="misc.launch"/>
</div>
</div>
<div className='shrink-0 w-full h-full flex justify-center'>
<div className='p-4 bg-light-main-color-2 dark:bg-main-color-2 h-fit rounded-md'>
<img src={wipGif} alt="" />
<span className='block w-full text-center font-bold mt-2 text-gray-800 dark:text-white'>Work In Progress</span>
</div>
</div>
<div className='mt-2 w-full min-h-0 grow flex transition-transform duration-300 pt-3' style={{transform: `translate(${-(currentTabIndex * 100)}%, 0)`}}>
<LaunchSlide version={state}/>
<div className='shrink-0 w-full h-full flex justify-center'>
<div className='p-4 bg-light-main-color-2 dark:bg-main-color-2 h-fit rounded-md'>
<img src={wipGif} alt="" />
<span className='block w-full text-center font-bold mt-2 text-gray-800 dark:text-white'>Work In Progress</span>
</div>
</div>
<ModsSlide version={state}/>
</div>
</div>
<BsmDropdownButton className='absolute top-5 right-5 h-9 w-9' items={[
<BsmDropdownButton className='absolute top-5 right-5 h-9 w-9 bg-light-main-color-2 dark:bg-main-color-2 rounded-md' items={[
{text: "pages.version-viewer.dropdown.open-folder", icon: "folder", onClick: openFolder},
(!state.steam && {text: "pages.version-viewer.dropdown.verify-files", icon: "task", onClick: verifyFiles}),
(!state.steam && {text: "Exporter les misc.maps", icon: "export", onClick: exportMaps}),
@@ -149,19 +88,4 @@ export function VersionViewer() {
]}/>
</>
)
}
function ToogleLunchMod(props: {onClick: () => void, active: boolean, text: string, icon: BsmIconType}) {
const t = useTranslation();
return (
<div className={`relative rounded-full cursor-pointer group active:scale-95 transition-transform ${!props.active && "shadow-md shadow-black"}`} onClick={() => props.onClick()}>
<div className={`absolute glow-on-hover rounded-full ${props.active && "opacity-100 blur-[2px]"}`}/>
<div className='w-full h-full pl-6 pr-6 flex justify-center items-center bg-light-main-color-2 dark:bg-main-color-2 p-3 rounded-full text-gray-800 dark:text-white group-hover:bg-light-main-color-1 dark:group-hover:bg-main-color-1'>
<BsmIcon icon={props.icon} className='mr-1 h-7 text-gray-800 dark:text-white'/>
<span className='w-fit min-w-fit h-full text-lg font-bold uppercase tracking-wide italic'>{t(props.text)}</span>
</div>
</div>
);
}
}
@@ -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;
}
@@ -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<boolean> = new BehaviorSubject(false);
public readonly isUninstalling$: BehaviorSubject<boolean> = 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<Mod[]>{
return this.ipcService.send<Mod[], BSVersion>("get-available-mods", {args: version}).then(res => res.data);
}
public getInstalledMods(version: BSVersion): Promise<Mod[]>{
return this.ipcService.send<Mod[], BSVersion>("get-installed-mods", {args: version}).then(res => res.data);
}
public installMods(mods: Mod[], version: BSVersion): Promise<void>{
if(!this.progressBar.require()){ return undefined; }
const progress$: Observable<ProgressionInterface> = this.ipcService.watch<ModInstallProgression>("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<InstallModsResult, {mods: Mod[], version: BSVersion}>("install-mods", {args: {mods, version}}).then(res => {
if(res.success && res.data){
const isFullyInstalled = res.data.nbInstalledMods === res.data.nbModsToInstall;
const title = `notifications.mods.install-mods.titles.${isFullyInstalled ? "success" : "warning"}`;
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<void>{
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<ModInstallProgression>("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<ModInstallProgression>("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<UninstallModsResult>("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();
});
}
}
+1 -2
View File
@@ -53,11 +53,10 @@ export class I18nService {
public translate(translationKey: string, args?: Record<string, string>): 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;
}
+6 -4
View File
@@ -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<T> {
export interface ModalResponse<T = unknown> {
exitCode: ModalExitCode,
data?: T
}
@@ -0,0 +1,28 @@
import { BehaviorSubject } from "rxjs";
export class PageStateService {
private static instance: PageStateService;
private readonly _state$: BehaviorSubject<any> = 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 = unknown>(): T{
return this._state$.value;
}
public get state$(){
return this._state$.asObservable();
}
}
+29 -18
View File
@@ -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<number>;
private readonly _progression$: BehaviorSubject<ProgressionInterface>;
private readonly _visible$: BehaviorSubject<boolean>;
private readonly _style$: BehaviorSubject<CSSProperties>;
private subscription: Subscription;
@@ -24,51 +27,55 @@ export class ProgressBarService{
this.ipcService = IpcService.getInstance();
this.notificationService = NotificationService.getInstance();
this._progression$ = new BehaviorSubject<number>(0);
this._progression$ = new BehaviorSubject<ProgressionInterface>({progression: 0});
this._visible$ = new BehaviorSubject<boolean>(false);
this._style$ = new BehaviorSubject<CSSProperties>(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<number>){
public subscribreTo(obs: Observable<ProgressionInterface|number>){
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<number>, unsubscribe? : boolean){
public show(obs?: Observable<ProgressionInterface|number>, 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<ProgressionInterface> = 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<number>{ return this._progression$; }
public get visible$(): BehaviorSubject<boolean>{ return this._visible$; }
public get progressData$(): Observable<ProgressionInterface>{ return this._progression$.asObservable(); }
public get progress$(): Observable<number>{ return this._progression$.pipe(map(data => data.progression)); }
public get progressLabel(): Observable<string>{ return this._progression$.pipe(map(data => data?.label)); }
public get visible$(): Observable<boolean>{ return this._visible$.asObservable(); }
public get isVisible(): boolean{ return this._visible$.value; }
public get style$(): Observable<CSSProperties>{ return this._style$.asObservable(); }
+2
View File
@@ -0,0 +1,2 @@
export { ModInstallProgression, InstallModsResult, UninstallModsResult } from "./mod-ipc.model"
export { Mod, DownloadLink, DownloadLinkType, FileHashes, ModAuthor } from "./mod.interface"
+14
View File
@@ -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
}
+37
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
export { ProgressionInterface } from "./progress-bar.model"
@@ -0,0 +1,4 @@
export interface ProgressionInterface {
progression: number,
label?: string
};