Merge pull request #176 from Zagrios/feature/share-custom-folders/155

feature/share-custom-folders/155
This commit is contained in:
MathieuG-P
2023-03-12 21:32:34 +01:00
committed by GitHub
38 changed files with 912 additions and 232 deletions
+19 -1
View File
@@ -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": {
+19 -1
View File
@@ -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": {
+19 -1
View File
@@ -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": {
+89
View File
@@ -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<boolean> {
try{
await access(path);
return true;
}catch(e){
return false;
}
}
export async function ensureFolderExist(path: string): Promise<void> {
if(await pathExist(path)){ return Promise.resolve(); }
return mkdir(path, {recursive: true}).catch(log.error).then(() => {});
}
export async function deleteFolder(folderPath: string): Promise<void> {
if(!(await pathExist(folderPath))){ return; }
return rm(folderPath, {recursive: true, force: true});
}
export async function unlinkPath(path: string): Promise<void>{
if(!(await pathExist(path))){ return; }
return unlink(path);
}
export async function getFoldersInFolder(folderPath: string): Promise<string[]> {
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<Progression>{
const progress: Progression = { current: 0, total: 0 };
return new Observable<Progression>(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<T = unknown>{
total: number;
current: number;
extra?: T;
}
+5
View File
@@ -116,4 +116,9 @@ ipc.on("is-map-deep-links-enabled", async (request: IpcRequest<void>) => {
utils.ipcSend(request.responceChannel, {success: false});
}
});
ipc.on("get-version-maps-path", async (req: IpcRequest<BSVersion>, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(from(maps.getMapsFolderPath(req.args)));
});
+85 -1
View File
@@ -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<void>) => {
BSVersionLibService.getInstance().getAvailableVersions().then(versions => {
@@ -26,7 +36,7 @@ ipcMain.on('bs-version.installed-versions', async (event, req: IpcRequest<void>)
ipcMain.on("bs-version.open-folder", async (event, req: IpcRequest<BSVersion>) => {
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<BSVersion>, 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<BSVersion>, 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})));
});
+21 -4
View File
@@ -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<string>) => {
shell.openExternal(request.args);
});
ipcMain.on('choose-folder', async (event, request: IpcRequest<void>) => {
dialog.showOpenDialog({properties: ['openDirectory'],}).then(res => {
UtilsService.getInstance().ipcSend(request.responceChannel, {success: true, data: res});
});
ipc.on('choose-folder', async (req: IpcRequest<string>, reply) => {
reply(
from(dialog.showOpenDialog({properties: ['openDirectory'], defaultPath: req.args ?? ""}))
)
});
ipcMain.on("window.progression", async (event, request: IpcRequest<number>) => {
@@ -71,4 +77,15 @@ ipcMain.on("open-steam", async (event, request: IpcRequest<void>) => {
}).catch((e) => {
utils.ipcSend(request.responceChannel, {success: false, error: e});
});
});
ipc.on("is-folder-symlink", async (req: IpcRequest<string>, reply) => {
const promise = new Promise<boolean>(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));
});
@@ -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<string>{
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<BsmLocalMap>{
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<void>{
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<void>{
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<DeleteMapsProgress>{
@@ -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));
}
}
@@ -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;
@@ -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<BPList>{
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();
+7 -6
View File
@@ -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<string>{
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});
+3 -1
View File
@@ -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"); }
+27 -13
View File
@@ -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<PartialBSVersion>{
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<PartialBSVersion>(resolve => {
const stream = createReadStream(versionFilePath);
@@ -118,8 +119,7 @@ export class BSLocalVersionService{
private async getSteamVersion(): Promise<BSVersion>{
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<boolean>{
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<string[]>{
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);
}
}
+2 -3
View File
@@ -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<BSVersion[]>{
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<void>{
@@ -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<void> {
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<void> {
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<boolean> {
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,
}
@@ -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<Listener> = 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<string>{
const oldDir = this.installationDirectory;
const newDest = path.join(newDir, this.INSTALLATION_FOLDER);
return new Promise<string>((resolve, reject) => {
if(!this.utilsService.pathExist(oldDir)){ this.utilsService.createFolderIfNotExist(oldDir); }
return new Promise<string>(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); }
}
+1 -1
View File
@@ -40,7 +40,7 @@ export class IpcService {
}
public send<T>(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<unknown>): void{
@@ -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<Mod[]>{
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<Mod>{
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<boolean>(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
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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; }
+2 -2
View File
@@ -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<Supporter[]>{
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<Supporter[]>{
@@ -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";
+1 -63
View File
@@ -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<AppWindow, BrowserWindow>){ 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<void>{
if(!this.pathExist(pathToFile)){ return }
return unlink(pathToFile);
}
public async rmDirIfExist(path: string): Promise<void>{
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<string>((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<void>{
const folderExist = this.pathExist(folderPath);
if(!folderExist){ return; }
return rmSync(folderPath, {recursive: true});
}
public async moveDirContent(src: string, dest: string, overwrite = false): Promise<void>{
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<T = any>(channel: string, response: IpcResponse<T>): 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);
}
@@ -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 (
<div ref={ref} className={props.className} title={props.title} onClick={e => {e.preventDefault(); !props.disabled && props.onClick?.()}} style={{pointerEvents: props.disabled ? "none" : "auto"}}>
<span className="absolute top-0 left-0 h-full w-full rounded-full brightness-50 opacity-75 dark:opacity-20 dark:filter-none" style={{backgroundColor: linkedColor}}/>
<BsmIcon className="p-1 absolute top-0 left-0 h-full w-full !bg-transparent -rotate-45 brightness-150" icon={props.linked ? "link" : "unlink"} style={{color: linkedColor}} />
</div>
)
}))
@@ -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<BsmLocalMap[]>(null);
const [subs] = useState<Subscription[]>([]);
const [selectedMaps$] = useState(new BehaviorSubject<BsmLocalMap[]>([]));
@@ -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(() => {
@@ -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<MapFilter>({});
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<React.HTMLAttributes<HTMLLIElement>, 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) {
</motion.div>
)}
{(!!version) && (
<motion.div variants={variants} whileHover="hover" whileTap="tap" initial={{rotate: 0}} className="block p-0.5 h-[calc(100%-5px)] aspect-square blur-0 hover:brightness-75" title={t(mapsLinked ? "pages.version-viewer.maps.tabs.maps.actions.link-maps.tooltips.unlink" : "pages.version-viewer.maps.tabs.maps.actions.link-maps.tooltips.link")} onClick={e => {e.stopPropagation(); onClickLink(index)}}>
<span className="absolute top-0 left-0 h-full w-full rounded-full brightness-50 opacity-75 dark:opacity-20 dark:filter-none" style={{backgroundColor: linkedColor}}/>
<BsmIcon className="p-1 absolute top-0 left-0 h-full w-full !bg-transparent -rotate-45 brightness-150" icon={mapsLinked ? "link" : "unlink"} style={{color: linkedColor}} />
</motion.div>
<LinkButton
variants={variants}
disabled={linkingPending}
whileHover="hover"
whileTap="tap"
initial={{rotate: 0}}
className="block p-0.5 h-[calc(100%-5px)] aspect-square blur-0 hover:brightness-75"
linked={mapsLinked}
title={t(mapsLinked ? "pages.version-viewer.maps.tabs.maps.actions.link-maps.tooltips.unlink" : "pages.version-viewer.maps.tabs.maps.actions.link-maps.tooltips.link")}
onClick={() => onClickLink(index)}
/>
)}
</div>
)}
@@ -130,7 +157,7 @@ export function MapsPlaylistsPanel({version}: Props) {
<div className="w-full h-full flex flex-col bg-light-main-color-3 dark:bg-main-color-2 rounded-md shadow-black shadow-md overflow-hidden">
<TabNavBar className="!rounded-none shadow-sm" tabIndex={tabIndex} tabsText={["misc.maps", "misc.playlists"]} onTabChange={setTabIndex} renderTab={renderTab}/>
<div className="w-full grow min-h-0 flex flex-row items-center transition-transform duration-300" style={{transform: `translate(${-(tabIndex * 100)}%, 0)`}}>
<LocalMapsListPanel ref={mapsRef} className="w-full h-full shrink-0 flex flex-col" version={version} filter={mapFilter} search={mapSearch}/>
<LocalMapsListPanel ref={mapsRef} className="w-full h-full shrink-0 flex flex-col" version={version} filter={mapFilter} search={mapSearch} linked={mapsLinked}/>
<div className="w-full h-full shrink-0 flex flex-col justify-center items-center content-center gap-2 overflow-hidden text-gray-800 dark:text-gray-200">
<BsmImage className="rounded-md" image={wipGif}/>
<span>Coming soon</span>
@@ -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<void, BSVersion> = ({ data }) => {
const SHARED_FOLDERS_KEY = "default-shared-folders";
const config = ConfigurationService.getInstance();
const ipc = IpcService.getInstance();
const t = useTranslation();
const [folders, setFolders] = useState<string[]>([]);
const versionPath = useObservable(ipc.sendV2<string>("get-version-full-path", {args: data}));
useEffect(() => {
const defaultFolders = config.get<string[]>(SHARED_FOLDERS_KEY);
const promises = Promise.allSettled(defaultFolders.map(async (folder) => {
return ipc.sendV2<string>("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<string[]>("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<string>("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 (
<form className="w-full max-w-md ">
<h1 className="text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200">{t("modals.shared-folders.title")}</h1>
<p className="my-3">{t("modals.shared-folders.description")}</p>
<ul className="flex flex-col gap-1 mb-2 h-[300px] max-h-[300px] overflow-scroll scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-neutral-900 px-1">
{folders.map((folder, index) => (
<FolderItem key={index} path={folder} onDelete={() => {removeFolder(index)}}/>
))}
</ul>
<div className="w-full h-12 rounded-md flex justify-center items-center cursor-pointer bg-light-main-color-1 dark:bg-main-color-1 hover:bg-light-main-color-3 hover:dark:bg-main-color-3" onClick={addFolder}>
<BsmIcon className="aspect-square h-8" icon="add"/>
<span className="font-bold">{t("modals.shared-folders.buttons.add-folder")}</span>
</div>
</form>
)
}
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 (
<li className="w-full h-12 rounded-md shrink-0 flex flex-row items-center justify-between px-2 font-bold bg-light-main-color-1 dark:bg-main-color-1">
<span className="cursor-help" title={path}>{name}</span>
<div className="flex flex-row gap-1.5">
<Tippy placement="left" content={t(`modals.shared-folders.buttons.${linked ? "unlink-folder" : "link-folder"}`)} arrow={false}>
<LinkButton variants={variants} linked={linked} disabled={linkDisabled} whileHover="hover" whileTap="tap" className="p-0.5 h-7 shrink-0 aspect-square blur-0 cursor-pointer hover:brightness-75" onClick={onClickLink}/>
</Tippy>
{!processing ? (
!pending ? (
<BsmButton className="aspect-square h-7 rounded-md p-1" icon={"trash"} withBar={false} onClick={e => {e.preventDefault(); onDelete?.()}}/>
) : (
<BsmButton className="aspect-square h-7 rounded-md p-1" icon={"cross"} withBar={false} onClick={e => {e.preventDefault(); cancelLink()}}/>
)
) : (
<BsmBasicSpinner className="aspect-square h-7 rounded-md p-1 dark:bg-main-color-2" thikness="3.5px" style={{color}}/>
)}
</div>
</li>
)
}
@@ -64,7 +64,7 @@ export default function TitleBar({template = "index.html"} : {template: AppWindo
<div className='shrink-0 w-0 overflow-hidden transition-all group-hover:w-16 group-hover:overflow-visible group-active:w-16 group-active:overflow-visible text-main-color-3'>
<BsmRange min={0} max={1} step={.01} values={[volume.muted ? 0 : volume.volume]} colors={[color, "currentColor"]} onChange={val => audio.setVolume(val[0])} onFinalChange={val => audio.setFinalVolume(val[0])}/>
</div>
<BsmButton className='shrink-0 h-[23px] w-[23px] aspect-square !bg-transparent flex items-start' iconClassName={volumeIcon === "volume-down" && "-translate-x-[1.8px]"} icon={volumeIcon} withBar={false} onClick={() => audio.toggleMute()}/>
<BsmButton className='shrink-0 h-[23px] w-[23px] aspect-square !bg-transparent flex items-start' iconClassName={volumeIcon === "volume-down" ? "-translate-x-[1.8px]" : null} icon={volumeIcon} withBar={false} onClick={() => audio.toggleMute()}/>
</div>
<div onClick={minimizeWindow} className="text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-[#4F545C] cursor-pointer w-11 h-full shrink-0 flex justify-center items-center" id="min-button" >
<svg aria-hidden="false" width="12" height="12" viewBox="0 0 12 12"><rect fill="currentColor" width="10" height="1" x="1" y="6"> </rect></svg>
@@ -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"
@@ -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);
@@ -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);
@@ -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})
]}/>
</>
@@ -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<string, () => void>();
private readonly onUnlinkedListeners = new Map<string, () => void>();
private readonly onRemovedFromQueueListeners = new Map<string, () => void>();
private readonly publicOnLinkedListeners = new Set<(folder: string) => void>();
private readonly publicOnUnlinkedListeners = new Set<(folder: string) => void>();
private readonly _currentAction$ = new BehaviorSubject<LinkAction>(null);
private readonly _queue$ = new BehaviorSubject<LinkAction[]>([]);
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<void>{
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<void>{
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<boolean> {
return this.ipc.sendV2<boolean>("is-folder-symlink", {args: path});
}
public linkFolders(...folders: string[]): Map<string, Observable<void>> {
return new Map(folders.map(folder => [folder, this.linkFolder(folder)]));
}
public linkFolder(folder: string, keepContents = true): Observable<void> {
const promise = new Promise<void>(resolve => {
this.onLinked(folder, resolve);
this.onRemovedFromQueue(folder, resolve);
});
this.addFolderToQueue(folder, LinkActionType.Link, keepContents);
return from(promise);
}
public unlinkFolders(...folders: string[]): Map<string, Observable<void>> {
return new Map(folders.map(folder => [folder, this.unlinkFolder(folder)]));
}
public unlinkFolder(folder: string, keepContents = true): Observable<void> {
const promise = new Promise<void>(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<LinkAction[]> { return this._queue$.asObservable(); }
public get currentAction$(): Observable<LinkAction> { return this._currentAction$.asObservable(); }
public isPending(folder: string): Observable<boolean> {
return this.queue$.pipe(map(queue => queue.some(action => action.folder === folder), distinctUntilChanged()));
}
public isProcessing(folder: string): Observable<boolean> {
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
}
+1 -1
View File
@@ -53,7 +53,7 @@ export class IpcService {
public sendV2<T, U = unknown>(channel: string, request?: IpcRequest<U>, defaultValue?: T): Observable<T>{
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`;
+23 -36
View File
@@ -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<BSVersion> = new Subject();
private readonly lastUnlinkedVersion$: Subject<BSVersion> = 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<string>{
return this.ipcService.sendV2<string>("get-version-maps-path", {args: version}).toPromise();
}
public getMaps(version?: BSVersion): Observable<BsmLocalMapsProgress>{
return this.ipcService.sendV2<BsmLocalMapsProgress>("load-version-maps", {args: version}, {loaded: 0, total: 0, maps: []});
}
public versionHaveMapsLinked(version: BSVersion): Promise<boolean>{
return this.ipcService.send<boolean, BSVersion>("verion-have-maps-linked", {args: version}).then(res => {
if(!res.success){ throw "error"; }
return res.data;
})
public async versionHaveMapsLinked(version: BSVersion): Promise<boolean>{
const versionMapsPath = await this.getVersionMapsPath(version);
return this.linker.isFolderLinked(versionMapsPath).toPromise();
}
public async linkVersion(version: BSVersion): Promise<void>{
@@ -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<void, {version: BSVersion, keepMaps: boolean}>("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<void>{
@@ -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<void, {version: BSVersion, keepMaps: boolean}>("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<boolean>{
@@ -173,4 +153,11 @@ export class MapsManagerService {
return this.lastUnlinkedVersion$.asObservable();
}
public $mapsLinkingPending(version: BSVersion): Observable<boolean>{
return this.linker.queue$.pipe(mergeMap(async queue => {
const versionMapsPath = await this.getVersionMapsPath(version);
return queue.some(q => q.folder.includes(versionMapsPath));
}), distinctUntilChanged());
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ export class ModalService{
}
export type ModalComponent<T = unknown, K = any> = ({resolver, data}: {resolver : (x: ModalResponse<T>) => void, data?: K}) => JSX.Element;
export type ModalComponent<Return = unknown, Receive = any> = ({resolver, data}: {resolver : (x: ModalResponse<Return>) => void, data?: Receive}) => JSX.Element;
export const enum ModalExitCode {
NO_CHOICE = -1,
@@ -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<ProgressionInterface> = 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);
}