Merge pull request #320 from Zagrios/feature/liv-shortcuts

[feature] Liv shortcuts
This commit is contained in:
MathieuG-P
2023-09-15 09:51:13 +02:00
committed by GitHub
7 changed files with 245 additions and 6 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ import { LocalModelsManagerService } from "./services/additional-content/local-m
import { APP_NAME } from "./constants";
import { BSLauncherService } from "./services/bs-launcher.service";
import { IpcRequest } from "shared/models/ipc";
import { LivShortcut } from "./services/liv/liv-shortcut.service";
const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true";
@@ -63,7 +64,7 @@ const initServicesMustBeInitialized = () => {
LocalMapsManagerService.getInstance();
LocalPlaylistsManagerService.getInstance();
LocalModelsManagerService.getInstance();
LivShortcut.getInstance();
BSLauncherService.getInstance();
}
+19 -4
View File
@@ -50,8 +50,7 @@ export class BSLauncherService {
this.bsmProtocolService.on("launch", link => {
log.info("Launch from bsm protocol", link.toString());
const shortcutParams = objectFromEntries(link.searchParams.entries()) as ShortcutParams;
this.openShortcutLaunchWindow(shortcutParams).catch(log.error);
this.openShortcutLaunchWindow(this.shortcutLinkToShortcutParams(link)).catch(log.error);
});
}
@@ -179,6 +178,18 @@ export class BSLauncherService {
})});
}
public shortcutLinkToShortcutParams(shortcutLink: string|URL): ShortcutParams{
if(typeof shortcutLink === "string"){
shortcutLink = new URL(shortcutLink);
}
return objectFromEntries(shortcutLink.searchParams.entries()) as ShortcutParams;
}
public shortcutLinkToLaunchOptions(shortcutLink: string|URL): LaunchOption{
return this.shortcutParamsToLaunchOption(this.shortcutLinkToShortcutParams(shortcutLink));
}
private shortcutParamsToLaunchOption(params: ShortcutParams): LaunchOption{
const res: LaunchOption = {
version: {
@@ -263,9 +274,13 @@ export class BSLauncherService {
return iconPath;
}
public async createLaunchShortcut(launchOptions: LaunchOption): Promise<boolean>{
public createLaunchLink(launchOptions: LaunchOption): string{
const shortcutParams = this.launchOptionToShortcutParams(launchOptions);
const shortcutUrl = this.bsmProtocolService.buildLink("launch", shortcutParams).toString();
return this.bsmProtocolService.buildLink("launch", shortcutParams).toString();
}
public async createLaunchShortcut(launchOptions: LaunchOption): Promise<boolean>{
const shortcutUrl = this.createLaunchLink(launchOptions);
const shortcutName = ["Beat Saber", launchOptions.version.BSVersion, launchOptions.version.name].join(" ");
const shortcutIconColor = new Color(launchOptions.version.color, "hex");
@@ -15,6 +15,7 @@ import { copyDirectoryWithJunctions, deleteFolder, getFoldersInFolder, pathExist
import { FolderLinkerService } from "./folder-linker.service";
import { ReadStream, createReadStream } from "fs-extra";
import readline from "readline";
import { Observable, Subject } from "rxjs";
export class BSLocalVersionService {
private static instance: BSLocalVersionService;
@@ -27,6 +28,7 @@ export class BSLocalVersionService {
private readonly remoteVersionService: BSVersionLibService;
private readonly configService: ConfigurationService;
private readonly linker: FolderLinkerService;
private readonly _loadedVersions$: Subject<BSVersion[]>;
public static getInstance(): BSLocalVersionService {
if (!BSLocalVersionService.instance) {
@@ -42,6 +44,8 @@ export class BSLocalVersionService {
this.remoteVersionService = BSVersionLibService.getInstance();
this.configService = ConfigurationService.getInstance();
this.linker = FolderLinkerService.getInstance();
this._loadedVersions$ = new Subject<BSVersion[]>();
}
private async getVersionFromGlobalGameManagerFile(versionFilePath: string): Promise<BSVersion> {
@@ -233,6 +237,8 @@ export class BSLocalVersionService {
this.setCustomVersions(versions.filter(v => !!v.color));
this._loadedVersions$.next(versions);
return versions;
}
@@ -310,4 +316,8 @@ export class BSLocalVersionService {
return (await linkedFolder).filter(folder => folder);
}
public get loadedVersions$(): Observable<BSVersion[]>{
return this._loadedVersions$.asObservable();
}
}
@@ -1,6 +1,5 @@
import { Subject, Subscription, filter } from "rxjs";
import { DeepLinkService } from "./deep-link.service";
import { URL } from "url";
import { buildUrl, isValidUrl } from "../../shared/helpers/url.helpers";
export class BsmProtocolService {
@@ -0,0 +1,97 @@
import { BSLauncherService } from "../bs-launcher.service";
import { LivEntry, LivService } from "./liv.service";
import { LaunchOption } from "shared/models/bs-launch";
import { app } from "electron";
import { BSLocalVersionService } from "../bs-local-version.service";
import { BSVersion } from "shared/bs-version.interface";
import { debounceTime, distinctUntilChanged, map } from "rxjs";
import equal from "fast-deep-equal";
import sanitize from "sanitize-filename";
import { tryit } from "../../../shared/helpers/error.helpers";
import log from "electron-log"
export class LivShortcut {
private static instance: LivShortcut;
public static getInstance(): LivShortcut {
if (!LivShortcut.instance) {
LivShortcut.instance = new LivShortcut();
}
return LivShortcut.instance;
}
private readonly LIV_ID_PREFIX = "BSManager";
private readonly liv: LivService;
private readonly launcher: BSLauncherService;
private readonly versionManager: BSLocalVersionService;
private constructor() {
this.liv = LivService.getInstance();
this.launcher = BSLauncherService.getInstance();
this.versionManager = BSLocalVersionService.getInstance();
this.liv.isLivInstalled().then(installed => {
if(!installed){ return; }
this.versionManager.loadedVersions$.pipe(debounceTime(1000), distinctUntilChanged((prev, curr) => equal(prev, curr)), map(versions => versions.filter(v => !v.steam))).subscribe(versions => {
(async () => {
const clearedLaunchOptions = (await this.clearLivShortcuts().catch(() => [] as LivEntry[]))?.map(entry => {
return tryit(() => this.launcher.shortcutLinkToLaunchOptions(entry.arguments)).result;
});
versions.forEach(version => this.createLivShortcut({
...(clearedLaunchOptions?.find(launchOpt => launchOpt.version.ino === version.ino) ?? {}),
version,
skipAlreadyRunning: true
}));
})().catch(err => log.error("Error while creating LIV shortcuts", err));
});
});
}
private buildShortcutId(version: BSVersion): string {
const name = (() => {
if(version.steam){ return "Steam"; }
if(version.oculus){ return "Oculus"; }
return version.name;
})();
return sanitize([this.LIV_ID_PREFIX, name, version.BSVersion].filter(Boolean).join("-").replaceAll(".", "-").replaceAll(" ", "-"));
}
private buildShortcutName(version: BSVersion): string {
const name = (() => {
if(version.steam){ return "Steam"; }
if(version.oculus){ return "Oculus"; }
return version.name;
})();
return ["Beat Saber", name, `v${version.BSVersion}`].filter(Boolean).join(" ");
}
private async buildLivEntry(launchOptions: LaunchOption): Promise<LivEntry> {
return {
id: this.buildShortcutId(launchOptions.version),
name: this.buildShortcutName(launchOptions.version),
installPath: await this.versionManager.getInstalledVersionPath(launchOptions.version),
executable: app.getPath("exe"),
arguments: this.launcher.createLaunchLink(launchOptions)
};
}
private createLivShortcut(launchOptions: LaunchOption): Promise<void>{
return this.buildLivEntry(launchOptions).then(entry => this.liv.createLivShortcut(entry));
}
private async clearLivShortcuts(): Promise<LivEntry[]>{
const bsmShortcuts = (await this.liv.getLivShortcuts()).reduce((acc, shortcut) => {
if(shortcut.id?.startsWith(this.LIV_ID_PREFIX)){
acc.push(shortcut);
}
return acc;
}, [] as LivEntry[]);
return this.liv.deleteLivShortcuts(bsmShortcuts.map(shortcut => shortcut.id)).then(() => bsmShortcuts);
}
}
+110
View File
@@ -0,0 +1,110 @@
import { execOnOs } from "../../helpers/env.helpers";
import path from "path";
import regedit from "regedit";
export class LivService {
private static instance: LivService;
public static getInstance(): LivService {
if (!LivService.instance) {
LivService.instance = new LivService();
}
return LivService.instance;
}
private readonly livRegeditKey: string = path.join("HKCU", "SOFTWARE", "LIV.App");
private readonly livExternalAppsRegeditKey: string = path.join(this.livRegeditKey, "ExternalApplications");
private constructor() {
}
public isLivInstalled(): Promise<boolean> {
return execOnOs({
win32: async () => {
const regRes = await regedit.promisified.list([this.livRegeditKey]).then(res => res[this.livRegeditKey]);
return regRes?.exists;
}
});
}
public async createLivShortcut(entry: LivEntry): Promise<void> {
return execOnOs({
win32: async () => {
const livExternalAppRegeditKey = path.join(this.livExternalAppsRegeditKey, entry.id);
await regedit.promisified.createKey([livExternalAppRegeditKey]);
await regedit.promisified.putValue({
[livExternalAppRegeditKey]: {
"InstallPath": {
value: entry.installPath,
type: "REG_SZ"
},
"Executable": {
value: entry.executable,
type: "REG_SZ"
},
"Arguments": {
value: entry.arguments,
type: "REG_SZ"
},
"Name": {
value: entry.name,
type: "REG_SZ"
}
}
});
}
});
}
public async deleteLivShortcuts(ids: string[]): Promise<void> {
return execOnOs({
win32: async () => {
const shotcutsKeys = ids.map(id => path.join(this.livExternalAppsRegeditKey, id));
return regedit.promisified.deleteKey(shotcutsKeys);
}
})
}
public getLivShortcuts(): Promise<LivEntry[]> {
return execOnOs({
win32: async () => {
const regRes = await regedit.promisified.list([this.livExternalAppsRegeditKey]).then(res => res[this.livExternalAppsRegeditKey]);
if(!regRes.exists){
return [];
}
const promises = regRes.keys.map(async key => {
const shortcutKey = path.join(this.livExternalAppsRegeditKey, key);
const entries = await regedit.promisified.list([shortcutKey]).then(res => res[shortcutKey]);
if(!entries.exists){
return undefined;
}
return {
id: key,
name: entries.values.Name.value,
installPath: entries.values.InstallPath.value,
executable: entries.values.Executable.value,
arguments: entries.values.Arguments.value
} as LivEntry;
});
return Promise.all(promises).then(entries => entries.filter(Boolean));
}
});
}
}
export interface LivEntry {
id: string,
name: string,
installPath: string,
executable: string,
arguments: string
}
+7
View File
@@ -0,0 +1,7 @@
export function tryit<Return>(func: () => Return): {error: Error, result: Return} {
try {
return { error: null, result: func() };
} catch (err) {
return { error: err instanceof Error ? err : new Error(`${err}`), result: null }
}
}