From 8270ec800e011d27f0e1f7a22259b7c624a9ad10 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:27:51 +0100 Subject: [PATCH 1/4] Apply mods changes from [1.4.15-1.4.17] + some clean --- .../services/mods/beat-mods-api.service.ts | 105 ++++---------- .../services/mods/bs-mods-manager.service.ts | 109 +++++++------- .../uninstall-mod-modal.component.tsx | 38 ----- .../slides/mods/mod-item.component.tsx | 23 +-- .../slides/mods/mods-grid.component.tsx | 58 ++++---- .../slides/mods/mods-slide.component.tsx | 117 +++++++-------- .../services/bs-mods-manager.service.ts | 10 +- src/shared/helpers/semver.helpers.ts | 7 +- src/shared/models/ipc/ipc-routes.ts | 11 +- src/shared/models/mods/mod.interface.ts | 133 +++++++++++++----- 10 files changed, 277 insertions(+), 334 deletions(-) delete mode 100644 src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx diff --git a/src/main/services/mods/beat-mods-api.service.ts b/src/main/services/mods/beat-mods-api.service.ts index c1c7f6d9..ead1384b 100644 --- a/src/main/services/mods/beat-mods-api.service.ts +++ b/src/main/services/mods/beat-mods-api.service.ts @@ -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(); - private readonly versionModsCache = new Map(); - private readonly modsHashCache = new Map(); - - private allModsCache: Mod[]; + private readonly versionModsCache = new Map(); + private readonly modsHashCache = new Map(); 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> { - if (this.aliasesCache.size) { - return this.aliasesCache; - } - return this.requestService.getJSON>(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 { - 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 { + public async getVersionMods(version: BSVersion): Promise { if (this.versionModsCache.has(version.BSVersion)) { return this.versionModsCache.get(version.BSVersion); } - const alias = await this.getAliasOfVersion(version); + return this.requestService.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(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 { - if (this.allModsCache) { - return this.allModsCache; - } - return this.requestService.getJSON(this.getAllModsUrl()).then(({ data: mods }) => { - this.allModsCache = mods; - return this.allModsCache; - }); - } - - public getModByHash(hash: string): Promise { + public getModByHash(hash: string): Promise { if (this.modsHashCache.has(hash)) { return Promise.resolve(this.modsHashCache.get(hash)); } - return this.requestService.getJSON(`${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; }); } } diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index b26bda5e..df83de17 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -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 { - + private async getModFromHash(hash: string): Promise { 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 { + private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise { 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 { + private async getBsipaInstalled(version: BSVersion): Promise { const bsPath = await this.bsLocalService.getVersionPath(version); const injectorPath = path.join(bsPath, "Beat Saber_Data", "Managed", "IPA.Injector.dll"); if (!(await pathExist(injectorPath))) { @@ -112,15 +111,14 @@ export class BsModsManagerService { } private async downloadZip(zipUrl: string): Promise { - 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 { 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 { - log.info("INSTALL MOD", mod.name, "for version", `${version.BSVersion} - ${version.name}`); + private async installMod(mod: BbmFullMod, version: BSVersion): Promise { + 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 { - const download = this.getModDownload(mod, version); - + private async uninstallBSIPA(mod: BbmFullMod, version: BSVersion): Promise { 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 { - if (mod.name.toLowerCase() === "bsipa") { + private async uninstallMod(mod: BbmFullMod, version: BSVersion): Promise { + 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 { - return this.beatModsApi.getVersionMods(version); + public async getAvailableMods(version: BSVersion): Promise { + return this.beatModsApi.getVersionMods(version).catch(() => { + return [] as BbmFullMod[]; + }); } - public async getInstalledMods(version: BSVersion): Promise { + public async getInstalledMods(version: BSVersion): Promise { 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(); + const modsDict = new Map(); 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 { + public installMods(mods: BbmFullMod[], version: BSVersion): Observable { const progress = { current: 0, total: mods.length }; return new Observable(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 { + public uninstallMods(mods: BbmFullMod[], version: BSVersion): Observable { const progress = { current: 0, total: mods.length }; return new Observable(obs => { @@ -485,16 +477,19 @@ export class BsModsManagerService { public uninstallAllMods(version: BSVersion): Observable { return new Observable(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); diff --git a/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx b/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx deleted file mode 100644 index 4108c0e4..00000000 --- a/src/renderer/components/modal/modal-types/uninstall-mod-modal.component.tsx +++ /dev/null @@ -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 = ({ 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 ( -
{ - e.preventDefault(); - resolver({ exitCode: ModalExitCode.COMPLETED }); - }} - > -

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

- -

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

-
- { - resolver({ exitCode: ModalExitCode.CANCELED }); - }} - withBar={false} - text="misc.cancel" - /> - -
- - ); -}; diff --git a/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx b/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx index 215b019d..ab8be1dd 100644 --- a/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx +++ b/src/renderer/components/version-viewer/slides/mods/mod-item.component.tsx @@ -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 (
  • - onChange(!isChecked)} disabled={mod.required || isDependency || disabled} checked={isChecked} /> + onChange(!isChecked)} disabled={mod.mod.category === BbmCategories.Core || isDependency || disabled} checked={isChecked} />
    - {mod.name} + {mod.mod.name} {installedVersion || "-"} - {mod.version} + {mod.version.modVersion} - - {mod.description} + + {striptags(mod.mod?.summary ?? "", { tagReplacementText: " " })}
    {installedVersion && ( diff --git a/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx b/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx index ef5f8270..60dc0173 100644 --- a/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx +++ b/src/renderer/components/version-viewer/slides/mods/mods-grid.component.tsx @@ -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; - installed: Map; - modsSelected: Mod[]; - onModChange: (selected: boolean, mod: Mod) => void; - moreInfoMod?: Mod; - onWantInfos: (mod: Mod) => void + modsMap: Map; + installed: Map; + 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 ]} /> - {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)) && (

      {key}

      - {modsMap.get(key).map(mod => mod.name?.toLowerCase().includes(filter) && ( + {modsMap.get(key).map(mod => mod.mod.name.toLowerCase().includes(filter) && ( onModChange(val, mod)} onWantInfo={onWantInfos} - wantInfo={mod.name === moreInfoMod?.name} + wantInfo={mod.mod.id === moreInfoMod?.mod.id} disabled={disabled} onUninstall={() => uninstallMod?.(mod)} /> ))} diff --git a/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx b/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx index 24367846..4af4c9a6 100644 --- a/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx +++ b/src/renderer/components/version-viewer/slides/mods/mods-slide.component.tsx @@ -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); - const [modsInstalled, setModsInstalled] = useState(null as Map); - const [modsSelected, setModsSelected] = useState([] as Mod[]); - const [moreInfoMod, setMoreInfoMod] = useState(null as Mod); + const [modsAvailable, setModsAvailable] = useState(null as Map); + const [modsInstalled, setModsInstalled] = useState(null as Map); + 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 => { + const modsToCategoryMap = (mods: BbmFullMod[]): Map => { if (!mods) { - return new Map(); + return new Map(); } - const map = new Map(); - mods.forEach(mod => map.set(mod.category, [...(map.get(mod.category) ?? []), mod])); + const map = new Map(); + 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 { - // const availableMods = await this.beatModsApi.getVersionMods(version); - // return Array.from( - // new Map( - // 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(); + const getAllDependencies = (mods: BbmFullMod[], availableMods: BbmFullMod[]): BbmFullMod[] => { + const collectedDependencies = new Set(); + 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("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)); }); }; diff --git a/src/renderer/services/bs-mods-manager.service.ts b/src/renderer/services/bs-mods-manager.service.ts index 83ad64c5..b2488805 100644 --- a/src/renderer/services/bs-mods-manager.service.ts +++ b/src/renderer/services/bs-mods-manager.service.ts @@ -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 { + public getAvailableMods(version: BSVersion): Observable { return this.ipcService.sendV2("bs-mods.get-available-mods", version); } - public getInstalledMods(version: BSVersion): Observable { + public getInstalledMods(version: BSVersion): Observable { return this.ipcService.sendV2("bs-mods.get-installed-mods", version); } - public installMods(mods: Mod[], version: BSVersion): Observable { + public installMods(mods: BbmFullMod[], version: BSVersion): Observable { 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 { + public uninstallMod(mod: BbmFullMod, version: BSVersion): Observable { if (!this.progressBar.require()) { return throwError(() => new Error("Action already in progress")); } diff --git a/src/shared/helpers/semver.helpers.ts b/src/shared/helpers/semver.helpers.ts index 617982d3..94cbb8e5 100644 --- a/src/shared/helpers/semver.helpers.ts +++ b/src/shared/helpers/semver.helpers.ts @@ -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; +} + diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index bf19b76a..06f83794 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -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 = (data: Observable) => void; @@ -83,11 +82,11 @@ export interface IpcChannelMapping { "delete-models": { request: BsmLocalModel[], response: Progression }; /* ** 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 }; - "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 ** */ diff --git a/src/shared/models/mods/mod.interface.ts b/src/shared/models/mods/mod.interface.ts index 51ad0d7a..c0b42cec 100644 --- a/src/shared/models/mods/mod.interface.ts +++ b/src/shared/models/mods/mod.interface.ts @@ -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; +} From 0c65b7b53770b95a7b53c77078b56d2ba03250bf Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:36:05 +0100 Subject: [PATCH 2/4] Add "OC" token pattern to log filter --- src/main/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/main.ts b/src/main/main.ts index 841f91a9..371ca6bc 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -34,7 +34,7 @@ export const filterStrings = new Set(); export const filterPatterns = new Set(); // Filter all occulus tokens -filterPatterns.add(/FRL\S{10,}/g); +filterPatterns.add(/(FRL|OC)\S{10,}/g); initLogger(); deleteOlestLogs(); From 4968022b7ebeb6c1b1b33fb66993e7d6cc0a81e1 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:43:03 +0100 Subject: [PATCH 3/4] remove old mods models from the index --- src/shared/models/mods/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shared/models/mods/index.ts b/src/shared/models/mods/index.ts index ab59950d..c169a774 100644 --- a/src/shared/models/mods/index.ts +++ b/src/shared/models/mods/index.ts @@ -1,2 +1 @@ export { ModInstallProgression, InstallModsResult, UninstallModsResult } from "./mod-ipc.model"; -export { Mod, DownloadLink, DownloadLinkType, FileHashes, ModAuthor } from "./mod.interface"; From 5113d72db295794137cf3e3cb742468ede4ffac2 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Fri, 3 Jan 2025 15:46:51 +0100 Subject: [PATCH 4/4] fix lint issue --- src/main/services/bs-local-version.service.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index a224015c..14d31dde 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -8,7 +8,6 @@ import { ConfigurationService } from "./configuration.service"; import { lstat, rename } from "fs/promises"; import log from "electron-log"; import { OculusService } from "./oculus.service"; -import { DownloadLinkType } from "shared/models/mods"; import sanitize from "sanitize-filename"; import { Progression, copyDirectoryWithJunctions, deleteFolder, ensurePathNotAlreadyExist, getFoldersInFolder, rxCopy } from "../helpers/fs.helpers"; import { FolderLinkerService } from "./folder-linker.service"; @@ -242,16 +241,6 @@ export class BSLocalVersionService { return version.name ?? version.BSVersion; } - public getVersionType(version: BSVersion): DownloadLinkType { - if (version.steam) { - return "steam"; - } - if (version.oculus) { - return "oculus"; - } - return "universal"; - } - private async getSteamVersion(): Promise { const steamBsFolder = await this.steamService.getGameFolder(BS_APP_ID, "Beat Saber");