[feature] advancement on steam shortcut

This commit is contained in:
MathieuG-P
2025-01-05 21:33:23 +01:00
parent cab1fb93de
commit 774c3a2954
9 changed files with 310 additions and 44 deletions
+1 -1
View File
@@ -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) => {
@@ -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<boolean>{
private async getSteamShortcutData(launchOptions: LaunchOption): Promise<SteamShortcut>{
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<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");
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`), {
+226 -30
View File
@@ -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<number>{
public async getActiveUser(): Promise<number> {
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<boolean>{
public async isSteamRunning(): Promise<boolean> {
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<number>{
public async getSteamPid(): Promise<number> {
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<boolean>{
public async isElevated(): Promise<boolean> {
const steamPid = await this.getSteamPid();
if(!steamPid){ return false; }
if (!steamPid) {
return false;
}
return isElevated(steamPid);
}
public async getSteamPath(): Promise<string>{
if(this.steamPath){ return this.steamPath; }
public async getSteamPath(): Promise<string> {
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<void> {
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<string[]> {
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<string> {
return path.join(await this.getSteamPath(), "userdata", userId.toString(), "config", "shortcuts.vdf");
}
private async readShortcutsFile(shortcutsPath: string): Promise<SteamShortcut[]> {
// 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<SteamShortcut[]> {
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<void> {
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<void> {
const userIds = userId ? [userId] : await (async (): Promise<number[]> => {
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);
}
}
@@ -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,
</div>
</div>
</div>
<Tippy placement="right" theme="default" content={"Si activé, au lieu de créer un raccourci sur le bureau, celui-ci sera créé dans Steam."}>
<div className="h-full flex items-center gap-1.5 mt-3 mb-4 w-fit pr-1">
<BsmCheckbox className="h-5 aspect-square relative z-[1]" checked={steamShortcut} onChange={e => setSteamShortcut(() => e)} />
<span>Créer un raccourci Steam</span>
</div>
</Tippy>
{isSteamVersion && (
<Tippy placement="right" theme="default" content={"Si activé, au lieu de créer un raccourci sur le bureau, celui-ci sera créé dans Steam."}>
<div className="h-full flex items-center gap-1.5 mt-3 mb-4 w-fit pr-1">
<BsmCheckbox className="h-5 aspect-square relative z-[1]" checked={steamShortcut} onChange={e => setSteamShortcut(() => e)} />
<span>Créer un raccourci Steam</span>
</div>
</Tippy>
)}
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-2">
<BsmButton typeColor="cancel" className="rounded-md text-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md text-center transition-all" onClick={completeModal} withBar={false} text="modals.create-launch-shortcut.valid-btn" />
@@ -221,7 +221,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
handleSearch();
}}
/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} selected={searchParams.sortOrder} onChange={sort => handleSortChange(sort)} />
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center w-min" options={sortOptions} selected={searchParams.sortOrder} onChange={sort => handleSortChange(sort)} />
</div>
{(() => {
@@ -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"
+2 -2
View File
@@ -125,9 +125,9 @@ export class BSLauncherService {
}
public createLaunchShortcut(launchOptions: LaunchOption): Observable<boolean>{
public createLaunchShortcut(launchOptions: LaunchOption, steamShortcut: boolean): Observable<boolean>{
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<void>{
+1 -1
View File
@@ -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 };
+28
View File
@@ -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<BaseShortcut> {
// Mandatory fields
AppName: string;
Exe: string;
StartDir: string;
}