diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 5257f7b5..247c51cf 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -84,7 +84,8 @@ "verify-files": "Verify files", "clone": "Clone", "edit": "Edit", - "uninstall": "Uninstall" + "uninstall": "Uninstall", + "shared-folders": "Shared Folders" } }, "available-versions": { @@ -414,6 +415,14 @@ "success":"Model installation complete", "error":"An error occurred while installing the model" } + }, + "shared-folder": { + "success": { + "yeet-mods-disabled": { + "title": "YeetMods disabled", + "msg": "To prevent mod removal, the value of the 'YeetMods' variable in the 'Beat Saber IPA.json' file has been disabled" + } + } } }, "modals": { @@ -563,6 +572,15 @@ "li-2": "Mods are subject to being broken by updates and that's normal - be patient and respectful when this happens, as modders are volunteers with real lives.", "li-3": "Beat Games aren't purposefully trying to break mods. They wish to work on the codebase and sometimes this breaks mods, but they are not out to kill mods.", "p-2": "Do not attack the game developers for issues related to mods, and vice versa - modders and game developers are two separate groups. Just don't be a jerk ok." + }, + "shared-folders":{ + "title": "Shared Folders", + "description": "Link BeatSaber folders to synchronize their contents with shared folders in other versions. Note that deleting content will also be shared.", + "buttons": { + "add-folder": "Add Folder", + "link-folder": "Link Folder", + "unlink-folder": "Unlink Folder" + } } }, "maps": { diff --git a/assets/jsons/translations/es.json b/assets/jsons/translations/es.json index fde7c7e9..c122b051 100644 --- a/assets/jsons/translations/es.json +++ b/assets/jsons/translations/es.json @@ -83,7 +83,8 @@ "verify-files": "Verificar los archivos", "clone": "Clonar", "edit": "Editar", - "uninstall": "Desinstalar" + "uninstall": "Desinstalar", + "shared-folders": "Carpetas Compartidas" } }, "available-versions": { @@ -413,6 +414,14 @@ "success":"Instalación del modelo completada", "error":"Se produjo un error durante la instalación del modelo" } + }, + "shared-folder": { + "success": { + "yeet-mods-disabled": { + "title": "YeetMods desactivado", + "msg": "Para evitar la eliminación de mods, se ha desactivado el valor de la variable 'YeetMods' en el archivo 'Beat Saber IPA.json'" + } + } } }, "modals": { @@ -562,6 +571,15 @@ "li-2": "Es probable que los mods dejen de funcionar debido a las actualizaciones y eso es normal. Sé paciente y respetuoso cuando suceda esto, ya que los modders son voluntarios y tienen una vida.", "li-3": "Beat Games no está intencionalmente tratando de romper los mods. Quieren mejorar el juego base y a veces esto rompe los mods, pero no es su objetivo matar a los mods.", "p-2": "No ataques a los desarrolladores por problemas relacionados con los mods y viceversa. Los modders y los desarrolladores son dos grupos distintos. No seas tonto, ¿de acuerdo?" + }, + "shared-folders":{ + "title": "Carpetas Compartidas", + "description": "Enlace las carpetas de BeatSaber para sincronizar su contenido con las carpetas compartidas en otras versiones. Tenga en cuenta que la eliminación de contenido también se compartirá.", + "buttons": { + "add-folder": "Añadir Carpeta", + "link-folder": "Enlazar Carpeta", + "unlink-folder": "Desenlazar Carpeta" + } } }, "maps": { diff --git a/assets/jsons/translations/fr.json b/assets/jsons/translations/fr.json index a9479454..2e8e1b21 100644 --- a/assets/jsons/translations/fr.json +++ b/assets/jsons/translations/fr.json @@ -83,7 +83,8 @@ "verify-files": "Vérifier les fichiers", "clone": "Cloner", "edit": "Editer", - "uninstall": "Désinstaller" + "uninstall": "Désinstaller", + "shared-folders": "Dossiers Partagés" } }, "available-versions": { @@ -413,6 +414,14 @@ "success":"Installation du modèle terminée", "error":"Une erreur s'est produite lors de l'installation du modèle" } + }, + "shared-folder":{ + "success": { + "yeet-mods-disabled":{ + "title": "YeetMods désactivé", + "msg": "Pour éviter la suppression des mods, la valeur de la variable 'YeetMods' dans le fichier 'Beat Saber IPA.json' a été désactivée" + } + } } }, "modals": { @@ -562,6 +571,15 @@ "li-2": "Les mods sont susceptibles de ne plus fonctionner à cause des mises à jour et c'est normal - soyez patient et respectueux lorsque cela se produit, parce que les moddeurs sont bénévoles et ont une vie.", "li-3": "Beat Games ne cherche pas volontairement à casser les mods. Ils souhaitent améliorer la base du jeu et parfois cela casse les mods, mais ce n'est pas dans leur objectif de tuer les mods.", "p-2": "N'attaquez pas les développeurs pour des problèmes relatifs aux mods, et inversement - les moddeurs et les développeurs sont deux groupes distincts. Ne soyez pas idiots, d'accord ?" + }, + "shared-folders":{ + "title": "Dossiers Partagés", + "description": "Liez les dossiers de BeatSaber pour synchroniser leurs contenus avec les dossiers partagés des autres versions. Notez que la suppression de contenu sera également partagée.", + "buttons": { + "add-folder": "Ajouter un dossier", + "link-folder": "Lier le dossier", + "unlink-folder": "Délier le dossier" + } } }, "maps": { diff --git a/src/main/helpers/array-tools.ts b/src/main/helpers/array.helpers.ts similarity index 100% rename from src/main/helpers/array-tools.ts rename to src/main/helpers/array.helpers.ts diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts new file mode 100644 index 00000000..d2ab14f3 --- /dev/null +++ b/src/main/helpers/fs.helpers.ts @@ -0,0 +1,89 @@ +import { move } from "fs-extra"; +import { access, mkdir, rm, readdir, unlink, lstat, readlink } from "fs/promises"; +import path from "path"; +import { Observable } from "rxjs"; +import log from "electron-log" + +export async function pathExist(path: string): Promise { + try{ + await access(path); + return true; + }catch(e){ + return false; + } + +} + +export async function ensureFolderExist(path: string): Promise { + if(await pathExist(path)){ return Promise.resolve(); } + return mkdir(path, {recursive: true}).catch(log.error).then(() => {}); +} + +export async function deleteFolder(folderPath: string): Promise { + if(!(await pathExist(folderPath))){ return; } + return rm(folderPath, {recursive: true, force: true}); +} + +export async function unlinkPath(path: string): Promise{ + if(!(await pathExist(path))){ return; } + return unlink(path); +} + +export async function getFoldersInFolder(folderPath: string): Promise { + if(!(await pathExist(folderPath))){ return []; } + + const files = await readdir(folderPath, {withFileTypes: true}); + + const promises = files.map(async file => { + if(file.isDirectory()){ return path.join(folderPath, file.name); } + if(!file.isSymbolicLink()){ return undefined; } + try{ + const targetPath = await readlink(path.join(folderPath, file.name)); + return (await lstat(targetPath)).isDirectory() ? path.join(folderPath, file.name) : undefined; + }catch(e){ + return undefined; + } + }); + + return (await Promise.all(promises)).filter(folder => folder); +} + +export function moveFolderContent(src: string, dest: string): Observable{ + const progress: Progression = { current: 0, total: 0 }; + return new Observable(subscriber => { + subscriber.next(progress); + (async () => { + + const srcExist = await pathExist(src); + + if(!srcExist){ return subscriber.complete(); } + + ensureFolderExist(dest); + + const files = await readdir(src, {encoding: "utf-8"}); + progress.total = files.length; + + const promises = files.map(async file => { + const srcFullPath = path.join(src, file); + const destFullPath = path.join(dest, file); + if(await pathExist(destFullPath)){ + progress.current++; + return subscriber.next(progress); + } + await move(srcFullPath, destFullPath); + progress.current++; + subscriber.next(progress); + }); + + Promise.allSettled(promises).then(() => subscriber.complete()); + + })(); + }); +} + +export interface Progression{ + total: number; + current: number; + extra?: T; +} + diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index 2abc2284..ee6c87f7 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -116,4 +116,9 @@ ipc.on("is-map-deep-links-enabled", async (request: IpcRequest) => { utils.ipcSend(request.responceChannel, {success: false}); } +}); + +ipc.on("get-version-maps-path", async (req: IpcRequest, reply) => { + const maps = LocalMapsManagerService.getInstance(); + reply(from(maps.getMapsFolderPath(req.args))); }); \ No newline at end of file diff --git a/src/main/ipcs/bs-version-ipcs.ts b/src/main/ipcs/bs-version-ipcs.ts index 1fb0cfd3..36b2f9cd 100644 --- a/src/main/ipcs/bs-version-ipcs.ts +++ b/src/main/ipcs/bs-version-ipcs.ts @@ -6,6 +6,16 @@ import { exec } from 'child_process'; import { IpcRequest } from 'shared/models/ipc'; import { BSLocalVersionService } from '../services/bs-local-version.service'; import { BsmException } from 'shared/models/bsm-exception.model'; +import { IpcService } from '../services/ipc.service'; +import { from } from 'rxjs'; +import path from 'path'; +import { pathExist } from '../helpers/fs.helpers'; +import { FolderLinkerService } from '../services/folder-linker.service'; +import { LocalMapsManagerService } from '../services/additional-content/local-maps-manager.service'; +import { readJSON, writeJSON } from 'fs-extra'; +import log from "electron-log" + +const ipc = IpcService.getInstance(); ipcMain.on('bs-version.get-version-dict', (event, req: IpcRequest) => { BSVersionLibService.getInstance().getAvailableVersions().then(versions => { @@ -26,7 +36,7 @@ ipcMain.on('bs-version.installed-versions', async (event, req: IpcRequest) ipcMain.on("bs-version.open-folder", async (event, req: IpcRequest) => { const localVersionService = BSLocalVersionService.getInstance(); const versionFolder = await localVersionService.getVersionPath(req.args); - UtilsService.getInstance().pathExist(versionFolder) && exec(`start "" "${versionFolder}"`); + (await pathExist(versionFolder)) && exec(`start "" "${versionFolder}"`); }); ipcMain.on("bs-version.edit", async (event, req: IpcRequest<{version: BSVersion, name: string, color: string}>) => { @@ -44,3 +54,77 @@ ipcMain.on("bs-version.clone", async (event, req: IpcRequest<{version: BSVersion UtilsService.getInstance().ipcSend(req.responceChannel, {success: false, error}); }); }); + +ipc.on("get-version-full-path", async (req: IpcRequest, reply) => { + const localVersions = BSLocalVersionService.getInstance(); + reply(from( + localVersions.getVersionPath(req.args) + )); +}); + +ipc.on("relative-version-path-to-full", async (req: IpcRequest<{version: BSVersion, relative: string}>, reply) => { + path.isAbsolute(req.args.relative) && reply(from(Promise.resolve(req.args.relative))); + + const localVersions = BSLocalVersionService.getInstance(); + const promise = localVersions.getVersionPath(req.args.version).catch(() => null).then(versionPath => { + return path.join(versionPath, req.args.relative); + }); + reply(from(promise)); +}); + +ipc.on("full-version-path-to-relative", async (req: IpcRequest<{version: BSVersion, fullPath: string}>, reply) => { + const localVersions = BSLocalVersionService.getInstance(); + const promise = localVersions.getVersionPath(req.args.version).catch(() => null).then(versionPath => { + return path.relative(versionPath, req.args.fullPath); + }); + reply(from(promise)); +}); + +ipc.on("get-linked-folders", async (req: IpcRequest, reply) => { + const localVersions = BSLocalVersionService.getInstance(); + reply(from(localVersions.getLinkedFolders(req.args))); +}); + +ipc.on("link-folder", async (req: IpcRequest<{ folder: string, keepContents?: boolean}>, reply) => { + const linker = FolderLinkerService.getInstance(); + + const relativeMapsFolder = path.join(LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER) + + if(req.args.folder.includes(relativeMapsFolder)){ + return reply(from(linker.linkFolder(req.args.folder, {keepContents: req.args.keepContents, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER}))); + } + + const res = from(linker.linkFolder(req.args.folder, {keepContents: true})); + + const jsonIPAPath = path.join(req.args.folder, "Beat Saber IPA.json"); + + if(!(await pathExist(jsonIPAPath))){ return reply(res); } + + await res.toPromise(); + + try{ + const ipaData = (await readJSON(jsonIPAPath)) ?? {} as any; + ipaData["YeetMods"] = false; + await writeJSON(jsonIPAPath, ipaData, {spaces: 4}); + }catch(e){ + log.error("Disable YeetMods", e); + } + + reply(res); + +}); + +ipc.on("unlink-folder", async (req: IpcRequest<{folder: string, keepContents?: boolean}>, reply) => { + const linker = FolderLinkerService.getInstance(); + + const relativeMapsFolder = path.join(LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER) + + if(req.args.folder.includes(relativeMapsFolder)){ + return reply(from(linker.unlinkFolder(req.args.folder, {keepContents: req.args.keepContents, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER}))); + } + + reply(from(linker.unlinkFolder(req.args.folder, {keepContents: req.args.keepContents}))); + +}); + + diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index d84f209f..ee08eaac 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -4,10 +4,16 @@ import { IpcRequest } from 'shared/models/ipc'; import { SystemNotificationOptions } from 'shared/models/notification/system-notification.model'; import { NotificationService } from '../services/notification.service'; import { SteamService } from '../services/steam.service'; +import { IpcService } from '../services/ipc.service'; +import { from } from 'rxjs'; +import { fstat, lstat } from 'fs'; +import { pathExist } from '../helpers/fs.helpers'; // TODO IMPROVE WINDOW CONTROL BY USING WINDOW SERVICE +const ipc = IpcService.getInstance(); + ipcMain.on('window.close', async () => { const utils = UtilsService.getInstance(); utils.getMainWindows("index.html")?.close(); @@ -32,10 +38,10 @@ ipcMain.on('new-window', async (event, request: IpcRequest) => { shell.openExternal(request.args); }); -ipcMain.on('choose-folder', async (event, request: IpcRequest) => { - dialog.showOpenDialog({properties: ['openDirectory'],}).then(res => { - UtilsService.getInstance().ipcSend(request.responceChannel, {success: true, data: res}); - }); +ipc.on('choose-folder', async (req: IpcRequest, reply) => { + reply( + from(dialog.showOpenDialog({properties: ['openDirectory'], defaultPath: req.args ?? ""})) + ) }); ipcMain.on("window.progression", async (event, request: IpcRequest) => { @@ -71,4 +77,15 @@ ipcMain.on("open-steam", async (event, request: IpcRequest) => { }).catch((e) => { utils.ipcSend(request.responceChannel, {success: false, error: e}); }); +}); + +ipc.on("is-folder-symlink", async (req: IpcRequest, reply) => { + const promise = new Promise(async resolve => { + if(!(await pathExist(req.args))){ return resolve(false); } + lstat(req.args, (err, stats) => { + if(err){ return resolve(false); } + resolve(stats.isSymbolicLink()); + }); + }); + reply(from(promise)); }); \ No newline at end of file diff --git a/src/main/services/additional-content/local-maps-manager.service.ts b/src/main/services/additional-content/local-maps-manager.service.ts index 859b3d54..106558e9 100644 --- a/src/main/services/additional-content/local-maps-manager.service.ts +++ b/src/main/services/additional-content/local-maps-manager.service.ts @@ -6,8 +6,8 @@ import { BSLocalVersionService } from "../bs-local-version.service"; import { InstallationLocationService } from "../installation-location.service"; import { UtilsService } from "../utils.service"; import crypto from "crypto"; -import { lstatSync, symlinkSync, unlinkSync } from "fs"; -import { copy, copySync } from "fs-extra"; +import { lstatSync, unlinkSync } from "fs"; +import { copySync } from "fs-extra"; import StreamZip from "node-stream-zip"; import { RequestService } from "../request.service"; import sanitize from "sanitize-filename"; @@ -18,6 +18,9 @@ import { ipcMain } from "electron"; import { IpcRequest } from 'shared/models/ipc'; import { Observable } from "rxjs"; import { Archive } from "../../models/archive.class"; +import { deleteFolder, ensureFolderExist, getFoldersInFolder, pathExist } from "../../helpers/fs.helpers"; +import { readFile } from "fs/promises"; +import { FolderLinkerService } from "../folder-linker.service"; export class LocalMapsManagerService { @@ -28,8 +31,9 @@ export class LocalMapsManagerService { return LocalMapsManagerService.instance; } - private readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data"; - private readonly CUSTOM_LEVELS_FOLDER = "CustomLevels"; + public static readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data"; + public static readonly CUSTOM_LEVELS_FOLDER = "CustomLevels"; + public static readonly SHARED_MAPS_FOLDER = "SharedMaps"; private readonly DEEP_LINKS = { BeatSaver: "beatsaver", @@ -42,6 +46,7 @@ export class LocalMapsManagerService { private readonly reqService: RequestService; private readonly deepLink: DeepLinkService; private readonly windows: WindowManagerService; + private readonly linker = FolderLinkerService.getInstance(); private constructor(){ this.localVersion = BSLocalVersionService.getInstance(); @@ -50,6 +55,7 @@ export class LocalMapsManagerService { this.reqService = RequestService.getInstance(); this.deepLink = DeepLinkService.getInstance(); this.windows = WindowManagerService.getInstance(); + this.linker = FolderLinkerService.getInstance(); this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => { log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link); @@ -64,10 +70,10 @@ export class LocalMapsManagerService { public async getMapsFolderPath(version?: BSVersion): Promise{ - if(version){ return path.join(await this.localVersion.getVersionPath(version), this.LEVELS_ROOT_FOLDER, this.CUSTOM_LEVELS_FOLDER); } - const sharedMapsPath = path.join(this.installLocation.sharedMapsPath, this.CUSTOM_LEVELS_FOLDER); - if(!(await this.utils.pathExist(sharedMapsPath))){ - this.utils.createFolderIfNotExist(sharedMapsPath); + if(version){ return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER); } + const sharedMapsPath = path.join(this.installLocation.sharedContentPath, LocalMapsManagerService.SHARED_MAPS_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER); + if(!(await pathExist(sharedMapsPath))){ + await ensureFolderExist(sharedMapsPath); } return sharedMapsPath; } @@ -79,7 +85,7 @@ export class LocalMapsManagerService { for(const set of mapRawInfo._difficultyBeatmapSets){ for(const diff of set._difficultyBeatmaps){ const diffFilePath = path.join(mapPath, diff._beatmapFilename); - const diffContent = await this.utils.readFileAsync(diffFilePath).catch(() => null); + const diffContent = await readFile(diffFilePath, {encoding: "utf-8"}).catch(() => null); diffContent && shasum.update(diffContent); } } @@ -90,9 +96,9 @@ export class LocalMapsManagerService { private async loadMapInfoFromPath(mapPath: string): Promise{ const infoFilePath = path.join(mapPath, "Info.dat"); - if(!(await this.utils.pathExist(infoFilePath))){ return null; } + if(!(await pathExist(infoFilePath))){ return null; } - const rawInfoString = await this.utils.readFileAsync(infoFilePath); + const rawInfoString = await readFile(infoFilePath, {encoding: "utf-8"}); const rawInfo: RawMapInfoData = JSON.parse(rawInfoString); const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href; @@ -106,7 +112,7 @@ export class LocalMapsManagerService { private async downloadMapZip(zipUrl: string): Promise<{zip: StreamZip.StreamZipAsync, zipPath: string}>{ const fileName = path.basename(zipUrl); const tempPath = this.utils.getTempPath(); - this.utils.createFolderIfNotExist(this.utils.getTempPath()); + await ensureFolderExist(this.utils.getTempPath()); const dest = path.join(tempPath, fileName); const zipPath = await this.reqService.downloadFile(zipUrl, dest); @@ -137,7 +143,7 @@ export class LocalMapsManagerService { (async () => { const levelsFolder = await this.getMapsFolderPath(version); - const levelsPaths = (await this.utils.pathExist(levelsFolder)) ? this.utils.listDirsInDir(levelsFolder, true) : []; + const levelsPaths = (await pathExist(levelsFolder)) ? await getFoldersInFolder(levelsFolder) : []; progression.total = levelsPaths.length; @@ -169,7 +175,7 @@ export class LocalMapsManagerService { const levelsPath = await this.getMapsFolderPath(version); - const isPathExist = await this.utils.pathExist(levelsPath); + const isPathExist = await pathExist(levelsPath); if(!isPathExist){ return false; } @@ -177,36 +183,13 @@ export class LocalMapsManagerService { } public async linkVersionMaps(version: BSVersion, keepMaps: boolean): Promise{ - - if(await this.versionIsLinked(version)){ return; } - - const sharedMapsPath = await this.getMapsFolderPath(); const versionMapsPath = await this.getMapsFolderPath(version); - - if(keepMaps){ - await this.utils.moveDirContent(versionMapsPath, sharedMapsPath); - } - - await this.utils.deleteFolder(versionMapsPath); - - symlinkSync(sharedMapsPath, versionMapsPath, "junction"); + return this.linker.linkFolder(versionMapsPath, {keepContents: keepMaps, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER}); } public async unlinkVersionMaps(version: BSVersion, keepMaps: boolean): Promise{ - - const sharedMapsPath = await this.getMapsFolderPath(); const versionMapsPath = await this.getMapsFolderPath(version); - - if(await this.versionIsLinked(version)){ - unlinkSync(versionMapsPath); - } - - this.utils.createFolderIfNotExist(versionMapsPath); - - if(keepMaps){ - await copy(sharedMapsPath, versionMapsPath); - } - + return this.linker.unlinkFolder(versionMapsPath, {keepContents: keepMaps, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER}); } public deleteMaps(maps: BsmLocalMap[]): Observable{ @@ -221,7 +204,7 @@ export class LocalMapsManagerService { for(const folder of mapsFolders){ const detail = await this.loadMapInfoFromPath(folder); if(!mapsHashsToDelete.includes(detail?.hash)){ continue; } - await this.utils.deleteFolder(folder); + await deleteFolder(folder); progress.deleted++; observer.next(progress); } @@ -249,7 +232,7 @@ export class LocalMapsManagerService { if(!zip){ throw `Cannot download ${zipUrl}`; } - this.utils.createFolderIfNotExist(mapPath); + await ensureFolderExist(mapPath); await zip.extract(null, mapPath); await zip.close(); @@ -291,7 +274,7 @@ export class LocalMapsManagerService { const versionMapsPath = await this.getMapsFolderPath(version); - this.utils.createFolderIfNotExist(versionMapsPath); + await ensureFolderExist(versionMapsPath); copySync(downloadedMap.path, path.join(versionMapsPath, path.basename(downloadedMap.path)), {overwrite: true}); @@ -311,6 +294,4 @@ export class LocalMapsManagerService { return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link)); } - - } \ No newline at end of file diff --git a/src/main/services/additional-content/local-models-manager.service.ts b/src/main/services/additional-content/local-models-manager.service.ts index f266d93a..df329b9b 100644 --- a/src/main/services/additional-content/local-models-manager.service.ts +++ b/src/main/services/additional-content/local-models-manager.service.ts @@ -11,6 +11,7 @@ import path from "path"; import { RequestService } from "../request.service"; import { copyFileSync } from "fs-extra"; import sanitize from "sanitize-filename"; +import { ensureFolderExist } from "../../helpers/fs.helpers"; export class LocalModelsManagerService { @@ -74,7 +75,7 @@ export class LocalModelsManagerService { const versionPath = await this.localVersion.getVersionPath(version); const modelFolderPath = path.join(versionPath, this.MODEL_TYPE_FOLDER[type]); - this.utils.createFolderIfNotExist(modelFolderPath); + await ensureFolderExist(modelFolderPath); return modelFolderPath; diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index ca40a768..47339efa 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -15,6 +15,7 @@ import { BPList, DownloadPlaylistProgression } from "shared/models/playlists/pla import { copyFileSync, readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copySync } from "fs-extra"; +import { ensureFolderExist, pathExist } from "../../helpers/fs.helpers"; export class LocalPlaylistsManagerService { @@ -80,7 +81,7 @@ export class LocalPlaylistsManagerService { const folder = path.join(versionFolder, this.PLAYLISTS_FOLDER); - await this.utils.createFolderIfNotExist(folder) + await ensureFolderExist(folder) return folder; @@ -92,7 +93,7 @@ export class LocalPlaylistsManagerService { const bpListDest = path.join(playlistFolder, path.basename(bpListUrlOrPath)); - if(this.utils.pathExist(bpListUrlOrPath)){ + if(await pathExist(bpListUrlOrPath)){ copyFileSync(bpListUrlOrPath, bpListDest); } else{ @@ -105,7 +106,7 @@ export class LocalPlaylistsManagerService { private async readPlaylistFile(path: string): Promise{ - if(!this.utils.pathExist(path)){ throw `bplist file not exist at ${path}`; } + if(!(await pathExist(path))){ throw `bplist file not exist at ${path}`; } const rawContent = readFileSync(path).toString(); diff --git a/src/main/services/bs-installer.service.ts b/src/main/services/bs-installer.service.ts index 430e4b44..f34678cb 100644 --- a/src/main/services/bs-installer.service.ts +++ b/src/main/services/bs-installer.service.ts @@ -12,6 +12,7 @@ import isOnline from 'is-online'; import { WindowManagerService } from "./window-manager.service"; import { copy, copySync } from "fs-extra"; import { clean, satisfies } from "semver"; +import { ensureFolderExist, pathExist } from "../helpers/fs.helpers"; export class BSInstallerService{ @@ -94,11 +95,11 @@ export class BSInstallerService{ if(!bsVersion){ return {type: "[Error]"}; } if(!(await isOnline({timeout: 1500}))){ throw "no-internet"; } - this.utils.createFolderIfNotExist(this.installLocationService.versionsDirectory); + await ensureFolderExist(this.installLocationService.versionsDirectory); const versionPath = await this.localVersionService.getVersionPath(bsVersion) - const dest = !downloadInfos.isVerification ? this.getPathNotAleardyExist(versionPath) : versionPath; + const dest = !downloadInfos.isVerification ? await this.getPathNotAleardyExist(versionPath) : versionPath; const downloadVersion: BSVersion = {...downloadInfos.bsVersion, ...(path.basename(dest) !== downloadInfos.bsVersion.BSVersion && {name: path.basename(dest)})} @@ -186,15 +187,15 @@ export class BSInstallerService{ }) } - private getPathNotAleardyExist(path: string): string{ + private async getPathNotAleardyExist(path: string): Promise{ let destPath = path; - let folderExist = this.utils.pathExist(destPath); + let folderExist = await pathExist(destPath); let i = 0; while(folderExist){ i++; destPath = `${path} (${i})`; - folderExist = this.utils.pathExist(destPath); + folderExist = await pathExist(destPath); } return destPath @@ -206,7 +207,7 @@ export class BSInstallerService{ if(!rawBsVersion){ throw new Error("NOT_BS_FOLDER"); } - const destPath = this.getPathNotAleardyExist(await this.localVersionService.getVersionPath(rawBsVersion)); + const destPath = await this.getPathNotAleardyExist(await this.localVersionService.getVersionPath(rawBsVersion)); await copy(path, destPath, {dereference: true}); diff --git a/src/main/services/bs-launcher.service.ts b/src/main/services/bs-launcher.service.ts index 416bf95c..d3d299e7 100644 --- a/src/main/services/bs-launcher.service.ts +++ b/src/main/services/bs-launcher.service.ts @@ -6,6 +6,7 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import { SteamService } from "./steam.service"; import { BSLocalVersionService } from "./bs-local-version.service"; import { OculusService } from "./oculus.service"; +import { pathExist } from "../helpers/fs.helpers"; export class BSLauncherService{ @@ -42,10 +43,11 @@ export class BSLauncherService{ const cwd = await this.localVersionService.getVersionPath(launchOptions.version); const exePath = path.join(cwd, BS_EXECUTABLE); - if(!this.utilsService.pathExist(exePath)){ return "EXE_NOT_FINDED"; } + if(!(await pathExist(exePath))){ return "EXE_NOT_FINDED"; } const launchMods = []; + if(!launchOptions.version.steam && !launchOptions.version.oculus){ launchMods.push("--no-yeet"); } if(launchOptions.oculus){ launchMods.push("-vrmode oculus"); } if(launchOptions.desktop){ launchMods.push("fpfc"); } if(launchOptions.debug){ launchMods.push("--verbose"); } diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index e58ebfeb..95906cf3 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -2,7 +2,6 @@ import { BSVersionLibService } from "./bs-version-lib.service"; import { BSVersion, PartialBSVersion } from 'shared/bs-version.interface'; import { InstallationLocationService } from "./installation-location.service"; import { SteamService } from "./steam.service"; -import { UtilsService } from "./utils.service"; import { BS_APP_ID, OCULUS_BS_DIR } from "../constants"; import path from "path"; import { createReadStream } from "fs"; @@ -14,6 +13,8 @@ import log from "electron-log"; import { OculusService } from "./oculus.service"; import { DownloadLinkType } from "shared/models/mods"; import sanitize from "sanitize-filename"; +import { deleteFolder, getFoldersInFolder, pathExist } from "../helpers/fs.helpers"; +import { FolderLinkerService } from "./folder-linker.service"; export class BSLocalVersionService{ @@ -22,11 +23,11 @@ export class BSLocalVersionService{ private readonly CUSTOM_VERSIONS_KEY = "custom-versions"; private readonly installLocationService: InstallationLocationService; - private readonly utilsService: UtilsService; private readonly steamService: SteamService; private readonly oculusService: OculusService; private readonly remoteVersionService: BSVersionLibService; private readonly configService: ConfigurationService; + private readonly linker: FolderLinkerService; public static getInstance(): BSLocalVersionService{ if(!BSLocalVersionService.instance){ BSLocalVersionService.instance = new BSLocalVersionService(); } @@ -35,18 +36,18 @@ export class BSLocalVersionService{ private constructor(){ this.installLocationService = InstallationLocationService.getInstance(); - this.utilsService = UtilsService.getInstance(); this.steamService = SteamService.getInstance(); this.oculusService = OculusService.getInstance(); this.remoteVersionService = BSVersionLibService.getInstance(); this.configService = ConfigurationService.getInstance(); + this.linker = FolderLinkerService.getInstance(); } public async getVersionOfBSFolder(bsPath: string): Promise{ const versionFilePath = path.join(bsPath, 'Beat Saber_Data', 'globalgamemanagers'); - if(!this.utilsService.pathExist(versionFilePath)){ return null; } + if(!(await pathExist(versionFilePath))){ return null; } const versionsAvailable = await this.remoteVersionService.getAvailableVersions(); return new Promise(resolve => { const stream = createReadStream(versionFilePath); @@ -118,8 +119,7 @@ export class BSLocalVersionService{ private async getSteamVersion(): Promise{ const steamBsFolder = await this.steamService.getGameFolder(BS_APP_ID, "Beat Saber"); - - if(!steamBsFolder || !this.utilsService.pathExist(steamBsFolder)){ return null; } + if(!steamBsFolder || !(await pathExist(steamBsFolder))){ return null; } const steamBsVersion = await this.getVersionOfBSFolder(steamBsFolder); if(!steamBsVersion){ return null; } @@ -146,11 +146,11 @@ export class BSLocalVersionService{ if(steamVersion){ versions.push(steamVersion); } const oculusVersion = await this.getOculusVersion(); - if(oculusVersion){ versions.push(oculusVersion); } + if(oculusVersion){ versions.push(oculusVersion); } - if(!this.utilsService.pathExist(this.installLocationService.versionsDirectory)){ return versions } + if(!(await pathExist(this.installLocationService.versionsDirectory))){ return versions } - const folderInInstallation = this.utilsService.listDirsInDir(this.installLocationService.versionsDirectory, true); + const folderInInstallation = await getFoldersInFolder(this.installLocationService.versionsDirectory); for(const f of folderInInstallation){ log.info("try get version from folder", f); @@ -176,9 +176,9 @@ export class BSLocalVersionService{ public async deleteVersion(version: BSVersion): Promise{ if(version.steam || version.oculus){ return false; } const versionFolder = await this.getVersionPath(version); - if(!this.utilsService.pathExist(versionFolder)){ return true; } + if(!(await pathExist(versionFolder))){ return true; } - return this.utilsService.deleteFolder(versionFolder) + return deleteFolder(versionFolder) .then(() => { return true; }) .catch(() => { return false; }) } @@ -197,7 +197,7 @@ export class BSLocalVersionService{ return editedVersion; } - if(this.utilsService.pathExist(newPath) && newPath === oldPath){ throw {title: "VersionAlreadExist"} as BsmException; } + if((await pathExist(newPath)) && newPath === oldPath){ throw {title: "VersionAlreadExist"} as BsmException; } return rename(oldPath, newPath).then(() => { this.deleteCustomVersion(version); @@ -221,7 +221,7 @@ export class BSLocalVersionService{ this.addCustomVersion(cloneVersion); } - if(this.utilsService.pathExist(newPath)){ throw {title: "VersionAlreadExist"} as BsmException; } + if(await pathExist(newPath)){ throw {title: "VersionAlreadExist"} as BsmException; } return fs.copy(originPath, newPath, {dereference: true}).then(() => { this.addCustomVersion(cloneVersion); @@ -232,4 +232,18 @@ export class BSLocalVersionService{ }) } + public async getLinkedFolders(version: BSVersion): Promise{ + const versionPath = await this.getVersionPath(version); + const [rootFolders, beatSaberDataFolders] = await Promise.all([ + getFoldersInFolder(versionPath), + getFoldersInFolder(path.join(versionPath, "Beat Saber_Data")) + ]); + + const linkedFolder = Promise.all([...rootFolders, ...beatSaberDataFolders].map(async folder => { + if(!(await this.linker.isFolderSymlink(folder))){ return null; } + return folder; + })); + + return (await linkedFolder).filter(folder => folder); + } } diff --git a/src/main/services/bs-version-lib.service.ts b/src/main/services/bs-version-lib.service.ts index c6121bc1..89faaa7e 100644 --- a/src/main/services/bs-version-lib.service.ts +++ b/src/main/services/bs-version-lib.service.ts @@ -1,10 +1,10 @@ -import { get } from 'https' import { UtilsService } from './utils.service'; import path from 'path'; import { writeFileSync } from 'fs'; import { BSVersion } from 'shared/bs-version.interface'; import { RequestService } from "./request.service" import isOnline from 'is-online'; +import { readJSON } from 'fs-extra'; export class BSVersionLibService{ @@ -35,8 +35,7 @@ export class BSVersionLibService{ private async getLocalVersions(): Promise{ const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE); - const rawVersion = await this.utilsService.readFileAsync(localVersionsPath); - return JSON.parse(rawVersion); + return readJSON(localVersionsPath); } private async updateLocalVersions(versions: BSVersion[]): Promise{ diff --git a/src/main/services/folder-linker.service.ts b/src/main/services/folder-linker.service.ts new file mode 100644 index 00000000..dc47f121 --- /dev/null +++ b/src/main/services/folder-linker.service.ts @@ -0,0 +1,83 @@ +import { InstallationLocationService } from "./installation-location.service"; +import log from "electron-log"; +import { deleteFolder, ensureFolderExist, moveFolderContent, pathExist, unlinkPath } from "../helpers/fs.helpers"; +import { lstat, symlink } from "fs/promises"; +import path from "path"; +import { copy } from "fs-extra"; + +export class FolderLinkerService { + + private static instance: FolderLinkerService; + + public static getInstance(): FolderLinkerService { + if (!FolderLinkerService.instance) { + FolderLinkerService.instance = new FolderLinkerService(); + } + return FolderLinkerService.instance; + } + + private readonly installLocationService = InstallationLocationService.getInstance(); + + private readonly sharedFolder: string; + + private constructor(){ + this.installLocationService = InstallationLocationService.getInstance(); + + this.sharedFolder = this.installLocationService.sharedContentPath; + } + + private getSharedFolder(folderPath: string, intermediateFolder?: string): string { + return path.join(this.sharedFolder, intermediateFolder ?? "", path.basename(folderPath)); + } + + public async linkFolder(folderPath: string, options?: LinkOptions): Promise { + + if(await this.isFolderSymlink(folderPath)){ return; } + + const sharedPath = this.getSharedFolder(folderPath, options?.intermediateFolder); + + await ensureFolderExist(folderPath); + await ensureFolderExist(sharedPath); + + if(options?.keepContents !== false){ + await moveFolderContent(folderPath, sharedPath).toPromise(); + } + + await deleteFolder(folderPath); + + return symlink(sharedPath, folderPath, "junction"); + } + + public async unlinkFolder(folderPath: string, options?: LinkOptions): Promise { + + if(!(await this.isFolderSymlink(folderPath))){ return; } + await unlinkPath(folderPath); + + const sharedPath = this.getSharedFolder(folderPath, options?.intermediateFolder); + + await ensureFolderExist(folderPath); + + if(options?.keepContents === false){ return; } + + await ensureFolderExist(sharedPath); + + return copy(sharedPath, folderPath, { errorOnExist: false }); + } + + public async isFolderSymlink(folder: string): Promise { + try{ + if(!(await pathExist(folder))){ return false; } + return lstat(folder).then(stat => stat.isSymbolicLink()); + } + catch(e){ + log.error(e); + } + return false; + } + +} + +export interface LinkOptions { + keepContents?: boolean, + intermediateFolder?: string, +} \ No newline at end of file diff --git a/src/main/services/installation-location.service.ts b/src/main/services/installation-location.service.ts index dfe6013c..317057ab 100644 --- a/src/main/services/installation-location.service.ts +++ b/src/main/services/installation-location.service.ts @@ -1,10 +1,10 @@ import path from "path"; -import { UtilsService } from "./utils.service"; import fs from 'fs-extra'; import log from "electron-log"; import { app } from "electron"; import { BsmException } from "shared/models/bsm-exception.model"; import ElectronStore from "electron-store"; +import { ensureFolderExist, pathExist } from "../helpers/fs.helpers"; export class InstallationLocationService { @@ -19,10 +19,8 @@ export class InstallationLocationService { private readonly VERSIONS_FOLDER = "BSInstances"; private readonly SHARED_CONTENT_FOLDER = "SharedContent"; - private readonly SHARED_MAPS_FOLDER = "SharedMaps"; private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder"; - private readonly utilsService: UtilsService; private readonly installPathConfig: ElectronStore; private readonly updateListeners: Set = new Set(); @@ -31,7 +29,6 @@ export class InstallationLocationService { private constructor(){ this.installPathConfig = new ElectronStore({watch: true}); - this.utilsService = UtilsService.getInstance(); this.initInstallationLocation(); this.installPathConfig.onDidChange(this.STORE_INSTALLATION_PATH_KEY, () => { @@ -50,8 +47,8 @@ export class InstallationLocationService { public setInstallationDirectory(newDir: string): Promise{ const oldDir = this.installationDirectory; const newDest = path.join(newDir, this.INSTALLATION_FOLDER); - return new Promise((resolve, reject) => { - if(!this.utilsService.pathExist(oldDir)){ this.utilsService.createFolderIfNotExist(oldDir); } + return new Promise(async (resolve, reject) => { + if(!(await pathExist(oldDir))){ ensureFolderExist(oldDir); } fs.move(oldDir, newDest, { overwrite: true }).then(() => { this._installationDirectory = newDir; this.installPathConfig.set(this.STORE_INSTALLATION_PATH_KEY, newDir); @@ -72,7 +69,6 @@ export class InstallationLocationService { public get versionsDirectory(): string { return path.join(this.installationDirectory, this.VERSIONS_FOLDER); } public get sharedContentPath(): string { return path.join(this.installationDirectory, this.SHARED_CONTENT_FOLDER); } - public get sharedMapsPath(): string { return path.join(this.sharedContentPath, this.SHARED_MAPS_FOLDER); } } diff --git a/src/main/services/ipc.service.ts b/src/main/services/ipc.service.ts index e6b64796..9bd832ab 100644 --- a/src/main/services/ipc.service.ts +++ b/src/main/services/ipc.service.ts @@ -40,7 +40,7 @@ export class IpcService { } public send(channel: IpcChannel, window: AppWindow, response?: T|Error): void{ - this.windows.getWindow(window).webContents.send(channel, response); + this.windows.getWindow(window)?.webContents?.send(channel, response); } public connectStream(channel: IpcChannel, window: AppWindow, observable: Observable): void{ diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index 339cc331..a13f6fad 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -11,6 +11,7 @@ import { RequestService } from "../request.service"; import { spawn } from "child_process"; import { BS_EXECUTABLE } from "../../constants"; import log from "electron-log"; +import { deleteFolder, ensureFolderExist, pathExist, unlinkPath } from "../../helpers/fs.helpers"; export class BsModsManagerService { @@ -60,7 +61,7 @@ export class BsModsManagerService { private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise{ const bsPath = await this.bsLocalService.getVersionPath(version); const modsPath = path.join(bsPath, modsDir); - if(!this.utilsService.pathExist(modsPath)){ return []; } + if(!(await pathExist(modsPath))){ return []; } const files = fs.readdirSync(modsPath); const promises = files.map(f => { return (async() => { @@ -89,7 +90,7 @@ export class BsModsManagerService { 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(!this.utilsService.pathExist(injectorPath)){ return undefined; } + if(!(await pathExist(injectorPath))){ return undefined; } const injectorMd5 = await md5File(injectorPath); return this.getIpaFromHash(injectorMd5); } @@ -98,7 +99,7 @@ export class BsModsManagerService { zipUrl = path.join(this.beatModsApi.BEAT_MODS_URL, zipUrl); const fileName = path.basename(zipUrl); const tempPath = this.utilsService.getTempPath(); - this.utilsService.createFolderIfNotExist(this.utilsService.getTempPath()); + await ensureFolderExist(this.utilsService.getTempPath()); const dest = path.join(tempPath, fileName); const zipPath = await this.requestService.downloadFile(zipUrl, dest); @@ -111,7 +112,7 @@ export class BsModsManagerService { const versionPath = await this.bsLocalService.getVersionPath(version); const ipaPath = path.join(versionPath, "IPA.exe"); const bsExePath = path.join(versionPath, BS_EXECUTABLE); - if(!this.utilsService.pathExist(ipaPath) || !this.utilsService.pathExist(bsExePath)){ return false; } + if(!(await pathExist(ipaPath)) || !(await pathExist(bsExePath))){ return false; } return new Promise(resolve => { const processIPA = spawn(`start /wait /min "" "${ipaPath}" ${args.join(" ")}`, {cwd: versionPath, detached: true, shell: true}); @@ -163,7 +164,7 @@ export class BsModsManagerService { const extracted = await zip.extract(null, destDir).then(() => true).catch(err => {log.error(err); return false}); await zip.close(); - await this.utilsService.unlinkIfExist(zipPath); + await unlinkPath(zipPath); const res = isBSIPA ? (extracted && (await this.executeBSIPA(version, ["-n"]))) : extracted; @@ -194,8 +195,8 @@ export class BsModsManagerService { const download = this.getModDownload(mod, version); const verionPath = await this.bsLocalService.getVersionPath(version); - const hasIPAExe = this.utilsService.pathExist(path.join(verionPath, "IPA.exe")); - const hasIPADir = this.utilsService.pathExist(path.join(verionPath, "IPA")); + const hasIPAExe = await pathExist(path.join(verionPath, "IPA.exe")); + const hasIPADir = await pathExist(path.join(verionPath, "IPA")); if(!hasIPADir || !hasIPAExe){ return; } @@ -203,7 +204,7 @@ export class BsModsManagerService { const promises = download.hashMd5.map(files => { const file = files.file.replaceAll("IPA/", "").replaceAll("Data", "Beat Saber_Data"); - return this.utilsService.unlinkIfExist(path.join(verionPath, file)); + return unlinkPath(path.join(verionPath, file)); }) await Promise.all(promises); @@ -220,8 +221,10 @@ export class BsModsManagerService { const versionPath = await this.bsLocalService.getVersionPath(version); const promises = download.hashMd5.map(async files => { - this.utilsService.unlinkIfExist(path.join(versionPath, files.file)); - this.utilsService.unlinkIfExist(path.join(versionPath, "IPA", "Pending", files.file)); + return Promise.all([ + unlinkPath(path.join(versionPath, files.file)), + unlinkPath(path.join(versionPath, "IPA", "Pending", files.file)) + ]); }); await Promise.all(promises); @@ -306,9 +309,9 @@ export class BsModsManagerService { const versionPath = await this.bsLocalService.getVersionPath(version); - this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.PLUGINS)); - this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.LIBS)); - this.utilsService.rmDirIfExist(path.join(versionPath, ModsInstallFolder.IPA)); + await deleteFolder(path.join(versionPath, ModsInstallFolder.PLUGINS)); + await deleteFolder(path.join(versionPath, ModsInstallFolder.LIBS)); + await deleteFolder(path.join(versionPath, ModsInstallFolder.IPA)); path.resolve diff --git a/src/main/services/oculus.service.ts b/src/main/services/oculus.service.ts index 65b38432..50e3cf97 100644 --- a/src/main/services/oculus.service.ts +++ b/src/main/services/oculus.service.ts @@ -1,6 +1,7 @@ import { UtilsService } from "./utils.service"; import regedit from 'regedit' import path from "path"; +import { pathExist } from "../helpers/fs.helpers"; export class OculusService { @@ -57,7 +58,7 @@ export class OculusService { for(const lib of libsFolders){ const gameFullPath = path.join(lib, rootLibDir, gameFolder); - if(this.utils.pathExist(gameFullPath)){ return gameFullPath; } + if(await pathExist(gameFullPath)){ return gameFullPath; } } return null; diff --git a/src/main/services/steam.service.ts b/src/main/services/steam.service.ts index 622d175d..ad6aacb6 100644 --- a/src/main/services/steam.service.ts +++ b/src/main/services/steam.service.ts @@ -4,6 +4,7 @@ import path from "path"; import { parse } from "@node-steam/vdf"; import { readFile } from "fs/promises"; import { spawn } from "child_process"; +import { pathExist } from "../helpers/fs.helpers"; export class SteamService{ @@ -50,7 +51,7 @@ export class SteamService{ let libraryFolders: any = path.join(steamPath, 'steamapps', 'libraryfolders.vdf'); - if(!this.utils.pathExist(libraryFolders)){ return null; } + if(!(await pathExist(libraryFolders))){ return null; } libraryFolders = parse(await readFile(libraryFolders, {encoding: 'utf-8'})); if(!libraryFolders.libraryfolders){ return null; } diff --git a/src/main/services/supporters.service.ts b/src/main/services/supporters.service.ts index 14d1c628..d02abbed 100644 --- a/src/main/services/supporters.service.ts +++ b/src/main/services/supporters.service.ts @@ -1,4 +1,5 @@ import { writeFileSync } from "fs"; +import { readJSON } from "fs-extra"; import { get } from "https"; import isOnline from "is-online"; import path from "path"; @@ -43,8 +44,7 @@ export class SupportersService { private async getLocalSupporters(): Promise{ const patreonsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.PATREONS_FILE); - const rawPatreons = await this.utilsService.readFileAsync(patreonsPath); - return JSON.parse(rawPatreons); + return readJSON(patreonsPath); } private async loadSupporters(): Promise{ diff --git a/src/main/services/thrid-party/beat-saver/beat-saver.service.ts b/src/main/services/thrid-party/beat-saver/beat-saver.service.ts index 619293ed..d46735d5 100644 --- a/src/main/services/thrid-party/beat-saver/beat-saver.service.ts +++ b/src/main/services/thrid-party/beat-saver/beat-saver.service.ts @@ -1,4 +1,4 @@ -import { splitIntoChunk } from "../../../helpers/array-tools"; +import { splitIntoChunk } from "../../../helpers/array.helpers"; import { BsvMapDetail } from "shared/models/maps"; import { BsvPlaylist, SearchParams } from "shared/models/maps/beat-saver.model"; import { BeatSaverApiService } from "./beat-saver-api.service"; diff --git a/src/main/services/utils.service.ts b/src/main/services/utils.service.ts index ed3af52d..462e0158 100644 --- a/src/main/services/utils.service.ts +++ b/src/main/services/utils.service.ts @@ -1,10 +1,6 @@ -import { existsSync, mkdirSync, readdirSync, readFile, rmSync } from "fs"; -import { moveSync } from "fs-extra" import { spawnSync } from "child_process"; -import { homedir } from "os"; import path from "path"; import { app, BrowserWindow } from "electron"; -import { rm, unlink } from "fs/promises"; import { IpcResponse } from "shared/models/ipc"; import log from "electron-log"; import { AppWindow } from "shared/models/window-manager/app-window.model"; @@ -37,72 +33,14 @@ export class UtilsService{ public setMainWindows(windows: Map){ this.windows = windows; } public getMainWindows(win: AppWindow){ return this.windows.get(win); } - public pathExist(path: string): boolean{ return existsSync(path); } - - public createFolderIfNotExist(path: string): void{ - if(!this.pathExist(path)){ mkdirSync(path, {recursive: true}); } - } - - public async unlinkIfExist(pathToFile: string): Promise{ - if(!this.pathExist(pathToFile)){ return } - return unlink(pathToFile); - } - - public async rmDirIfExist(path: string): Promise{ - if(!this.pathExist(path)){ return; } - return rm(path, {recursive: true, force: true}); - } - public taskRunning(task: string): boolean{ const tasks = spawnSync('tasklist').stdout.toString(); return tasks.includes(task); } - public getUserFolder(){ return homedir(); } - - public getUserDocumentsFolder(): string{ - return path.join(this.getUserFolder(), 'Documents'); - } - - public readFileAsync(path: string){ - return new Promise((resolve, reject) => { - readFile(path, {encoding: "utf-8", flag: "r"}, (err, data) => { - if(err){ reject(err); } - else{ resolve(data); } - }); - }); - } - - public listDirsInDir(dirPath: string, fullPath = false): string[]{ - let files = readdirSync(dirPath, { withFileTypes:true }); - return files.reduce((acc, f) => { - if(!f.isDirectory()){ return acc; } - acc.push(fullPath ? path.join(dirPath, f.name) : f.name); - return acc; - }, []); - } - - public async deleteFolder(folderPath: string): Promise{ - const folderExist = this.pathExist(folderPath); - if(!folderExist){ return; } - return rmSync(folderPath, {recursive: true}); - } - - public async moveDirContent(src: string, dest: string, overwrite = false): Promise{ - const [srcExist, destExist] = await Promise.all([this.pathExist(src), this.pathExist(dest)]); - if(!srcExist){ return; } - if(!destExist){ await this.createFolderIfNotExist(dest); } - readdirSync(src, {encoding: "utf-8"}).forEach(file => { - const srcFullPath = path.join(src, file); - const destFullPath = path.join(dest, file); - if(!overwrite && this.pathExist(destFullPath)){ return; } - moveSync(srcFullPath, destFullPath, {overwrite}); - }); - } - public ipcSend(channel: string, response: IpcResponse): void{ try { - Array.from(this.windows.values()).forEach(window => window.webContents.send(channel, response)); + Array.from(this.windows.values()).forEach(window => window?.webContents?.send(channel, response)); } catch (error) { log.error(error); } diff --git a/src/renderer/components/maps-mangement-components/link-button.component.tsx b/src/renderer/components/maps-mangement-components/link-button.component.tsx new file mode 100644 index 00000000..0b84c594 --- /dev/null +++ b/src/renderer/components/maps-mangement-components/link-button.component.tsx @@ -0,0 +1,30 @@ +import { motion } from "framer-motion" +import { forwardRef } from "react" +import { useThemeColor } from "renderer/hooks/use-theme-color.hook" +import { BsmIcon } from "../svgs/bsm-icon.component" + +type Props = { + className?: string, + title?: string, + linked?: boolean + disabled?: boolean, + onClick?: () => void +} + +export const LinkButton = motion(forwardRef((props: Props, ref) => { + + const color = useThemeColor("first-color"); + + const linkedColor = (() => { + if(props.disabled){ return "orange"; } + if(props.linked){ return color; } + return "red"; + })() + + return ( +
{e.preventDefault(); !props.disabled && props.onClick?.()}} style={{pointerEvents: props.disabled ? "none" : "auto"}}> + + +
+ ) +})) diff --git a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx index 7aa05c73..b3b69704 100644 --- a/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-mangement-components/local-maps-list-panel.component.tsx @@ -24,9 +24,10 @@ type Props = { className?: string, filter?: MapFilter search?: string, + linked?: boolean } -export const LocalMapsListPanel = forwardRef(({version, className, filter, search} : Props, forwardRef) => { +export const LocalMapsListPanel = forwardRef(({version, className, filter, search, linked} : Props, forwardRef) => { const mapsManager = MapsManagerService.getInstance(); const mapsDownloader = MapsDownloaderService.getInstance(); @@ -35,7 +36,7 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc const t = useTranslation(); const ref = useRef(null) - const isVisible = useInView(ref, {once: true}); + const isVisible = useInView(ref, {once: true, amount: .5}); const [maps, setMaps] = useState(null); const [subs] = useState([]); const [selectedMaps$] = useState(new BehaviorSubject([])); @@ -65,8 +66,6 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc if(isVisible){ loadMaps(); - subs.push(mapsManager.versionLinked$.subscribe(loadMaps)); - subs.push(mapsManager.versionUnlinked$.subscribe(loadMaps)); mapsDownloader.addOnMapDownloadedListener((map, targerVersion) => { if(targerVersion !== version){ return; } setMaps(maps => maps ? [map, ...maps] : [map]); @@ -79,7 +78,7 @@ export const LocalMapsListPanel = forwardRef(({version, className, filter, searc subs.forEach(s => s.unsubscribe()); mapsDownloader.removeOnMapDownloadedListener(loadMaps); } - }, [isVisible, version]); + }, [isVisible, version, linked]); useEffect(() => { diff --git a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx b/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx index 1b6192ac..2162e71d 100644 --- a/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-mangement-components/maps-playlists-panel.component.tsx @@ -15,6 +15,9 @@ import { OsDiagnosticService } from "renderer/services/os-diagnostic.service" import { useObservable } from "renderer/hooks/use-observable.hook" import { BsmIcon } from "../svgs/bsm-icon.component" import { useTranslation } from "renderer/hooks/use-translation.hook" +import { LinkButton } from "./link-button.component" +import { debounceTime } from "rxjs/operators" +import { FolderLinkerService } from "renderer/services/folder-linker.service" type Props = { version?: BSVersion @@ -25,12 +28,14 @@ export function MapsPlaylistsPanel({version}: Props) { const mapsService = MapsManagerService.getInstance(); const mapsDownloader = MapsDownloaderService.getInstance(); const osDiagnostic = OsDiagnosticService.getInstance(); + const linker = FolderLinkerService.getInstance(); const [tabIndex, setTabIndex] = useState(0); const [mapFilter, setMapFilter] = useState({}); const [mapSearch, setMapSearch] = useState(""); const [playlistSearch, setPlaylistSearch] = useState(""); const [mapsLinked, setMapsLinked] = useState(false); + const [linkingPending, setLinkingPending] = useState(false); const isOnline = useObservable(osDiagnostic.isOnline$); const color = useThemeColor("first-color"); const t = useTranslation(); @@ -38,6 +43,23 @@ export function MapsPlaylistsPanel({version}: Props) { useEffect(() => { loadMapIsLinked(); + + const sub = mapsService.$mapsLinkingPending(version).pipe(debounceTime(50)).subscribe(setLinkingPending); + + const onMapsLinked = (folder: string) => { + if(!folder.includes("CustomLevels")){ return; } + loadMapIsLinked(); + } + + linker.onFolderLinked(onMapsLinked); + linker.onFolderUnlinked(onMapsLinked); + + return () => { + sub.unsubscribe(); + linker.removeOnFolderLinked(onMapsLinked); + linker.removeOnFolderUnlinked(onMapsLinked); + } + }, [version]); const loadMapIsLinked = () => { @@ -53,9 +75,9 @@ export function MapsPlaylistsPanel({version}: Props) { const handleMapsLinkClick = () => { if(!mapsLinked){ - return mapsService.linkVersion(version).then(loadMapIsLinked); + return mapsService.linkVersion(version); } - return mapsService.unlinkVersion(version).then(loadMapIsLinked); + return mapsService.unlinkVersion(version); } const handleMapsAddClick = () => { @@ -64,8 +86,6 @@ export function MapsPlaylistsPanel({version}: Props) { const renderTab = (props: DetailedHTMLProps, HTMLLIElement>, text: string, index: number): JSX.Element => { - const linkedColor = mapsLinked ? color : "red"; - const onClickLink = (index: number) => { if(index === 0){ handleMapsLinkClick(); } } @@ -91,10 +111,17 @@ export function MapsPlaylistsPanel({version}: Props) { )} {(!!version) && ( - {e.stopPropagation(); onClickLink(index)}}> - - - + onClickLink(index)} + /> )} )} @@ -130,7 +157,7 @@ export function MapsPlaylistsPanel({version}: Props) {
- +
Coming soon diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx new file mode 100644 index 00000000..ed999188 --- /dev/null +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -0,0 +1,174 @@ +import Tippy from "@tippyjs/react"; +import { Variants } from "framer-motion"; +import { useEffect, useState } from "react"; +import { LinkButton } from "renderer/components/maps-mangement-components/link-button.component"; +import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bsm-basic-spinner.component"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmIcon } from "renderer/components/svgs/bsm-icon.component"; +import { useObservable } from "renderer/hooks/use-observable.hook"; +import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ConfigurationService } from "renderer/services/configuration.service"; +import { FolderLinkerService } from "renderer/services/folder-linker.service"; +import { IpcService } from "renderer/services/ipc.service"; +import { ModalComponent } from "renderer/services/modale.service"; +import { BSVersion } from "shared/bs-version.interface"; + +export const ShareFoldersModal: ModalComponent = ({ data }) => { + + const SHARED_FOLDERS_KEY = "default-shared-folders"; + + const config = ConfigurationService.getInstance(); + const ipc = IpcService.getInstance(); + + const t = useTranslation(); + + const [folders, setFolders] = useState([]); + const versionPath = useObservable(ipc.sendV2("get-version-full-path", {args: data})); + + useEffect(() => { + + const defaultFolders = config.get(SHARED_FOLDERS_KEY); + + const promises = Promise.allSettled(defaultFolders.map(async (folder) => { + return ipc.sendV2("relative-version-path-to-full", {args: {version: data, relative: folder}}).toPromise(); + })); + + promises.then((res) => { + + const fullPathFolders = res.reduce((acc, curent) => { + if(curent.status !== "fulfilled"){ return acc; } + acc.push(curent.value); + return acc; + }, []); + + setFolders(prev => Array.from(new Set([...prev, ...fullPathFolders]).values())); + }); + + ipc.sendV2("get-linked-folders", {args: data}).toPromise().then(linkedFolders => { + setFolders(prev => Array.from(new Set([...prev, ...linkedFolders]).values())); + }); + + }, []); + + useEffect(() => { + + if(!folders?.length){ + config.delete(SHARED_FOLDERS_KEY); + return; + } + + const promises = Promise.allSettled(folders.map(async folder => { + return ipc.sendV2("full-version-path-to-relative", {args: {version: data, fullPath: folder}}).toPromise(); + })); + + promises.then((res) => { + const sharedFolders = res.reduce((acc, curent) => { + if(curent.status !== "fulfilled"){ return acc; } + acc.push(curent.value); + return acc; + }, [] as string[]); + config.set(SHARED_FOLDERS_KEY, sharedFolders); + }); + + }, [folders]); + + const addFolder = async () => { + const folder = await ipc.sendV2<{canceled: boolean, filePaths: string[]}, string>("choose-folder", {args: versionPath}).toPromise(); + + if(!folder || folder.canceled || !folder.filePaths?.length){ return; } + const linkedFolder = folder.filePaths[0]; + + if(folders.includes(linkedFolder)){ return; } + + setFolders(pre => [...pre, linkedFolder]); + } + + const removeFolder = (index: number) => { + setFolders((prev) => prev.filter((_, i) => i !== index)); + } + + return ( +
+

{t("modals.shared-folders.title")}

+

{t("modals.shared-folders.description")}

+
    + {folders.map((folder, index) => ( + {removeFolder(index)}}/> + ))} +
+
+ + {t("modals.shared-folders.buttons.add-folder")} +
+
+ ) +} + +type FolderProps = { + path: string; + onDelete?: () => void; +} + +const FolderItem = ({path, onDelete}: FolderProps) => { + + const linker = FolderLinkerService.getInstance(); + + const t = useTranslation(); + + const name = path.split("\\").at(-1); + const color = useThemeColor("first-color"); + + const variants: Variants = { + hover: {rotate: 22.5}, + tap: {rotate: 45} + }; + + const pending = useObservable(linker.isPending(path)); + const processing = useObservable(linker.isProcessing(path)); + const linkDisabled = pending || processing; + + const [linked, setLinked] = useState(false); + + useEffect(() => { + loadFolderIsLinked(); + }, [path]); + + const loadFolderIsLinked = () => { + linker.isFolderLinked(path).toPromise().then(setLinked); + } + + const onClickLink = () => { + if(linked){ + return linker.unlinkFolder(path).toPromise().then(loadFolderIsLinked); + } + return linker.linkFolder(path).toPromise().then(loadFolderIsLinked); + } + + const cancelLink = () => { + linker.cancelFolder(path); + loadFolderIsLinked(); + } + + return ( +
  • + {name} +
    + + + + {!processing ? ( + !pending ? ( + {e.preventDefault(); onDelete?.()}}/> + ) : ( + {e.preventDefault(); cancelLink()}}/> + ) + ) : ( + + )} + +
    +
  • + ) + +} diff --git a/src/renderer/components/title-bar/title-bar.component.tsx b/src/renderer/components/title-bar/title-bar.component.tsx index 367cca8d..a953dbd9 100644 --- a/src/renderer/components/title-bar/title-bar.component.tsx +++ b/src/renderer/components/title-bar/title-bar.component.tsx @@ -64,7 +64,7 @@ export default function TitleBar({template = "index.html"} : {template: AppWindo
    audio.setVolume(val[0])} onFinalChange={val => audio.setFinalVolume(val[0])}/>
    - audio.toggleMute()}/> + audio.toggleMute()}/>
    diff --git a/src/renderer/config/default-configuration.config.ts b/src/renderer/config/default-configuration.config.ts index a6a9eed9..6e5875dc 100644 --- a/src/renderer/config/default-configuration.config.ts +++ b/src/renderer/config/default-configuration.config.ts @@ -4,9 +4,14 @@ export const defaultConfiguration: {[key in DefaultConfigKey]: any} = { "theme": "os", "language": window.navigator.language.length <= 2 ? `${window.navigator.language}-${window.navigator.language.toLocaleUpperCase()}` : window.navigator.language, "supported_languages": ["en-US", "en-EN", "fr-FR", "es-ES"], - "default_mods": ["SongCore", "WhyIsThereNoLeaderboard", "BeatSaverDownloader", "BeatSaverVoting", "PlaylistManager"] + "default_mods": ["SongCore", "WhyIsThereNoLeaderboard", "BeatSaverDownloader", "BeatSaverVoting", "PlaylistManager"], + "default-shared-folders": [ + "Beat Saber_Data\\CustomLevels", + "Beat Saber_Data\\CustomWIPLevels", + "DLC" + ] } -export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default_mods"; +export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default_mods" | "default-shared-folders"; export type ThemeConfig = "dark" | "light" | "os" diff --git a/src/renderer/pages/available-versions-list.components.tsx b/src/renderer/pages/available-versions-list.components.tsx index 979952f1..8b06ca57 100644 --- a/src/renderer/pages/available-versions-list.components.tsx +++ b/src/renderer/pages/available-versions-list.components.tsx @@ -54,15 +54,15 @@ export function AvailableVersionsList() { if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } - const folderRes = await ipc.send<{canceled: boolean, filePaths: string[]}>("choose-folder"); + const folderRes = await ipc.sendV2<{canceled: boolean, filePaths: string[]}>("choose-folder").toPromise(); - if(!folderRes.success || folderRes.data.canceled || !folderRes.data.filePaths?.length){ return; } + if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; } notification.notifySuccess({title: "notifications.bs-import-version.success.start-import.title", desc: "notifications.bs-import-version.success.start-import.desc", duration: 6_000}); progressBar.showFake(.008); - const toImport = folderRes.data.filePaths.at(0); + const toImport = folderRes.filePaths.at(0); const imported = await installer.importVersion(toImport); diff --git a/src/renderer/pages/settings-page.component.tsx b/src/renderer/pages/settings-page.component.tsx index f99d6183..9f68c2aa 100644 --- a/src/renderer/pages/settings-page.component.tsx +++ b/src/renderer/pages/settings-page.component.tsx @@ -110,11 +110,12 @@ export function SettingsPage() { modalService.openModal(InstallationFolderModal).then(async res => { if(res.exitCode !== ModalExitCode.COMPLETED){ return; } - const fileChooserRes = await ipcService.send<{canceled: boolean, filePaths: string[]}>("choose-folder"); - if(fileChooserRes.success && !fileChooserRes.data.canceled && fileChooserRes.data.filePaths?.length){ + const fileChooserRes = await ipcService.sendV2<{canceled: boolean, filePaths: string[]}>("choose-folder").toPromise(); + + if(!fileChooserRes.canceled && fileChooserRes.filePaths?.length){ progressBarService.showFake(.008); - downloaderService.setInstallationFolder(fileChooserRes.data.filePaths[0]).then(res => { + downloaderService.setInstallationFolder(fileChooserRes.filePaths[0]).then(res => { setTimeout(() => { progressBarService.complete(); setTimeout(() => progressBarService.hide(true), 1000); diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index ab5b9a19..b8dbd722 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -15,6 +15,7 @@ import { LaunchSlide } from 'renderer/components/version-viewer/slides/launch/la import { ModsSlide } 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-mangement-components/maps-playlists-panel.component'; +import { ShareFoldersModal } from 'renderer/components/modal/modal-types/share-folders-modal.component'; export function VersionViewer() { @@ -66,6 +67,10 @@ export function VersionViewer() { setCurrentTabIndex(() => 0); } + const openShareFolderModal = () => { + modalService.openModal(ShareFoldersModal, state); + } + return ( <> @@ -87,6 +92,7 @@ export function VersionViewer() { ((!state.steam && !state.oculus) && {text: "pages.version-viewer.dropdown.verify-files", icon: "task", onClick: verifyFiles}), ((!state.steam && !state.oculus) && {text: "pages.version-viewer.dropdown.edit", icon: "edit", onClick: edit}), (!state.oculus && {text: "pages.version-viewer.dropdown.clone", icon: "copy", onClick: clone}), + {text: "pages.version-viewer.dropdown.shared-folders", icon: "link", onClick: openShareFolderModal}, ((!state.steam && !state.oculus) && {text: "pages.version-viewer.dropdown.uninstall", icon:"trash", onClick: uninstall}) ]}/> diff --git a/src/renderer/services/folder-linker.service.ts b/src/renderer/services/folder-linker.service.ts new file mode 100644 index 00000000..554d4316 --- /dev/null +++ b/src/renderer/services/folder-linker.service.ts @@ -0,0 +1,181 @@ +import { of, timer } from "rxjs"; +import { BehaviorSubject } from "rxjs"; +import { map, distinctUntilChanged, filter } from "rxjs/operators"; +import { from } from "rxjs"; +import { Observable } from "rxjs"; +import { IpcService } from "./ipc.service"; +import { ProgressBarService } from "./progress-bar.service"; +import { NotificationService } from "./notification.service"; + +export class FolderLinkerService { + + private static instance: FolderLinkerService; + + public static getInstance(): FolderLinkerService { + if (!FolderLinkerService.instance) { + FolderLinkerService.instance = new FolderLinkerService(); + } + return FolderLinkerService.instance; + } + + private readonly ipc: IpcService; + private readonly progress: ProgressBarService; + private readonly notification: NotificationService; + + private readonly onLinkedListeners = new Map void>(); + private readonly onUnlinkedListeners = new Map void>(); + private readonly onRemovedFromQueueListeners = new Map void>(); + + private readonly publicOnLinkedListeners = new Set<(folder: string) => void>(); + private readonly publicOnUnlinkedListeners = new Set<(folder: string) => void>(); + + private readonly _currentAction$ = new BehaviorSubject(null); + private readonly _queue$ = new BehaviorSubject([]); + + private constructor() { + this.ipc = IpcService.getInstance(); + this.progress = ProgressBarService.getInstance(); + this.notification = NotificationService.getInstance(); + + this.queue$.pipe(map(queue => !!queue.length), distinctUntilChanged(), filter(haveAction => haveAction)).subscribe(() => { + this.startQueue(); + }); + } + + private async startQueue(): Promise{ + + while(this._queue$.value.at(0)){ + + const toDo = this._queue$.value.at(0); + + let progressOpened = false; + + if(!this.progress.isVisible){ + this.progress.showFake(.01, null, toDo.folder.split("\\").at(-1)); + progressOpened = true; + } + + this._currentAction$.next(toDo); + await this.doAction(toDo).toPromise(); + + this.specialFolderNotification(toDo); + + if(toDo.type === LinkActionType.Link){ + this.onLinkedListeners.get(toDo.folder)?.(); + this.publicOnLinkedListeners.forEach(listener => listener(toDo.folder)); + this.onLinkedListeners.delete(toDo.folder); + } + else{ + this.onUnlinkedListeners.get(toDo.folder)?.(); + this.publicOnUnlinkedListeners.forEach(listener => listener(toDo.folder)); + this.onUnlinkedListeners.delete(toDo.folder); + } + + const newArr = [...this._queue$.value]; + newArr.shift(); + this._queue$.next(newArr); + + if(progressOpened){ + this.progress.hide(true); + } + + } + + this._currentAction$.next(null); + } + + private specialFolderNotification(action: LinkAction): void{ + if(action.type === LinkActionType.Link && action.folder.includes("UserData")){ + this.notification.notifySuccess({ + title: "notifications.shared-folder.success.yeet-mods-disabled.title", + desc: "notifications.shared-folder.success.yeet-mods-disabled.msg" + }) + } + } + + private doAction(action: LinkAction): Observable{ + return this.ipc.sendV2(action.type === LinkActionType.Link ? "link-folder" : "unlink-folder", { args: { folder: action.folder, keepContents: action.keepContents } }); + } + + private addFolderToQueue(folder: string, type: LinkActionType, keepContents: boolean){ + if(this._queue$.value.some(action => action.folder === folder)){ return; } + this._queue$.next([...this._queue$.value, {folder, type, keepContents}]); + } + + private removeFolderFromQueue(folder: string){ + const queue = this._queue$.value; + const index = queue.findIndex(action => action.folder === folder); + if(index === -1){ return; } + this.onRemovedFromQueueListeners.get(folder)?.(); + queue.splice(index, 1); + this._queue$.next(queue); + } + + private onLinked(folder: string, callback: () => void){ this.onLinkedListeners.set(folder, callback); } + private onUnlinked(folder: string, callback: () => void){ this.onUnlinkedListeners.set(folder, callback); } + private onRemovedFromQueue(folder: string, callback: () => void){ this.onRemovedFromQueueListeners.set(folder, callback); } + + public isFolderLinked(path: string): Observable { + return this.ipc.sendV2("is-folder-symlink", {args: path}); + } + + public linkFolders(...folders: string[]): Map> { + return new Map(folders.map(folder => [folder, this.linkFolder(folder)])); + } + + public linkFolder(folder: string, keepContents = true): Observable { + const promise = new Promise(resolve => { + this.onLinked(folder, resolve); + this.onRemovedFromQueue(folder, resolve); + }); + + this.addFolderToQueue(folder, LinkActionType.Link, keepContents); + return from(promise); + } + + public unlinkFolders(...folders: string[]): Map> { + return new Map(folders.map(folder => [folder, this.unlinkFolder(folder)])); + } + + public unlinkFolder(folder: string, keepContents = true): Observable { + const promise = new Promise(resolve => { + this.onUnlinked(folder, resolve); + this.onRemovedFromQueue(folder, resolve); + }); + + this.addFolderToQueue(folder, LinkActionType.Unlink, keepContents); + return from(promise); + } + + public cancelFolder(folder: string): void { + this.removeFolderFromQueue(folder); + } + + public get queue$(): Observable { return this._queue$.asObservable(); } + public get currentAction$(): Observable { return this._currentAction$.asObservable(); } + + public isPending(folder: string): Observable { + return this.queue$.pipe(map(queue => queue.some(action => action.folder === folder), distinctUntilChanged())); + } + + public isProcessing(folder: string): Observable { + return this.currentAction$.pipe(distinctUntilChanged(), map(action => action?.folder === folder)); + } + + public onFolderLinked(callback: (folder: string) => void): void { this.publicOnLinkedListeners.add(callback); } + public removeOnFolderLinked(callback: (folder: string) => void): void { this.publicOnLinkedListeners.delete(callback); } + public onFolderUnlinked(callback: (folder: string) => void): void { this.publicOnUnlinkedListeners.add(callback); } + public removeOnFolderUnlinked(callback: (folder: string) => void): void { this.publicOnUnlinkedListeners.delete(callback); } + +} + +export interface LinkAction { + folder: string; + type: LinkActionType; + keepContents?: boolean; +} + +export enum LinkActionType { + Link, + Unlink +} \ No newline at end of file diff --git a/src/renderer/services/ipc.service.ts b/src/renderer/services/ipc.service.ts index 2f9ee7c1..af725b7d 100644 --- a/src/renderer/services/ipc.service.ts +++ b/src/renderer/services/ipc.service.ts @@ -53,7 +53,7 @@ export class IpcService { public sendV2(channel: string, request?: IpcRequest, defaultValue?: T): Observable{ if(!request){ request = {args: null, responceChannel: null}; } - if(!request.responceChannel){ request.responceChannel = `${channel}_responce_${new Date().getTime()}`; } + if(!request.responceChannel){ request.responceChannel = `${channel}_responce_${crypto.randomUUID()}`; } const completeChannel = `${request.responceChannel}_complete`; const errorChannel = `${request.responceChannel}_error`; diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 75f43f61..5f46a1c9 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -1,6 +1,6 @@ import { LinkMapsModal } from "renderer/components/modal/modal-types/link-maps-modal.component"; import { UnlinkMapsModal } from "renderer/components/modal/modal-types/unlink-maps-modal.component"; -import { Subject, Observable } from "rxjs"; +import { Subject, Observable, of } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { BsmLocalMapsProgress, BsmLocalMap, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface"; import { IpcService } from "./ipc.service"; @@ -11,9 +11,9 @@ import { OpenSaveDialogOption } from "shared/models/ipc"; import { NotificationService } from "./notification.service"; import { ConfigurationService } from "./configuration.service"; import { ArchiveProgress } from "shared/models/archive.interface"; -import { map, last, catchError } from "rxjs/operators"; -import { of } from "rxjs"; +import { map, last, catchError, distinctUntilChanged, mergeMap } from "rxjs/operators"; import { ProgressionInterface } from "shared/models/progress-bar"; +import { FolderLinkerService } from "./folder-linker.service"; export class MapsManagerService { @@ -31,6 +31,7 @@ export class MapsManagerService { private readonly progressBar: ProgressBarService; private readonly notifications: NotificationService; private readonly config: ConfigurationService; + private readonly linker: FolderLinkerService; private readonly lastLinkedVersion$: Subject = new Subject(); private readonly lastUnlinkedVersion$: Subject = new Subject(); @@ -41,17 +42,20 @@ export class MapsManagerService { this.progressBar = ProgressBarService.getInstance(); this.notifications = NotificationService.getInstance(); this.config = ConfigurationService.getInstance(); + this.linker = FolderLinkerService.getInstance(); + } + + private async getVersionMapsPath(version: BSVersion): Promise{ + return this.ipcService.sendV2("get-version-maps-path", {args: version}).toPromise(); } public getMaps(version?: BSVersion): Observable{ return this.ipcService.sendV2("load-version-maps", {args: version}, {loaded: 0, total: 0, maps: []}); } - public versionHaveMapsLinked(version: BSVersion): Promise{ - return this.ipcService.send("verion-have-maps-linked", {args: version}).then(res => { - if(!res.success){ throw "error"; } - return res.data; - }) + public async versionHaveMapsLinked(version: BSVersion): Promise{ + const versionMapsPath = await this.getVersionMapsPath(version); + return this.linker.isFolderLinked(versionMapsPath).toPromise(); } public async linkVersion(version: BSVersion): Promise{ @@ -60,21 +64,9 @@ export class MapsManagerService { if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } - const showProgressBar = this.progressBar.require(); + const versionMapsPath = await this.getVersionMapsPath(version); - if(showProgressBar){ - this.progressBar.showFake(.01); - } - - const res = await this.ipcService.send("link-version-maps", {args: {version, keepMaps: !!modalRes.data}}); - - if(showProgressBar){ - this.progressBar.hide(true); - } - - if(res.success){ - this.lastLinkedVersion$.next(version); - } + return this.linker.linkFolder(versionMapsPath, !!modalRes.data).toPromise(); } public async unlinkVersion(version: BSVersion): Promise{ @@ -83,21 +75,9 @@ export class MapsManagerService { if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } - const showProgressBar = this.progressBar.require(); + const versionMapsPath = await this.getVersionMapsPath(version); - if(showProgressBar){ - this.progressBar.showFake(.01); - } - - const res = await this.ipcService.send("unlink-version-maps", {args: {version, keepMaps: !!modalRes.data}}); - - if(showProgressBar){ - this.progressBar.hide(true); - } - - if(res.success){ - this.lastUnlinkedVersion$.next(version); - } + return this.linker.unlinkFolder(versionMapsPath, !!modalRes.data).toPromise(); } public async deleteMaps(maps: BsmLocalMap[], version?: BSVersion): Promise{ @@ -173,4 +153,11 @@ export class MapsManagerService { return this.lastUnlinkedVersion$.asObservable(); } + public $mapsLinkingPending(version: BSVersion): Observable{ + return this.linker.queue$.pipe(mergeMap(async queue => { + const versionMapsPath = await this.getVersionMapsPath(version); + return queue.some(q => q.folder.includes(versionMapsPath)); + }), distinctUntilChanged()); + } + } \ No newline at end of file diff --git a/src/renderer/services/modale.service.ts b/src/renderer/services/modale.service.ts index 82a4ba95..c63fd60e 100644 --- a/src/renderer/services/modale.service.ts +++ b/src/renderer/services/modale.service.ts @@ -51,7 +51,7 @@ export class ModalService{ } -export type ModalComponent = ({resolver, data}: {resolver : (x: ModalResponse) => void, data?: K}) => JSX.Element; +export type ModalComponent = ({resolver, data}: {resolver : (x: ModalResponse) => void, data?: Receive}) => JSX.Element; export const enum ModalExitCode { NO_CHOICE = -1, diff --git a/src/renderer/services/progress-bar.service.ts b/src/renderer/services/progress-bar.service.ts index f9959a3c..36fecb2a 100644 --- a/src/renderer/services/progress-bar.service.ts +++ b/src/renderer/services/progress-bar.service.ts @@ -59,12 +59,12 @@ export class ProgressBarService{ this._style$.next(style); } - public showFake(speed: number, style?: CSSProperties): void{ + public showFake(speed: number, style?: CSSProperties, label?: string): void{ const obs: Observable = timer(1000, 100).pipe(map(val => { - if(this._progression$.value.progression >= 100){ return {progression: 100}; } + if(this._progression$.value.progression >= 100){ return {progression: 100, label}; } const currentProgress = speed * (val + 1); const progress = Math.round(Math.atan(currentProgress) / (Math.PI / 2) * 100 * 1000) / 1000; - return {progression: progress} as ProgressionInterface; + return {progression: progress, label} as ProgressionInterface; })); this.show(obs, true, style); }