Files
bs-manager/src/main/services/bs-local-version.service.ts
T

425 lines
16 KiB
TypeScript

import { BSVersionLibService } from "./bs-version-lib.service";
import { BSVersion, BSVersionMetadata } from "shared/bs-version.interface";
import { InstallationLocationService } from "./installation-location.service";
import { SteamService } from "./steam.service";
import { BS_APP_ID, OCULUS_BS_BACKUP_DIR, OCULUS_BS_DIR } from "../constants";
import path from "path";
import { ConfigurationService } from "./configuration.service";
import { lstat, rename } from "fs/promises";
import log from "electron-log";
import { OculusService } from "./oculus.service";
import sanitize from "sanitize-filename";
import { Progression, copyDirectoryWithJunctions, deleteFolder, ensurePathNotAlreadyExist, getFoldersInFolder, rxCopy } from "../helpers/fs.helpers";
import { FolderLinkerService } from "./folder-linker.service";
import { ReadStream, createReadStream, pathExists, pathExistsSync, readFile, writeFile } from "fs-extra";
import readline from "readline";
import { Observable, Subject, catchError, finalize, from, map, switchMap, throwError } from "rxjs";
import { BsStore } from "../../shared/models/bs-store.enum";
import { CustomError } from "../../shared/models/exceptions/custom-error.class";
import crypto from "crypto";
import { StaticConfigurationService } from "./static-configuration.service";
export class BSLocalVersionService {
private static instance: BSLocalVersionService;
private readonly CUSTOM_VERSIONS_KEY = "custom-versions";
private readonly METADATA_FILE = "metadata.config";
private readonly installLocationService: InstallationLocationService;
private readonly steamService: SteamService;
private readonly oculusService: OculusService;
private readonly remoteVersionService: BSVersionLibService;
private readonly configService: ConfigurationService;
private readonly linker: FolderLinkerService;
private readonly staticConfig: StaticConfigurationService;
private readonly _loadedVersions$: Subject<BSVersion[]>;
public static getInstance(): BSLocalVersionService {
if (!BSLocalVersionService.instance) {
BSLocalVersionService.instance = new BSLocalVersionService();
}
return BSLocalVersionService.instance;
}
private constructor() {
this.installLocationService = InstallationLocationService.getInstance();
this.steamService = SteamService.getInstance();
this.oculusService = OculusService.getInstance();
this.remoteVersionService = BSVersionLibService.getInstance();
this.configService = ConfigurationService.getInstance();
this.linker = FolderLinkerService.getInstance();
this.staticConfig = StaticConfigurationService.getInstance();
this._loadedVersions$ = new Subject<BSVersion[]>();
}
private async getVersionFromGlobalGameManagerFile(versionFilePath: string): Promise<BSVersion> {
if(!(await pathExists(versionFilePath))){
log.info("globalgamemanagers file not found", versionFilePath);
return null;
}
const versionsDict = await this.remoteVersionService.getAvailableVersions();
let stream: ReadStream;
try{
stream = createReadStream(versionFilePath);
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity
});
for await (const line of rl) {
for (const bsVersion of versionsDict) {
if (line.includes(bsVersion.BSVersion)) {
stream.close();
return {...bsVersion};
}
}
}
} catch(e) {
log.error(e);
} finally {
stream?.close();
}
log.info("unable to get version from globalgamemanagers file", versionFilePath);
return null;
}
public async getVersionOfBSFolder(
bsPath: string,
options?: {
steam?: boolean;
oculus?: boolean;
}
): Promise<BSVersion>{
log.info("getVersionOfBSFolder", bsPath, options);
if(!bsPath){ return null; }
const versionFilePath = path.join(bsPath, 'Beat Saber_Data', 'globalgamemanagers');
const folderVersion = await this.getVersionFromGlobalGameManagerFile(versionFilePath);
if(!folderVersion){ return null; }
folderVersion.path = bsPath;
if(options?.steam || options?.oculus){
return {...folderVersion, ...options};
}
if(folderVersion.BSVersion !== path.basename(bsPath)){
folderVersion.name = path.basename(bsPath);
}
const folderStats = await lstat(bsPath);
if(folderStats.ino){
folderVersion.ino = folderStats.ino;
}
let metadata = await this.getAllVersionMetadata(folderVersion);
// Will be removed in future version. It just to prepare future features
if(!metadata?.id){
metadata = await this.initVersionMetadata(folderVersion, metadata ?? { store: BsStore.STEAM });
}
folderVersion.metadata = metadata;
const customVersion = this.getCustomVersions().find(customVersion => {
return customVersion.BSVersion === folderVersion.BSVersion && customVersion.name === folderVersion.name;
});
folderVersion.color = customVersion?.color;
return folderVersion;
}
private async writeVersionMetadata(version: BSVersion, metadata: BSVersionMetadata): Promise<void>{
const versionPath = await this.getVersionPath(version);
const metadataPath = path.join(versionPath, this.METADATA_FILE);
return writeFile(metadataPath, JSON.stringify(metadata));
}
public initVersionMetadata(version: BSVersion, metadata: Omit<BSVersionMetadata, "id">): Promise<BSVersionMetadata>{
const firstMetadata = { id: crypto.randomUUID(), ...metadata };
return this.writeVersionMetadata(version, firstMetadata).then(() => firstMetadata).catch(err => {
log.error("initVersionMetadata error", err);
return firstMetadata;
});
}
public getAllVersionMetadata(version: BSVersion): Promise<BSVersionMetadata>{
return (async () => {
const versionPath = await this.getVersionPath(version);
const contents = await readFile(path.join(versionPath, this.METADATA_FILE), "utf-8");
return JSON.parse(contents);
})().catch(e => {
log.warn("Error getAllVersionMetadata", e);
});
}
private setCustomVersions(versions: BSVersion[]): void{
this.configService.set(this.CUSTOM_VERSIONS_KEY, versions);
}
private addCustomVersion(version: BSVersion): void{
this.setCustomVersions([...this.getCustomVersions() ?? [], version]);
}
private updateLastVersionLaunched(version: BSVersion, editedVersion: BSVersion): void {
const lastVersion = this.staticConfig.get("last-version-launched");
if (!lastVersion) {
return;
}
if (
version.BSVersion !== lastVersion.BSVersion
|| version.name !== lastVersion.name
|| version.steam !== lastVersion.steam
|| version.oculus !== lastVersion.oculus
) {
return;
}
this.staticConfig.set("last-version-launched", editedVersion);
}
private getCustomVersions(): BSVersion[]{
return this.configService.get<BSVersion[]>(this.CUSTOM_VERSIONS_KEY) || [];
}
private deleteCustomVersion(version: BSVersion): void{
const customVersions = this.getCustomVersions() || [];
this.setCustomVersions(customVersions.filter(v => (v.name !== version.name || v.BSVersion !== version.BSVersion || v.color !== version.color)));
}
/**
* Return path of a version even if it's not installed.
* @param {BSVersion} version
* @returns {Promise<string>}
*/
public async getVersionPath(version: BSVersion): Promise<string>{
if(version.steam){ return this.steamService.getGameFolder(BS_APP_ID, "Beat Saber") }
if(version.oculus){ return this.oculusService.tryGetGameFolder([OCULUS_BS_DIR, OCULUS_BS_BACKUP_DIR]); }
return path.join(
this.installLocationService.versionsDirectory(),
this.getVersionFolder(version)
);
}
/**
* Return path of an installed version. Returns null if not found.
* @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(this.installLocationService.versionsDirectory());
for(const folder of versionFolders){
const stats = await lstat(folder);
if(stats.ino === version.ino){
return folder;
}
}
return null;
}
public getVersionFolder(version: BSVersion): string{
return version.name ?? version.BSVersion;
}
private async getSteamVersion(): Promise<BSVersion> {
const steamBsFolder = await this.steamService.getGameFolder(BS_APP_ID, "Beat Saber");
if (!steamBsFolder || !(await pathExists(steamBsFolder))) {
return null;
}
return this.getVersionOfBSFolder(steamBsFolder, { steam: true });
}
private async getOculusVersion(): Promise<BSVersion> {
const oculusBsFolder = await this.oculusService.tryGetGameFolder([OCULUS_BS_DIR, OCULUS_BS_BACKUP_DIR]);
if (!oculusBsFolder) {
return null;
}
return this.getVersionOfBSFolder(oculusBsFolder, { oculus: true });
}
public async getInstalledVersions(): Promise<BSVersion[]> {
const versions: BSVersion[] = [];
const steamVersion = await this.getSteamVersion().catch(e => {
log.error("unable to get original Steam version", e);
});
if (steamVersion) {
versions.push(steamVersion);
}
const oculusVersion = await this.getOculusVersion().catch(e => {
log.error("unable to get original Oculus version", e);
});
if (oculusVersion) {
versions.push(oculusVersion);
}
if (!(await pathExists(this.installLocationService.versionsDirectory()))) {
return versions;
}
const folderInInstallation = await getFoldersInFolder(this.installLocationService.versionsDirectory());
log.info("Finded versions folders", folderInInstallation);
for (const f of folderInInstallation) {
const version = await this.getVersionOfBSFolder(f).catch(e => {
log.error("unable to get version of folder", f, e);
});
if(!version){ continue; }
versions.push(version);
};
this.setCustomVersions(versions.filter(v => !!v.color));
this._loadedVersions$.next(versions);
return versions;
}
public async deleteVersion(version: BSVersion): Promise<void>{
if(version.steam || version.oculus){ throw new CustomError("BSManager is not able to delete official Beat Saber versions", "CantDeleteOfficialVersion"); }
const versionFolder = await this.getVersionPath(version);
if(!(await pathExists(versionFolder))){ throw new CustomError("Version not found", "VersionNotFound"); }
return deleteFolder(versionFolder);
}
public async editVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
if(version.steam || version.oculus){ throw new CustomError("Do not edit official Beat Saber versions", "CantEditSteam") }
const oldPath = await this.getVersionPath(version);
const editedVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color}
: {...version, name: sanitize(name), color};
const newPath = await this.getVersionPath(editedVersion);
if(oldPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
this.updateLastVersionLaunched(version, editedVersion);
return editedVersion;
}
if(pathExistsSync(newPath)){
throw new CustomError("Unable to edit the version, path already exist", "VersionAlreadExist");
}
return rename(oldPath, newPath).then(() => {
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
this.updateLastVersionLaunched(version, editedVersion);
return editedVersion;
}).catch((err: Error) => {
log.error("edit version error", err, version, name, color);
throw CustomError.fromError(err, "CantRename");
});
}
public async cloneVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
const originPath = await this.getVersionPath(version);
const cloneVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color, steam: false, oculus: false}
: {...version, name: sanitize(name), color, steam: false, oculus: false};
const newPath = await this.getVersionPath(cloneVersion);
if(pathExistsSync(newPath)){
throw new CustomError("Unable to clone the version, path already exist", "VersionAlreadExist");
}
if(originPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(cloneVersion);
}
return copyDirectoryWithJunctions(originPath, newPath).then(() => {
this.addCustomVersion(cloneVersion);
return cloneVersion;
}).catch((err: Error) => {
log.error("Error occured while cloning the version", err, version, name, color);
throw CustomError.fromError(err, "CantClone");
})
}
public importVersion(opt: ImportVersionOptions): Observable<Progression<BSVersion>>{
const { fromPath, store } = opt;
let failed = false;
let versionDest: {version: BSVersion, dest: string} = null;
return from(this.getVersionOfBSFolder(fromPath)).pipe(
map(version => version || CustomError.throw(new Error("Unable to get BS version of path"), "NOT_BS_FOLDER")),
switchMap(version => this.getVersionPath(version).then(dest => ({version, dest}))),
switchMap(({version, dest}) => ensurePathNotAlreadyExist(dest).then(uniquePath => {
const res = dest === uniquePath ? {version, dest} : {version: {...version, name: path.basename(uniquePath)}, dest: uniquePath} as {version: BSVersion, dest: string};
versionDest = res;
return res;
})),
switchMap(({version, dest}) => rxCopy(fromPath, dest, { dereference: true }).pipe(
map(progress => ({...progress, data: version}))
)),
catchError(err => {
failed = true;
return throwError(() => err);
}),
finalize(async () => {
if(failed){ return; }
await this.initVersionMetadata(versionDest.version, { store });
})
);
}
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);
}
public get loadedVersions$(): Observable<BSVersion[]>{
return this._loadedVersions$.asObservable();
}
}
export interface ImportVersionOptions {
fromPath: string;
store: BsStore
}