diff --git a/assets/jsons/translations/de.json b/assets/jsons/translations/de.json index c7e7a533..67881067 100644 --- a/assets/jsons/translations/de.json +++ b/assets/jsons/translations/de.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Mods bereits installiert", "description": "Alle ausgewählten Mods sind bereits installiert" + }, + "outdated-mods": { + "title": "Veraltetes Mod", + "title-plural": "Veraltete Mods", + "description": "Das Mod {name} ist veraltet. Möchten Sie es aktualisieren?", + "description-plural": "Diese Version enthält {nb} veraltete Mods. Möchten Sie sie aktualisieren?", + "dont-remind-me": "Nicht mehr daran erinnern", + "update": "Aktualisieren" } }, "drop-zone": { diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 8b769c7d..439d65c4 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Mods already installed", "description": "All selected mods are already installed" + }, + "outdated-mods": { + "title": "Outdated Mod", + "title-plural": "Outdated Mods", + "description": "{name} mod is outdated. Do you want to update it?", + "description-plural": "This version has {nb} outdated mods. Do you want to update them?", + "dont-remind-me": "Don't remind me", + "update": "Update" } }, "drop-zone": { diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index 61cba828..aca42f3a 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Mods ya instalados", "description": "Todos los mods seleccionados ya están instalados" + }, + "outdated-mods": { + "title": "Mod desactualizado", + "title-plural": "Mods desactualizados", + "description": "El mod {name} está desactualizado. ¿Quieres actualizarlo?", + "description-plural": "Esta versión tiene {nb} mods desactualizados. ¿Quieres actualizarlos?", + "dont-remind-me": "No me lo recuerdes", + "update": "Actualizar" } }, "drop-zone": { diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index 09ff7267..b652643a 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Mods déjà installées", "description": "Tous les mods séléctionnées sont déjà installées" + }, + "outdated-mods": { + "title": "Mod obsolète", + "title-plural": "Mods obsolètes", + "description": "Le mod {name} est obsolète. Voulez-vous le mettre à jour ?", + "description-plural": "Cette version contient {nb} mods obsolètes. Voulez-vous les mettre à jour ?", + "dont-remind-me": "Ne plus me rappeler", + "update": "Mettre à jour" } }, "drop-zone": { diff --git a/assets/jsons/translations/it.json b/assets/jsons/translations/it.json index 6be54279..80b2ceba 100644 --- a/assets/jsons/translations/it.json +++ b/assets/jsons/translations/it.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Mod già installata", "description": "Tutte le mod selezionate sono già installate" + }, + "outdated-mods": { + "title": "Mod obsoleto", + "title-plural": "Mod obsoleti", + "description": "Il mod {name} è obsoleto. Vuoi aggiornarlo?", + "description-plural": "Questa versione ha {nb} mod obsoleti. Vuoi aggiornarli?", + "dont-remind-me": "Non ricordarmelo", + "update": "Aggiorna" } }, "drop-zone": { diff --git a/assets/jsons/translations/ja.json b/assets/jsons/translations/ja.json index 356fb81e..b3ef17ba 100644 --- a/assets/jsons/translations/ja.json +++ b/assets/jsons/translations/ja.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "すでにインストール済みのMOD", "description": "選択したすべてのMODはすでにインストールされています" + }, + "outdated-mods": { + "title": "古いMod", + "title-plural": "古いMod", + "description": "{name} Modが古くなっています。更新しますか?", + "description-plural": "このバージョンには古いModが{nb}個あります。更新しますか?", + "dont-remind-me": "もう知らせない", + "update": "更新する" } }, "drop-zone": { diff --git a/assets/jsons/translations/ko.json b/assets/jsons/translations/ko.json index d95476be..5b84c9df 100644 --- a/assets/jsons/translations/ko.json +++ b/assets/jsons/translations/ko.json @@ -148,7 +148,16 @@ "all-mods-already-installed": { "title": "이미 설치된 모드", "description": "선택한 모든 모드가 이미 설치되었습니다." + }, + "outdated-mods": { + "title": "구식 모드", + "title-plural": "구식 모드", + "description": "{name} 모드가 오래되었습니다. 업데이트하시겠습니까?", + "description-plural": "이 버전에는 {nb}개의 구식 모드가 있습니다. 업데이트하시겠습니까?", + "dont-remind-me": "다시는 알리지 않기", + "update": "업데이트" } + }, "drop-zone": { "text": "모드를 가져오기", diff --git a/assets/jsons/translations/ru.json b/assets/jsons/translations/ru.json index d583e90e..0823c3a7 100644 --- a/assets/jsons/translations/ru.json +++ b/assets/jsons/translations/ru.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "Моды уже установлены", "description": "Все выбранные моды уже установлены" + }, + "outdated-mods": { + "title": "Устаревший мод", + "title-plural": "Устаревшие моды", + "description": "Мод {name} устарел. Хотите его обновить?", + "description-plural": "В этой версии есть {nb} устаревших модов. Хотите их обновить?", + "dont-remind-me": "Не напоминать мне", + "update": "Обновить" } }, "drop-zone": { diff --git a/assets/jsons/translations/zh-tw.json b/assets/jsons/translations/zh-tw.json index bec5592d..adbc7502 100644 --- a/assets/jsons/translations/zh-tw.json +++ b/assets/jsons/translations/zh-tw.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "模組已安裝", "description": "所有選中的模組已經安裝" + }, + "outdated-mods": { + "title": "過時的模組", + "title-plural": "過時的模組", + "description": "{name} 模組已過時。您想更新嗎?", + "description-plural": "此版本有 {nb} 個過時的模組。您想更新它們嗎?", + "dont-remind-me": "不要提醒我", + "update": "更新" } }, "drop-zone": { diff --git a/assets/jsons/translations/zh.json b/assets/jsons/translations/zh.json index 93434081..0dc0f347 100644 --- a/assets/jsons/translations/zh.json +++ b/assets/jsons/translations/zh.json @@ -148,6 +148,14 @@ "all-mods-already-installed": { "title": "模组已安装", "description": "所有选中的模组已经安装" + }, + "outdated-mods": { + "title": "过时的模组", + "title-plural": "过时的模组", + "description": "{name} 模组已过时。您想更新吗?", + "description-plural": "此版本有 {nb} 个过时的模组。您想更新它们吗?", + "dont-remind-me": "不要提醒我", + "update": "更新" } }, "drop-zone": { diff --git a/src/main/services/mods/beat-mods-api.service.ts b/src/main/services/mods/beat-mods-api.service.ts index ead1384b..f35ae07d 100644 --- a/src/main/services/mods/beat-mods-api.service.ts +++ b/src/main/services/mods/beat-mods-api.service.ts @@ -3,6 +3,7 @@ import { BbmFullMod, BbmMod, BbmModVersion, BbmPlatform } from "../../../shared/ import { RequestService } from "../request.service"; import { BsStore } from "../../../shared/models/bs-store.enum"; import log from "electron-log" +import { tryit } from "shared/helpers/error.helpers"; export class BeatModsApiService { private static instance: BeatModsApiService; @@ -59,17 +60,42 @@ export class BeatModsApiService { }); } - public getModByHash(hash: string): Promise { - if (this.modsHashCache.has(hash)) { - return Promise.resolve(this.modsHashCache.get(hash)); + public async getModByHash(hashs: T[]): Promise> { + + const getModsFromCache = (hashs: T[]): Record => { + const mods = {} as Record; + + for (const hash of hashs) { + const mod = this.modsHashCache.get(hash); + if(mod){ + mods[hash] = mod; + } + } + + return mods; } - 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); + const mods = getModsFromCache(hashs); + const missingHashs = hashs.filter(hash => !mods[hash]); + + if(!missingHashs.length){ + return mods; + } + + const url = new URL(`${this.MODS_REPO_API_URL}/hashlookup`); + missingHashs.forEach(hash => url.searchParams.append("hash", hash)); + + const res = await tryit(() => this.requestService.getJSON<{ modVersions: BbmModVersion[] }>(url.toString())); + + if(res.error){ + log.error(`Failed to get mod by hashes`, res.error); return undefined; - }); + } + + this.updateModsHashCache(res.result?.data?.modVersions ?? []); + + const missingMods = getModsFromCache(missingHashs); + + return {...mods, ...missingMods}; } } diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index 4121cf31..993edb04 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -19,6 +19,7 @@ import crypto from "crypto"; import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class"; import { BsmShellLog, bsmSpawn } from "main/helpers/os.helpers"; import { BbmFullMod, BbmModVersion, ExternalMod } from "../../../shared/models/mods/mod.interface"; +import { Stats } from "fs"; export class BsModsManagerService { private static instance: BsModsManagerService; @@ -28,8 +29,6 @@ export class BsModsManagerService { private readonly linuxService: LinuxService; private readonly requestService: RequestService; - private manifestMatches: BbmModVersion[]; - public static getInstance(): BsModsManagerService { if (!BsModsManagerService.instance) { BsModsManagerService.instance = new BsModsManagerService(); @@ -44,14 +43,9 @@ export class BsModsManagerService { this.requestService = RequestService.getInstance(); } - private async getModFromHash(hash: string): Promise { - const mod = await this.beatModsApi.getModByHash(hash); - - if(mod?.contentHashes?.some(content => content.path.includes("IPA.exe"))){ - return undefined; - } - - return mod; + private async getModsFromHashes(hashes: string[]): Promise { + const mods = await this.beatModsApi.getModByHash(hashes) + return Object.values(mods).filter(mod => !mod.contentHashes.some(content => content.path.includes("IPA"))); } @@ -63,40 +57,16 @@ export class BsModsManagerService { return []; } - const files = await recursiveReadDir(modsPath); + const ignoreFunc = (file: string, stats: Stats): boolean => { + if(!stats.isFile()) { return true; } + const ext = path.extname(file); + return !(ext === ".dll" || ext === ".exe" || ext === ".manifest"); + } - const promises = files.map(async filePath => { - const ext = path.extname(filePath); + const files = await recursiveReadDir(modsPath, [ignoreFunc]); + const hashes = await Promise.all(files.map(file => md5File(file))); - if (ext !== ".dll" && ext !== ".exe" && ext !== ".manifest") { - return undefined; - } - const hash = await md5File(filePath); - const mod = await this.getModFromHash(hash); - - if (!mod) { - return undefined; - } - - if (ext === ".manifest") { - this.manifestMatches.push(mod); - return undefined; - } - - if (filePath.toLowerCase().includes("libs")) { - const manifestIndex = this.manifestMatches.findIndex(m => m.id === mod.id); - - if (manifestIndex < 0) { - return undefined; - } - - this.manifestMatches.splice(manifestIndex, 1); - } - - return mod; - }); - - const mods = await Promise.all(promises); + const mods = await this.getModsFromHashes(hashes); return mods.filter(Boolean); } @@ -107,7 +77,7 @@ export class BsModsManagerService { return undefined; } const injectorMd5 = await md5File(injectorPath); - return this.beatModsApi.getModByHash(injectorMd5); + return (await this.beatModsApi.getModByHash([injectorMd5]))[injectorMd5]; } private async downloadZip(zipUrl: string): Promise { @@ -338,12 +308,10 @@ export class BsModsManagerService { } public async getInstalledMods(version: BSVersion): Promise { - this.manifestMatches = []; - const bsipa = await this.getBsipaInstalled(version); - const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS), this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING)]); - const libsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.LIBS), this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING)]); + const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING), this.getModsInDir(version, ModsInstallFolder.PLUGINS)]); + const libsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING), this.getModsInDir(version, ModsInstallFolder.LIBS)]); const dirMods = pluginsMods.flat().concat(libsMods.flat()); 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 97cfbf71..97854b4d 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,7 +1,7 @@ -import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { forwardRef, ReactNode, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; import { BsModsManagerService } from "renderer/services/bs-mods-manager.service"; import { BSVersion } from "shared/bs-version.interface"; -import { BbmCategories, BbmFullMod, BbmModVersion } 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 { BsmButton } from "renderer/components/shared/bsm-button.component"; @@ -19,11 +19,17 @@ import { useService } from "renderer/hooks/use-service.hook"; import { NotificationService } from "renderer/services/notification.service"; import { noop } from "shared/helpers/function.helpers"; import { UninstallAllModsModal } from "renderer/components/modal/modal-types/uninstall-all-mods-modal.component"; -import { Dropzone } from "renderer/components/shared/dropzone.component"; import Tippy from "@tippyjs/react"; import { ProgressBarService } from "renderer/services/progress-bar.service"; +import { Dropzone } from "renderer/components/shared/dropzone.component"; -export function ModsSlide({ version, isActive, onDisclamerDecline }: { version: BSVersion; isActive?: boolean, onDisclamerDecline: () => void }) { +export type ModsSlideRef = { + loadMods: () => Promise; +} + +type Props = { version: BSVersion; isActive?: boolean, onDisclamerDecline: () => void }; + +export const ModsSlide = forwardRef(({ version, isActive, onDisclamerDecline }, forwaredRef) => { const ACCEPTED_DISCLAIMER_KEY = "accepted-mods-disclaimer"; const { text: t } = useTranslationV2(); @@ -100,14 +106,7 @@ export function ModsSlide({ version, isActive, onDisclamerDecline }: { version: return Array.from(collectedDependencies); }; - const installMods = (reinstallAll: boolean): void => { - - setReinstallAllMods(false); - - if (installing) { - return; - } - + const getModsToInstall = (reinstallAll?: boolean): BbmFullMod[] => { let modsToInstall = [ ...modsSelected, ...getAllDependencies(modsSelected, Array.from(modsAvailable.values()).flat()) @@ -127,18 +126,30 @@ export function ModsSlide({ version, isActive, onDisclamerDecline }: { version: set.delete(null); set.delete(undefined); - modsToInstall = Array.from(set); // Remove duplicates + return Array.from(set); // Remove duplicates + } + + const installMods = (reinstallAll: boolean): Promise => { + + setReinstallAllMods(false); + + if (installing) { + return Promise.resolve(); + } + + const modsToInstall = getModsToInstall(reinstallAll); 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" }); - loadMods(); - return; + return loadMods(); } setInstalling(() => true) - lastValueFrom(modsManager.installMods(modsToInstall, version)).then(() => { - loadMods(); - }).catch(noop).finally(() => setInstalling(() => false)); + return lastValueFrom(modsManager.installMods(modsToInstall, version)).then(() => ( + loadMods() + )).catch(noop).finally(() => ( + setInstalling(() => false) + )); }; const importMods = (files: string[]): void => { @@ -179,26 +190,19 @@ export function ModsSlide({ version, isActive, onDisclamerDecline }: { version: return Promise.resolve(); } - 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]) => { + return modsManager.getVersionModsState(version).then(({ available, installed }) => { const defaultMods = installed?.length ? [] : available.filter(m => m.mod.category === BbmCategories.Core || m.mod.category === BbmCategories.Essential); setModsAvailable(() => modsToCategoryMap(available)); - 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)); + setModsSelected(available.filter(m => m.mod.category === BbmCategories.Core || defaultMods.some(d => m.mod.name.toLowerCase() === d.mod.name.toLowerCase()) || installed.some(i => m.mod.id === i.mod.id))); + setModsInstalled(modsToCategoryMap(installed)); }); }; + useImperativeHandle(forwaredRef, () => ({ + loadMods + }), [version]); + useEffect(() => { if(!isActive || !isOnline){ @@ -325,7 +329,7 @@ export function ModsSlide({ version, isActive, onDisclamerDecline }: { version: ); -} +}); function ModStatus({ text, image, spin = false, children }: { text: string; image: string; spin?: boolean, children?: ReactNode}) { const t = useTranslation(); diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index d79f9d67..601e3063 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -1,5 +1,5 @@ import { BSVersion } from "shared/bs-version.interface"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { TabNavBar } from "renderer/components/shared/tab-nav-bar.component"; import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component"; @@ -10,7 +10,7 @@ import { ModalExitCode, ModalService } from "../services/modale.service"; import DefautVersionImage from "../../../assets/images/default-version-img.jpg"; import { IpcService } from "renderer/services/ipc.service"; import { LaunchSlide } from "renderer/components/version-viewer/slides/launch/launch-slide.component"; -import { ModsSlide } from "renderer/components/version-viewer/slides/mods/mods-slide.component"; +import { ModsSlide, ModsSlideRef } from "renderer/components/version-viewer/slides/mods/mods-slide.component"; import { UninstallModal } from "renderer/components/modal/modal-types/uninstall-modal.component"; import { MapsPlaylistsPanel } from "renderer/components/maps-playlists-panel/maps-playlists-panel.component"; import { ShareFoldersModal } from "renderer/components/modal/modal-types/share-folders-modal.component"; @@ -25,12 +25,18 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { safeLt } from "shared/helpers/semver.helpers"; import { ConfigurationService } from "renderer/services/configuration.service"; import { logRenderError } from "renderer"; +import { BsModsManagerService } from "renderer/services/bs-mods-manager.service"; +import { noop } from "shared/helpers/function.helpers"; +import { useTranslationV2 } from "renderer/hooks/use-translation.hook"; export function VersionViewer() { + const { text: t } = useTranslationV2(); + const bsUninstallerService = useService(BSUninstallerService); const bsVersionManagerService = useService(BSVersionManagerService); const modalService = useService(ModalService); + const modsService = useService(BsModsManagerService); const bsDownloader = useService(BsDownloaderService); const ipcService = useService(IpcService); const bsLauncher = useService(BSLauncherService); @@ -40,10 +46,12 @@ export function VersionViewer() { const { state, pathname: url } = useLocation() as { state: BSVersion; pathname: string }; const navigate = useNavigate(); const [currentTabIndex, setCurrentTabIndex] = useState(0); + const modsSlideRef = useRef(null); useOnUpdate(() => { checkIsVersionOutaded(); + checkOutdatedMods(); }, [state]); @@ -70,6 +78,42 @@ export function VersionViewer() { } } + const checkOutdatedMods = async () => { + + if(config.get("not-show-outdated-mods-notification")){ + return; + } + + const { available: availableMods, installed: installedMods } = await modsService.getVersionModsState(state); + + const modsToUpdate = availableMods.filter(availableMod => { + const installedMod = installedMods.find(installedMod => installedMod.mod.id === availableMod.mod.id); + return installedMod && safeLt(installedMod.version.modVersion, availableMod.version.modVersion); + }); + + if(!modsToUpdate.length){ + return; + } + + const choice = await notification.notifyInfo({ + title: modsToUpdate.length > 1 ? t("pages.version-viewer.mods.notifications.outdated-mods.title-plural") : t("pages.version-viewer.mods.notifications.outdated-mods.title"), + desc: modsToUpdate.length > 1 ? t("pages.version-viewer.mods.notifications.outdated-mods.description-plural", { nb: `${modsToUpdate.length}` }) : t("pages.version-viewer.mods.notifications.outdated-mods.description", { name: `${modsToUpdate[0].mod.name}` }), + duration: 9000, + actions: [ + { id: "0", title: "pages.version-viewer.mods.notifications.outdated-mods.dont-remind-me", cancel: true }, + { id: "1", title: "pages.version-viewer.mods.notifications.outdated-mods.update" } + ] + }); + + if(choice === "1"){ + lastValueFrom(modsService.installMods(modsToUpdate, state)).then(() => { + modsSlideRef?.current?.loadMods?.(); + }).catch(noop); + } else if(choice === "0"){ + config.set("not-show-outdated-mods-notification", true); + } + } + const navigateToVersion = (version?: BSVersion) => { if (!version) { return navigate("/available-versions"); @@ -153,7 +197,7 @@ export function VersionViewer() {
setCurrentTabIndex(() => 3)} />
- + { + const [available, installed]: [BbmFullMod[], BbmModVersion[]] = await (async () => { + const available = await lastValueFrom(this.getAvailableMods(version)); + const installed = await lastValueFrom(this.getInstalledMods(version)); + return [available ?? [], installed ?? []] as [BbmFullMod[], BbmModVersion[]]; // Make TS happy + })().catch(e => { + logRenderError(e); + return [[], []] as [BbmFullMod[], BbmModVersion[]]; // Make TS happy + }) + + const installedMods: BbmFullMod[] = installed.reduce((acc, installedMod) => { + const mod = available.find(m => m.mod.id === installedMod.modId); + + if(mod){ + acc.push({ ...mod, version: installedMod } as BbmFullMod); + } + + return acc; + }, []); + + return { available: (available ?? []), installed: (installedMods ?? []) }; + } + }