diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 7f061f64..30be6757 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -116,6 +116,10 @@ "title": "Mods already installed", "description": "All selected mods are already installed" } + }, + "drop-zone": { + "text": "Import your mods", + "subtext": "Drop your \"zip\" or \"dll\" files here to import them" } }, "dropdown": { @@ -526,6 +530,17 @@ "no-mods": "No mods are installed in this version 😑" } } + }, + "import-mod": { + "titles": { + "success": "Mods import completed", + "error": "An error occurred during the mods import" + }, + "msgs": { + "success": "Mods successfully imported.", + "some-success": "Some mods were successfully imported.", + "no-dlls": "The file(s) do not contain any \"dll\" files." + } } }, "maps": { diff --git a/src/main/ipcs/bs-mods-ipcs.ts b/src/main/ipcs/bs-mods-ipcs.ts index f53869a8..0792538a 100644 --- a/src/main/ipcs/bs-mods-ipcs.ts +++ b/src/main/ipcs/bs-mods-ipcs.ts @@ -4,27 +4,32 @@ import { from } from "rxjs"; const ipc = IpcService.getInstance(); -ipc.on("get-available-mods", (args, reply) => { +ipc.on("bs-mods.get-available-mods", (args, reply) => { const modsManager = BsModsManagerService.getInstance(); reply(from(modsManager.getAvailableMods(args))); }); -ipc.on("get-installed-mods", (args, reply) => { +ipc.on("bs-mods.get-installed-mods", (args, reply) => { const modsManager = BsModsManagerService.getInstance(); reply(from(modsManager.getInstalledMods(args))); }); -ipc.on("install-mods", (args, reply) => { +ipc.on("bs-mods.import-mods", (args, reply) => { + const modsManager = BsModsManagerService.getInstance(); + reply(modsManager.importMods(args.paths, args.version)); +}); + +ipc.on("bs-mods.install-mods", (args, reply) => { const modsManager = BsModsManagerService.getInstance(); reply(modsManager.installMods(args.mods, args.version)); }); -ipc.on("uninstall-mods", (args, reply) => { +ipc.on("bs-mods.uninstall-mods", (args, reply) => { const modsManager = BsModsManagerService.getInstance(); reply(modsManager.uninstallMods(args.mods, args.version)); }); -ipc.on("uninstall-all-mods", (args, reply) => { +ipc.on("bs-mods.uninstall-all-mods", (args, reply) => { const modsManager = BsModsManagerService.getInstance(); reply(modsManager.uninstallAllMods(args)); }); diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index c93b8daa..cc653444 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -11,7 +11,7 @@ import { deleteFolder, pathExist, Progression, unlinkPath } from "../../helpers/ import { lastValueFrom, Observable } from "rxjs"; import recursiveReadDir from "recursive-readdir"; import { sToMs } from "../../../shared/helpers/time.helpers"; -import { pathExistsSync } from "fs-extra"; +import { copyFile, pathExistsSync } from "fs-extra"; import { CustomError } from "shared/models/exceptions/custom-error.class"; import { popElement } from "shared/helpers/array.helpers"; import { LinuxService } from "../linux.service"; @@ -19,6 +19,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"; export class BsModsManagerService { private static instance: BsModsManagerService; @@ -320,6 +321,108 @@ export class BsModsManagerService { return Array.from(modsDict.values()); } + private async importMod(modPath: string, destination: string): Promise { + const extensionName = path.extname(modPath).toLowerCase(); + + if (extensionName === ".dll") { + log.info("Copying", `${modPath}`, "to", `"${destination}"`); + const filename = path.basename(modPath, ".dll"); + const copied = await copyFile(modPath, path.join(destination, filename)) + .then(() => true) + .catch(error => { + log.warn("Could not copy", `"${modPath}"`, error); + return false; + }); + return copied ? [ filename ] : []; + } + + if (extensionName !== ".zip") { + log.warn("Mod file is not a dll or zip file"); + return []; + } + + log.info("Extracting", `"${modPath}"`, "to", `"${destination}"`); + const zip = await BsmZipExtractor.fromPath(modPath); + + try { + let hasDll = false; + for await (const entry of zip.entries()) { + if (entry.fileName.endsWith(".dll")) { + hasDll = true; + break; + } + } + + if (!hasDll) { + log.warn("No \"dll\" found in zip", modPath); + return []; + } + + return await zip.extract(destination); + } catch (error) { + log.warn("Could not extract", `"${modPath}"`, error); + return []; + } finally { + zip.close(); + } + } + + public importMods(paths: string[], version: BSVersion): Observable> { + return new Observable>(obs => { + const progress: Progression = { total: 0, current: 0 }; + const externalMod: ExternalMod = { + name: "", + files: [], + }; + let modsInstalledCount = 0; + const abortController = new AbortController(); + + (async () => { + const versionPath = await this.bsLocalService.getVersionPath(version); + const modsPendingFolder = path.join(versionPath, ModsInstallFolder.PENDING); + + for (const modPath of paths) { + if (abortController.signal?.aborted) { + log.info("Mods import has been cancelled"); + return; + } + + progress.total = paths.length; + obs.next(progress); + + if (!pathExistsSync(modPath)) { continue; } + + const modFiles = await this.importMod(modPath, modsPendingFolder); + if (modFiles.length > 0) { + ++modsInstalledCount; + + externalMod.name = path.basename(modPath, path.extname(modPath)); + externalMod.files = modFiles; + progress.data = externalMod; + } else { + progress.data = undefined; + } + ++progress.current; + obs.next(progress); + } + })() + .then(() => { + if (modsInstalledCount === 0) { + throw new CustomError("No \"dll\" found in any of the files dropped", "no-dlls"); + } + log.info("Successfully imported", modsInstalledCount, "mods from", paths.length, "files"); + }) + .catch(error => { + obs.error(error); + }) + .finally(() => obs.complete()); + + return () => { + abortController.abort(); + } + }); + } + public installMods(mods: Mod[], version: BSVersion): Observable { const progress = { current: 0, total: mods.length }; 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 7f54df2f..caae8470 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 @@ -21,10 +21,13 @@ 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"; export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; onDisclamerDecline: () => void }) { const ACCEPTED_DISCLAIMER_KEY = "accepted-mods-disclaimer"; + const t = useTranslation(); + const modsManager = useService(BsModsManagerService); const configService = useService(ConfigurationService); const notification = useService(NotificationService); @@ -166,6 +169,10 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; }).catch(noop).finally(() => setInstalling(() => false)); }; + const importMods = (files: string[]): void => { + modsManager.importMods(files, version); + }; + const uninstallMod = (mod: Mod): void => { setUninstalling(() => true); lastValueFrom(modsManager.uninstallMod(mod, version)).catch(noop).finally(() => { @@ -192,7 +199,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; setModsSelected(() => []); } - const loadMods = (): Promise => { + const loadMods = async (): Promise => { if (os.isOffline) { return Promise.resolve(); } @@ -265,6 +272,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; ); } + return ( <>
@@ -297,7 +305,18 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion; return (
-
{renderContent()}
+ +
{renderContent()}
+
); } diff --git a/src/renderer/services/bs-mods-manager.service.ts b/src/renderer/services/bs-mods-manager.service.ts index ef449208..83ad64c5 100644 --- a/src/renderer/services/bs-mods-manager.service.ts +++ b/src/renderer/services/bs-mods-manager.service.ts @@ -1,11 +1,12 @@ -import { Observable, BehaviorSubject, throwError, of } from "rxjs"; -import { catchError, tap } from "rxjs/operators"; +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"; export class BsModsManagerService { private static instance: BsModsManagerService; @@ -32,11 +33,11 @@ export class BsModsManagerService { } public getAvailableMods(version: BSVersion): Observable { - return this.ipcService.sendV2("get-available-mods", version); + return this.ipcService.sendV2("bs-mods.get-available-mods", version); } public getInstalledMods(version: BSVersion): Observable { - return this.ipcService.sendV2("get-installed-mods", version); + return this.ipcService.sendV2("bs-mods.get-installed-mods", version); } public installMods(mods: Mod[], version: BSVersion): Observable { @@ -46,7 +47,7 @@ export class BsModsManagerService { } return new Observable(obs => { - const install$ = this.ipcService.sendV2("install-mods", { mods, version }); + const install$ = this.ipcService.sendV2("bs-mods.install-mods", { mods, version }); this.progressBar.show(install$.pipe(catchError(() => of({ current: 0, total: 0} as Progression))), { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" }); const sub = install$.pipe( @@ -77,7 +78,7 @@ export class BsModsManagerService { } return new Observable(obs => { - const uninstall$ = this.ipcService.sendV2("uninstall-mods", { mods: [mod], version }); + const uninstall$ = this.ipcService.sendV2("bs-mods.uninstall-mods", { mods: [mod], version }); this.progressBar.show(uninstall$.pipe(catchError(() => of({ current: 0, total: 0} as Progression))), { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" }); const sub = uninstall$.pipe( @@ -102,13 +103,57 @@ export class BsModsManagerService { }); } + public async importMods(paths: string[], version: BSVersion): Promise { + if (!this.progressBar.require()) { + throw new Error("Action already in progress"); + } + + // TODO: Check for conflicting mod files, and ask the user if they want to overwrite the files + + const import$ = this.ipcService.sendV2("bs-mods.import-mods", { + paths, version + }); + + this.progressBar.show(import$.pipe( + catchError(() => of()), + map(progress => ({ + progression: (progress.current / progress.total) * 100, + label: progress.data?.name + }) as ProgressionInterface) + )); + + return lastValueFrom(import$) + .then(progress => { + if (progress.current === 0) { + return; + } + this.notifications.notifySuccess({ + title: "notifications.mods.import-mod.titles.success", + desc: progress.current === progress.total + ? "notifications.mods.import-mod.msgs.success" + : "notifications.mods.import-mod.msgs.some-success", + }); + }) + .catch(error => { + this.notifications.notifyError({ + title: "notifications.mods.import-mod.titles.error", + desc: ["no-dlls"].includes(error?.code) + ? `notifications.mods.import-mod.msgs.${error.code}` + : "misc.unknown", + }); + }) + .finally(() => { + this.progressBar.hide(); + }); + } + public uninstallAllMods(version: BSVersion): Observable { if (!this.progressBar.require()) { return throwError(() => new Error("Action already in progress")); } return new Observable(obs => { - const uninstall$ = this.ipcService.sendV2("uninstall-all-mods", version); + const uninstall$ = this.ipcService.sendV2("bs-mods.uninstall-all-mods", version); this.progressBar.show(uninstall$.pipe(catchError(() => of({ current: 0, total: 0} as Progression))), { paddingLeft: "190px", paddingRight: "190px", bottom: "20px" }); const sub = uninstall$.pipe( @@ -133,4 +178,5 @@ export class BsModsManagerService { }); } + } diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 8ab6e082..bd961326 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -20,6 +20,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"; export type IpcReplier = (data: Observable) => void; @@ -78,11 +79,12 @@ export interface IpcChannelMapping { "delete-models": { request: BsmLocalModel[], response: Progression }; /* ** bs-mods-ipcs ** */ - "get-available-mods": { request: BSVersion, response: Mod[] }; - "get-installed-mods": { request: BSVersion, response: Mod[] }; - "install-mods": { request: { mods: Mod[]; version: BSVersion }, response: Progression }; - "uninstall-mods": { request: { mods: Mod[]; version: BSVersion }, response: Progression }; - "uninstall-all-mods": { request: BSVersion, response: Progression }; + "bs-mods.get-available-mods": { request: BSVersion, response: Mod[] }; + "bs-mods.get-installed-mods": { request: BSVersion, response: Mod[] }; + "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.uninstall-all-mods": { request: BSVersion, response: Progression }; /* ** bs-playlist-ipcs ** */ "one-click-install-playlist": { request: string, response: Progression }; diff --git a/src/shared/models/mods/mod.interface.ts b/src/shared/models/mods/mod.interface.ts index 02bb21b2..51ad0d7a 100644 --- a/src/shared/models/mods/mod.interface.ts +++ b/src/shared/models/mods/mod.interface.ts @@ -34,3 +34,9 @@ export interface FileHashes { hash: string; file: string; } + +// Any mods that are not supported in beatmods +export interface ExternalMod { + name: string; + files: string[]; +}