diff --git a/src/main/ipcs/bs-launcher-ipcs.ts b/src/main/ipcs/bs-launcher-ipcs.ts index dd2ffcd6..c1581779 100644 --- a/src/main/ipcs/bs-launcher-ipcs.ts +++ b/src/main/ipcs/bs-launcher-ipcs.ts @@ -27,7 +27,7 @@ ipc.on("bs-launch.need-start-as-admin", (_, reply) => { ipc.on("create-launch-shortcut", (args, reply) => { const bsLauncher = BSLauncherService.getInstance(); - reply(from(bsLauncher.createLaunchShortcut(args))); + reply(from(bsLauncher.createLaunchShortcut(args.options, args.steamShortcut))); }); ipc.on("bs-launch.restore-steamvr", (_, reply) => { diff --git a/src/main/services/bs-launcher/bs-launcher.service.ts b/src/main/services/bs-launcher/bs-launcher.service.ts index 94692ccd..11916a7c 100644 --- a/src/main/services/bs-launcher/bs-launcher.service.ts +++ b/src/main/services/bs-launcher/bs-launcher.service.ts @@ -22,6 +22,9 @@ import { BSVersion } from "shared/bs-version.interface"; import { BsStore } from "../../../shared/models/bs-store.enum"; import { LaunchMod, LaunchMods } from "shared/models/bs-launch/launch-option.interface"; import { StaticConfigurationService } from "../static-configuration.service"; +import { SteamService } from "../steam.service"; +import { tryit } from "shared/helpers/error.helpers"; +import { SteamShortcut } from "shared/models/steam/shortcut.model"; export class BSLauncherService { private static instance: BSLauncherService; @@ -32,6 +35,7 @@ export class BSLauncherService { private readonly ipc: IpcService; private readonly remoteVersion: BSVersionLibService; private readonly steamLauncher: SteamLauncherService; + private readonly steam: SteamService; private readonly oculusLauncher: OculusLauncherService; private readonly staticConfig: StaticConfigurationService; @@ -51,6 +55,7 @@ export class BSLauncherService { this.steamLauncher = SteamLauncherService.getInstance(); this.oculusLauncher = OculusLauncherService.getInstance(); this.staticConfig = StaticConfigurationService.getInstance(); + this.steam = SteamService.getInstance(); this.bsmProtocolService.on("launch", link => { log.info("Launch from bsm protocol", link.toString()); @@ -195,12 +200,42 @@ export class BSLauncherService { return this.bsmProtocolService.buildLink("launch", shortcutParams).toString(); } - public async createLaunchShortcut(launchOptions: LaunchOption): Promise{ + private async getSteamShortcutData(launchOptions: LaunchOption): Promise{ + const shortcutName = ["Beat Saber", launchOptions.version.BSVersion, launchOptions.version.name].join(" "); + const shortcutIconColor = new Color(launchOptions.version.color, "hex"); + + const exePath = app.getPath("exe"); + + return { + appid: "\u0000\u0000\u0000", + AppName: shortcutName, + Exe: exePath, + StartDir: path.dirname(exePath), + LaunchOptions: this.createLaunchLink(launchOptions), + icon: await this.createShortcutPng(shortcutIconColor), + tags: [ + "BSManager", + "Beat Saber", + "VR", + ] + } as SteamShortcut; + } + + public async createLaunchShortcut(launchOptions: LaunchOption, steamShortcut?: boolean): 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"); + if(steamShortcut){ + const userId = await tryit(() => this.steam.getActiveUser()); + return this.steam.createShortcut(await this.getSteamShortcutData(launchOptions), userId.result).then(() => true).catch(e => { + log.error(e); + return false; + }); + } + + return execOnOs({ win32: async () => ( shell.writeShortcutLink(path.join(app.getPath("desktop"), `${shortcutName}.lnk`), { diff --git a/src/main/services/steam.service.ts b/src/main/services/steam.service.ts index 9d099b0d..257c270c 100644 --- a/src/main/services/steam.service.ts +++ b/src/main/services/steam.service.ts @@ -2,49 +2,55 @@ import { RegDwordValue } from "regedit-rs" import path from "path"; import { parse } from "@node-steam/vdf"; import { readFile } from "fs/promises"; -import { pathExist } from "../helpers/fs.helpers"; import log from "electron-log"; import { app, shell } from "electron"; import { getProcessId, isProcessRunning } from "main/helpers/os.helpers"; import { isElevated } from "query-process"; import { execOnOs } from "../helpers/env.helpers"; +import { pathExists, pathExistsSync, readdir, writeFile } from "fs-extra"; +import { SteamShortcut, SteamShortcutKey } from "../../shared/models/steam/shortcut.model"; const { list } = (execOnOs({ win32: () => require("regedit-rs") }, true) ?? {}) as typeof import("regedit-rs"); export class SteamService { - - private static readonly PROCESS_NAME: string = process.platform === "linux" - ? "steam-runtime-launcher-service" - : "steam.exe"; + private static readonly PROCESS_NAME: string = process.platform === "linux" ? "steam-runtime-launcher-service" : "steam.exe"; private static instance: SteamService; - private steamPath: string = ''; + private steamPath: string = ""; - private constructor(){} + private constructor() {} - public static getInstance(){ - if(!SteamService.instance){ SteamService.instance = new SteamService(); } + public static getInstance() { + if (!SteamService.instance) { + SteamService.instance = new SteamService(); + } return SteamService.instance; } - public async getActiveUser(): Promise{ + public async getActiveUser(): Promise { const res = await list("HKCU\\Software\\Valve\\Steam\\ActiveProcess"); const key = res["HKCU\\Software\\Valve\\Steam\\ActiveProcess"]; - if(!key.exists){ throw new Error("Key \"HKCU\\Software\\Valve\\Steam\\ActiveProcess\" not exist"); } + if (!key.exists) { + throw new Error('Key "HKCU\\Software\\Valve\\Steam\\ActiveProcess" not exist'); + } const registryValue = key.values.ActiveUser as RegDwordValue; - if(!registryValue){ throw new Error("Value \"ActiveUser\" not exist"); } + if (!registryValue) { + throw new Error('Value "ActiveUser" not exist'); + } return registryValue.value; } - public async isSteamRunning(): Promise{ + public async isSteamRunning(): Promise { const steamProcessRunning = await isProcessRunning(SteamService.PROCESS_NAME); - if(process.platform === "linux") { return steamProcessRunning; } + if (process.platform === "linux") { + return steamProcessRunning; + } const activeUser = await this.getActiveUser().catch(err => log.error(err)); return steamProcessRunning && !!activeUser; } - public async getSteamPid(): Promise{ + public async getSteamPid(): Promise { return getProcessId(SteamService.PROCESS_NAME); } @@ -53,26 +59,29 @@ export class SteamService { * @throws Can throw an error if the Steam process is running as admin * @returns true if the Steam process is running as administrator */ - public async isElevated(): Promise{ + public async isElevated(): Promise { const steamPid = await this.getSteamPid(); - if(!steamPid){ return false; } + if (!steamPid) { + return false; + } return isElevated(steamPid); } - public async getSteamPath(): Promise{ - - if(this.steamPath){ return this.steamPath; } + public async getSteamPath(): Promise { + if (this.steamPath) { + return this.steamPath; + } switch (process.platform) { case "linux": - this.steamPath = path.join(app.getPath('home'), '.steam', "steam"); + this.steamPath = path.join(app.getPath("home"), ".steam", "steam"); return this.steamPath; case "win32": { - const res = await list(['HKLM\\SOFTWARE\\Valve\\Steam', 'HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam']); - const win64 = res['HKLM\\SOFTWARE\\Valve\\Steam']; - const win32 = res['HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam']; + const res = await list(["HKLM\\SOFTWARE\\Valve\\Steam", "HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam"]); + const win64 = res["HKLM\\SOFTWARE\\Valve\\Steam"]; + const win32 = res["HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam"]; if (win64.exists && win64?.values?.InstallPath?.value) { this.steamPath = win64.values.InstallPath.value as string; @@ -93,15 +102,21 @@ export class SteamService { let libraryFolders: any = path.join(steamPath, "steamapps", "libraryfolders.vdf"); - if (!(await pathExist(libraryFolders))) { return null; } + if (!(await pathExists(libraryFolders))) { + return null; + } libraryFolders = parse(await readFile(libraryFolders, { encoding: "utf-8" })); - if (!libraryFolders.libraryfolders) { return null; } + if (!libraryFolders.libraryfolders) { + return null; + } libraryFolders = libraryFolders.libraryfolders; for (const libKey in Object.keys(libraryFolders)) { - if (!libraryFolders?.[libKey]?.apps) { continue; } + if (!libraryFolders?.[libKey]?.apps) { + continue; + } if (libraryFolders[libKey].apps[gameId] != null) { return path.join(libraryFolders[libKey].path, "steamapps", "common", gameFolder); @@ -109,7 +124,6 @@ export class SteamService { } return null; - } catch (e) { log.error(e); return null; @@ -117,7 +131,6 @@ export class SteamService { } public async openSteam(): Promise { - await shell.openExternal("steam://open/games"); return new Promise((resolve, reject) => { @@ -125,7 +138,9 @@ export class SteamService { const interval = setInterval(() => { const steamRunning = this.isSteamRunning().catch(() => false); steamRunning.then(running => { - if(!running){ return; } + if (!running) { + return; + } clearInterval(interval); resolve(); }); @@ -138,4 +153,185 @@ export class SteamService { }, 60_000); }); } + + private async getUserDataFolders(): Promise { + const steamPath = await this.getSteamPath(); + + if (!steamPath) { + return []; + } + + const configPath = path.join(steamPath, "userdata"); + + if (!pathExistsSync(configPath)) { + return []; + } + + const folders = readdir(configPath, { withFileTypes: true }) + .then(entries => { + return entries.reduce((acc, entry) => { + if (entry.isDirectory() && Number.isInteger(parseInt(entry.name))) { + acc.push(path.join(configPath, entry.name)); + } + return acc; + }, []); + }) + .catch(err => { + log.error("Error while reading steam user data folders", err); + return []; + }); + + return folders; + } + + private async getShortcutsPath(userId: number): Promise { + return path.join(await this.getSteamPath(), "userdata", userId.toString(), "config", "shortcuts.vdf"); + } + + private async readShortcutsFile(shortcutsPath: string): Promise { + // Code taken from: https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts#Reading_the_shortcuts.vdf + + const rawData: string = await readFile(shortcutsPath, "utf-8"); + const startIndex = rawData.indexOf("\u0000shortcuts\u0000"); + if (startIndex < 0) { + console.error("Could not find shortcuts in shortcuts.vdf"); + return []; + } + const start = startIndex + "\u0000shortcuts\u0000".length; + const end = rawData.lastIndexOf("\u0008\u0008"); + if (end < 0 || end <= start) { + console.error("Could not find end of shortcuts in shortcuts.vdf"); + return []; + } + + const shortcutsString = rawData.substring(start, end - start); + + const result: SteamShortcut[] = []; + let currentShortcut: SteamShortcut | null = null; + let word = ""; + let key = ""; + let readingTags = false; + let tagId = -1; + + const shortcutKeysRegex = new RegExp(`(\u0001|\u0002)(${Object.values(SteamShortcutKey).join("|")})`, "i"); + + for (const c of shortcutsString) { + + if (c === "\u0000") { + if (word.endsWith(`\u0001${SteamShortcutKey.AppName}`)) { + if (currentShortcut) { + result.push(currentShortcut); + } + currentShortcut = { + AppName: "", + Exe: "", + StartDir: "", + icon: "", + LaunchOptions: "", + IsHidden: null, + tags: [], + }; + key = `\u0001${SteamShortcutKey.AppName}`; + } else if (shortcutKeysRegex.test(word)) { + key = word; + } else if (word === SteamShortcutKey.tags) { + readingTags = true; + } else if (key !== "") { + const currentKey = shortcutKeysRegex.exec(key).pop().replaceAll("\u0001", "").replaceAll("\u0002", "") as SteamShortcutKey; + if (currentShortcut && currentKey && currentKey !== SteamShortcutKey.tags) { + currentShortcut[currentKey] = (word as string & string[]); + } + key = ""; + } else if (readingTags) { + if (word.startsWith("\u0001")) { + tagId = parseInt(word.substring(1), 10); + } else if (tagId >= 0 && currentShortcut) { + currentShortcut.tags.push(word); + tagId = -1; + } else { + readingTags = false; + } + } + word = ""; + } else { + word += c; + } + } + + if (currentShortcut) { + result.push(currentShortcut); + } + + return result; + } + + private async getShortcuts(userId: number): Promise { + const shortcutsPath = await this.getShortcutsPath(userId); + return this.readShortcutsFile(shortcutsPath); + } + + private buildShortcutTagsString(tags: string[]): string { + let tagString = "\u0000tags\u0000"; + for (let i = 0; i < tags.length; i++) { + tagString += `\u0001${i}\u0000${tags[i]}\u0000`; + } + tagString += "\u0008"; + return tagString; + } + + private buildShortcutString(shortcut: SteamShortcut): string { + const getSeparator = (key: SteamShortcutKey): string => { + return key === SteamShortcutKey.IsHidden || key === SteamShortcutKey.appid ? "\u0002" : "\u0001"; + }; + + const getQuote = (key: SteamShortcutKey): string => { + return key === SteamShortcutKey.Exe || key === SteamShortcutKey.StartDir || key === SteamShortcutKey.icon ? "\"" : ""; + } + + let shortcutString = ""; + for (const key of Object.keys(shortcut)) { + console.log("KEY", key); + if(key === SteamShortcutKey.tags) { + continue; + } + + const value = shortcut[key as SteamShortcutKey]; + shortcutString += `${getSeparator(key as SteamShortcutKey)}${key}\u0000\"${value}\"\u0000`; + } + + shortcutString += `\u0000\u0000${this.buildShortcutTagsString(shortcut.tags)}`; + return shortcutString; + } + + private async writeShortcuts(shortcuts: SteamShortcut[], userId: number): Promise { + let shortcutsString = "\u0000shortcuts\u0000"; + for (let i = 0; i < shortcuts.length; i++) { + shortcutsString += `\u0000${i}\u0000`; + shortcutsString += this.buildShortcutString(shortcuts[i]); + shortcutsString += "\u0008"; + } + shortcutsString += "\u0008\u0008"; + + const shortcutsPath = await this.getShortcutsPath(userId); + await writeFile(shortcutsPath, shortcutsString, { encoding: "utf-8" }); + + } + + public async createShortcut(shortcutData: SteamShortcut, userId?: number): Promise { + const userIds = userId ? [userId] : await (async (): Promise => { + const folders = await this.getUserDataFolders(); + return folders.map(folder => parseInt(path.basename(folder))); + })(); + + for (const userId of userIds) { + const shortcuts = await this.getShortcuts(userId).catch(e => { + log.warn("Error while reading shortcuts", e); + return []; + }); + shortcuts.push(shortcutData); + await this.writeShortcuts(shortcuts, userId); + } + + log.info("Shortcut created", shortcutData); + } } diff --git a/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx b/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx index 108d07b0..c8831752 100644 --- a/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/create-launch-shortcut-modal.component.tsx @@ -6,12 +6,13 @@ import { BsmButton } from "renderer/components/shared/bsm-button.component"; import { LaunchOption } from "shared/models/bs-launch"; import { useService } from "renderer/hooks/use-service.hook"; import { BSLauncherService } from "renderer/services/bs-launcher.service"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { BsNoteFill } from "renderer/components/svgs/icons/bs-note-fill.component"; import { useThemeColor } from "renderer/hooks/use-theme-color.hook"; import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component"; import Tippy from "@tippyjs/react"; import { LaunchMod } from "shared/models/bs-launch/launch-option.interface"; +import { BsStore } from "shared/models/bs-store.enum"; export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean, launchOption: LaunchOption }, BSVersion> = ({resolver, options: {data}}) => { @@ -25,6 +26,10 @@ export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean, const [additionalArgsString, setAdditionalArgsString] = useState(launchOption.additionalArgs?.join("; ") ?? ""); const [steamShortcut, setSteamShortcut] = useState(false); + const isSteamVersion = useMemo(() => { + return data.steam || data.metadata?.store === BsStore.STEAM; + }, [data]); + const completeModal = () => { if(advanced) { @@ -97,12 +102,14 @@ export const CreateLaunchShortcutModal: ModalComponent<{ steamShortcut: boolean, - -
- setSteamShortcut(() => e)} /> - Créer un raccourci Steam -
-
+ {isSteamVersion && ( + +
+ setSteamShortcut(() => e)} /> + Créer un raccourci Steam +
+
+ )}
resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 15c768e5..eeb7cf57 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -221,7 +221,7 @@ export const DownloadMapsModal: ModalComponent - handleSortChange(sort)} /> + handleSortChange(sort)} />
{(() => { diff --git a/src/renderer/pages/version-viewer.component.tsx b/src/renderer/pages/version-viewer.component.tsx index 9e41ca49..d54f0fa2 100644 --- a/src/renderer/pages/version-viewer.component.tsx +++ b/src/renderer/pages/version-viewer.component.tsx @@ -120,7 +120,7 @@ export function VersionViewer() { const { exitCode, data } = await modalService.openModal(CreateLaunchShortcutModal, {data: state}); if(exitCode !== ModalExitCode.COMPLETED){ return; } - lastValueFrom(bsLauncher.createLaunchShortcut(data.launchOption)).then(() => { + lastValueFrom(bsLauncher.createLaunchShortcut(data.launchOption, data.steamShortcut)).then(() => { notification.notifySuccess({ title: "notifications.create-launch-shortcut.success.title", desc: "notifications.create-launch-shortcut.success.msg" diff --git a/src/renderer/services/bs-launcher.service.ts b/src/renderer/services/bs-launcher.service.ts index 00766b27..86da6d59 100644 --- a/src/renderer/services/bs-launcher.service.ts +++ b/src/renderer/services/bs-launcher.service.ts @@ -125,9 +125,9 @@ export class BSLauncherService { } - public createLaunchShortcut(launchOptions: LaunchOption): Observable{ + public createLaunchShortcut(launchOptions: LaunchOption, steamShortcut: boolean): Observable{ const options: LaunchOption = {...launchOptions, version: {...launchOptions.version, color: launchOptions.version.color || this.theme.getBsmColors()[1]}}; - return this.ipcService.sendV2("create-launch-shortcut", options); + return this.ipcService.sendV2("create-launch-shortcut", { options, steamShortcut }); } public restoreSteamVR(): Promise{ diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 06f83794..9337b337 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -53,7 +53,7 @@ export interface IpcChannelMapping { "bs-installer.set-install-path": { request: { path: string, move: boolean }, response: string}; /* ** bs-launcher-ipcs ** */ - "create-launch-shortcut": { request: LaunchOption, response: boolean }; + "create-launch-shortcut": { request: { options: LaunchOption, steamShortcut?: boolean }, response: boolean }; "bs-launch.need-start-as-admin": { request: void, response: boolean }; "bs-launch.launch": { request: LaunchOption, response: BSLaunchEventData }; "bs-launch.restore-steamvr": { request: void, response: void }; diff --git a/src/shared/models/steam/shortcut.model.ts b/src/shared/models/steam/shortcut.model.ts new file mode 100644 index 00000000..c3561d26 --- /dev/null +++ b/src/shared/models/steam/shortcut.model.ts @@ -0,0 +1,28 @@ +export enum SteamShortcutKey { + AppName = "AppName", + Exe = "Exe", + StartDir = "StartDir", + appid = "appid", + icon = "icon", + ShortcutPath = "ShortcutPath", + LaunchOptions = "LaunchOptions", + IsHidden = "IsHidden", + AllowDesktopConfig = "AllowDesktopConfig", + OpenVR = "OpenVR", + Devkit = "Devkit", + DevkitGameID = "DevkitGameID", + LastPlayTime = "LastPlayTime", + FlatpakAppID = "FlatpakAppID", + tags = "tags" +} + +type BaseShortcut = { + [K in SteamShortcutKey]: K extends "tags" ? string[] : string +}; + +export interface SteamShortcut extends Partial { + // Mandatory fields + AppName: string; + Exe: string; + StartDir: string; +}