diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index 6bd98495..ba48b163 100644 --- a/src/main/services/bs-launcher/abstract-launcher.service.ts +++ b/src/main/services/bs-launcher/abstract-launcher.service.ts @@ -46,7 +46,7 @@ export abstract class AbstractLauncherService { return Array.from(new Set(launchArgs).values()); } - protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { + protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): ChildProcessWithoutNullStreams { const spawnOptions: SpawnOptionsWithoutStdio = { detached: true, cwd: path.dirname(bsExePath), ...(options || {}) }; @@ -57,7 +57,7 @@ export abstract class AbstractLauncherService { spawnOptions.shell = true; // For windows to spawn properly return bsmSpawn(`"${bsExePath}"`, { args, options: spawnOptions, log: BsmShellLog.Command, - linux: { prefix: this.linux.getProtonPrefix() }, + linux: { prefix: options?.protonPrefix || "" }, flatpak: { host: IS_FLATPAK, env: [ @@ -80,7 +80,7 @@ export abstract class AbstractLauncherService { protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { const process = this.launchBSProcess(bsExePath, args, options); - let timoutId: NodeJS.Timeout; + let timeoutId: NodeJS.Timeout; const exit = new Promise((resolve, reject) => { // Don't remove, useful for debugging! @@ -103,7 +103,7 @@ export abstract class AbstractLauncherService { const unrefAfter = options?.unrefAfter ?? sToMs(10); - timoutId = setTimeout(() => { + timeoutId = setTimeout(() => { log.error("BS process unref after timeout", unrefAfter); process.unref(); process.removeAllListeners(); @@ -111,7 +111,7 @@ export abstract class AbstractLauncherService { }, unrefAfter); }).finally(() => { - clearTimeout(timoutId); + clearTimeout(timeoutId); }); return { process, exit }; @@ -119,5 +119,6 @@ export abstract class AbstractLauncherService { } export type SpawnBsProcessOptions = { + protonPrefix?: string; unrefAfter?: number; } & SpawnOptionsWithoutStdio; diff --git a/src/main/services/bs-launcher/bs-launcher.service.ts b/src/main/services/bs-launcher/bs-launcher.service.ts index 6ebe7ac7..ac8cd546 100644 --- a/src/main/services/bs-launcher/bs-launcher.service.ts +++ b/src/main/services/bs-launcher/bs-launcher.service.ts @@ -24,6 +24,7 @@ import { LaunchMod, LaunchMods } from "shared/models/bs-launch/launch-option.int import { StaticConfigurationService } from "../static-configuration.service"; import { SteamService } from "../steam.service"; import { tryit } from "shared/helpers/error.helpers"; +import { LinuxService } from "../linux.service"; export class BSLauncherService { private static instance: BSLauncherService; @@ -37,6 +38,7 @@ export class BSLauncherService { private readonly steam: SteamService; private readonly oculusLauncher: OculusLauncherService; private readonly staticConfig: StaticConfigurationService; + private readonly linux: LinuxService; public static getInstance(): BSLauncherService { if (!BSLauncherService.instance) { @@ -55,6 +57,7 @@ export class BSLauncherService { this.oculusLauncher = OculusLauncherService.getInstance(); this.staticConfig = StaticConfigurationService.getInstance(); this.steam = SteamService.getInstance(); + this.linux = LinuxService.getInstance(); this.bsmProtocolService.on("launch", link => { log.info("Launch from bsm protocol", link.toString()); @@ -230,12 +233,13 @@ export class BSLauncherService { description: [shortcutName, launchOptions.version.color].join(" "), // <= Need color in description to help windows know that the shortcut is different }) ), - linux: async () => ( - createDesktopUrlShortcut(path.join(app.getPath("desktop"), `${shortcutName}.desktop`), { - name: shortcutName, - url: shortcutUrl, - icon: await this.createShortcutPng(shortcutIconColor), - }) + linux: async () => this.linux.createDesktopShortcut( + path.join(app.getPath("desktop"), `${shortcutName}.desktop`), + shortcutName, + await this.createShortcutPng(shortcutIconColor), + launchOptions, + await this.steam.getSteamPath(), + await this.localVersionService.getVersionPath(launchOptions.version) ) }) @@ -282,26 +286,3 @@ type ShortcutParams = { versionOculus?: string; } -/** - * Create .desktop file for url shortcut (only for linux) - * @param {string} shortcutPath - * @param options - * @returns - */ -function createDesktopUrlShortcut(shortcutPath: string, options?: { - url: string - name: string, - icon: string -}): Promise { - const { url, name, icon } = options || {}; - - const data = [ - "[Desktop Entry]", - "Type=Link", - `Name=${name}`, - `Icon=${icon}`, - `URL=${url}` - ].join("\n"); - - return writeFile(shortcutPath, data).then(() => true); -} diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index d3d25b31..96eb3822 100644 --- a/src/main/services/bs-launcher/steam-launcher.service.ts +++ b/src/main/services/bs-launcher/steam-launcher.service.ts @@ -112,9 +112,14 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto "SteamGameId": BS_APP_ID, }; + let protonPrefix = ""; // Linux setup if (process.platform === "linux") { - await this.linux.setupLaunch(launchOptions, steamPath, bsFolderPath, env); + const linuxSetup = await this.linux.setupLaunch( + launchOptions, steamPath, bsFolderPath + ); + protonPrefix = linuxSetup.protonPrefix; + Object.assign(env, linuxSetup.env); } obs.next({type: BSLaunchEvent.BS_LAUNCHING}); @@ -122,7 +127,10 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto const spawnOpts = { env, cwd: bsFolderPath }; const launchPromise = !launchOptions.admin ? ( - this.launchBs(bsExePath, launchArgs, spawnOpts).exit + this.launchBs(bsExePath, launchArgs, { + ...spawnOpts, + protonPrefix + }).exit ) : ( new Promise(resolve => { const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${bsExePath}" ${launchArgs.join(" ")} --log-path "${path.join(app.getPath("logs"), "bs-admin-start.log")}"`, spawnOpts); diff --git a/src/main/services/linux.service.ts b/src/main/services/linux.service.ts index 8db15e8d..b3ef82af 100644 --- a/src/main/services/linux.service.ts +++ b/src/main/services/linux.service.ts @@ -1,7 +1,7 @@ import fs from "fs-extra"; import log from "electron-log"; import path from "path"; -import { BS_APP_ID, IS_FLATPAK, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants"; +import { BS_APP_ID, BS_EXECUTABLE, IS_FLATPAK, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants"; import { InstallationLocationService } from "./installation-location.service"; import { StaticConfigurationService } from "./static-configuration.service"; import { CustomError } from "shared/models/exceptions/custom-error.class"; @@ -21,7 +21,6 @@ export class LinuxService { private readonly installLocationService: InstallationLocationService; private readonly staticConfig: StaticConfigurationService; - private protonPrefix = ""; private nixOS: boolean | undefined; @@ -40,24 +39,16 @@ export class LinuxService { public async setupLaunch( launchOptions: LaunchOption, steamPath: string, - bsFolderPath: string, - env: Record - ) { + bsFolderPath: string + ): Promise<{ + protonPrefix: string; + env: Record; + }> { if (launchOptions.admin) { log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user."); launchOptions.admin = false; } - // Create the compat data path if it doesn't exist. - // If the user never ran Beat Saber through steam before - // using bsmanager, it won't exist, and proton will fail - // to launch the game. - const compatDataPath = this.getCompatDataPath(); - if (!fs.existsSync(compatDataPath)) { - log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`); - fs.mkdirSync(compatDataPath); - } - if (!this.staticConfig.has("proton-folder")) { throw CustomError.fromError( new Error("Proton folder not set"), @@ -75,27 +66,46 @@ export class LinuxService { ); } - this.protonPrefix = await this.isNixOS() - ? `steam-run "${protonPath}" run` - : `"${protonPath}" run`; + return { + protonPrefix: await this.isNixOS() + ? `steam-run "${protonPath}" run` + : `"${protonPath}" run`, + env: await this.prepareEnvVariables(launchOptions, steamPath, bsFolderPath) + }; + } + + private async prepareEnvVariables( + launchOptions: LaunchOption, + steamPath: string, + bsFolderPath: string + ): Promise> { + // Create the compat data path if it doesn't exist. + // If the user never ran Beat Saber through steam before + // using bsmanager, it won't exist, and proton will fail + // to launch the game. + const compatDataPath = this.getCompatDataPath(); + if (!fs.existsSync(compatDataPath)) { + log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`); + fs.mkdirSync(compatDataPath); + } // Setup Proton environment variables - Object.assign(env, { + const envVars: Record = { "WINEDLLOVERRIDES": "winhttp=n,b", // Required for mods to work "STEAM_COMPAT_DATA_PATH": compatDataPath, "STEAM_COMPAT_INSTALL_PATH": bsFolderPath, "STEAM_COMPAT_CLIENT_INSTALL_PATH": steamPath, "STEAM_COMPAT_APP_ID": BS_APP_ID, // Run game in steam environment; fixes #585 for unicode song titles - "SteamEnv": 1, - }); + "SteamEnv": "1", + }; if (launchOptions.launchMods?.includes(LaunchMods.PROTON_LOGS)) { - Object.assign(env, { - "PROTON_LOG": 1, - "PROTON_LOG_DIR": path.join(bsFolderPath, "Logs"), - }); + envVars.PROTON_LOG = "1"; + envVars.PROTON_LOG_DIR = path.join(bsFolderPath, "Logs"); } + + return envVars; } public verifyProtonPath(protonFolder: string = ""): boolean { @@ -134,11 +144,6 @@ export class LinuxService { ? path.join(compatDataPath, "pfx") : ""; } - public getProtonPrefix(): string { - // Set in setupLaunch - return this.protonPrefix; - } - // === NixOS Specific === // public async isNixOS(): Promise { @@ -159,4 +164,50 @@ export class LinuxService { return this.nixOS; } + + // === Shortcuts === // + + public async createDesktopShortcut( + shortcutPath: string, + name: string, + icon: string, + launchOptions: LaunchOption, + steamPath: string, + bsFolderPath: string + ): Promise { + try { + const { + protonPrefix, env + } = await this.setupLaunch(launchOptions, steamPath, bsFolderPath); + + Object.assign(env, { + "SteamAppId": BS_APP_ID, + "SteamOverlayGameId": BS_APP_ID, + "SteamGameId": BS_APP_ID, + }); + + const envString = Object.entries(env) + .map(([ key, value ]) => `${key}="${value}"`) + .join(" "); + const command = `${envString} ${protonPrefix} "${ + path.join(bsFolderPath, BS_EXECUTABLE) + }"`; + + const desktopEntry = [ + "[Desktop Entry]", + "Type=Application", + `Name=${name}`, + `Icon=${icon}`, + `Path=${bsFolderPath}`, + `Exec=${command}` + ].join("\n"); + + await fs.writeFile(shortcutPath, desktopEntry); + log.info("Created shorcut at ", `"${shortcutPath}/${name}"`); + return true; + } catch (error) { + log.error("Could not create shortcut", error); + return false; + } + } } diff --git a/src/main/services/steam.service.ts b/src/main/services/steam.service.ts index 9298f8c8..54414ff5 100644 --- a/src/main/services/steam.service.ts +++ b/src/main/services/steam.service.ts @@ -211,7 +211,7 @@ export class SteamService { for (const userId of userIds) { const shortcuts = await this.getShortcuts(userId).catch(e => { log.warn("Error while reading shortcuts", e); - return []; + return [] as SteamShortcut[]; }); shortcuts.push(new SteamShortcut(shortcutData));