Merge branch 'master' into feature/Moving-config-file/27

This commit is contained in:
MathieuG-P
2022-12-31 20:40:41 +01:00
163 changed files with 7962 additions and 1728 deletions
@@ -0,0 +1,306 @@
import path from "path";
import { BSVersion } from "shared/bs-version.interface";
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
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, readdirSync, createWriteStream } from "fs";
import { copySync } from "fs-extra";
import StreamZip from "node-stream-zip";
import { RequestService } from "../request.service";
import sanitize from "sanitize-filename";
import archiver from "archiver";
import { DeepLinkService } from "../deep-link.service";
import log from 'electron-log';
import { WindowManagerService } from "../window-manager.service";
import { ipcMain } from "electron";
import { IpcRequest } from 'shared/models/ipc';
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
public static getInstance(): LocalMapsManagerService{
if(!LocalMapsManagerService.instance){ LocalMapsManagerService.instance = new LocalMapsManagerService(); }
return LocalMapsManagerService.instance;
}
private readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data";
private readonly CUSTOM_LEVELS_FOLDER = "CustomLevels";
private readonly DEEP_LINKS = {
BeatSaver: "beatsaver",
ScoreSaber: "web+bsmap"
};
private readonly localVersion: BSLocalVersionService;
private readonly installLocation: InstallationLocationService;
private readonly utils: UtilsService;
private readonly reqService: RequestService;
private readonly deepLink: DeepLinkService;
private readonly windows: WindowManagerService;
private constructor(){
this.localVersion = BSLocalVersionService.getInstance();
this.installLocation = InstallationLocationService.getInstance();
this.utils = UtilsService.getInstance();
this.reqService = RequestService.getInstance();
this.deepLink = DeepLinkService.getInstance();
this.windows = WindowManagerService.getInstance();
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, (link) => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
this.openOneClickDownloadMapWindow(new URL(link).host);
});
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.ScoreSaber, (link) => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.ScoreSaber, link);
this.openOneClickDownloadMapWindow(new URL(link).host, true);
});
}
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);
}
return sharedMapsPath;
}
private async computeMapHash(mapPath: string, rawInfoString: string): Promise<string>{
const mapRawInfo = JSON.parse(rawInfoString);
let content = rawInfoString;
for(const set of mapRawInfo._difficultyBeatmapSets){
for(const diff of set._difficultyBeatmaps){
const diffFilePath = path.join(mapPath, diff._beatmapFilename);
if(!await this.utils.pathExist(diffFilePath)){ continue; }
const diffContent = (await this.utils.readFileAsync(diffFilePath)).toString();
content += diffContent;
}
}
const shasum = crypto.createHash("sha1");
shasum.update(content);
return shasum.digest("hex");
}
private async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap>{
const infoFilePath = path.join(mapPath, "Info.dat");
if(!(await this.utils.pathExist(infoFilePath))){ return null; }
const rawInfoString = await (await (this.utils.readFileAsync(infoFilePath))).toString();
const rawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href;
const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href;
const hash = await this.computeMapHash(mapPath, rawInfoString);
return {rawInfo, coverUrl, songUrl, hash};
}
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());
const dest = path.join(tempPath, fileName);
const zipPath = await this.reqService.downloadFile(zipUrl, dest);
const zip = new StreamZip.async({file : zipPath});
return {zip, zipPath};
}
private async getAbsoluteFolderOfMaps(maps: BsmLocalMap[], version: BSVersion): Promise<string[]>{
const mapsFolder = await this.getMapsFolderPath(version);
const res: string[] = [];
const mapHashs = maps.map(map => map.hash);
const mapsFolders = readdirSync(mapsFolder, {withFileTypes: true});
for(const content of mapsFolders){
if(!content.isDirectory()){ continue; }
const mapFolderPath = path.join(mapsFolder, content.name);
const { hash } = await this.loadMapInfoFromPath(mapFolderPath);
if(mapHashs.includes(hash)){
res.push(mapFolderPath);
}
}
return res;
}
private openOneClickDownloadMapWindow(mapId: string, isHash = false): void{
ipcMain.once("one-click-map-info", async (event, req: IpcRequest<void>) => {
this.utils.ipcSend(req.responceChannel, {success: true, data: {id: mapId, isHash}});
});
this.windows.openWindow("oneclick-download-map.html");
}
public async getMaps(version?: BSVersion): Promise<BsmLocalMap[]>{
const levelsFolder = await this.getMapsFolderPath(version);
const levelsPath = (await this.utils.pathExist(levelsFolder)) ? this.utils.listDirsInDir(levelsFolder, true) : [];
const mapsInfo = await Promise.all(levelsPath.map(levelPath => this.loadMapInfoFromPath(levelPath)));
return mapsInfo.filter(info => !!info);
}
public async versionIsLinked(version: BSVersion): Promise<boolean>{
const levelsPath = await this.getMapsFolderPath(version);
const isPathExist = await this.utils.pathExist(levelsPath);
if(!isPathExist){ return false; }
return lstatSync(levelsPath).isSymbolicLink()
}
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");
}
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){
copySync(sharedMapsPath, versionMapsPath);
}
}
public async deleteMaps(maps: BsmLocalMap[], verion?: BSVersion){
const mapsFolders = await this.getAbsoluteFolderOfMaps(maps, verion);
const mapsHashsToDelete = maps.map(map => map.hash);
for(const folder of mapsFolders){
const { hash } = await this.loadMapInfoFromPath(folder);
if(mapsHashsToDelete.includes(hash)){
await this.utils.deleteFolder(folder);
}
}
}
public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise<string>{
if(!map.versions.at(0).hash){ throw "Cannot download map, no hash found"; }
const zipUrl = map.versions.at(0).downloadURL;
const mapsFolder = await this.getMapsFolderPath(version);
const {zip, zipPath} = await this.downloadMapZip(zipUrl);
const mapFolderName = sanitize(`${map.id}-${map.name}`);
const mapPath = path.join(mapsFolder, mapFolderName);
if(!zip){ throw `Cannot download ${zipUrl}`; }
this.utils.createFolderIfNotExist(mapPath);
await zip.extract(null, mapPath);
await zip.close();
unlinkSync(zipPath);
return mapPath;
}
public async exportMaps(version: BSVersion, maps: BsmLocalMap[], outPath: string){
const output = createWriteStream(outPath);
const archive = archiver("zip", {zlib: {level: 9}});
archive.pipe(output);
archive.on("error", (e) => {throw e});
if(!maps || maps.length === 0){
const mapsFolder = await this.getMapsFolderPath(version);
archive.directory(mapsFolder, false);
}
else{
const mapsFolders = await this.getAbsoluteFolderOfMaps(maps, version);
for(const folder of mapsFolders){
archive.directory(folder, path.basename(folder));
}
}
await archive.finalize();
}
public async oneClickDownloadMap(map: BsvMapDetail): Promise<void>{
const downloadedMap = await this.downloadMap(map);
const versions = await this.localVersion.getInstalledVersions();
for(const version of versions){
if(await this.versionIsLinked(version)){ continue; }
const versionMapsPath = await this.getMapsFolderPath(version);
this.utils.createFolderIfNotExist(versionMapsPath);
copySync(downloadedMap, path.join(versionMapsPath, path.basename(downloadedMap)), {overwrite: true});
}
}
public enableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
}
public disableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
}
public isDeepLinksEnabled(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
}
}
@@ -0,0 +1,126 @@
import { DeepLinkService } from "../deep-link.service";
import log from "electron-log"
import { ipcMain } from "electron";
import { IpcRequest } from "shared/models/ipc";
import { UtilsService } from "../utils.service";
import { WindowManagerService } from "../window-manager.service";
import { MSModel, MSModelType } from "shared/models/model-saber/model-saber.model";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
import { RequestService } from "../request.service";
import { copyFileSync } from "fs-extra";
import sanitize from "sanitize-filename";
export class LocalModelsManagerService {
private static instance: LocalModelsManagerService;
public static getInstance(): LocalModelsManagerService{
if(!LocalModelsManagerService.instance){ LocalModelsManagerService.instance = new LocalModelsManagerService(); }
return LocalModelsManagerService.instance;
}
private readonly DEEP_LINKS = {
ModelSaber: "modelsaber",
};
private readonly MODEL_TYPE_FOLDER: Record<Exclude<MSModelType, "misc">, string> = {
avatar: "CustomAvatars",
bloq: "CustomNotes",
platform: "CustomPlatforms",
saber: "CustomSabers"
}
private readonly deepLink: DeepLinkService;
private readonly utils: UtilsService;
private readonly windows: WindowManagerService;
private readonly localVersion: BSLocalVersionService;
private readonly request: RequestService;
private constructor(){
this.deepLink = DeepLinkService.getInstance();
this.utils = UtilsService.getInstance();
this.windows = WindowManagerService.getInstance();
this.localVersion = BSLocalVersionService.getInstance();
this.request = RequestService.getInstance();
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.ModelSaber, (link) => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.ModelSaber, link);
const url = new URL(link);
const type = url.host
const id = url.pathname.replace("/", '').split("/").at(0);
this.openOneClickDownloadModelWindow(id, type);
});
}
private openOneClickDownloadModelWindow(id: string, type: string){
ipcMain.once("one-click-model-info", async (event, req: IpcRequest<void>) => {
this.utils.ipcSend(req.responceChannel, {success: true, data: {id, type}});
});
this.windows.openWindow("oneclick-download-model.html");
}
private async getModelFolderPath(type: MSModelType, version?: BSVersion): Promise<string>{
if(!version){ throw "will be implemented whith models management" }
if(type === "misc"){ throw "model type not supported"; }
const versionPath = await this.localVersion.getVersionPath(version);
const modelFolderPath = path.join(versionPath, this.MODEL_TYPE_FOLDER[type]);
this.utils.createFolderIfNotExist(modelFolderPath);
return modelFolderPath;
}
public async downloadModel(model: MSModel, version: BSVersion): Promise<string>{
const modelFolder = await this.getModelFolderPath(model.type, version);
const modelDest = path.join(modelFolder, sanitize(path.basename(model.download)));
return this.request.downloadFile(model.download, modelDest);
}
public async oneClickDownloadModel(model: MSModel): Promise<void>{
if(!model){ return; }
const versions = await this.localVersion.getInstalledVersions();
if(versions?.length === 0){ return; }
const fisrtVersion = versions.shift();
const downloaded = await this.downloadModel(model, fisrtVersion);
for(const version of versions){
const modelDest = path.join(await this.getModelFolderPath(model.type, version), path.basename(downloaded));
copyFileSync(downloaded, modelDest);
}
}
public enableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
}
public disableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
}
public isDeepLinksEnabled(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
}
}
@@ -0,0 +1,226 @@
import path from "path";
import { BehaviorSubject, Observable } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import { DeepLinkService } from "../deep-link.service";
import { RequestService } from "../request.service";
import { UtilsService } from "../utils.service";
import { LocalMapsManagerService } from "./local-maps-manager.service";
import log from "electron-log"
import { isValidUrl } from "../../helpers/url.helpers";
import { ipcMain } from "electron";
import { WindowManagerService } from "../window-manager.service";
import { IpcRequest } from "shared/models/ipc";
import { BPList, DownloadPlaylistProgression } from "shared/models/playlists/playlist.interface";
import { copyFileSync, readFileSync } from "fs";
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copySync } from "fs-extra";
export class LocalPlaylistsManagerService {
private static instance: LocalPlaylistsManagerService
public static getInstance(): LocalPlaylistsManagerService {
if (!LocalPlaylistsManagerService.instance) { LocalPlaylistsManagerService.instance = new LocalPlaylistsManagerService(); }
return LocalPlaylistsManagerService.instance;
}
private readonly PLAYLISTS_FOLDER = "Playlists";
private readonly DEEP_LINKS = {
BeatSaver: "bsplaylist",
};
private readonly versions: BSLocalVersionService;
private readonly maps: LocalMapsManagerService;
private readonly utils: UtilsService;
private readonly request: RequestService;
private readonly deepLink: DeepLinkService;
private readonly windows: WindowManagerService;
private readonly bsaver: BeatSaverService;
private constructor(){
this.maps = LocalMapsManagerService.getInstance();
this.versions = BSLocalVersionService.getInstance();
this.utils = UtilsService.getInstance();
this.request = RequestService.getInstance();
this.deepLink = DeepLinkService.getInstance();
this.windows = WindowManagerService.getInstance();
this.bsaver = BeatSaverService.getInstance();
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
const url = new URL(link);
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") : "";
this.openOneClickDownloadPlaylistWindow(bplistUrl);
});
}
private getPlaylistIdFromDownloadUrl(url: string): string{
if(!isValidUrl(url)){ return ""; }
const splited = url.split("/")
const idIndex = splited.indexOf("id");
if(idIndex < 0){ return ""; }
return splited[idIndex + 1];
}
private async getPlaylistsFolder(version?: BSVersion){
if(!version){ throw "Playlists are not available to be linked yet" }
const versionFolder = await this.versions.getVersionPath(version);
const folder = path.join(versionFolder, this.PLAYLISTS_FOLDER);
await this.utils.createFolderIfNotExist(folder)
return folder;
}
private async installBPListFile(bpListUrlOrPath: string, version: BSVersion): Promise<string>{
const playlistFolder = await this.getPlaylistsFolder(version);
const bpListDest = path.join(playlistFolder, path.basename(bpListUrlOrPath));
if(this.utils.pathExist(bpListUrlOrPath)){
copyFileSync(bpListUrlOrPath, bpListDest);
}
else{
await this.request.downloadFile(bpListUrlOrPath, bpListDest);
}
return bpListDest;
}
private async readPlaylistFile(path: string): Promise<BPList>{
if(!this.utils.pathExist(path)){ throw `bplist file not exist at ${path}`; }
const rawContent = readFileSync(path).toString();
return JSON.parse(rawContent);
}
private openOneClickDownloadPlaylistWindow(downloadUrl: string): void{
ipcMain.once("one-click-playlist-info", async (event, req: IpcRequest<void>) => {
this.utils.ipcSend(req.responceChannel, {success: true, data: {bpListUrl: downloadUrl, id: this.getPlaylistIdFromDownloadUrl(downloadUrl)}});
});
this.windows.openWindow("oneclick-download-playlist.html");
}
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<DownloadPlaylistProgression>{
const res = new BehaviorSubject<DownloadPlaylistProgression>({progression: 0, current: null, downloadedMaps: [], mapsPath: [], bpListPath: ""});
const sub = res.subscribe(process => {
this.utils.ipcSend("download-playlist-progress", {success: true, data: process});
}, err => {
this.utils.ipcSend("download-playlist-progress", {success: false, error: err});
});
const observer = async () => {
try{
const bpListPath = await this.installBPListFile(bpListUrl, version);
res.next({...res.value, bpListPath});
const bpList = await this.readPlaylistFile(bpListPath);
for(const song of bpList.songs){
if(!song.key){ continue; }
const map = await this.bsaver.getMapDetailsById(song.key);
res.next({
...res.value,
current: map,
progression: ((res.value.downloadedMaps.length + .5) / bpList.songs.length) * 100
});
const mapPath = await this.maps.downloadMap(map, version);
const progression = ((res.value.downloadedMaps.length + 1) / bpList.songs.length) * 100;
res.next({
...res.value,
current: null,
downloadedMaps: [...res.value.downloadedMaps, map],
mapsPath: [...res.value.mapsPath, mapPath],
progression
});
}
}
catch(e){
res.error(e);
}
res.complete();
}
observer().finally(() => sub.unsubscribe());
return res.asObservable();
}
public async oneClickInstallPlaylist(bpListUrl: string): Promise<void>{
const versions = await this.versions.getInstalledVersions();
const firstVersion = versions.shift();
const fistVersionLinked = await this.maps.versionIsLinked(firstVersion);
const {bpListPath, mapsPath} = await this.downloadPlaylist(bpListUrl, firstVersion).toPromise();
for(const version of versions){
await this.installBPListFile(bpListPath, version);
const versionIsLinked = await this.maps.versionIsLinked(version);
if(fistVersionLinked && versionIsLinked){ continue; }
for(const mapPath of mapsPath){
const versionMapsFolder = await this.maps.getMapsFolderPath(version);
const mapDest = path.join(versionMapsFolder, path.basename(mapPath));
copySync(mapPath, mapDest, {overwrite: true});
}
}
}
public enableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.registerDeepLink(link));
}
public disableDeepLinks(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.unRegisterDeepLink(link));
}
public isDeepLinksEnabled(): boolean{
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistred(link));
}
}
+17 -2
View File
@@ -2,12 +2,13 @@ import { BS_APP_ID, BS_DEPOT } from "../constants";
import path from "path";
import { BSVersion } from 'shared/bs-version.interface';
import { UtilsService } from "./utils.service";
import { ChildProcessWithoutNullStreams, spawn } from "child_process";
import { ChildProcessWithoutNullStreams, spawn, spawnSync } from "child_process";
import log from "electron-log";
import { InstallationLocationService } from "./installation-location.service";
import { ctrlc } from "ctrlc-windows";
import { BSLocalVersionService } from "./bs-local-version.service";
import isOnline from 'is-online';
import { WindowManagerService } from "./window-manager.service";
export class BSInstallerService{
@@ -16,6 +17,7 @@ export class BSInstallerService{
private readonly utils: UtilsService;
private readonly installLocationService: InstallationLocationService;
private readonly localVersionService: BSLocalVersionService;
private readonly windows: WindowManagerService;
private downloadProcess: ChildProcessWithoutNullStreams;
@@ -23,8 +25,9 @@ export class BSInstallerService{
this.utils = UtilsService.getInstance();
this.installLocationService = InstallationLocationService.getInstance();
this.localVersionService = BSLocalVersionService.getInstance();
this.windows = WindowManagerService.getInstance();
this.utils.getMainWindow().on("close", () => {
this.windows.getWindows("index.html")?.on("close", () => {
this.killDownloadProcess();
});
}
@@ -62,6 +65,18 @@ export class BSInstallerService{
});
}
public async isDotNet6Installed(): Promise<boolean>{
try{
const process = spawnSync(this.getDepotDownloaderExePath());
const out = process.output.toString();
if(out.includes(".NET runtime can be found at")){ return false; }
return true;
}
catch(e){
return false;
}
}
public async downloadBsVersion(downloadInfos: DownloadInfo): Promise<DownloadEvent>{
if(this.downloadProcess && this.downloadProcess.connected){ throw "AlreadyDownloading"; }
+99
View File
@@ -0,0 +1,99 @@
import { app } from "electron";
import path from "path";
import { URL } from "url";
import log from "electron-log"
export class DeepLinkService {
// See docs : https://www.electronjs.org/docs/latest/tutorial/launch-app-from-url-in-another-app#main-process-mainjs
private static instance: DeepLinkService;
public static getInstance(): DeepLinkService{
if(!DeepLinkService.instance){ DeepLinkService.instance = new DeepLinkService(); }
return DeepLinkService.instance;
}
private readonly listeners = new Map<string, Listerner[]>()
private constructor(){}
public registerDeepLink(protocol: string): boolean{
if(process.defaultApp && process.argv.length >= 2){
return app.setAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
}
return app.setAsDefaultProtocolClient(protocol);
}
public unRegisterDeepLink(protocol: string): boolean{
if(process.defaultApp && process.argv.length >= 2) {
return app.removeAsDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
}
return app.removeAsDefaultProtocolClient(protocol);
}
public isDeepLinkRegistred(protocol: string): boolean{
if(process.defaultApp && process.argv.length >= 2) {
return app.isDefaultProtocolClient(protocol, process.execPath, [path.resolve(process.argv[1])]);
}
return app.isDefaultProtocolClient(protocol);
}
public addLinkOpenedListener(protocol: string, fn: Listerner){
if(!this.listeners.has(protocol)){
this.listeners.set(protocol, [] as Listerner[]);
}
this.listeners.get(protocol).push(fn);
}
public removeLinkOpenedListener(protocol: string, fn: Listerner){
if(!this.listeners.get(protocol)?.length){ return; }
const listeners = this.listeners.get(protocol);
const fnIndex = listeners.findIndex(listener => listener === fn);
if(fnIndex < 0){ return; }
listeners.splice(fnIndex, 1);
}
public dispatchLinkOpened(link: string){
log.info("DEISPATCH", link);
const url = new URL(link);
const protocolListeners = this.listeners.get(url.protocol.replace(":", "")) ?? [];
protocolListeners.forEach(listerner => {
listerner(link);
});
}
public isDeepLink(link: string): boolean{
try{
const url = new URL(link);
const protocol = url.protocol.replace(":", "");
return Array.from(this.listeners.keys()).some(key => key === protocol);
}
catch(e){
return false;
}
}
}
type Listerner = (link: string) => void;
@@ -12,6 +12,11 @@ export class InstallationLocationService {
private readonly INSTALLATION_FOLDER = "BSManager";
private readonly VERSIONS_FOLDER = "BSInstances";
private readonly SHARED_CONTENT_FOLDER = "SharedContent";
private readonly SHARED_MAPS_FOLDER = "SharedMaps";
private readonly SHARED_PLAYLISTS_FOLDER = "SharedPlaylists";
private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder";
@@ -36,9 +41,6 @@ export class InstallationLocationService {
this._installationDirectory = this.configService.get<string>(this.STORE_INSTALLATION_PATH_KEY) || app.getPath("documents");
}
public get installationDirectory(): string{ return path.join(this._installationDirectory, this.INSTALLATION_FOLDER); }
public get versionsDirectory(): string { return path.join(this.installationDirectory, this.VERSIONS_FOLDER); }
public setInstallationDirectory(newDir: string): Promise<string>{
const oldDir = this.installationDirectory;
const newDest = path.join(newDir, this.INSTALLATION_FOLDER);
@@ -53,7 +55,15 @@ export class InstallationLocationService {
log.error(err);
})
})
}
public get installationDirectory(): string{ return path.join(this._installationDirectory, this.INSTALLATION_FOLDER); }
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); }
public get sharedPlaylistsPath(): string { return path.join(this.sharedContentPath, this.SHARED_PLAYLISTS_FOLDER); }
}
-47
View File
@@ -1,47 +0,0 @@
import path from "path";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "./bs-local-version.service";
import archiver from "archiver";
import { createWriteStream } from "fs";
import log from "electron-log";
export class MapService{
private readonly MAP_PATH = path.join("Beat Saber_Data", "CustomLevels");
private static instance: MapService;
private readonly localVersionService: BSLocalVersionService;
public static getInstance(): MapService{
if(!MapService.instance){ MapService.instance = new MapService(); }
return MapService.instance;
}
private constructor(){
this.localVersionService = BSLocalVersionService.getInstance();
}
private getMapsPath(versionPath: string): string{
return path.join(versionPath, this.MAP_PATH);
}
public async exportVersionMaps(version: BSVersion, outputZip: string): Promise<void>{
const versionPath = await this.localVersionService.getVersionPath(version);
const mapsPath = this.getMapsPath(versionPath);
const output = createWriteStream(outputZip);
const archive = archiver("zip", {zlib: {level: 9}});
const promise = new Promise<void>((resolve, reject) => {
archive.pipe(output);
archive.directory(mapsPath, false);
archive.on("error", reject);
output.on("close", resolve);
archive.finalize()
});
return promise;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Notification } from "electron";
import { SystemNotificationOptions } from "shared/models/notification/system-notification.model";
import { UtilsService } from "./utils.service";
export class NotificationService {
private static instance: NotificationService;
public static getInstance(): NotificationService{
if(!NotificationService.instance){ NotificationService.instance = new NotificationService(); }
return NotificationService.instance;
}
private readonly APP_ICON: string;
private readonly utils: UtilsService;
private constructor(){
this.utils = UtilsService.getInstance();
this.APP_ICON = this.utils.getAssetsPath("favicon.ico");
}
public notify(options: SystemNotificationOptions){
new Notification({...options, icon: this.APP_ICON}).show();
}
}
+1
View File
@@ -27,6 +27,7 @@ export class RequestService {
public downloadFile(url: string, dest: string): Promise<string>{
return new Promise((resolve, reject) => {
const file = createWriteStream(dest);
get(url, res => {
res.pipe(file);
+10
View File
@@ -3,6 +3,7 @@ import regedit from 'regedit'
import path from "path";
import { parse } from "@node-steam/vdf";
import { readFile } from "fs/promises";
import { spawn } from "child_process";
export class SteamService{
@@ -62,4 +63,13 @@ export class SteamService{
return null;
}
public openSteam(): Promise<boolean>{
const process = spawn("start", ["steam://open/games"], {shell: true});
return new Promise(resolve => {
process.on("exit", () => resolve(true));
process.on("error", () => resolve(false));
});
}
}
@@ -0,0 +1,130 @@
import { ApiResult } from "renderer/models/api/api.model";
import { BsvMapDetail } from "shared/models/maps";
import { BsvPlaylist, BsvPlaylistPage, MapFilter, SearchParams, SearchResponse } from "shared/models/maps/beat-saver.model";
import fetch from "node-fetch"
export class BeatSaverApiService {
private static instance: BeatSaverApiService;
public static getInstance(): BeatSaverApiService{
if(!BeatSaverApiService.instance){ BeatSaverApiService.instance = new BeatSaverApiService(); }
return BeatSaverApiService.instance;
}
private readonly bsaverApiUrl = "https://beatsaver.com/api"
private constructor(){}
private mapFilterToUrlParams(filter: MapFilter): URLSearchParams{
if(!filter){ return new URLSearchParams(); }
const enbledTagsString = filter.enabledTags ? Array.from(filter.enabledTags) : null;
const excludedTagsString = filter.excludedTags ? Array.from(filter.excludedTags).map(tag => `!${tag}`) : null;
const tags = (enbledTagsString || excludedTagsString) ? [...enbledTagsString, excludedTagsString].join("|") : null;
const params = {
...(filter.automapper && {automapper: String(filter.automapper)}),
...(filter.chroma && {chroma: String(filter.chroma)}),
...(filter.cinema && {cinema: String(filter.cinema)}),
...(filter.me && {me: String(filter.me)}),
...(filter.noodle && {noodle: String(filter.noodle)}),
...(filter.ranked && {ranked: String(filter.ranked)}),
...(filter.verified && {verified: String(filter.verified)}),
...(filter.curated && {curated: String(filter.curated)}),
...(filter.fullSpread && {fullSpread: String(filter.fullSpread)}),
...(filter.from && {from: String(filter.from)}),
...(filter.to && {to: String(filter.to)}),
...(tags && {tags: String(tags)}),
...(filter.minDuration && {minDuration: String(filter.minDuration)}),
...(filter.maxDuration && {maxDuration: String(filter.maxDuration)}),
...(filter.minNps && {minNps: String(filter.minNps)}),
...(filter.maxNps && {maxNps: String(filter.maxNps)})
};
return new URLSearchParams(params);
}
private searchParamsToUrlParams(search: SearchParams): URLSearchParams{
if(!search){ return new URLSearchParams(); }
const searchParams = {
...(search.includeEmpty && {includeEmpty: String(search.includeEmpty)}),
...(search.sortOrder && {sortOrder: search.sortOrder}),
...(search.q && {q: search.q})
};
const filterUrlParms = this.mapFilterToUrlParams(search.filter);
return new URLSearchParams({
...searchParams,
...Object.fromEntries(filterUrlParms)
});
}
public async getMapsDetailsByHashs<T extends string>(hashs: T[]): Promise<ApiResult<Record<Lowercase<T>, BsvMapDetail>>>{
if(hashs.length > 50){ throw "too musch map hashs"; }
const paramsHashs = hashs.join(",");
const resp = await fetch(`${this.bsaverApiUrl}/maps/hash/${paramsHashs}`);
const data = await resp.json() as Record<Lowercase<T>, BsvMapDetail> | BsvMapDetail;
if((data as BsvMapDetail).id){
const key = (data as BsvMapDetail).versions.at(0).hash.toLowerCase();
const parsedData = {
[key]: data as BsvMapDetail
} as Record<Lowercase<T>, BsvMapDetail>;
return {status: resp.status, data: parsedData}
}
return {status: resp.status, data: (data as Record<Lowercase<T>, BsvMapDetail>)};
}
public async getMapDetailsById(id: string): Promise<ApiResult<BsvMapDetail>>{
const res = await fetch(`${this.bsaverApiUrl}/maps/id/${id}`);
const data = await res.json() as BsvMapDetail;
return {status: res.status, data};
}
public async searchMaps(search: SearchParams): Promise<ApiResult<SearchResponse>>{
const url = new URL(`${this.bsaverApiUrl}/search/text/${search?.page ?? 0}`);
url.search = this.searchParamsToUrlParams(search).toString();
const res = await fetch(url.toString());
if(!res.ok){
return {status: res.status, data: null};
}
const data: any = await res.json();
return {status: res.status, data};
}
public async getPlaylistDetails(id: string): Promise<ApiResult<BsvPlaylist>>{
const res = await fetch(`${this.bsaverApiUrl}/playlists/id/${id}/0`);
const data = await res.json() as BsvPlaylistPage;
return {status: res.status, data: data.playlist};
}
}
@@ -0,0 +1,76 @@
import { splitIntoChunk } from "../../../helpers/array-tools";
import { BsvMapDetail } from "shared/models/maps";
import { BsvPlaylist, SearchParams } from "shared/models/maps/beat-saver.model";
import { BeatSaverApiService } from "./beat-saver-api.service";
export class BeatSaverService {
private static instance: BeatSaverService;
public static getInstance(): BeatSaverService{
if(!BeatSaverService.instance){ BeatSaverService.instance = new BeatSaverService(); }
return BeatSaverService.instance;
}
private readonly bsaverApi: BeatSaverApiService;
private readonly cachedMapsDetails = new Map<string, BsvMapDetail>();
private constructor(){
this.bsaverApi = BeatSaverApiService.getInstance();
}
public async getMapDetailsFromHashs(hashs: string[]): Promise<BsvMapDetail[]>{
const filtredHashs = hashs.map(h => h.toLowerCase()).filter(hash => !Array.from(this.cachedMapsDetails.keys()).includes(hash));
const chunkHash = splitIntoChunk(filtredHashs, 50);
const mapDetails = Array.from(this.cachedMapsDetails.entries()).reduce((res , [hash, details]) => {
if(hashs.includes(hash)){
res.push(details);
}
return res;
}, [] as BsvMapDetail[]);
for(const hashs of chunkHash){
const res = await this.bsaverApi.getMapsDetailsByHashs(hashs);
if(res.status === 200){
mapDetails.push(...Object.values<BsvMapDetail>(res.data).filter(detail => !!detail));
mapDetails.forEach(detail => {
this.cachedMapsDetails.set(detail.versions.at(0).hash.toLowerCase(), detail);
});
}
}
return mapDetails;
}
public async getMapDetailsById(id: string): Promise<BsvMapDetail>{
const res = await this.bsaverApi.getMapDetailsById(id);
return res.data;
}
public searchMaps(search: SearchParams): Promise<BsvMapDetail[]>{
return this.bsaverApi.searchMaps(search).then(res => {
return res.status === 200 ? res.data.docs : [];
}).catch(err => {
return [];
});
}
public async getPlaylistPage(id: string): Promise<BsvPlaylist>{
const res = await this.bsaverApi.getPlaylistDetails(id);
return res.data;
}
}
@@ -0,0 +1,70 @@
import fetch from "node-fetch";
import { ApiResult } from "renderer/models/api/api.model";
import { MSGetQuery, MSGetQueryFilter, MSGetResponse } from "shared/models/model-saber/model-saber.model";
export class ModelSaberApiService {
private static instance: ModelSaberApiService;
public static getInstance(): ModelSaberApiService{
if(!ModelSaberApiService.instance){ ModelSaberApiService.instance = new ModelSaberApiService(); }
return ModelSaberApiService.instance;
}
private readonly API_URL = "https://modelsaber.com/api/v2/";
private readonly ENDPOINTS = {get: "get.php", types: "types.php"};
private constructor(){}
private parseFilters(filters: MSGetQueryFilter[]): string{
if(!filters){ return null; }
const parsed = filters.map(filter => {
const stringFilter = filter.type === "searchName" ? filter.value : `${filter.type}:${filter.value}`;
return filter.isNegative ? `-${stringFilter}` : stringFilter;
});
return parsed.join(",");
}
private buildUrlQuery(query: MSGetQuery): URLSearchParams{
if(!query){ return new URLSearchParams(); }
const filterQuery = this.parseFilters(query.filter);
const searchParams = {
...(query.type && {type: query.type}),
...(query.platform && {platform: query.platform}),
...(query.start && {start: `${query.start}`}),
...(query.end && {end: `${query.end}`}),
...(query.sort && {sort: query.sort}),
...(query.sortDirection && {sortDirection: query.sortDirection}),
...(filterQuery && {filter: filterQuery}),
}
return new URLSearchParams(searchParams);
}
public async searchModel(query: MSGetQuery): Promise<ApiResult<MSGetResponse>>{
const url = new URL(this.ENDPOINTS.get, this.API_URL);
url.search = this.buildUrlQuery(query).toString();
const res = await fetch(url.toString());
if(!res.ok){
return {data: null, status: res.status};
}
const data = await res.json() as MSGetResponse;
return {data, status: res.status};
}
}
@@ -0,0 +1,45 @@
import { MSGetQuery, MSGetQueryFilter, MSModel } from "shared/models/model-saber/model-saber.model";
import { ModelSaberApiService } from "./model-saber-api.service";
export class ModelSaberService {
private static instance: ModelSaberService;
public static getInstance(): ModelSaberService{
if(!ModelSaberService.instance){ ModelSaberService.instance = new ModelSaberService(); }
return ModelSaberService.instance;
}
private readonly modelSaberApi: ModelSaberApiService;
private constructor(){
this.modelSaberApi = ModelSaberApiService.getInstance();
}
public async getModelById(id: number|string): Promise<MSModel>{
const query: MSGetQuery = {
start: 0,
end: 1,
platform: "pc",
filter: [{type: "id", value: id}]
}
try{
const res = await this.modelSaberApi.searchModel(query);
if(res.status !== 200){ return null; }
if(Object.keys(res.data).length === 0){
return null;
}
return res.data[`${id}`];
}
catch(e){
return null;
}
}
}
+28 -10
View File
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, readdirSync, readFile, unlinkSync } from "fs";
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";
@@ -6,6 +7,9 @@ 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";
// TODO : REFACTOR
export class UtilsService{
@@ -13,7 +17,7 @@ export class UtilsService{
private assetsPath: string = '';
private mainWindow: BrowserWindow;
private windows: Map<AppWindow, BrowserWindow> = new Map<AppWindow, BrowserWindow>();
private constructor(){}
@@ -29,8 +33,8 @@ export class UtilsService{
public getAssestsJsonsPath(): string { return this.getAssetsPath("jsons"); }
public getTempPath(): string{ return path.join(app.getPath("temp"), app.getName()) }
public setMainWindow(win: BrowserWindow){ this.mainWindow = win; }
public getMainWindow(){ return this.mainWindow; }
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); }
@@ -43,7 +47,7 @@ export class UtilsService{
return unlink(pathToFile);
}
public rmDirIfExist(path: string): Promise<void>{
public async rmDirIfExist(path: string): Promise<void>{
if(!this.pathExist(path)){ return; }
return rm(path, {recursive: true, force: true});
}
@@ -68,19 +72,33 @@ export class UtilsService{
});
}
public listDirsInDir(dirPath: string): string[]{
public listDirsInDir(dirPath: string, fullPath = false): string[]{
let files = readdirSync(dirPath, { withFileTypes:true});
files = files.filter(f => f.isDirectory())
return files.map(f => f.name);
return files.map(f => fullPath ? path.join(dirPath, f.name) : f.name);
}
public deleteFolder(folderPath: string): Promise<void>{
return rm(folderPath, {recursive: true});
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 {
this.mainWindow.webContents.send(channel, response);
Array.from(this.windows.values()).forEach(window => window.webContents.send(channel, response));
} catch (error) {
log.error(error);
}
+25 -11
View File
@@ -2,25 +2,32 @@ import { app, BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import { resolveHtmlPath } from "../util";
import { UtilsService } from "./utils.service";
import { AppWindow } from "shared/models/window-manager/app-window.model";
import { PRELOAD_PATH } from "../main";
import path from "path";
import { APP_NAME } from "../constants";
export class WindowManagerService{
private static instance: WindowManagerService;
private readonly PRELOAD_PATH = app.isPackaged ? path.join(__dirname, 'preload.js') : path.join(__dirname, '../../../.erb/dll/preload.js')
private readonly utilsService: UtilsService = UtilsService.getInstance();
private readonly appWindowsOptions: Record<AppWindow, BrowserWindowConstructorOptions> = {
"launcher.html": {width: 380, height: 500, minWidth: 380, minHeight: 500, resizable: false},
"index.html": {width: 1080, height: 720, minWidth: 900, minHeight: 500}
"index.html": {width: 1080, height: 720, minWidth: 900, minHeight: 500},
"oneclick-download-map.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
"oneclick-download-playlist.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
"oneclick-download-model.html": {width: 350, height: 400, minWidth: 350, minHeight: 400, resizable: false},
}
private readonly baseWindowOption: BrowserWindowConstructorOptions = {
title: APP_NAME,
icon: this.utilsService.getAssetsPath("favicon.ico"),
show: false,
frame: false,
titleBarOverlay: false,
webPreferences: { preload: PRELOAD_PATH }
webPreferences: { preload: this.PRELOAD_PATH, webSecurity: false }
}
private readonly windows: Map<AppWindow, BrowserWindow> = new Map<AppWindow, BrowserWindow>();
@@ -32,15 +39,16 @@ export class WindowManagerService{
private constructor(){}
public openWindow(windowType: AppWindow): Promise<BrowserWindow>{
const window = new BrowserWindow({...this.appWindowsOptions[windowType], ...this.baseWindowOption});
public openWindow(windowType: AppWindow, options?: BrowserWindowConstructorOptions): Promise<BrowserWindow>{
const window = new BrowserWindow({...this.appWindowsOptions[windowType], ...this.baseWindowOption, ...options});
const promise = window.loadURL(resolveHtmlPath(windowType));
window.removeMenu();
window.setMenu(null);
window.once("ready-to-show", () => {
if (!window) { throw new Error('"window" is not defined'); }
return window.show();
window.show();
});
window.once("closed", () => {
@@ -49,15 +57,11 @@ export class WindowManagerService{
});
this.windows.set(windowType, window);
this.utilsService.setMainWindow(window);
this.utilsService.setMainWindows(this.windows);
return promise.then(() => window);
}
public closeWindow(window: AppWindow){
this.windows.get(window).close();
}
public closeAllWindows(except?: AppWindow){
this.windows.forEach((window, key) => {
if(key === except){ return; }
@@ -65,4 +69,14 @@ export class WindowManagerService{
})
}
public close(...win: AppWindow[]){
win.forEach(window => {
this.windows.get(window)?.close();
});
}
public getWindows(window: AppWindow): BrowserWindow{
return this.windows.get(window);
}
}