From 9ee036527c6589dd96d409b1c10dc00ec0db5f81 Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Wed, 13 Sep 2023 23:41:46 +0200 Subject: [PATCH] [feature] LIV shortcut synchronise with installed BSM versions --- src/main/services/bs-launcher.service.ts | 23 +++- src/main/services/bs-local-version.service.ts | 10 ++ src/main/services/bsm-protocol.service.ts | 1 - src/main/services/liv.service.ts | 69 ---------- src/main/services/liv/liv-shortcut.service.ts | 97 ++++++++++++++ src/main/services/liv/liv.service.ts | 119 ++++++++++++++++++ src/shared/helpers/error.helpers.ts | 7 ++ 7 files changed, 252 insertions(+), 74 deletions(-) delete mode 100644 src/main/services/liv.service.ts create mode 100644 src/main/services/liv/liv-shortcut.service.ts create mode 100644 src/main/services/liv/liv.service.ts create mode 100644 src/shared/helpers/error.helpers.ts diff --git a/src/main/services/bs-launcher.service.ts b/src/main/services/bs-launcher.service.ts index 36b428f1..29ce4e21 100644 --- a/src/main/services/bs-launcher.service.ts +++ b/src/main/services/bs-launcher.service.ts @@ -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{ + 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{ + const shortcutUrl = this.createLaunchLink(launchOptions); const shortcutName = ["Beat Saber", launchOptions.version.BSVersion, launchOptions.version.name].join(" "); const shortcutIconColor = new Color(launchOptions.version.color, "hex"); diff --git a/src/main/services/bs-local-version.service.ts b/src/main/services/bs-local-version.service.ts index 7966d443..ffcea0d5 100644 --- a/src/main/services/bs-local-version.service.ts +++ b/src/main/services/bs-local-version.service.ts @@ -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 { BehaviorSubject, 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; 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(); } private async getVersionFromGlobalGameManagerFile(versionFilePath: string): Promise { @@ -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{ + return this._loadedVersions$.asObservable(); + } } diff --git a/src/main/services/bsm-protocol.service.ts b/src/main/services/bsm-protocol.service.ts index 8320e919..b6551dd3 100644 --- a/src/main/services/bsm-protocol.service.ts +++ b/src/main/services/bsm-protocol.service.ts @@ -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 { diff --git a/src/main/services/liv.service.ts b/src/main/services/liv.service.ts deleted file mode 100644 index 717e5f9d..00000000 --- a/src/main/services/liv.service.ts +++ /dev/null @@ -1,69 +0,0 @@ -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 constructor() { - - } - - public isLivInstalled(): Promise { - return execOnOs({ - win32: async () => { - const regRes = await regedit.promisified.list([this.livRegeditKey]).then(res => res[this.livRegeditKey]); - return regRes !== undefined && regRes.exists; - } - }); - } - - public async createLivShortcut(entry: LivEntry): Promise { - return execOnOs({ - win32: async () => { - const livExternalAppsRegeditKey = path.join(this.livRegeditKey, "ExternalApplications", entry.id); - await regedit.promisified.createKey([livExternalAppsRegeditKey]); - await regedit.promisified.putValue({ - [livExternalAppsRegeditKey]: { - "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" - } - } - }); - } - }); - } - -} - -export interface LivEntry { - id: string, - name: string, - installPath: string, - executable: string, - arguments: string -} - diff --git a/src/main/services/liv/liv-shortcut.service.ts b/src/main/services/liv/liv-shortcut.service.ts new file mode 100644 index 00000000..71ce0997 --- /dev/null +++ b/src/main/services/liv/liv-shortcut.service.ts @@ -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"; + +export class LivShortcut { + + private static instance: LivShortcut; + + public static getInstance(): LivShortcut { + if (!LivShortcut.instance) { + LivShortcut.instance = new LivShortcut(); + } + return LivShortcut.instance; + } + + private readonly LIB_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(async versions => { + 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 + })); + }); + }); + } + + private buildShortcutId(version: BSVersion): string { + const name = (() => { + if(version.steam){ return "Steam"; } + if(version.oculus){ return "Oculus"; } + return version.name; + })(); + return sanitize([this.LIB_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 { + 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{ + return this.buildLivEntry(launchOptions).then(entry => this.liv.createLivShortcut(entry)); + } + + private async deleteLivShortcut(bsVersion: BSVersion): Promise{ + return this.liv.deleteLivShortcut(this.buildShortcutId(bsVersion)); + } + + private async clearLivShortcuts(): Promise{ + const bsmShortcuts = (await this.liv.getLivShortcuts()).reduce((acc, shortcut) => { + if(shortcut.id?.startsWith(this.LIB_ID_PREFIX)){ + acc.push(shortcut); + } + return acc; + }, [] as LivEntry[]); + + return this.liv.deleteLivShortcuts(bsmShortcuts.map(shortcut => shortcut.id)).then(() => bsmShortcuts); + } + +} \ No newline at end of file diff --git a/src/main/services/liv/liv.service.ts b/src/main/services/liv/liv.service.ts new file mode 100644 index 00000000..4460d91d --- /dev/null +++ b/src/main/services/liv/liv.service.ts @@ -0,0 +1,119 @@ +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 { + return execOnOs({ + win32: async () => { + const regRes = await regedit.promisified.list([this.livRegeditKey]).then(res => res[this.livRegeditKey]); + return regRes !== undefined && regRes.exists; + } + }); + } + + public async createLivShortcut(entry: LivEntry): Promise { + 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 deleteLivShortcut(id: string): Promise { + return execOnOs({ + win32: async () => { + const livExternalAppRegeditKey = path.join(this.livExternalAppsRegeditKey, id); + return regedit.promisified.deleteKey([livExternalAppRegeditKey]); + } + }); + } + + public async deleteLivShortcuts(ids: string[]): Promise { + return execOnOs({ + win32: async () => { + const shotcutsKeys = ids.map(id => path.join(this.livExternalAppsRegeditKey, id)); + return regedit.promisified.deleteKey(shotcutsKeys); + } + }) + } + + public getLivShortcuts(): Promise { + 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 +} + diff --git a/src/shared/helpers/error.helpers.ts b/src/shared/helpers/error.helpers.ts new file mode 100644 index 00000000..ce67ffa0 --- /dev/null +++ b/src/shared/helpers/error.helpers.ts @@ -0,0 +1,7 @@ +export function tryit(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 } + } +} \ No newline at end of file