mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] lot of changes but mainly, maps now use a cache and load almost instantly after first load
This commit is contained in:
@@ -4,13 +4,13 @@ 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 { LocalMapsManagerService } from "./local-maps-manager.service";
|
||||
import { LocalMapsManagerService } from "./maps/local-maps-manager.service";
|
||||
import log from "electron-log";
|
||||
import { WindowManagerService } from "../window-manager.service";
|
||||
import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface";
|
||||
import { readFileSync } from "fs";
|
||||
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
|
||||
import { copy, copyFile, pathExists, realpath } from "fs-extra";
|
||||
import { copy, copyFile, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra";
|
||||
import { Progression, ensureFolderExist, pathExist } from "../../helpers/fs.helpers";
|
||||
import { FileAssociationService } from "../file-association.service";
|
||||
|
||||
@@ -100,6 +100,37 @@ export class LocalPlaylistsManagerService {
|
||||
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`);
|
||||
}
|
||||
|
||||
private getReadBPListOfFolder(folerPath: string): Observable<Progression<BPList[]>> {
|
||||
return new Observable<Progression<BPList[]>>(obs => {
|
||||
|
||||
const progress: Progression<BPList[]> = { current: 0, total: 0, data: [] };
|
||||
|
||||
(async () => {
|
||||
if(!pathExistsSync(folerPath)) {
|
||||
throw new Error(`Playlists folder not found ${folerPath}`);
|
||||
}
|
||||
|
||||
const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist");
|
||||
progress.total = playlists.length;
|
||||
|
||||
for (const playlist of playlists) {
|
||||
const playlistPath = path.join(folerPath, playlist);
|
||||
const playlistContent = await this.readPlaylistFile(playlistPath);
|
||||
progress.data.push(playlistContent);
|
||||
progress.current += 1;
|
||||
obs.next(progress);
|
||||
}
|
||||
})().catch(err => obs.error(err)).finally(() => obs.complete());
|
||||
});
|
||||
}
|
||||
|
||||
public getVersionPlaylists(version: BSVersion): Observable<Progression<BPList[]>> {
|
||||
return new Observable<Progression<BPList[]>>(obs => {
|
||||
this.getPlaylistsFolder(version)
|
||||
.then(folder => this.getReadBPListOfFolder(folder).subscribe(obs))
|
||||
});
|
||||
}
|
||||
|
||||
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
|
||||
|
||||
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
|
||||
|
||||
+46
-28
@@ -2,26 +2,29 @@ import path from "path";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
|
||||
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } 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 { BSLocalVersionService } from "../../bs-local-version.service";
|
||||
import { InstallationLocationService } from "../../installation-location.service";
|
||||
import { UtilsService } from "../../utils.service";
|
||||
import crypto from "crypto";
|
||||
import { lstatSync } from "fs";
|
||||
import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra";
|
||||
import StreamZip from "node-stream-zip";
|
||||
import { RequestService } from "../request.service";
|
||||
import { RequestService } from "../../request.service";
|
||||
import sanitize from "sanitize-filename";
|
||||
import { DeepLinkService } from "../deep-link.service";
|
||||
import { DeepLinkService } from "../../deep-link.service";
|
||||
import log from "electron-log";
|
||||
import { WindowManagerService } from "../window-manager.service";
|
||||
import { WindowManagerService } from "../../window-manager.service";
|
||||
import { Observable, lastValueFrom, of } from "rxjs";
|
||||
import { Archive } from "../../models/archive.class";
|
||||
import { deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../helpers/fs.helpers";
|
||||
import { Archive } from "../../../models/archive.class";
|
||||
import { deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers";
|
||||
import { readFile } from "fs/promises";
|
||||
import { FolderLinkerService } from "../folder-linker.service";
|
||||
import { allSettled } from "../../../shared/helpers/promise.helpers";
|
||||
import { splitIntoChunk } from "../../../shared/helpers/array.helpers";
|
||||
import { IpcService } from "../ipc.service";
|
||||
import { FolderLinkerService } from "../../folder-linker.service";
|
||||
import { allSettled } from "../../../../shared/helpers/promise.helpers";
|
||||
import { splitIntoChunk } from "../../../../shared/helpers/array.helpers";
|
||||
import { IpcService } from "../../ipc.service";
|
||||
import { SongDetailsCacheService } from "./song-details-cache.service";
|
||||
import { sToMs } from "shared/helpers/time.helpers";
|
||||
import { SongCacheService } from "./song-cache.service";
|
||||
|
||||
export class LocalMapsManagerService {
|
||||
private static instance: LocalMapsManagerService;
|
||||
@@ -49,7 +52,9 @@ export class LocalMapsManagerService {
|
||||
private readonly deepLink: DeepLinkService;
|
||||
private readonly windows: WindowManagerService;
|
||||
private readonly ipc: IpcService;
|
||||
private readonly linker = FolderLinkerService.getInstance();
|
||||
private readonly linker: FolderLinkerService;
|
||||
private readonly songDetailsCache: SongDetailsCacheService;
|
||||
private readonly songCache: SongCacheService;
|
||||
|
||||
private constructor() {
|
||||
this.localVersion = BSLocalVersionService.getInstance();
|
||||
@@ -60,6 +65,8 @@ export class LocalMapsManagerService {
|
||||
this.windows = WindowManagerService.getInstance();
|
||||
this.linker = FolderLinkerService.getInstance();
|
||||
this.ipc = IpcService.getInstance();
|
||||
this.songDetailsCache = SongDetailsCacheService.getInstance();
|
||||
this.songCache = SongCacheService.getInstance();
|
||||
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
|
||||
@@ -87,7 +94,7 @@ export class LocalMapsManagerService {
|
||||
const mapRawInfo: RawMapInfoData = JSON.parse(rawInfoString);
|
||||
const shasum = crypto.createHash("sha1");
|
||||
shasum.update(rawInfoString);
|
||||
|
||||
|
||||
const hashFile = (filePath: string): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(filePath);
|
||||
@@ -103,11 +110,24 @@ export class LocalMapsManagerService {
|
||||
await hashFile(diffFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return shasum.digest("hex");
|
||||
}
|
||||
|
||||
private async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap> {
|
||||
|
||||
const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => {
|
||||
const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href;
|
||||
const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href;
|
||||
return { rawInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) } as BsmLocalMap;
|
||||
};
|
||||
|
||||
const cachedInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
|
||||
|
||||
if (cachedInfos) {
|
||||
return getUrlsAndReturn(cachedInfos.rawInfo, cachedInfos.hash, mapPath);
|
||||
}
|
||||
|
||||
const files = await getFilesInFolder(mapPath);
|
||||
const infoFile = files.find(file => path.basename(file).toLowerCase() === "info.dat");
|
||||
|
||||
@@ -116,14 +136,10 @@ export class LocalMapsManagerService {
|
||||
}
|
||||
|
||||
const rawInfoString = await readFile(infoFile, { encoding: "utf-8" });
|
||||
|
||||
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, path: mapPath };
|
||||
return getUrlsAndReturn(rawInfo, hash, mapPath);
|
||||
}
|
||||
|
||||
private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> {
|
||||
@@ -132,6 +148,7 @@ export class LocalMapsManagerService {
|
||||
await ensureFolderExist(this.utils.getTempPath());
|
||||
const dest = path.join(tempPath, fileName);
|
||||
|
||||
|
||||
const zipPath = (await lastValueFrom(this.reqService.downloadFile(zipUrl, dest))).data;
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
|
||||
@@ -140,14 +157,10 @@ export class LocalMapsManagerService {
|
||||
|
||||
private openOneClickDownloadMapWindow(mapId: string, isHash = false): void {
|
||||
this.windows.openWindow("oneclick-download-map.html").then(window => {
|
||||
|
||||
this.ipc.once("one-click-map-info", async (_, reply) => {
|
||||
reply(of({ id: mapId, isHash }));
|
||||
}, window.webContents.ipc);
|
||||
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
public getMaps(version?: BSVersion): Observable<BsmLocalMapsProgress> {
|
||||
@@ -159,6 +172,8 @@ export class LocalMapsManagerService {
|
||||
|
||||
return new Observable<BsmLocalMapsProgress>(observer => {
|
||||
(async () => {
|
||||
await this.songDetailsCache.waitLoaded(sToMs(30));
|
||||
|
||||
const levelsFolder = await this.getMapsFolderPath(version);
|
||||
|
||||
if(!(await pathExist(levelsFolder))) {
|
||||
@@ -180,6 +195,8 @@ export class LocalMapsManagerService {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.songCache.setMapInfoFromDirname(path.basename(mapPath), { rawInfo: mapInfo.rawInfo, hash: mapInfo.hash });
|
||||
|
||||
progression.loaded++;
|
||||
observer.next(progression);
|
||||
return mapInfo;
|
||||
@@ -235,6 +252,7 @@ export class LocalMapsManagerService {
|
||||
continue;
|
||||
}
|
||||
await deleteFolder(folder);
|
||||
this.songCache.deleteMapInfoFromDirname(path.basename(folder));
|
||||
progress.deleted++;
|
||||
observer.next(progress);
|
||||
}
|
||||
@@ -248,7 +266,7 @@ export class LocalMapsManagerService {
|
||||
|
||||
public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise<BsmLocalMap> {
|
||||
if (!map.versions.at(0).hash) {
|
||||
throw "Cannot download map, no hash found";
|
||||
throw new Error("Cannot download map, no hash found");
|
||||
}
|
||||
|
||||
const zipUrl = map.versions.at(0).downloadURL;
|
||||
@@ -261,7 +279,7 @@ export class LocalMapsManagerService {
|
||||
if(!exists){ return null; }
|
||||
return this.loadMapInfoFromPath(mapPath);
|
||||
}).catch(() => null);
|
||||
|
||||
|
||||
if(map.versions.every(version => version.hash === installedMap?.hash)) {
|
||||
return installedMap;
|
||||
}
|
||||
@@ -269,7 +287,7 @@ export class LocalMapsManagerService {
|
||||
const { zip, zipPath } = await this.downloadMapZip(zipUrl);
|
||||
|
||||
if (!zip) {
|
||||
throw `Cannot download ${zipUrl}`;
|
||||
throw new Error(`Cannot download ${zipUrl}`);
|
||||
}
|
||||
|
||||
await ensureFolderExist(mapPath);
|
||||
@@ -279,7 +297,7 @@ export class LocalMapsManagerService {
|
||||
await unlink(zipPath);
|
||||
|
||||
const localMap = await this.loadMapInfoFromPath(mapPath);
|
||||
localMap.bsaverInfo = map;
|
||||
localMap.songDetails = this.songDetailsCache.getSongDetails(localMap.hash);
|
||||
|
||||
return localMap;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CACHE_PATH } from "main/constants";
|
||||
import { JsonCache } from "main/models/json-cache.class";
|
||||
import path from "path";
|
||||
import { RawMapInfoData } from "shared/models/maps";
|
||||
|
||||
export class SongCacheService {
|
||||
|
||||
private static instance: SongCacheService;
|
||||
|
||||
public static getInstance(): SongCacheService {
|
||||
if (!SongCacheService.instance) {
|
||||
SongCacheService.instance = new SongCacheService();
|
||||
}
|
||||
return SongCacheService.instance;
|
||||
}
|
||||
|
||||
private readonly RAW_INFOS_CACHE_PATH = path.join(CACHE_PATH, "song-raw-info-cache.json");
|
||||
|
||||
private readonly rawInfosCache: JsonCache<CachedRawInfoWithHash>;
|
||||
|
||||
private constructor(){
|
||||
this.rawInfosCache = new JsonCache(this.RAW_INFOS_CACHE_PATH);
|
||||
}
|
||||
|
||||
public getMapInfoFromDirname(dirname: string): CachedRawInfoWithHash {
|
||||
return this.rawInfosCache.get(dirname);
|
||||
}
|
||||
|
||||
public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void {
|
||||
this.rawInfosCache.set(dirname, info);
|
||||
}
|
||||
|
||||
public deleteMapInfoFromDirname(dirname: string): void {
|
||||
this.rawInfosCache.delete(dirname);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type CachedRawInfoWithHash = {
|
||||
hash: string;
|
||||
rawInfo: RawMapInfoData;
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import path from "path";
|
||||
import { ensureDirSync, existsSync, readFile, writeFile } from "fs-extra";
|
||||
import { BehaviorSubject, Observable, catchError, filter, lastValueFrom, of, take, timeout } from "rxjs";
|
||||
import { ConfigurationService } from "../../configuration.service";
|
||||
import { RequestService } from "../../request.service";
|
||||
import { tryit } from "shared/helpers/error.helpers";
|
||||
import { CACHE_PATH, HTTP_STATUS_CODES } from "main/constants";
|
||||
import log from "electron-log";
|
||||
import protobuf from "protobufjs";
|
||||
import { UtilsService } from "../../utils.service";
|
||||
import { SongDetails, SongDetailsCache } from "shared/models/maps/song-details-cache.model";
|
||||
import { inflate } from "pako";
|
||||
|
||||
export class SongDetailsCacheService {
|
||||
|
||||
private static instance: SongDetailsCacheService;
|
||||
|
||||
public static getInstance(): SongDetailsCacheService {
|
||||
if (!SongDetailsCacheService.instance) {
|
||||
SongDetailsCacheService.instance = new SongDetailsCacheService();
|
||||
}
|
||||
return SongDetailsCacheService.instance;
|
||||
}
|
||||
|
||||
private readonly dataSource = [
|
||||
"https://raw.githubusercontent.com/Zagrios/beat-saber-scraped-maps/master/song_details_cache_v1.gz",
|
||||
"https://cdn.jsdelivr.net/gh/Zagrios/beat-saber-scraped-maps@master/song_details_cache_v1.gz",
|
||||
]
|
||||
|
||||
private readonly PROTO_CACHE_PATH = path.join(CACHE_PATH, "song-details-cache");
|
||||
private readonly etagKey = "song-details-cache-etag";
|
||||
|
||||
private readonly config: ConfigurationService;
|
||||
private readonly request: RequestService;
|
||||
private readonly utils: UtilsService;
|
||||
|
||||
private songDetailsCache: Record<string, SongDetails> = {};
|
||||
private readonly _loaded$ = new BehaviorSubject<boolean>(null);
|
||||
|
||||
private constructor(){
|
||||
this.config = ConfigurationService.getInstance();
|
||||
this.request = RequestService.getInstance();
|
||||
this.utils = UtilsService.getInstance();
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private async loadCache(): Promise<void> {
|
||||
const protoCacheExists = existsSync(this.PROTO_CACHE_PATH);
|
||||
const etag = protoCacheExists ? this.config.get<string>(this.etagKey) : null;
|
||||
|
||||
await this.downloadCacheFile(etag).then(etag => {
|
||||
this.config.set(this.etagKey, etag);
|
||||
}).catch(err => {
|
||||
log.error("Unable to download cache file", err);
|
||||
});
|
||||
|
||||
this.readProtoMessageCacheFile(this.PROTO_CACHE_PATH).then(cache => {
|
||||
this.songDetailsCache = cache;
|
||||
log.info("SongDetailsCache loaded");
|
||||
}).catch(err => {
|
||||
log.error("Failed to read cache file", this.PROTO_CACHE_PATH, err);
|
||||
}).finally(() => {
|
||||
this._loaded$.next(true);
|
||||
})
|
||||
}
|
||||
|
||||
private async readProtoMessageCacheFile(filePath: string): Promise<Record<string, SongDetails>> {
|
||||
const protobufRoot = await protobuf.load(this.getProtoShemaPath());
|
||||
const cacheMessage = protobufRoot.lookupType("SongDetailsCache");
|
||||
|
||||
const buffer = await readFile(filePath);
|
||||
|
||||
const messageBuffer = cacheMessage.decode(buffer);
|
||||
const messageObj = cacheMessage.toObject(messageBuffer) as SongDetailsCache;
|
||||
|
||||
const res: Record<string, SongDetails> = {};
|
||||
|
||||
for(const song of messageObj.songs){
|
||||
res[song.hash.toLocaleLowerCase()] = song;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the GZipped Proto file and write it to the cache destination
|
||||
* @param etag
|
||||
* @returns {string} new etag or the same if the file is the same
|
||||
*/
|
||||
private async downloadCacheFile(etag?: string): Promise<string> {
|
||||
const { buffer, etag: newEtag } = await this.downloadGZCacheFile(etag);
|
||||
|
||||
if(!buffer) { return etag; }
|
||||
|
||||
ensureDirSync(path.dirname(this.PROTO_CACHE_PATH));
|
||||
|
||||
await writeFile(this.PROTO_CACHE_PATH, inflate(buffer), { encoding: "binary" });
|
||||
|
||||
return newEtag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the GZipped Proto file from the sources
|
||||
* @returns {Promise<{ buffer: Buffer, etag: string }>} {\
|
||||
* buffer: GZipper Buffer, will be empty if etag is the same\
|
||||
* etag: ETag of the file, should be never empty\
|
||||
* }
|
||||
*/
|
||||
private async downloadGZCacheFile(etag?: string): Promise<{ buffer: Buffer, etag: string }> {
|
||||
|
||||
let lastError: Error;
|
||||
|
||||
for(const sourceUrl of this.dataSource){
|
||||
|
||||
const { result, error } = await tryit(() => {
|
||||
return lastValueFrom(this.request.downloadBuffer(sourceUrl, {
|
||||
headers: etag ? { "If-None-Match": etag } : {},
|
||||
decompress: false
|
||||
})).then(res => ({ buffer: res.data, request: res.extra}));
|
||||
});
|
||||
|
||||
|
||||
if(error) {
|
||||
lastError = error;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info("Downloaded SongDetailCache file from source:", sourceUrl, "ETAG:", result.request.headers.etag, result.request.statusCode);
|
||||
|
||||
return {
|
||||
buffer: result.request.statusCode === HTTP_STATUS_CODES.HTTP_STATUS_NOT_MODIFIED ? null : result.buffer,
|
||||
etag: result.request.headers.etag
|
||||
}
|
||||
}
|
||||
|
||||
log.error("Failed to download SongDetailCache file", etag, lastError);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private getProtoShemaPath(): string {
|
||||
return this.utils.getAssetsPath(path.join("protos", "song_details_cache_v1.proto"))
|
||||
}
|
||||
|
||||
public get loaded$(): Observable<boolean> {
|
||||
return this._loaded$.pipe(filter(val => typeof val === "boolean"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Promise that resolves when the cache is loaded (loaded does not mean cache contains data, just the all load process is done)
|
||||
* @param timeoutMs in milliseconds
|
||||
* @throws {TimeoutError} if the cache is not ready after the provided timeout
|
||||
*/
|
||||
public waitLoaded(timeoutMs: number): Promise<boolean> {
|
||||
|
||||
const obs = this.loaded$.pipe(take(1));
|
||||
|
||||
return lastValueFrom(obs.pipe(timeout(timeoutMs), catchError((err => {
|
||||
log.error("Wait loaded SongDetailsCache timed out", err);
|
||||
return of(false);
|
||||
}))));
|
||||
}
|
||||
|
||||
public getSongDetails(hash: string): any {
|
||||
return this.songDetailsCache[hash.toLocaleLowerCase()];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export class BSLocalVersionService {
|
||||
}
|
||||
|
||||
public async getVersionOfBSFolder(
|
||||
bsPath: string,
|
||||
bsPath: string,
|
||||
options?: {
|
||||
steam?: boolean;
|
||||
oculus?: boolean;
|
||||
@@ -126,7 +126,7 @@ export class BSLocalVersionService {
|
||||
|
||||
// Will be removed in future version. It just to prepare future features
|
||||
if(!metadata?.id){
|
||||
metadata = await this.initVersionMetadata(folderVersion, metadata ?? { store: BsStore.STEAM });
|
||||
metadata = await this.initVersionMetadata(folderVersion, metadata ?? { store: BsStore.STEAM });
|
||||
}
|
||||
folderVersion.metadata = metadata;
|
||||
|
||||
@@ -182,8 +182,8 @@ export class BSLocalVersionService {
|
||||
|
||||
|
||||
/**
|
||||
* Return path of a version even if it's not installed.
|
||||
* @param {BSVersion} version
|
||||
* Return path of a version even if it's not installed.
|
||||
* @param {BSVersion} version
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
public async getVersionPath(version: BSVersion): Promise<string>{
|
||||
@@ -191,25 +191,25 @@ export class BSLocalVersionService {
|
||||
if(version.oculus){ return this.oculusService.tryGetGameFolder([OCULUS_BS_DIR, OCULUS_BS_BACKUP_DIR]); }
|
||||
|
||||
return path.join(
|
||||
await this.installLocationService.versionsDirectory(),
|
||||
this.installLocationService.versionsDirectory(),
|
||||
this.getVersionFolder(version)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return path of an installed version. Returns null if not found.
|
||||
* @param {BSVersion} version
|
||||
* @param {BSVersion} version
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
public async getInstalledVersionPath(version: BSVersion): Promise<string>{
|
||||
const versionPath = await this.getVersionPath(version);
|
||||
if(await pathExists(versionPath)){ return versionPath; }
|
||||
|
||||
const versionFolders = await getFoldersInFolder(await this.installLocationService.versionsDirectory());
|
||||
const versionFolders = await getFoldersInFolder(this.installLocationService.versionsDirectory());
|
||||
|
||||
for(const folder of versionFolders){
|
||||
const stats = await lstat(folder);
|
||||
if(stats.ino === version.ino){
|
||||
if(stats.ino === version.ino){
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
@@ -270,11 +270,11 @@ export class BSLocalVersionService {
|
||||
versions.push(oculusVersion);
|
||||
}
|
||||
|
||||
if (!(await pathExists(await this.installLocationService.versionsDirectory()))) {
|
||||
if (!(await pathExists(this.installLocationService.versionsDirectory()))) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
const folderInInstallation = await getFoldersInFolder(await this.installLocationService.versionsDirectory());
|
||||
const folderInInstallation = await getFoldersInFolder(this.installLocationService.versionsDirectory());
|
||||
|
||||
log.info("Finded versions folders", folderInInstallation);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export class BsSteamDownloaderService {
|
||||
qr
|
||||
}
|
||||
|
||||
await ensureDir(await this.installLocationService.versionsDirectory());
|
||||
await ensureDir(this.installLocationService.versionsDirectory());
|
||||
|
||||
const isLinux = process.platform === 'linux';
|
||||
const exePath = this.getDepotDownloaderExePath();
|
||||
@@ -91,7 +91,7 @@ export class BsSteamDownloaderService {
|
||||
const depotDownloader = new DepotDownloader({
|
||||
command: isLinux ? 'dotnet' : exePath,
|
||||
args: isLinux ? [exePath, ...args] : args,
|
||||
options: { cwd: await this.installLocationService.versionsDirectory() },
|
||||
options: { cwd: this.installLocationService.versionsDirectory() },
|
||||
echoStartData: downloadVersion
|
||||
}, log);
|
||||
|
||||
|
||||
@@ -23,11 +23,12 @@ export class ConfigurationService {
|
||||
}
|
||||
|
||||
private async initStore() {
|
||||
const contentPath = await this.locations.installationDirectory();
|
||||
const contentPath = this.locations.installationDirectory();
|
||||
this.store = new ElectronStore({
|
||||
cwd: contentPath,
|
||||
name: "config",
|
||||
fileExtension: "cfg",
|
||||
accessPropertiesByDotNotation: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import path from "path";
|
||||
import { app } from "electron";
|
||||
import ElectronStore from "electron-store";
|
||||
import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist, pathExist } from "../helpers/fs.helpers";
|
||||
import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist } from "../helpers/fs.helpers";
|
||||
import { pathExistsSync } from "fs-extra";
|
||||
|
||||
export class InstallationLocationService {
|
||||
private static instance: InstallationLocationService;
|
||||
@@ -17,6 +18,7 @@ export class InstallationLocationService {
|
||||
public readonly VERSIONS_FOLDER = "BSInstances";
|
||||
|
||||
private readonly SHARED_CONTENT_FOLDER = "SharedContent";
|
||||
private readonly CACHE_FOLDER = "cache";
|
||||
|
||||
private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder";
|
||||
|
||||
@@ -39,7 +41,7 @@ export class InstallationLocationService {
|
||||
|
||||
public async setInstallationDirectory(newDir: string): Promise<string> {
|
||||
newDir = path.basename(newDir) === this.INSTALLATION_FOLDER ? path.join(newDir, "..") : newDir;
|
||||
const oldDir = await this.installationDirectory();
|
||||
const oldDir = this.installationDirectory();
|
||||
|
||||
await ensureFolderExist(oldDir);
|
||||
await copyDirectoryWithJunctions(oldDir, path.join(newDir, this.INSTALLATION_FOLDER), { overwrite: true });
|
||||
@@ -56,9 +58,9 @@ export class InstallationLocationService {
|
||||
this.updateListeners.add(fn);
|
||||
}
|
||||
|
||||
public async installationDirectory(): Promise<string> {
|
||||
public installationDirectory(): string {
|
||||
|
||||
const installParentPath = async () => {
|
||||
const installParentPath = () => {
|
||||
if(this._installationDirectory) {
|
||||
return this._installationDirectory;
|
||||
}
|
||||
@@ -68,24 +70,28 @@ export class InstallationLocationService {
|
||||
}
|
||||
|
||||
const oldPath = path.join(app.getPath("documents"), this.INSTALLATION_FOLDER);
|
||||
if(await pathExist(oldPath)){
|
||||
if(pathExistsSync(oldPath)){
|
||||
return app.getPath("documents");
|
||||
}
|
||||
|
||||
return app.getPath("home");
|
||||
};
|
||||
|
||||
this._installationDirectory = await installParentPath();
|
||||
this._installationDirectory = installParentPath();
|
||||
|
||||
return path.join(this._installationDirectory, this.INSTALLATION_FOLDER);
|
||||
}
|
||||
|
||||
public async versionsDirectory(): Promise<string> {
|
||||
return path.join(await this.installationDirectory(), this.VERSIONS_FOLDER);
|
||||
public versionsDirectory(): string {
|
||||
return path.join(this.installationDirectory(), this.VERSIONS_FOLDER);
|
||||
}
|
||||
|
||||
public async sharedContentPath(): Promise<string> {
|
||||
return path.join(await this.installationDirectory(), this.SHARED_CONTENT_FOLDER);
|
||||
public sharedContentPath(): string {
|
||||
return path.join(this.installationDirectory(), this.SHARED_CONTENT_FOLDER);
|
||||
}
|
||||
|
||||
public cachePath(): string {
|
||||
return path.join(this.installationDirectory(), this.CACHE_FOLDER);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IpcRequest } from "shared/models/ipc";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
import { BrowserWindow, IpcMainEvent, ipcMain } from "electron";
|
||||
import { Observable } from "rxjs";
|
||||
import { IpcCompleteChannel, IpcErrorChannel, IpcTearDownChannel } from "shared/models/ipc/ipc-response.interface";
|
||||
import { IpcReplier } from "shared/models/ipc/ipc-request.interface";
|
||||
@@ -31,7 +31,7 @@ export class IpcService {
|
||||
}
|
||||
|
||||
private buildProxyListener<T>(listener: IpcListener<T>) {
|
||||
return (event: Electron.IpcMainEvent, req: IpcRequest<T>) => {
|
||||
return (event: IpcMainEvent, req: IpcRequest<T>) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender);
|
||||
const replier = (data: Observable<unknown>) => this.connectStream(req.responceChannel, window, data);
|
||||
listener(req, replier);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Progression } from "main/helpers/fs.helpers";
|
||||
import { Observable, shareReplay, tap } from "rxjs";
|
||||
import log from "electron-log";
|
||||
import fetch, { RequestInfo, RequestInit } from "node-fetch";
|
||||
import got, { GotOptions } from "got";
|
||||
import { IncomingMessage } from "http";
|
||||
|
||||
export class RequestService {
|
||||
private static instance: RequestService;
|
||||
@@ -30,7 +32,7 @@ export class RequestService {
|
||||
throw new Error(`HTTP error! status: ${response.status} ${url}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json() as T;
|
||||
} catch (err) {
|
||||
log.error(err);
|
||||
throw err;
|
||||
@@ -67,7 +69,7 @@ export class RequestService {
|
||||
}).pipe(tap({ error: e => log.error(e, url, dest) }), shareReplay(1));
|
||||
}
|
||||
|
||||
public downloadBuffer(url: string): Observable<Progression<Buffer>> {
|
||||
public downloadBuffer(url: string, options?: GotOptions<string>): Observable<Progression<Buffer, IncomingMessage>> {
|
||||
return new Observable<Progression<Buffer>>(subscriber => {
|
||||
const progress: Progression<Buffer> = {
|
||||
current: 0,
|
||||
@@ -75,27 +77,42 @@ export class RequestService {
|
||||
data: null,
|
||||
};
|
||||
|
||||
const allChunks: Buffer[] = [];
|
||||
const req = got.stream(url, options);
|
||||
|
||||
const req = get(url, { agent: this.ipv4Agent }, res => {
|
||||
progress.total = parseInt(res.headers?.["content-length"] || "0", 10);
|
||||
let data = Buffer.alloc(0);
|
||||
let response: IncomingMessage;
|
||||
|
||||
res.on("data", chunk => {
|
||||
allChunks.push(chunk);
|
||||
progress.current += chunk.length;
|
||||
subscriber.next(progress);
|
||||
});
|
||||
res.on("end", () => {
|
||||
progress.data = Buffer.concat(allChunks);
|
||||
subscriber.next(progress);
|
||||
subscriber.complete();
|
||||
});
|
||||
res.on("error", err => subscriber.error(err));
|
||||
req.once("response", res => {
|
||||
response = res;
|
||||
});
|
||||
|
||||
req.on("error", err => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
data = Buffer.concat([data, chunk]);
|
||||
})
|
||||
|
||||
req.on("downloadProgress", ({ transferred, total }) => {
|
||||
progress.current = transferred;
|
||||
progress.total = total;
|
||||
subscriber.next(progress);
|
||||
});
|
||||
|
||||
req.once("error", err => {
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
req.once("end", () => {
|
||||
progress.data = data;
|
||||
progress.extra = response;
|
||||
subscriber.next(progress);
|
||||
subscriber.complete();
|
||||
});
|
||||
|
||||
req.resume();
|
||||
|
||||
return () => {
|
||||
req.destroy();
|
||||
}
|
||||
|
||||
}).pipe(tap({ error: e => log.error(e) }), shareReplay(1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getFoldersInFolder } from "../helpers/fs.helpers";
|
||||
import path from "path";
|
||||
import { VersionLinkerAction, VersionLinkFolderAction, VersionUnlinkFolderAction } from "renderer/services/version-folder-linker.service";
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { LocalMapsManagerService } from "./additional-content/local-maps-manager.service";
|
||||
import { LocalMapsManagerService } from "./additional-content/maps/local-maps-manager.service";
|
||||
import { BSLocalVersionService } from "./bs-local-version.service";
|
||||
import { FolderLinkerService, LinkOptions } from "./folder-linker.service";
|
||||
import { allSettled } from "../../shared/helpers/promise.helpers";
|
||||
|
||||
Reference in New Issue
Block a user