[feat-76] support drag and drop for mod files

This commit is contained in:
silentrald
2024-11-16 17:17:53 +08:00
parent f73b1cd855
commit a9cc281d27
7 changed files with 216 additions and 20 deletions
+15
View File
@@ -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": {
+10 -5
View File
@@ -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));
});
@@ -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<string[]> {
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<Progression<ExternalMod>> {
return new Observable<Progression<ExternalMod>>(obs => {
const progress: Progression<ExternalMod> = { 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<Progression> {
const progress = { current: 0, total: mods.length };
@@ -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<void> => {
const loadMods = async (): Promise<void> => {
if (os.isOffline) {
return Promise.resolve();
}
@@ -265,6 +272,7 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
</ModStatus>
);
}
return (
<>
<div className="grow overflow-y-scroll w-full min-h-0 scrollbar-default p-0 m-0">
@@ -297,7 +305,18 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
return (
<div ref={ref} className="shrink-0 w-full h-full px-8 pb-7 flex justify-center">
<div className="relative flex flex-col grow-0 bg-light-main-color-2 dark:bg-main-color-2 size-full rounded-md shadow-black shadow-center overflow-hidden">{renderContent()}</div>
<Dropzone
className="w-full h-full shrink-0"
onFiles={importMods}
text={t("pages.version-viewer.mods.drop-zone.text")}
subtext={t("pages.version-viewer.mods.drop-zone.subtext")}
filters={[
{ name: ".zip", extensions: ["zip"] },
{ name: ".dll", extensions: ["dll"] }
]}
>
<div className="relative flex flex-col grow-0 bg-light-main-color-2 dark:bg-main-color-2 size-full rounded-md shadow-black shadow-center overflow-hidden">{renderContent()}</div>
</Dropzone>
</div>
);
}
@@ -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<Mod[]> {
return this.ipcService.sendV2("get-available-mods", version);
return this.ipcService.sendV2("bs-mods.get-available-mods", version);
}
public getInstalledMods(version: BSVersion): Observable<Mod[]> {
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<Progression> {
@@ -46,7 +47,7 @@ export class BsModsManagerService {
}
return new Observable<Progression>(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<Progression>(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<void> {
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<Progression> {
if (!this.progressBar.require()) {
return throwError(() => new Error("Action already in progress"));
}
return new Observable<Progression>(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 {
});
}
}
+7 -5
View File
@@ -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<T> = (data: Observable<T>) => void;
@@ -78,11 +79,12 @@ export interface IpcChannelMapping {
"delete-models": { request: BsmLocalModel[], response: Progression<BsmLocalModel[]> };
/* ** 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<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.uninstall-all-mods": { request: BSVersion, response: Progression };
/* ** bs-playlist-ipcs ** */
"one-click-install-playlist": { request: string, response: Progression<DownloadPlaylistProgressionData> };
+6
View File
@@ -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[];
}