diff --git a/src/__tests__/unit/env.test.ts b/src/__tests__/unit/env.test.ts index fc6c80d2..f6d5cbdb 100644 --- a/src/__tests__/unit/env.test.ts +++ b/src/__tests__/unit/env.test.ts @@ -3,59 +3,66 @@ import { parseEnvString } from "main/helpers/env.helpers"; describe("Test parseEnvString", () => { it("Empty", () => { - const envVars = parseEnvString(""); - expect(envVars).toEqual({}); + const { env, command } = parseEnvString(""); + expect(env).toEqual({}); + expect(command).toEqual(""); }); it("Single test; no quotes", () => { const envString = "HELLO=World!"; - const envVars = parseEnvString(envString); - expect(envVars).toEqual({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual({ HELLO: "World!", }); + expect(command).toEqual(""); }); it("Single test; single quotes", () => { const envString = "SINGLE_QOUTE='Single quote with spaces'"; - const envVars = parseEnvString(envString); - expect(envVars).toEqual({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual({ SINGLE_QOUTE: "Single quote with spaces", }); + expect(command).toEqual(""); }); it("Single test; double quotes", () => { const envString = 'DOUBLE_QOUTE="Some random quote."'; - const envVars = parseEnvString(envString); - expect(envVars).toEqual({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual({ DOUBLE_QOUTE: "Some random quote.", }); + expect(command).toEqual(""); }); it("Single test; empty value", () => { const envString = "EMPTY="; - const envVars = parseEnvString(envString); - expect(envVars).toEqual({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual({ EMPTY: "", }); + expect(command).toEqual(""); }); it("Multiple test; combined", () => { const envString = `HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=` - const envVars = parseEnvString(envString); - expect(envVars).toEqual(expect.objectContaining({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual(expect.objectContaining({ HELLO: "World!", DOUBLE_QUOTE: "Two Words", SINGLE_QUOTE: "", EMPTY: "" })); + expect(command).toEqual(""); }); it("Key with numbers and lower case", () => { const envString = "H3ll0=world"; - const envVars = parseEnvString(envString); - expect(envVars).toEqual({ + const { env, command } = parseEnvString(envString); + expect(env).toEqual({ H3ll0: "world", }); + expect(command).toEqual(""); }); }); diff --git a/src/main/helpers/env.helpers.ts b/src/main/helpers/env.helpers.ts index bb4dd8c6..e2112303 100644 --- a/src/main/helpers/env.helpers.ts +++ b/src/main/helpers/env.helpers.ts @@ -28,7 +28,19 @@ const isAlphaCharacter = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z"); const isNumber = (c: string) => c >= "0" && c <= "9"; -export function parseEnvString(envString: string): Record { +/** + * Parses the env values from an envString command + * + * @params envString + * @returns ({ + * env - parsed environment variables + * command - part of the env string which is the command + * }) + */ +export function parseEnvString(envString: string): { + env: Record; + command: string; +} { const envVars: Record = {}; let state: EnvParserState = EnvParserState.NAME_START; @@ -104,6 +116,7 @@ export function parseEnvString(envString: string): Record { default: } + // TODO: Change to an early exit instead if (state === EnvParserState.ERROR) { throw new CustomError( `parseEnvString failed: invalid character at position ${pos}`, @@ -114,11 +127,14 @@ export function parseEnvString(envString: string): Record { if (state === EnvParserState.VALUE_START || state === EnvParserState.VALUE) { envVars[newName] = envString.substring(index); - return envVars; + return { env: envVars, command: "" }; } if (state === EnvParserState.NAME_START || state === EnvParserState.SPACE) { - return envVars; + return { + env: envVars, + command: envString.substring(index + 1, envString.length) + }; } throw new CustomError( diff --git a/src/main/helpers/launchOptions.helper.ts b/src/main/helpers/launchOptions.helper.ts new file mode 100644 index 00000000..ca5fb67e --- /dev/null +++ b/src/main/helpers/launchOptions.helper.ts @@ -0,0 +1,46 @@ +import { parseEnvString } from "./env.helpers"; + +/** + * Parses the launch options command into parts to be used for bsmSpawn + * + * @params command + * @params options.beatSaberExe - Replaces the %command% string + * @returns { + * env - environment variables + * cmdlet - BS.exe or a binary executable like gamemoderun and gamescope + * args - Arguments for the cmdlet. + * } + */ +export function parseLaunchOptions(launchOption: string, options: { + beatSaberExe: string; +}): { + env: Record; + cmdlet: string; + args: string; +} { + if (!launchOption) { + return { env: {}, cmdlet: "", args: "" }; + } + + // Get the env variables first + const { + env, command + } = parseEnvString(launchOption); + + // Replace the %command% + if (options.beatSaberExe) { + launchOption.replace("%command%", `"${options.beatSaberExe}"`); + } + + // First word/token is the cmdlet, the rest are the arguments + const index = command.indexOf(" "); + if (index === -1) { + return { env, cmdlet: command, args: "" } + } + + return { + env, cmdlet: command.substring(index), + args: command.substring(index + 1, command.length).trim(), + } +} + diff --git a/src/main/helpers/os.helpers.ts b/src/main/helpers/os.helpers.ts index cdafd0ac..08b9b122 100644 --- a/src/main/helpers/os.helpers.ts +++ b/src/main/helpers/os.helpers.ts @@ -24,7 +24,7 @@ export enum BsmShellLog { }; interface BsmShellOptions { - args?: string[]; + args?: string[] | string; options?: OptionsType; // Look into BsmShellLog values log?: number; @@ -37,7 +37,9 @@ export type BsmExecOptions = BsmShellOptions; function updateCommand(command: string, options: BsmSpawnOptions) { if (options?.args) { - command += ` ${options.args.join(" ")}`; + command += typeof(options.args) === "string" + ? ` ${options.args}` + : ` ${options.args.join(" ")}`; } if (process.platform === "linux") { diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index 223869a1..90975424 100644 --- a/src/main/services/bs-launcher/abstract-launcher.service.ts +++ b/src/main/services/bs-launcher/abstract-launcher.service.ts @@ -1,14 +1,12 @@ import { LaunchOption } from "shared/models/bs-launch"; import { BSLocalVersionService } from "../bs-local-version.service"; import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from "child_process"; -import path from "path"; import log from "electron-log"; import { sToMs } from "../../../shared/helpers/time.helpers"; import { LinuxService } from "../linux.service"; import { BsmShellLog, bsmSpawn } from "main/helpers/os.helpers"; import { IS_FLATPAK } from "main/constants"; import { LaunchMods } from "shared/models/bs-launch/launch-option.interface"; -import { parseEnvString } from "main/helpers/env.helpers"; export function buildBsLaunchArgs(launchOptions: LaunchOption): string[] { const launchArgs = []; @@ -47,20 +45,22 @@ export abstract class AbstractLauncherService { this.localVersions = BSLocalVersionService.getInstance(); } - private readonly COMMAND_FORMAT = "%command%"; + protected launchBeatSaberProcess(options: LaunchBeatSaberOptions): ChildProcessWithoutNullStreams { - protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): ChildProcessWithoutNullStreams { + const spawnOptions: SpawnOptionsWithoutStdio = { + detached: true, + cwd: options.beatSaberFolderPath, + }; - const spawnOptions: SpawnOptionsWithoutStdio = { detached: true, cwd: path.dirname(bsExePath), ...(options || {}) }; - - if(args.includes("--verbose")){ + if(options.args && options.args.includes("--verbose")){ spawnOptions.windowsVerbatimArguments = true; } spawnOptions.shell = true; // For windows to spawn properly - return bsmSpawn(`"${bsExePath}"`, { - args, options: spawnOptions, log: BsmShellLog.Command, - linux: { prefix: options?.protonPrefix || "" }, + // TODO: bsExePath can be another executable here + return bsmSpawn(`"${options.cmdlet}"`, { + args: options.args, options: spawnOptions, log: BsmShellLog.Command, + linux: { prefix: options.protonPrefix ?? "" }, flatpak: { host: IS_FLATPAK, env: [ @@ -80,8 +80,8 @@ export abstract class AbstractLauncherService { }); } - protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { - const process = this.launchBSProcess(bsExePath, args, options); + protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { + const process = this.launchBeatSaberProcess(options); let timeoutId: NodeJS.Timeout; @@ -120,23 +120,13 @@ export abstract class AbstractLauncherService { return { process, exit }; } - protected injectAdditionalArgsEnvs( - launchOptions: LaunchOption, - env: Record - ) { - if (!launchOptions.command) { - return; - } - - const { command } = launchOptions; - const index = command.indexOf(this.COMMAND_FORMAT); - if (index === -1) { - return; - } - - const envString = command.substring(0, index); - log.info("Parsing env string ", `"${envString}"`) - for (const [ key, value ] of Object.entries(parseEnvString(envString))) { + // Launch option helper function + protected mergeEnvVariables( + originalEnv: Record, + newEnv: Record + ): Record { + const env = { ...originalEnv }; + for (const [ key, value ] of Object.values(newEnv)) { log.info( key in env ? "Overriding" : "Injecting", `${key}="${value}"`, @@ -144,13 +134,24 @@ export abstract class AbstractLauncherService { ); env[key] = value; } - - launchOptions.command = command.substring(index + this.COMMAND_FORMAT.length); + return env; } } -export type SpawnBsProcessOptions = { - protonPrefix?: string; +export type LaunchBeatSaberOptions = { + // To be passed to the bsmSpawn helper function + // Can be the Beat Saber exe or wrapper exe (for linux) + cmdlet: string; + env: Record; + beatSaberFolderPath: string; + + args?: string[]; // Appended to the cmdlet string + + // Timeout value (in ms) to unref the Beat Saber process to BSM unrefAfter?: number; -} & SpawnOptionsWithoutStdio; + + // For linux + protonPrefix?: string; +} + diff --git a/src/main/services/bs-launcher/oculus-launcher.service.ts b/src/main/services/bs-launcher/oculus-launcher.service.ts index 072ef7bc..a992d522 100644 --- a/src/main/services/bs-launcher/oculus-launcher.service.ts +++ b/src/main/services/bs-launcher/oculus-launcher.service.ts @@ -9,6 +9,7 @@ import { pathExists } from "fs-extra"; import { AbstractLauncherService, buildBsLaunchArgs } from "./abstract-launcher.service"; import { isProcessRunning } from "../../helpers/os.helpers"; import { CustomError } from "../../../shared/models/exceptions/custom-error.class"; +import { parseLaunchOptions } from "main/helpers/launchOptions.helper"; export class OculusLauncherService extends AbstractLauncherService implements StoreLauncherInterface { @@ -49,19 +50,25 @@ export class OculusLauncherService extends AbstractLauncherService implements St // Make sure Oculus is running await this.oculus.startOculus().catch(err => log.error("Error while starting Oculus", err)); - const env: Record = { + let env: Record = { ...process.env, }; - this.injectAdditionalArgsEnvs(launchOptions, env); + const { + env: parsedEnv, + cmdlet, args, + } = parseLaunchOptions(launchOptions.command, { + beatSaberExe: exePath + }); + env = this.mergeEnvVariables(env, parsedEnv); obs.next({type: BSLaunchEvent.BS_LAUNCHING}); // Launch Beat Saber - const bsProcess = this.launchBs( - exePath, - buildBsLaunchArgs(launchOptions), - { env } - ); + const bsProcess = this.launchBeatSaber({ + env, cmdlet, + beatSaberFolderPath: bsPath, + args: [ args, ...buildBsLaunchArgs(launchOptions) ] + }); return bsProcess.exit.catch(err => { throw CustomError.fromError(err, BSLaunchError.BS_EXIT_ERROR); diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index 68100b31..f5d47497 100644 --- a/src/main/services/bs-launcher/steam-launcher.service.ts +++ b/src/main/services/bs-launcher/steam-launcher.service.ts @@ -6,12 +6,13 @@ import { SteamService } from "../steam.service"; import path from "path"; import { BS_APP_ID, BS_EXECUTABLE, STEAMVR_APP_ID } from "../../constants"; import log from "electron-log"; -import { AbstractLauncherService, buildBsLaunchArgs, SpawnBsProcessOptions } from "./abstract-launcher.service"; +import { AbstractLauncherService, buildBsLaunchArgs, LaunchBeatSaberOptions } from "./abstract-launcher.service"; import { CustomError } from "../../../shared/models/exceptions/custom-error.class"; import { UtilsService } from "../utils.service"; import { exec, ChildProcessWithoutNullStreams } from "child_process"; import { LaunchMods } from "shared/models/bs-launch/launch-option.interface"; import { app, Event } from "electron"; +import { parseLaunchOptions } from "main/helpers/launchOptions.helper"; export class SteamLauncherService extends AbstractLauncherService implements StoreLauncherInterface{ @@ -64,8 +65,8 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto }); } - protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { - const process = this.launchBSProcess(bsExePath, args, options); + protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { + const process = this.launchBeatSaberProcess(options); const exit = new Promise((resolve, reject) => { // Don't remove, useful for debugging! @@ -146,7 +147,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto const steamPath = await this.steam.getSteamPath(); - const env = { + let env: Record = { ...process.env, "SteamAppId": BS_APP_ID, "SteamOverlayGameId": BS_APP_ID, @@ -163,7 +164,14 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto Object.assign(env, linuxSetup.env); } - this.injectAdditionalArgsEnvs(launchOptions, env); + const { + env: parsedEnv, + cmdlet, args + } = parseLaunchOptions(launchOptions.command, { + beatSaberExe: bsExePath + }) + env = this.mergeEnvVariables(env, parsedEnv); + const launchArgs = buildBsLaunchArgs(launchOptions); obs.next({type: BSLaunchEvent.BS_LAUNCHING}); @@ -171,8 +179,10 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto const spawnOpts = { env, cwd: bsFolderPath }; const launchPromise = !launchOptions.admin ? ( - this.launchBs(bsExePath, launchArgs, { - ...spawnOpts, + this.launchBeatSaber({ + env, cmdlet, + args: [ args, ...launchArgs ], + beatSaberFolderPath: bsFolderPath, protonPrefix }).exit ) : (