diff --git a/package.json b/package.json index b7347f4d..e35ce304 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon .\"", "start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts", "start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts", - "test": "jest", + "test": "jest ./src/__tests__/**/*.test.ts", "test:unit": "jest ./src/__tests__/unit", "publish": "npm run build && electron-builder -c.win.certificateSha1=206941d969c4fa8a0e04d9427def361e13b02fd0 --config electron-builder.config.js --publish always --win --x64", "publish:linux": "npm run build && electron-builder --config electron-builder.config.js --publish never --linux --x64", diff --git a/src/__tests__/unit/env.test.ts b/src/__tests__/unit/env.test.ts index fc6c80d2..c512586a 100644 --- a/src/__tests__/unit/env.test.ts +++ b/src/__tests__/unit/env.test.ts @@ -3,59 +3,89 @@ 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(""); }); + it("Simple command", () => { + const { env, command } = parseEnvString("some-command"); + expect(env).toEqual({}); + expect(command).toBe("some-command"); + }); + + it("Env with command", () => { + const { env, command } = parseEnvString("SAMPLE=value some-command"); + expect(env).toEqual(expect.objectContaining({ + SAMPLE: "value" + })); + expect(command).toBe("some-command"); + }); + + it("Complex with %command%", () => { + const envString = "KEY=value gamescope -h 720 -H 1440 -S integer -- %command% "; + const { env, command } = parseEnvString(envString); + expect(env).toEqual(expect.objectContaining({ + KEY: "value" + })); + expect(command).toBe("gamescope -h 720 -H 1440 -S integer -- %command%"); + }) + }); diff --git a/src/__tests__/unit/launchOptions.helpers.test.ts b/src/__tests__/unit/launchOptions.helpers.test.ts new file mode 100644 index 00000000..a6014501 --- /dev/null +++ b/src/__tests__/unit/launchOptions.helpers.test.ts @@ -0,0 +1,102 @@ +import { parseLaunchOptions } from "main/helpers/launchOptions.helper"; + +const SAMPLE_EXE = "Beat Saber.exe"; +const WRAPPED_EXE = `"${SAMPLE_EXE}"` + +describe("Test parseLaunchOptions", () => { + + it("Empty", () => { + const { + env, cmdlet, args + } = parseLaunchOptions("", { beatSaberExe: SAMPLE_EXE }); + expect(env).toEqual({}); + expect(cmdlet).toBe(WRAPPED_EXE); + expect(args).toBe(""); + }); + + it("Envs", () => { + const { env, cmdlet, args } = parseLaunchOptions( + `HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=`, + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual(expect.objectContaining({ + HELLO: "World!", + DOUBLE_QUOTE: "Two Words", + SINGLE_QUOTE: "", + EMPTY: "" + })); + expect(cmdlet).toEqual(WRAPPED_EXE); + expect(args).toEqual(""); + }); + + it("Env with %command%", () => { + const { env, cmdlet, args } = parseLaunchOptions( + `TEST=TEST %command%`, + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual(expect.objectContaining({ + TEST: "TEST", + })); + expect(cmdlet).toEqual(WRAPPED_EXE); + expect(args).toEqual(""); + }); + + it("Envs with arguments", () => { + const { env, cmdlet, args } = parseLaunchOptions( + `HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY= %command% --vr-mode`, + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual(expect.objectContaining({ + HELLO: "World!", + DOUBLE_QUOTE: "Two Words", + SINGLE_QUOTE: "", + EMPTY: "" + })); + expect(cmdlet).toEqual(WRAPPED_EXE); + expect(args).toEqual("--vr-mode"); + }); + + it("Linux Command 1", () => { + const { env, cmdlet, args } = parseLaunchOptions( + "gamemoderun %command%", + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual({}); + expect(cmdlet).toBe("gamemoderun"); + expect(args).toBe(WRAPPED_EXE); + }); + + it("Linux Command 2", () => { + const { env, cmdlet, args } = parseLaunchOptions( + "mangohud %command%", + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual({}); + expect(cmdlet).toBe("mangohud"); + expect(args).toBe(WRAPPED_EXE); + }); + + it("Linux Command 3", () => { + const { env, cmdlet, args } = parseLaunchOptions( + "gamescope -h 720 -H 1440 -S integer -- %command%", + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual({}); + expect(cmdlet).toBe("gamescope"); + expect(args).toBe(`-h 720 -H 1440 -S integer -- ${WRAPPED_EXE}`); + }); + + it("Complex Linux Command", () => { + const { env, cmdlet, args } = parseLaunchOptions( + "WINEPREFIX=some-path HELLO=World gamescope -h 720 -H 1440 -S integer -- %command% --debug", + { beatSaberExe: SAMPLE_EXE } + ); + expect(env).toEqual(expect.objectContaining({ + WINEPREFIX: "some-path", + HELLO: "World", + })); + expect(cmdlet).toBe("gamescope"); + expect(args).toBe(`-h 720 -H 1440 -S integer -- ${WRAPPED_EXE} --debug`); + }); + +}); diff --git a/src/main/helpers/env.helpers.ts b/src/main/helpers/env.helpers.ts index bb4dd8c6..41247b3f 100644 --- a/src/main/helpers/env.helpers.ts +++ b/src/main/helpers/env.helpers.ts @@ -21,6 +21,7 @@ enum EnvParserState { QUOTE_VALUE, DQUOTE_VALUE, SPACE, + EXIT, ERROR, }; @@ -28,7 +29,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; @@ -39,13 +52,13 @@ export function parseEnvString(envString: string): Record { switch (state) { case EnvParserState.NAME_START: + index = pos; if (isAlphaCharacter(c) || c === "_") { state = EnvParserState.NAME; - index = pos; } else if (c !== " ") { - state = EnvParserState.ERROR; + state = EnvParserState.EXIT; } - break; + break; case EnvParserState.NAME: if (c === "=") { @@ -53,57 +66,65 @@ export function parseEnvString(envString: string): Record { newName = envString.substring(index, pos); index = pos + 1; } else if (!isAlphaCharacter(c) && !isNumber(c) && c !== "_") { - state = EnvParserState.ERROR; + state = EnvParserState.EXIT; } - break; + break; case EnvParserState.VALUE_START: if (c === "'") { - ++index; - state = EnvParserState.QUOTE_VALUE; - } else if (c === '"') { - ++index; - state = EnvParserState.DQUOTE_VALUE; - } else if (c === " ") { - state = EnvParserState.NAME_START; - envVars[newName] = ""; - } else { - state = EnvParserState.VALUE; - } - break; + ++index; + state = EnvParserState.QUOTE_VALUE; + } else if (c === '"') { + ++index; + state = EnvParserState.DQUOTE_VALUE; + } else if (c === " ") { + state = EnvParserState.NAME_START; + envVars[newName] = ""; + } else { + state = EnvParserState.VALUE; + } + break; case EnvParserState.VALUE: if (c === " ") { - state = EnvParserState.NAME_START; - envVars[newName] = envString.substring(index, pos); - } - break; + state = EnvParserState.NAME_START; + envVars[newName] = envString.substring(index, pos); + } + break; case EnvParserState.QUOTE_VALUE: if (c === "'") { - state = EnvParserState.SPACE; - envVars[newName] = envString.substring(index, pos); - } - break; + state = EnvParserState.SPACE; + envVars[newName] = envString.substring(index, pos); + } + break; case EnvParserState.DQUOTE_VALUE: if (c === '"') { - state = EnvParserState.SPACE; - envVars[newName] = envString.substring(index, pos); - } - break; + state = EnvParserState.SPACE; + envVars[newName] = envString.substring(index, pos); + } + break; case EnvParserState.SPACE: if (c === " ") { - state = EnvParserState.NAME_START; - } else { - state = EnvParserState.ERROR; - } - break; + state = EnvParserState.NAME_START; + } else { + state = EnvParserState.ERROR; + } + break; default: } + // Early exit + if (state === EnvParserState.EXIT) { + return { + env: envVars, + command: envString.substring(index).trim() + }; + } + if (state === EnvParserState.ERROR) { throw new CustomError( `parseEnvString failed: invalid character at position ${pos}`, @@ -114,15 +135,15 @@ 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: "" }; } - throw new CustomError( - "parseEnvString failed: invalid ending state", - "generic.env.parse" - ); + return { + env: envVars, + command: envString.substring(index + 1).trim(), + } } diff --git a/src/main/helpers/launchOptions.helper.ts b/src/main/helpers/launchOptions.helper.ts new file mode 100644 index 00000000..4e14316a --- /dev/null +++ b/src/main/helpers/launchOptions.helper.ts @@ -0,0 +1,55 @@ +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; +} { + const wrappedExe = `"${options.beatSaberExe}"`; + if (!launchOption) { + return { env: {}, cmdlet: wrappedExe, args: "" }; + } + + const parsed = parseEnvString(launchOption); + const { env } = parsed; + + // If launch options only contains env strings + if (!parsed.command) { + return { env, cmdlet: wrappedExe, args: "" }; + } + + const command = parsed.command.replace("%command%", wrappedExe); + + // Offset if it starts with a " or ' + let offset = 0; + if (command.startsWith('"')) { + offset = command.indexOf('"', 1); + } else if (command.startsWith("'")) { + offset = command.indexOf("'", 1); + } + + // First word/token is the cmdlet, the rest are the arguments + const index = command.indexOf(" ", offset); + if (index === -1) { + return { env, cmdlet: command.trim(), args: "" }; + } + + return { + env, cmdlet: command.substring(0, 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..8653112a 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,21 @@ export abstract class AbstractLauncherService { this.localVersions = BSLocalVersionService.getInstance(); } - private readonly COMMAND_FORMAT = "%command%"; + protected launchBeatSaberProcess(options: LaunchBeatSaberOptions): ChildProcessWithoutNullStreams { + const spawnOptions: SpawnOptionsWithoutStdio = { + detached: true, + cwd: options.beatSaberFolderPath, + env: options.env, + }; - protected launchBSProcess(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): ChildProcessWithoutNullStreams { - - 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 || "" }, + return bsmSpawn(options.cmdlet, { + args: options.args, options: spawnOptions, log: BsmShellLog.Command, + linux: { prefix: options.protonPrefix ?? "" }, flatpak: { host: IS_FLATPAK, env: [ @@ -80,8 +79,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 +119,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.entries(newEnv)) { log.info( key in env ? "Overriding" : "Injecting", `${key}="${value}"`, @@ -144,13 +133,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..d350c394 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,12 @@ 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 + ? [ args, ...launchArgs ] + : launchArgs, + beatSaberFolderPath: bsFolderPath, protonPrefix }).exit ) : (