[chore] migrate to the new mods repo API

This commit is contained in:
MathieuG-P
2024-12-31 02:29:27 +01:00
parent 054f457055
commit 9ecb3daf55
10 changed files with 178 additions and 177 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ import { BsModsManagerService } from "../services/mods/bs-mods-manager.service";
import { UtilsService } from "../services/utils.service";
import { BSVersion } from "shared/bs-version.interface";
import { IpcRequest } from "shared/models/ipc";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod } from "shared/models/mods/mod.interface";
import { InstallModsResult } from "shared/models/mods";
import log from "electron-log";
import { IpcService } from "../services/ipc.service";
@@ -21,7 +21,7 @@ ipc.on<BSVersion>("get-installed-mods", (req, reply) => {
reply(from(modsManager.getInstalledMods(req.args)));
});
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
@@ -36,7 +36,7 @@ ipcMain.on("install-mods", (event, request: IpcRequest<{ mods: Mod[]; version: B
});
});
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: Mod[]; version: BSVersion }>) => {
ipcMain.on("uninstall-mods", (event, request: IpcRequest<{ mods: BbmFullMod[]; version: BSVersion }>) => {
const utils = UtilsService.getInstance();
const modsManager = BsModsManagerService.getInstance();
+21 -40
View File
@@ -1,19 +1,18 @@
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmFullMod, BbmMod, BbmModVersion, BbmPlatform } from "../../../shared/models/mods/mod.interface";
import { RequestService } from "../request.service";
import { BsStore } from "../../../shared/models/bs-store.enum";
export class BeatModsApiService {
private static instance: BeatModsApiService;
private readonly requestService: RequestService;
public readonly MODS_REPO_URL = "https://bbm.saera.gay";
private readonly BEAT_MODS_API_URL = `${this.MODS_REPO_URL}/api`;
public readonly MODS_REPO_URL = "https://beatmods.com";
private readonly MODS_REPO_API_URL = `${this.MODS_REPO_URL}/api`;
private readonly versionModsCache = new Map<string, Mod[]>();
private readonly modsHashCache = new Map<string, Mod>();
private allModsCache: Mod[];
private readonly versionModsCache = new Map<string, BbmFullMod[]>();
private readonly modsHashCache = new Map<string, BbmModVersion>();
public static getInstance(): BeatModsApiService {
if (!BeatModsApiService.instance) {
@@ -26,65 +25,47 @@ export class BeatModsApiService {
this.requestService = RequestService.getInstance();
}
private convertToBeatModsMod(mod: Mod): Mod {
}
private getVersionModsUrl(version: BSVersion): string {
return `${this.BEAT_MODS_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}`;
const platform: BbmPlatform = version.oculus || version.metadata.store === BsStore.OCULUS ? BbmPlatform.OculusPC : BbmPlatform.SteamPC;
return `${this.MODS_REPO_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
}
private asignDependencies(mod: Mod, mods: Mod[]): Mod {
mod.dependencies = mod.dependencies.map(dep => mods.find(mod => mod.name === dep.name));
return mod;
}
private updateModsHashCache(mods: Mod[]): void {
private updateModsHashCache(mods: BbmModVersion[]): void {
if(!Array.isArray(mods)){
return;
}
for (const mod of mods) {
for (const downloads of (mod.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, mod);
}
}
for (const dep of (mod.dependencies ?? [])) {
for (const downloads of (dep.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, dep);
}
}
for (const content of (mod.contentHashes ?? [])) {
this.modsHashCache.set(content.hash, mod);
}
}
}
public async getVersionMods(version: BSVersion): Promise<Mod[]> {
public async getVersionMods(version: BSVersion): Promise<BbmFullMod[]> {
if (this.versionModsCache.has(version.BSVersion)) {
return this.versionModsCache.get(version.BSVersion);
}
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl(version)).then(mods => {
mods = mods.map(mod => this.asignDependencies(mod, mods));
this.versionModsCache.set(version.BSVersion, mods);
return this.requestService.getJSON<{ mods: {mod: BbmMod, latest: BbmModVersion}[] }>(this.getVersionModsUrl(version)).then(res => {
const fullMods: BbmFullMod[] = res?.mods?.map(mod => ({ mod: mod.mod, version: mod.latest })) ?? [];
this.versionModsCache.set(version.BSVersion, fullMods);
this.updateModsHashCache(mods);
this.updateModsHashCache(fullMods.map(mod => mod.version));
return mods;
return fullMods;
});
}
public getModByHash(hash: string): Promise<Mod> {
public getModByHash(hash: string): Promise<BbmModVersion> {
if (this.modsHashCache.has(hash)) {
return Promise.resolve(this.modsHashCache.get(hash));
}
return this.requestService.getJSON<Mod[]>(`${this.BEAT_MODS_API_URL}/hashlookup?hash=${hash}`).then(mods => {
this.updateModsHashCache(mods);
return mods.at(0);
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`).then(res => {
this.updateModsHashCache(res?.modVersions ?? []);
return res?.modVersions?.at(0);
});
}
}
@@ -1,5 +1,5 @@
import { BSVersion } from "shared/bs-version.interface";
import { DownloadLink, InstallModsResult, Mod, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { InstallModsResult, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { BeatModsApiService } from "./beat-mods-api.service";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
@@ -16,6 +16,7 @@ import { sToMs } from "../../../shared/helpers/time.helpers";
import { pathExistsSync } from "fs-extra";
import { BsmZipExtractor } from "../../models/bsm-zip-extractor.class";
import crypto from "crypto";
import { BbmCategories, BbmFullMod, BbmModVersion } from "../../../shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -25,7 +26,7 @@ export class BsModsManagerService {
private readonly utilsService: UtilsService;
private readonly requestService: RequestService;
private manifestMatches: Mod[];
private manifestMatches: BbmModVersion[];
private nbModsToInstall = 0;
private nbInstalledMods = 0;
@@ -48,18 +49,18 @@ export class BsModsManagerService {
this.utilsService = UtilsService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
private async getModFromHash(hash: string): Promise<BbmModVersion> {
const mod = await this.beatModsApi.getModByHash(hash);
if(mod?.name?.toLowerCase() === "bsipa"){
if(mod?.contentHashes?.some(content => content.path.includes("IPA.exe"))){
return undefined;
}
return mod;
}
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<Mod[]> {
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<BbmModVersion[]> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const modsPath = path.join(bsPath, modsDir);
@@ -88,7 +89,7 @@ export class BsModsManagerService {
}
if (filePath.toLowerCase().includes("libs")) {
const manifestIndex = this.manifestMatches.findIndex(m => m.name === mod.name);
const manifestIndex = this.manifestMatches.findIndex(m => m.id === mod.id);
if (manifestIndex < 0) {
return undefined;
@@ -104,7 +105,7 @@ export class BsModsManagerService {
return mods.filter(Boolean);
}
private async getBsipaInstalled(version: BSVersion): Promise<Mod> {
private async getBsipaInstalled(version: BSVersion): Promise<BbmModVersion> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const injectorPath = path.join(bsPath, "Beat Saber_Data", "Managed", "IPA.Injector.dll");
if (!(await pathExist(injectorPath))) {
@@ -177,26 +178,23 @@ export class BsModsManagerService {
});
}
private getModDownload(mod: Mod, version: BSVersion): DownloadLink {
return mod.downloads.find(download => {
const type = download.type.toLowerCase();
return type === "universal" || type === this.bsLocalService.getVersionType(version);
});
private getModDownload(modVersion: BbmModVersion): string {
return `/cdn/mod/${modVersion.zipHash}.zip`
}
private async installMod(mod: Mod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
private async installMod(mod: BbmFullMod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
const download = this.getModDownload(mod, version);
const downloadUrl = this.getModDownload(mod.version);
if (!download) {
if (!downloadUrl) {
return false;
}
log.info("Start download mod zip", mod.name, download.url);
const zip = await this.downloadZip(download.url);
log.info("Mod zip download end", mod.name, download.url);
log.info("Start download mod zip", mod.mod.name, downloadUrl);
const zip = await this.downloadZip(downloadUrl);
log.info("Mod zip download end", mod.mod.name, downloadUrl);
if (!zip) {
return false;
@@ -208,18 +206,18 @@ export class BsModsManagerService {
const md5Hash = crypto.createHash("md5")
.update(buffer)
.digest("hex");
hashCount += +download.hashMd5.some(md5 => md5.hash === md5Hash);
hashCount += +mod.version.contentHashes.some(content => content.hash === md5Hash);
}
if (hashCount !== download.hashMd5.length) {
if (hashCount !== mod.version.contentHashes.length) {
return false;
}
const versionPath = await this.bsLocalService.getVersionPath(version);
const isBSIPA = mod.name.toLowerCase() === "bsipa";
const isBSIPA = mod.mod.name.toLowerCase() === "bsipa";
const destDir = isBSIPA ? versionPath : path.join(versionPath, ModsInstallFolder.PENDING);
log.info("Start extracting mod zip", mod.name, "to", destDir);
log.info("Start extracting mod zip", mod.mod.name, "to", destDir);
const extracted = await zip.extract(destDir)
.then(() => true)
.catch(e => {
@@ -230,7 +228,7 @@ export class BsModsManagerService {
zip.close();
});
log.info("Mod zip extraction end", mod.name, "to", destDir, "success:", extracted);
log.info("Mod zip extraction end", mod.mod.name, "to", destDir, "success:", extracted);
const res = isBSIPA
? extracted &&
@@ -245,23 +243,20 @@ export class BsModsManagerService {
return res;
}
private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]) {
return selectedMods.some(m => {
const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name));
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
private isDependency(mod: BbmFullMod, selectedMods: BbmFullMod[], availableMods: BbmFullMod[]): boolean {
const selectedDepsIds = selectedMods.flatMap(mod => mod.version.dependencies);
const modsDeps = availableMods.filter(m => selectedDepsIds.includes(m.version.id));
const modsDepsDepsIds = modsDeps.flatMap(m => m.version.dependencies);
return selectedDepsIds.includes(mod.version.id) || modsDepsDepsIds.includes(mod.version.id);
}
private async resolveDependencies(mods: Mod[], version: BSVersion): Promise<Mod[]> {
private async resolveDependencies(mods: BbmFullMod[], version: BSVersion): Promise<BbmFullMod[]> {
const availableMods = await this.beatModsApi.getVersionMods(version);
return Array.from(
new Map<string, Mod>(
new Map<number, BbmFullMod>(
availableMods.reduce((res, mod) => {
if (mod.required || this.isDependency(mod, mods, availableMods)) {
res.push([mod.name, mod]);
if (mod.mod.category === BbmCategories.Core || this.isDependency(mod, mods, availableMods)) {
res.push([mod.mod.id, mod]);
}
return res;
}, [])
@@ -269,9 +264,7 @@ export class BsModsManagerService {
);
}
private async uninstallBSIPA(mod: Mod, version: BSVersion): Promise<void> {
const download = this.getModDownload(mod, version);
private async uninstallBSIPA(mod: BbmFullMod, version: BSVersion): Promise<void> {
const verionPath = await this.bsLocalService.getVersionPath(version);
const hasIPAExe = await pathExist(path.join(verionPath, "IPA.exe"));
const hasIPADir = await pathExist(path.join(verionPath, "IPA"));
@@ -282,37 +275,38 @@ export class BsModsManagerService {
await this.executeBSIPA(version, ["--revert", "-n"]);
const promises = download.hashMd5.map(files => {
const file = files.file.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
const promises = mod.version.contentHashes.map(content => {
const file = content.path.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data");
return unlinkPath(path.join(verionPath, file));
});
await Promise.all(promises);
}
private async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
private async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
this.nbUninstalledMods++;
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
if (mod.name.toLowerCase() === "bsipa") {
if (mod.mod.name.toLowerCase() === "bsipa") {
return this.uninstallBSIPA(mod, version);
}
const download = this.getModDownload(mod, version);
const versionPath = await this.bsLocalService.getVersionPath(version);
const promises = download.hashMd5.map(async files => {
return Promise.all([unlinkPath(path.join(versionPath, files.file)), unlinkPath(path.join(versionPath, "IPA", "Pending", files.file))]);
const promises = mod.version.contentHashes.map(async content => {
return Promise.all([unlinkPath(path.join(versionPath, content.path)), unlinkPath(path.join(versionPath, "IPA", "Pending", content.path))]);
});
await Promise.all(promises);
}
public getAvailableMods(version: BSVersion): Promise<Mod[]> {
return this.beatModsApi.getVersionMods(version);
public getAvailableMods(version: BSVersion): Promise<BbmFullMod[]> {
return this.beatModsApi.getVersionMods(version).catch(() => {
return [] as BbmFullMod[];
});
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]> {
public async getInstalledMods(version: BSVersion): Promise<BbmModVersion[]> {
this.manifestMatches = [];
const bsipa = await this.getBsipaInstalled(version);
@@ -322,23 +316,23 @@ export class BsModsManagerService {
const dirMods = pluginsMods.flat().concat(libsMods.flat());
const modsDict = new Map<string, Mod>();
const modsDict = new Map<number, BbmModVersion>();
if (bsipa) {
modsDict.set(bsipa.name, bsipa);
modsDict.set(bsipa.id, bsipa);
}
for (const mod of dirMods.flat()) {
if (modsDict.has(mod.name)) {
if (modsDict.has(mod.id)) {
continue;
}
modsDict.set(mod.name, mod);
modsDict.set(mod.id, mod);
}
return Array.from(modsDict.values());
}
public async installMods(mods: Mod[], version: BSVersion): Promise<InstallModsResult> {
public async installMods(mods: BbmFullMod[], version: BSVersion): Promise<InstallModsResult> {
const deps = await this.resolveDependencies(mods, version);
mods.push(...deps);
@@ -346,9 +340,9 @@ export class BsModsManagerService {
throw "no-mods";
}
const bsipa = mods.find(mod => mod.name.toLowerCase() === "bsipa");
const bsipa = mods.find(mod => mod.mod.name.toLowerCase() === "bsipa");
if (bsipa) {
mods = mods.filter(mod => mod.name.toLowerCase() !== "bsipa");
mods = mods.filter(mod => mod.mod.name.toLowerCase() !== "bsipa");
}
this.nbModsToInstall = mods.length + (bsipa && 1);
@@ -374,7 +368,7 @@ export class BsModsManagerService {
};
}
public async uninstallMods(mods: Mod[], version: BSVersion): Promise<UninstallModsResult> {
public async uninstallMods(mods: BbmFullMod[], version: BSVersion): Promise<UninstallModsResult> {
if (!mods?.length) {
throw "no-mods";
}
@@ -393,16 +387,21 @@ export class BsModsManagerService {
}
public async uninstallAllMods(version: BSVersion): Promise<UninstallModsResult> {
const mods = await this.getInstalledMods(version);
const versionMods = await this.getAvailableMods(version);
const installedMods = await this.getInstalledMods(version);
if (!mods?.length) {
const fullInstalledMods: BbmFullMod[] = installedMods?.map(version => {
return { version, mod: versionMods.find(mod => version.modId === mod.mod.id)?.mod };
}) ?? [];
if (!fullInstalledMods?.length) {
throw "no-mods";
}
this.nbModsToUninstall = mods.length;
this.nbModsToUninstall = fullInstalledMods.length;
this.nbUninstalledMods = 0;
for (const mod of mods) {
for (const mod of fullInstalledMods) {
await this.uninstallMod(mod, version);
}
+1 -1
View File
@@ -49,7 +49,7 @@ export class RequestService {
const response = await fetch(url, this.getInitWithOptions(options));
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} ${url}`);
throw new Error(`HTTP error! status: ${response.status} response: ${await response.text().catch(() => "No response")} url: ${url}`);
}
return await response.json();
@@ -3,9 +3,9 @@ import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmMod } from "shared/models/mods/mod.interface";
export const UninstallModModal: ModalComponent<void, Mod> = ({ resolver, data }) => {
export const UninstallModModal: ModalComponent<void, BbmMod> = ({ resolver, data }) => {
const mod = data;
const t = useTranslation();
@@ -1,6 +1,5 @@
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
import { Mod } from "shared/models/mods/mod.interface";
import { CSSProperties, useRef } from "react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
@@ -9,8 +8,9 @@ import { PageStateService } from "renderer/services/page-state.service";
import useDoubleClick from "use-double-click";
import { gt } from "semver";
import { useService } from "renderer/hooks/use-service.hook";
import { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
type Props = { className?: string; mod: Mod; installedVersion: string; isDependency?: boolean; isSelected?: boolean; onChange?: (val: boolean) => void; wantInfo?: boolean; onWantInfo?: (mod: Mod) => void };
type Props = { className?: string; mod: BbmFullMod; installedVersion: string; isDependency?: boolean; isSelected?: boolean; onChange?: (val: boolean) => void; wantInfo?: boolean; onWantInfo?: (mod: BbmFullMod) => void };
export function ModItem({ className, mod, installedVersion, isDependency, isSelected, onChange, wantInfo, onWantInfo }: Props) {
const modsManager = useService(BsModsManagerService);
@@ -28,7 +28,7 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
});
const wantInfoStyle: CSSProperties = wantInfo ? { borderColor: themeColor } : { borderColor: "transparent" };
const isOutDated = installedVersion ? gt(mod.version, installedVersion) : false;
const isOutDated = installedVersion ? gt(mod.version.modVersion, installedVersion) : false;
const uninstall = () => {
modsManager.uninstallMod(mod, pageState.getState());
@@ -43,24 +43,24 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
onChange(!isChecked);
};
const isChecked = isDependency || isSelected || mod.required;
const isChecked = isDependency || isSelected || mod.mod.category === BbmCategories.Core;
return (
<li ref={clickRef} className={`${className} group`}>
<div className="h-full aspect-square flex items-center justify-center p-[7px] rounded-l-md bg-inherit ml-3 border-2 border-r-0 z-[1] group-hover:brightness-90" style={wantInfoStyle}>
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={onChange} disabled={mod.required || isDependency} checked={isChecked} />
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={onChange} disabled={mod.mod.category === BbmCategories.Core || isDependency} checked={isChecked} />
</div>
<span className="bg-inherit py-2 pl-3 font-bold text-sm whitespace-nowrap border-t-2 border-b-2 blur-none group-hover:brightness-90" style={wantInfoStyle}>
{mod.name}
{mod.mod.name}
</span>
<span className={`min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90 ${installedVersion && isOutDated && "text-red-400 line-through"} ${installedVersion && !isOutDated && "text-green-400"}`} style={wantInfoStyle}>
{installedVersion || "-"}
</span>
<span className="min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.version}
{mod.version.modVersion}
</span>
<span title={mod.description} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.description}
<span title={mod.mod.description} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.mod.summary}
</span>
<div className="h-full bg-inherit flex items-center justify-center mr-3 rounded-r-md pr-2 border-t-2 border-b-2 border-r-2 group-hover:brightness-90" style={wantInfoStyle}>
{installedVersion && (
@@ -5,17 +5,17 @@ import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-butto
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { PageStateService } from "renderer/services/page-state.service";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
import { ModItem } from "./mod-item.component";
import { useService } from "renderer/hooks/use-service.hook";
type Props = {
modsMap: Map<string, Mod[]>;
installed: Map<string, Mod[]>;
modsSelected: Mod[];
onModChange: (selected: boolean, mod: Mod) => void;
moreInfoMod?: Mod;
onWantInfos: (mod: Mod) => void
modsMap: Map<BbmCategories, BbmFullMod[]>;
installed: Map<BbmCategories, BbmFullMod[]>;
modsSelected: BbmFullMod[];
onModChange: (selected: boolean, mod: BbmFullMod) => void;
moreInfoMod?: BbmFullMod;
onWantInfos: (mod: BbmFullMod) => void
unselectAllMods?: () => void;
};
@@ -28,32 +28,29 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
const [filterEnabled, setFilterEnabled] = useState(false);
const t = useTranslation();
const installedModVersion = (key: string, mod: Mod): string => {
const installedModVersion = (key: BbmCategories, mod: BbmFullMod): string => {
if (!installed?.get(key)) {
return undefined;
}
const installedMod = installed.get(key).find(m => m.name === mod.name);
const installedMod = installed.get(key).find(m => m.mod.id === mod.mod.id);
if (!installedMod) {
return undefined;
}
return installedMod.version;
return installedMod.version.modVersion;
};
const isDependency = (mod: Mod): boolean => {
return modsSelected.some(m => {
const deps = m.dependencies.map(dep =>
Array.from(modsMap.values())
.flat()
.find(m => dep.name === m.name)
);
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
const getAvailableMods = (): BbmFullMod[] => {
return Array.from(modsMap.values()).flat();
}
const isDependency = (mod: BbmFullMod): boolean => {
const selectedModsDepsIds = modsSelected.flatMap(m => m.version.dependencies);
const modsDeps = getAvailableMods().filter(m => selectedModsDepsIds.includes(m.version.id));
const modsDepsDepsIds = modsDeps.flatMap(m => m.version.dependencies);
return selectedModsDepsIds.includes(mod.version.id) || modsDepsDepsIds.includes(mod.version.id);
};
const isSelected = (mod: Mod): boolean => modsSelected.some(m => m.name === mod.name);
const isSelected = (mod: BbmFullMod): boolean => modsSelected.some(m => m.mod.id === mod.mod.id);
const handleInput = (val: string) => setFilter(val.toLowerCase());
@@ -86,10 +83,10 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
{Array.from(modsMap.keys()).map(
key =>
modsMap.get(key).some(mod => mod.name.toLowerCase().includes(filter)) && (
modsMap.get(key).some(mod => mod.mod.name.toLowerCase().includes(filter)) && (
<ul key={key} className="contents">
<h2 className="col-span-full py-1 font-bold pl-3">{key}</h2>
{modsMap.get(key).map(mod => mod.name.toLowerCase().includes(filter) && <ModItem key={mod.name} className="contents bg-light-main-color-3 dark:bg-main-color-1 text-main-color-1 dark:text-light-main-color-1 hover:cursor-pointer" mod={mod} installedVersion={installedModVersion(key, mod)} isDependency={isDependency(mod)} isSelected={isSelected(mod)} onChange={val => onModChange(val, mod)} onWantInfo={onWantInfos} wantInfo={mod.name === moreInfoMod?.name} />)}
<h2 className="col-span-full py-1 font-bold pl-3 capitalize">{key}</h2>
{modsMap.get(key).map(mod => mod.mod.name.toLowerCase().includes(filter) && <ModItem key={mod.mod.id} className="contents bg-light-main-color-3 dark:bg-main-color-1 text-main-color-1 dark:text-light-main-color-1 hover:cursor-pointer" mod={mod} installedVersion={installedModVersion(key, mod)} isDependency={isDependency(mod)} isSelected={isSelected(mod)} onChange={val => onModChange(val, mod)} onWantInfo={onWantInfos} wantInfo={mod.mod.id === moreInfoMod?.mod.id} />)}
</ul>
)
)}
@@ -1,7 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmCategories, BbmFullMod } 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";
@@ -31,49 +31,46 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
const ref = useRef(null);
const isVisible = useInView(ref, { amount: 0.5 });
const [modsAvailable, setModsAvailable] = useState(null as Map<string, Mod[]>);
const [modsInstalled, setModsInstalled] = useState(null as Map<string, Mod[]>);
const [modsSelected, setModsSelected] = useState([] as Mod[]);
const [moreInfoMod, setMoreInfoMod] = useState(null as Mod);
const [modsAvailable, setModsAvailable] = useState(null as Map<BbmCategories, BbmFullMod[]>);
const [modsInstalled, setModsInstalled] = useState(null as Map<BbmCategories, BbmFullMod[]>);
const [modsSelected, setModsSelected] = useState([] as BbmFullMod[]);
const [moreInfoMod, setMoreInfoMod] = useState(null as BbmFullMod);
const isOnline = useObservable(() => os.isOnline$);
const installing = useObservable(() => modsManager.isInstalling$);
const downloadRef = useRef(null);
const [downloadWith, setDownloadWidth] = useState(0);
const modsToCategoryMap = (mods: Mod[]): Map<string, Mod[]> => {
const modsToCategoryMap = (mods: BbmFullMod[]): Map<BbmCategories, BbmFullMod[]> => {
if (!mods) {
return new Map<string, Mod[]>();
return new Map<BbmCategories, BbmFullMod[]>();
}
const map = new Map<string, Mod[]>();
mods.forEach(mod => map.set(mod.category, [...(map.get(mod.category) ?? []), mod]));
const map = new Map<BbmCategories, BbmFullMod[]>();
mods.forEach(mod => map.set(mod.mod.category, [...(map.get(mod.mod.category) ?? []), mod]));
return map;
};
const handleModChange = (selected: boolean, mod: Mod) => {
const handleModChange = (selected: boolean, mod: BbmFullMod) => {
if (selected) {
return setModsSelected([...modsSelected, mod]);
}
const mods = [...modsSelected];
mods.splice(
mods.findIndex(m => m.name === mod.name),
1
);
mods.splice(mods.findIndex(m => m.mod.id === mod.mod.id), 1);
setModsSelected(mods);
};
const handleMoreInfo = (mod: Mod) => {
if (mod.name === moreInfoMod?.name) {
const handleMoreInfo = (mod: BbmFullMod) => {
if (mod.mod.id === moreInfoMod?.mod.id) {
return setMoreInfoMod(null);
}
setMoreInfoMod(mod);
};
const handleOpenMoreInfo = () => {
if (!moreInfoMod?.link) {
if (!moreInfoMod?.mod?.gitUrl) {
return;
}
linkOpener.open(moreInfoMod.link);
linkOpener.open(moreInfoMod.mod.gitUrl);
};
const installMods = () => {
@@ -81,14 +78,14 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
return;
}
const modsToInstall = modsSelected.filter(mod => {
const corespondingMod = modsAvailable.get(mod.category).find(availabeMod => availabeMod._id === mod._id);
const installedMod = modsInstalled.get(mod.category)?.find(installedMod => installedMod.name === mod.name);
const corespondingMod = modsAvailable.get(mod.mod.category).find(availabeMod => availabeMod.mod.id === mod.mod.id);
const installedMod = modsInstalled.get(mod.mod.category)?.find(installedMod => installedMod.mod.id === mod.mod.id);
if (corespondingMod?.version && lt(corespondingMod.version, mod.version)) {
if (corespondingMod?.version && lt(corespondingMod.version.modVersion, mod.version.modVersion)) {
return false;
}
if(installedMod?.version && lt(mod.version, installedMod?.version)){
if(installedMod?.version && lt(mod.version.modVersion, installedMod?.version.modVersion)){
return false;
}
@@ -111,8 +108,12 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
]).then(([available, installed]) => {
const defaultMods = installed?.length ? [] : 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));
const installedMods: BbmFullMod[] = installed.map(version => {
const mod = available.find(m => m.mod.id === version.modId);
return mod ? { ...mod, version } : null;
});
setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.toLowerCase()) || installedMods.some(i => m.mod.id === i.mod.id)));
setModsInstalled(modsToCategoryMap(installedMods));
});
};
@@ -3,7 +3,7 @@ import { UninstallModModal } from "renderer/components/modal/modal-types/uninsta
import { Observable, BehaviorSubject } from "rxjs";
import { map } from "rxjs/operators";
import { BSVersion } from "shared/bs-version.interface";
import { InstallModsResult, UninstallModsResult, Mod, ModInstallProgression } from "shared/models/mods";
import { InstallModsResult, UninstallModsResult, ModInstallProgression } from "shared/models/mods";
import { ProgressionInterface } from "shared/models/progress-bar";
import { IpcService } from "./ipc.service";
import { ModalExitCode, ModalService } from "./modale.service";
@@ -11,6 +11,7 @@ import { NotificationType } from "../../shared/models/notification/notification.
import { OsDiagnosticService } from "./os-diagnostic.service";
import { ProgressBarService } from "./progress-bar.service";
import { NotificationService } from "./notification.service";
import { BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -41,15 +42,15 @@ export class BsModsManagerService {
this.os = OsDiagnosticService.getInstance();
}
public getAvailableMods(version: BSVersion): Observable<Mod[]> {
return this.ipcService.sendV2<Mod[], BSVersion>("get-available-mods", { args: version });
public getAvailableMods(version: BSVersion): Observable<BbmFullMod[]> {
return this.ipcService.sendV2<BbmFullMod[], BSVersion>("get-available-mods", { args: version });
}
public getInstalledMods(version: BSVersion): Observable<Mod[]> {
return this.ipcService.sendV2<Mod[], BSVersion>("get-installed-mods", { args: version });
public getInstalledMods(version: BSVersion): Observable<BbmModVersion[]> {
return this.ipcService.sendV2<BbmModVersion[], BSVersion>("get-installed-mods", { args: version });
}
public installMods(mods: Mod[], version: BSVersion): Promise<void> {
public installMods(mods: BbmFullMod[], version: BSVersion): Promise<void> {
if (this.os.isOffline) {
this.notifications.notifyError({
title: "notifications.shared.errors.titles.no-internet",
@@ -71,7 +72,7 @@ export class BsModsManagerService {
this.progressBar.show(progress$, true, { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" });
this.isInstalling$.next(true);
return this.ipcService.send<InstallModsResult, { mods: Mod[]; version: BSVersion }>("install-mods", { args: { mods, version } }).then(res => {
return this.ipcService.send<InstallModsResult, { mods: BbmFullMod[]; version: BSVersion }>("install-mods", { args: { mods, version } }).then(res => {
if (res.success && res.data) {
const isFullyInstalled = res.data.nbInstalledMods === res.data.nbModsToInstall;
const title = `notifications.mods.install-mods.titles.${isFullyInstalled ? "success" : "warning"}`;
@@ -85,12 +86,12 @@ export class BsModsManagerService {
this.progressBar.hide();
});
}
public async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
public async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
if (!this.progressBar.require()) {
return;
}
const modalRes = await this.modals.openModal(UninstallModModal, mod);
const modalRes = await this.modals.openModal(UninstallModModal, mod.mod);
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return;
+22
View File
@@ -37,6 +37,28 @@ export interface FileHashes {
// BBM MOD
export interface BbmFullMod {
mod: BbmMod;
version: BbmModVersion;
}
export interface BbmMod {
id: number;
name: string;
summary: string;
description: string;
gameName: "BeatSaber";
category: BbmCategories;
authors: BbmUserAPIResponse[];
status: BbmStatus;
iconFileName: string;
gitUrl: string;
lastApprovedById: number;
lastUpdatedById: number;
createdAt: Date;
updatedAt: Date;
}
export interface BbmModVersion {
id: number;
modId: number;