Apply mods changes from [1.4.15-1.4.17] + some clean

This commit is contained in:
MathieuG-P
2025-01-03 15:27:51 +01:00
parent bf4e5aefa4
commit 8270ec800e
10 changed files with 277 additions and 334 deletions
+25 -80
View File
@@ -1,22 +1,19 @@
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";
import log from "electron-log"
export class BeatModsApiService {
private static instance: BeatModsApiService;
private readonly requestService: RequestService;
private readonly BEAT_MODS_ALIAS = "https://alias.beatmods.com/aliases.json";
public readonly MODS_REPO_URL = "https://beatmods.com";
private readonly MODS_REPO_API_URL = `${this.MODS_REPO_URL}/api`;
private readonly BEAT_MODS_API_URL = "https://beatmods.com/api/v1/";
public readonly BEAT_MODS_URL = "https://beatmods.com";
private readonly aliasesCache = new Map<string, BSVersion[]>();
private readonly versionModsCache = new Map<string, Mod[]>();
private 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) {
@@ -30,101 +27,49 @@ export class BeatModsApiService {
}
private getVersionModsUrl(version: BSVersion): string {
return `${this.BEAT_MODS_API_URL}mod?status=approved&gameVersion=${version.BSVersion}&sort=&sortDirection=1`;
const platform: BbmPlatform = version.oculus || version.metadata?.store === BsStore.OCULUS ? BbmPlatform.OculusPC : BbmPlatform.SteamPC;
return `${this.MODS_REPO_API_URL}/mods?status=verified&gameVersion=${version.BSVersion}&gameName=BeatSaber&platform=${platform}`;
}
private getAllModsUrl(): string {
return `${this.BEAT_MODS_API_URL}mod`;
}
private async getVersionAlias(): Promise<Map<string, BSVersion[]>> {
if (this.aliasesCache.size) {
return this.aliasesCache;
}
return this.requestService.getJSON<Record<string, string[]>>(this.BEAT_MODS_ALIAS).then(({ data: 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(([, 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;
}
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);
}
const alias = await this.getAliasOfVersion(version);
return this.requestService.getJSON<{ mods: {mod: BbmMod, latest: BbmModVersion}[] }>(this.getVersionModsUrl(version)).then(({ data }) => {
const fullMods: BbmFullMod[] = data?.mods?.map(mod => ({ mod: mod.mod, version: mod.latest })) ?? [];
this.versionModsCache.set(version.BSVersion, fullMods);
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl(alias)).then(({ data: mods }) => {
mods = mods.map(mod => this.asignDependencies(mod, mods));
this.versionModsCache.set(version.BSVersion, mods);
this.updateModsHashCache(fullMods.map(mod => mod.version));
this.updateModsHashCache(mods);
return mods;
return fullMods;
});
}
public async getAllMods(): Promise<Mod[]> {
if (this.allModsCache) {
return this.allModsCache;
}
return this.requestService.getJSON<Mod[]>(this.getAllModsUrl()).then(({ data: mods }) => {
this.allModsCache = mods;
return this.allModsCache;
});
}
public getModByHash(hash: string): Promise<Mod> {
public getModByHash(hash: string): Promise<BbmModVersion|undefined> {
if (this.modsHashCache.has(hash)) {
return Promise.resolve(this.modsHashCache.get(hash));
}
return this.requestService.getJSON<Mod[]>(`${this.BEAT_MODS_API_URL}mod?hash=${hash}`).then(({ data: mods }) => {
this.updateModsHashCache(mods);
return mods.at(0);
return this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(`${this.MODS_REPO_API_URL}/hashlookup?hash=${hash}`).then(({ data }) => {
this.updateModsHashCache(data?.modVersions ?? []);
return data?.modVersions?.at(0);
}).catch((e): undefined => {
log.error(`Failed to get mod by hash: ${hash}`, e);
return undefined;
});
}
}
@@ -1,5 +1,4 @@
import { BSVersion } from "shared/bs-version.interface";
import { DownloadLink, Mod } from "shared/models/mods";
import { BeatModsApiService } from "./beat-mods-api.service";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
@@ -19,7 +18,7 @@ import { tryit } from "shared/helpers/error.helpers";
import crypto from "crypto";
import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class";
import { bsmSpawn } from "main/helpers/os.helpers";
import { ExternalMod } from "shared/models/mods/mod.interface";
import { BbmFullMod, BbmModVersion, ExternalMod } from "../../../shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -29,7 +28,7 @@ export class BsModsManagerService {
private readonly linuxService: LinuxService;
private readonly requestService: RequestService;
private manifestMatches: Mod[];
private manifestMatches: BbmModVersion[];
public static getInstance(): BsModsManagerService {
if (!BsModsManagerService.instance) {
@@ -45,18 +44,18 @@ export class BsModsManagerService {
this.requestService = RequestService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
private async getModFromHash(hash: string): Promise<BbmModVersion | undefined> {
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);
@@ -85,7 +84,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;
@@ -101,7 +100,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))) {
@@ -112,15 +111,14 @@ export class BsModsManagerService {
}
private async downloadZip(zipUrl: string): Promise<BsmZipExtractor> {
zipUrl = path.join(this.beatModsApi.BEAT_MODS_URL, zipUrl);
zipUrl = path.join(this.beatModsApi.MODS_REPO_URL, zipUrl);
log.info("Download mod zip", zipUrl);
const buffer = await lastValueFrom(this.requestService.downloadBuffer(zipUrl))
.then(progress => progress.data)
.catch(e => {
.catch((e: Error) => {
log.error("ZIP", "Error while downloading zip", e);
return undefined;
});
if (!buffer) {
@@ -129,7 +127,6 @@ export class BsModsManagerService {
return BsmZipExtractor.fromBuffer(buffer);
}
private async executeBSIPA(version: BSVersion, args: string[]): Promise<boolean> {
log.info("executeBSIPA", version?.BSVersion, args);
@@ -190,25 +187,22 @@ 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}`);
private async installMod(mod: BbmFullMod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.mod.name, "for version", `${version.BSVersion} - ${version.name}`);
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;
@@ -220,18 +214,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 => {
@@ -242,7 +236,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 &&
@@ -255,9 +249,7 @@ export class BsModsManagerService {
return res;
}
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"));
@@ -268,34 +260,34 @@ 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> {
if (mod.name.toLowerCase() === "bsipa") {
private async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise<void> {
if (mod.mod.name.toLowerCase() === "bsipa") {
return this.uninstallBSIPA(mod, version);
}
const download = this.getModDownload(mod, version);
const versionPath = await this.bsLocalService.getVersionPath(version);
const promises = download.hashMd5.map(async files => {
return Promise.all([unlinkPath(path.join(versionPath, files.file)), unlinkPath(path.join(versionPath, "IPA", "Pending", files.file))]);
const promises = mod.version.contentHashes.map(async content => {
return Promise.all([unlinkPath(path.join(versionPath, content.path)), unlinkPath(path.join(versionPath, "IPA", "Pending", content.path))]);
});
await Promise.all(promises);
}
public getAvailableMods(version: BSVersion): Promise<Mod[]> {
return this.beatModsApi.getVersionMods(version);
public async getAvailableMods(version: BSVersion): Promise<BbmFullMod[]> {
return this.beatModsApi.getVersionMods(version).catch(() => {
return [] as BbmFullMod[];
});
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]> {
public async getInstalledMods(version: BSVersion): Promise<BbmModVersion[]> {
this.manifestMatches = [];
const bsipa = await this.getBsipaInstalled(version);
@@ -305,17 +297,17 @@ 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());
@@ -423,7 +415,7 @@ export class BsModsManagerService {
});
}
public installMods(mods: Mod[], version: BSVersion): Observable<Progression> {
public installMods(mods: BbmFullMod[], version: BSVersion): Observable<Progression> {
const progress = { current: 0, total: mods.length };
return new Observable<Progression>(obs => {
@@ -434,7 +426,7 @@ export class BsModsManagerService {
obs.next(progress);
const bsipa = popElement(mod => mod.name.toLowerCase() === "bsipa", mods);
const bsipa = popElement(mod => mod.mod.name.toLowerCase() === "bsipa", mods);
if(bsipa){
const bsipaInstalled = await this.installMod(bsipa, version).catch(err => {
@@ -460,7 +452,7 @@ export class BsModsManagerService {
});
}
public uninstallMods(mods: Mod[], version: BSVersion): Observable<Progression> {
public uninstallMods(mods: BbmFullMod[], version: BSVersion): Observable<Progression> {
const progress = { current: 0, total: mods.length };
return new Observable<Progression>(obs => {
@@ -485,16 +477,19 @@ export class BsModsManagerService {
public uninstallAllMods(version: BSVersion): Observable<Progression> {
return new Observable<Progression>(obs => {
(async () => {
const mods = await this.getInstalledMods(version).catch(err => {
log.error(err);
return [];
});
const progress = { current: 0, total: mods.length };
const versionMods = await this.getAvailableMods(version);
const installedMods = await this.getInstalledMods(version);
const fullInstalledMods: BbmFullMod[] = installedMods?.map(version => {
return { version, mod: versionMods.find(mod => version.modId === mod.mod.id)?.mod };
}) ?? [];
const progress = { current: 0, total: fullInstalledMods.length };
obs.next(progress);
for (const mod of mods) {
for (const mod of fullInstalledMods) {
await this.uninstallMod(mod, version);
progress.current++;
obs.next(progress);
@@ -1,38 +0,0 @@
import { ModalComponent, ModalExitCode } 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 const UninstallModModal: ModalComponent<void, Mod> = ({ resolver, options: {data} }) => {
const mod = data;
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>
);
};
@@ -1,21 +1,22 @@
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 { BbmCategories, BbmFullMod } from "shared/models/mods/mod.interface";
import { CSSProperties, MouseEvent, useMemo, useRef } from "react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import useDoubleClick from "use-double-click";
import { gt } from "semver";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import striptags from "striptags";
import { safeGt } from "shared/helpers/semver.helpers";
type Props = {
className?: string;
mod: Mod;
mod: BbmFullMod;
installedVersion: string;
isDependency?: boolean;
isSelected?: boolean;
onChange?: (val: boolean) => void;
wantInfo?: boolean;
onWantInfo?: (mod: Mod) => void;
onWantInfo?: (mod: BbmFullMod) => void;
disabled?: boolean;
onUninstall?: () => void;
};
@@ -25,7 +26,7 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
const themeColor = useThemeColor("second-color");
const clickRef = useRef();
const isChecked = useMemo(() => isDependency || isSelected || mod.required, [isDependency, isSelected, mod.required]);
const isChecked = useMemo(() => isDependency || isSelected || mod.mod.category === BbmCategories.Core, [isDependency, isSelected, mod.mod.category]);
useDoubleClick({
onSingleClick: e => handleWantInfo(e),
@@ -39,7 +40,7 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
}, [isChecked]);
const wantInfoStyle: CSSProperties = wantInfo ? { borderColor: themeColor } : { borderColor: "transparent" };
const isOutDated = installedVersion ? gt(mod.version, installedVersion) : false;
const isOutDated = installedVersion ? safeGt(mod.version.modVersion, installedVersion) : false;
const handleWantInfo = (e: MouseEvent) => {
e.preventDefault();
@@ -53,19 +54,19 @@ export function ModItem({ className, mod, installedVersion, isDependency, isSele
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(!isChecked)} disabled={mod.required || isDependency || disabled} checked={isChecked} />
<BsmCheckbox className="h-full aspect-square z-[1] relative bg-inherit" onChange={() => onChange(!isChecked)} disabled={mod.mod.category === BbmCategories.Core || isDependency || disabled} checked={isChecked} />
</div>
<span className="bg-inherit py-2 pl-3 font-bold text-sm whitespace-nowrap border-t-2 border-b-2 blur-none group-hover:brightness-90" style={wantInfoStyle}>
{mod.name}
{mod.mod.name}
</span>
<span className={`min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90 ${installedVersion && isOutDated && "text-red-400 line-through"} ${installedVersion && !isOutDated && "text-green-400"}`} style={wantInfoStyle}>
{installedVersion || "-"}
</span>
<span className="min-w-0 text-center bg-inherit py-2 px-1 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.version}
{mod.version.modVersion}
</span>
<span title={mod.description} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{mod.description}
<span title={striptags(mod.mod?.description ?? "", { tagReplacementText: " " })} className="px-3 bg-inherit whitespace-nowrap text-ellipsis overflow-hidden py-2 text-sm border-t-2 border-b-2 group-hover:brightness-90" style={wantInfoStyle}>
{striptags(mod.mod?.summary ?? "", { tagReplacementText: " " })}
</span>
<div className="h-full bg-inherit flex items-center justify-center mr-3 rounded-r-md pr-2 border-t-2 border-b-2 border-r-2 group-hover:brightness-90" style={wantInfoStyle}>
{installedVersion && (
@@ -2,19 +2,19 @@ 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 { Mod } from "shared/models/mods/mod.interface";
import { useTranslationV2 } from "renderer/hooks/use-translation.hook";
import { BbmCategories, BbmFullMod } 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
modsMap: Map<BbmCategories, BbmFullMod[]>;
installed: Map<BbmCategories, BbmFullMod[]>;
modsSelected: BbmFullMod[];
onModChange: (selected: boolean, mod: BbmFullMod) => void;
moreInfoMod?: BbmFullMod;
onWantInfos: (mod: BbmFullMod) => void
disabled?: boolean;
uninstallMod?: (mods: Mod) => void;
uninstallMod?: (mods: BbmFullMod) => void;
uninstallAllMods?: () => void;
unselectAllMods?: () => void;
openModsDropZone?: () => void;
@@ -24,30 +24,31 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
const [filter, setFilter] = useState("");
const [filterEnabled, setFilterEnabled] = useState(false);
const t = useTranslation();
const { text: t } = useTranslationV2();
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());
@@ -75,21 +76,20 @@ export function ModsGrid({ modsMap, installed, modsSelected, onModChange, moreIn
]} />
</span>
{Array.from(modsMap.keys()).map(
key =>
modsMap.get(key).some(mod => mod.name.toLowerCase().includes(filter)) && (
{Array.from(modsMap.keys()).map(key => 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) && (
{modsMap.get(key).map(mod => mod.mod.name.toLowerCase().includes(filter) && (
<ModItem
key={mod.name}
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)}
mod={mod}
installedVersion={installedModVersion(key, mod)}
isDependency={isDependency(mod)}
isSelected={isSelected(mod)}
onChange={val => onModChange(val, mod)}
onWantInfo={onWantInfos}
wantInfo={mod.name === moreInfoMod?.name}
wantInfo={mod.mod.id === moreInfoMod?.mod.id}
disabled={disabled}
onUninstall={() => uninstallMod?.(mod)} />
))}
@@ -1,16 +1,15 @@
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { BsModsManagerService } from "renderer/services/bs-mods-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { BbmCategories, BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
import { ModsGrid } from "./mods-grid.component";
import { ConfigurationService } from "renderer/services/configuration.service";
import { DefaultConfigKey } from "renderer/config/default-configuration.config";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import BeatWaitingImg from "../../../../../../assets/images/apngs/beat-waiting.png";
import BeatConflictImg from "../../../../../../assets/images/apngs/beat-conflict.png";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { lastValueFrom } from "rxjs";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { useTranslation, useTranslationV2 } from "renderer/hooks/use-translation.hook";
import { LinkOpenerService } from "renderer/services/link-opener.service";
import { useInView } from "framer-motion";
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
@@ -26,7 +25,7 @@ import { Dropzone } from "renderer/components/shared/dropzone.component";
export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; onDisclamerDecline: () => void }) {
const ACCEPTED_DISCLAIMER_KEY = "accepted-mods-disclaimer";
const t = useTranslation();
const { text: t } = useTranslationV2();
const modsManager = useService(BsModsManagerService);
const configService = useService(ConfigurationService);
@@ -37,10 +36,10 @@ 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 [reinstallAllMods, setReinstallAllMods] = useState(false);
const isOnline = useObservable(() => os.isOnline$);
const [installing, setInstalling] = useState(false);
@@ -50,86 +49,53 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
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(mods => {
if (mods.some(m => m.name === mod.name)) {
if (mods.some(m => m.mod.id === mod.mod.id)) {
return mods;
}
return [...mods, mod];
});
}
setModsSelected(mods => mods.filter(m => m.name !== mod.name));
setModsSelected(mods => mods.filter(m => m.mod.id !== mod.mod.id));
};
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) {
return;
if (moreInfoMod?.mod?.gitUrl) {
linkOpener.open(moreInfoMod.mod.gitUrl);
}
linkOpener.open(moreInfoMod.link);
};
// 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 (mod.required || this.isDependency(mod, mods, availableMods)) {
// res.push([mod.name, mod]);
// }
// return res;
// }, [])
// ).values()
// );
// }
const getAllDependencies = (mods: Mod[], availableMods: Mod[]): Mod[] => {
const collectedDependencies = new Set<Mod>();
const getAllDependencies = (mods: BbmFullMod[], availableMods: BbmFullMod[]): BbmFullMod[] => {
const collectedDependencies = new Set<BbmFullMod>();
const modIdsToProcess = new Set(mods.flatMap(m => m.version.dependencies));
const collectDependencies = (mod: Mod) => {
if (!mod.dependencies) { return; }
for (const dependency of mod.dependencies) {
const dependencyMod = availableMods.find(avMod => avMod.name === dependency.name);
if (dependencyMod && !collectedDependencies.has(dependencyMod)) {
collectedDependencies.add(dependencyMod);
collectDependencies(dependencyMod);
}
for (const currentId of modIdsToProcess) {
const dependency = availableMods.find(m => m.version.id === currentId);
if (dependency && !collectedDependencies.has(dependency)) {
collectedDependencies.add(dependency);
dependency.version.dependencies?.forEach(depId => modIdsToProcess.add(depId));
}
};
mods.forEach(collectDependencies);
availableMods.forEach(mod => {
if(mod.required){
collectedDependencies.add(mod);
}
});
}
return Array.from(collectedDependencies);
};
@@ -145,18 +111,23 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
let modsToInstall = [
...modsSelected,
...getAllDependencies(modsSelected, Array.from(modsAvailable.values()).flat())
]
];
modsToInstall = reinstallAll ? (
modsToInstall // If reinstalling all, we install all selected mods
) : (
modsToInstall.filter(mod => { // Else we only install the mods that are not installed or have a newer version
const installedMod = modsInstalled.get(mod.category)?.find(installedMod => installedMod.name === mod.name);
return !installedMod || lt(installedMod.version, mod.version);
const installedMod = modsInstalled.get(mod.mod.category)?.find(installedMod => installedMod.mod.id === mod.mod.id);
return !installedMod || lt(installedMod.version.modVersion, mod.version.modVersion);
})
);
modsToInstall = Array.from(new Set(modsToInstall)); // Remove duplicates
// Remove duplicates, null and undefined
const set = new Set(modsToInstall);
set.delete(null);
set.delete(undefined);
modsToInstall = Array.from(set); // Remove duplicates
if (!modsToInstall.length) {
notification.notifyInfo({ title: "pages.version-viewer.mods.notifications.all-mods-already-installed.title", desc: "pages.version-viewer.mods.notifications.all-mods-already-installed.description" });
@@ -175,7 +146,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
modsManager.importMods(files, version);
};
const uninstallMod = (mod: Mod): void => {
const uninstallMod = (mod: BbmFullMod): void => {
setUninstalling(() => true);
lastValueFrom(modsManager.uninstallMod(mod, version)).catch(noop).finally(() => {
loadMods();
@@ -206,17 +177,23 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
return Promise.resolve();
}
const promise = async () => {
const promise = async (): Promise<[BbmFullMod[], BbmModVersion[]]> => {
const available = await lastValueFrom(modsManager.getAvailableMods(version));
const installed = await lastValueFrom(modsManager.getInstalledMods(version));
return [available, installed];
}
return promise().then(([available, installed]) => {
const defaultMods = installed?.length ? [] : configService.get<string[]>("default_mods" as DefaultConfigKey);
const defaultMods = installed?.length ? [] : available.filter(m => m.mod.category === BbmCategories.Core || m.mod.category === BbmCategories.Essential);
setModsAvailable(() => modsToCategoryMap(available));
setModsSelected(() => available.filter(m => m.required || defaultMods.some(d => m.name?.toLowerCase() === d?.toLowerCase()) || installed.some(i => m.name === i.name)));
setModsInstalled(() => modsToCategoryMap(installed));
const installedMods: BbmFullMod[] = installed.map(version => {
const mod = available.find(m => m.mod.id === version.modId);
return mod ? { ...mod, version } : null;
}).filter(mod => mod);
setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.mod.name.toLowerCase()) || installedMods.some(i => m.mod.id === i.mod.id)));
setModsInstalled(modsToCategoryMap(installedMods));
});
};
@@ -1,12 +1,12 @@
import { Observable, BehaviorSubject, throwError, of, lastValueFrom } from "rxjs";
import { catchError, map, tap } from "rxjs/operators";
import { BSVersion } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods";
import { IpcService } from "./ipc.service";
import { ProgressBarService } from "./progress-bar.service";
import { NotificationService } from "./notification.service";
import { Progression } from "main/helpers/fs.helpers";
import { ProgressionInterface } from "shared/models/progress-bar";
import { BbmFullMod, BbmModVersion } from "shared/models/mods/mod.interface";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -32,15 +32,15 @@ export class BsModsManagerService {
this.notifications = NotificationService.getInstance();
}
public getAvailableMods(version: BSVersion): Observable<Mod[]> {
public getAvailableMods(version: BSVersion): Observable<BbmFullMod[]> {
return this.ipcService.sendV2("bs-mods.get-available-mods", version);
}
public getInstalledMods(version: BSVersion): Observable<Mod[]> {
public getInstalledMods(version: BSVersion): Observable<BbmModVersion[]> {
return this.ipcService.sendV2("bs-mods.get-installed-mods", version);
}
public installMods(mods: Mod[], version: BSVersion): Observable<Progression> {
public installMods(mods: BbmFullMod[], version: BSVersion): Observable<Progression> {
if (!this.progressBar.require()) {
return throwError(() => new Error("Action already in progress"));
@@ -72,7 +72,7 @@ export class BsModsManagerService {
});
}
public uninstallMod(mod: Mod, version: BSVersion): Observable<Progression> {
public uninstallMod(mod: BbmFullMod, version: BSVersion): Observable<Progression> {
if (!this.progressBar.require()) {
return throwError(() => new Error("Action already in progress"));
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { coerce, lt, valid } from "semver";
import { coerce, lt, gt, valid } from "semver";
import { tryit } from "./error.helpers";
export function safeLt(a: string, b: string): boolean {
@@ -6,3 +6,8 @@ export function safeLt(a: string, b: string): boolean {
return result ?? false;
}
export function safeGt(a: string, b: string): boolean {
const { result } = tryit(() => gt(valid(coerce(a)), valid(coerce(b))));
return result ?? false;
}
+5 -6
View File
@@ -11,7 +11,6 @@ import { DepotDownloaderEvent } from "../bs-version-download/depot-downloader.mo
import { MSGetQuery, MSModel, MSModelType } from "../models/model-saber.model";
import { ModelDownload } from "renderer/services/models-management/models-downloader.service";
import { BsmLocalModel } from "../models/bsm-local-model.interface";
import { Mod } from "../mods";
import { BPList, DownloadPlaylistProgressionData } from "../playlists/playlist.interface";
import { VersionLinkerAction } from "renderer/services/version-folder-linker.service";
import { FileFilter, OpenDialogOptions, OpenDialogReturnValue } from "electron";
@@ -20,7 +19,7 @@ import { Supporter } from "../supporters";
import { AppWindow } from "../window-manager/app-window.model";
import { LocalBPList, LocalBPListsDetails } from "../playlists/local-playlist.models";
import { StaticConfigGetIpcRequestResponse, StaticConfigKeys, StaticConfigSetIpcRequest } from "main/services/static-configuration.service";
import { ExternalMod } from "../mods/mod.interface";
import { BbmFullMod, BbmModVersion, ExternalMod } from "../mods/mod.interface";
import { OculusDownloadInfo } from "main/services/bs-version-download/bs-oculus-downloader.service";
export type IpcReplier<T> = (data: Observable<T>) => void;
@@ -83,11 +82,11 @@ export interface IpcChannelMapping {
"delete-models": { request: BsmLocalModel[], response: Progression<BsmLocalModel[]> };
/* ** bs-mods-ipcs ** */
"bs-mods.get-available-mods": { request: BSVersion, response: Mod[] };
"bs-mods.get-installed-mods": { request: BSVersion, response: Mod[] };
"bs-mods.get-available-mods": { request: BSVersion, response: BbmFullMod[] };
"bs-mods.get-installed-mods": { request: BSVersion, response: BbmModVersion[] };
"bs-mods.import-mods": { request: { paths: string[]; version: BSVersion; }, response: Progression<ExternalMod> };
"bs-mods.install-mods": { request: { mods: Mod[]; version: BSVersion }, response: Progression };
"bs-mods.uninstall-mods": { request: { mods: Mod[]; version: BSVersion }, response: Progression };
"bs-mods.install-mods": { request: { mods: BbmFullMod[]; version: BSVersion }, response: Progression };
"bs-mods.uninstall-mods": { request: { mods: BbmFullMod[]; version: BSVersion }, response: Progression };
"bs-mods.uninstall-all-mods": { request: BSVersion, response: Progression };
/* ** bs-playlist-ipcs ** */
+96 -37
View File
@@ -1,42 +1,101 @@
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;
}
// Any mods that are not supported in beatmods
export interface ExternalMod {
name: string;
files: string[];
}
// BBM Mods
export interface BbmFullMod {
mod: BbmMod;
version: BbmModVersion;
}
export interface BbmMod {
id: number;
name: string;
summary: string;
description: string;
gameName: "BeatSaber";
category: BbmCategories;
authors: BbmUserAPIResponse[];
status: BbmStatus;
iconFileName: string;
gitUrl: string;
lastApprovedById: number;
lastUpdatedById: number;
createdAt: Date;
updatedAt: Date;
}
export interface BbmModVersion {
id: number;
modId: number;
author: BbmUserAPIResponse;
modVersion: string;
platform: BbmPlatform;
zipHash: string;
status: BbmStatus;
dependencies: number[];
contentHashes: BbmContentHash[];
supportedGameVersions: BbmGameVersion[];
downloadCount: number;
lastApprovedById?: number;
lastUpdatedById?: number;
createdAt?: Date;
updatedAt?: Date;
}
export interface BbmContentHash {
path: string;
hash: string;
}
export enum BbmStatus {
Private = "private",
Removed = "removed",
Unverified = "unverified",
Verified = "verified",
}
export enum BbmPlatform {
SteamPC = `steampc`,
OculusPC = `oculuspc`,
UniversalPC = `universalpc`,
UniversalQuest = `universalquest`,
}
export interface BbmGameVersion {
readonly id: number;
gameName: "BeatSaber";
version: string; // semver
defaultVersion: boolean;
}
export enum BbmCategories {
Core = "core",
Essential = "essential",
Library = "library",
Cosmetic = "cosmetic",
PracticeTraining = "practice",
Gameplay = "gameplay",
StreamTools = "streamtools",
UIEnhancements = "ui",
Lighting = "lighting",
TweaksTools = "tweaks",
Multiplayer = "multiplayer",
TextChanges = "text",
Editor = "editor",
Other = "other",
}
export interface BbmUserAPIResponse {
id: number;
username: string;
githubId: string;
sponsorUrl: string;
displayName: string;
bio: string;
createdAt?: Date;
updatedAt?: Date;
}