From 2e2fb60050050231564b2296034efefc2cdcae5f Mon Sep 17 00:00:00 2001 From: silentrald Date: Tue, 8 Jul 2025 09:05:31 +0800 Subject: [PATCH 1/2] [fix] refactored launch functions to handle wrapper executables --- src/__tests__/unit/env.test.ts | 35 ++++++---- src/main/helpers/env.helpers.ts | 22 +++++- src/main/helpers/launchOptions.helper.ts | 46 +++++++++++++ src/main/helpers/os.helpers.ts | 6 +- .../bs-launcher/abstract-launcher.service.ts | 69 ++++++++++--------- .../bs-launcher/oculus-launcher.service.ts | 21 ++++-- .../bs-launcher/steam-launcher.service.ts | 24 +++++-- 7 files changed, 156 insertions(+), 67 deletions(-) create mode 100644 src/main/helpers/launchOptions.helper.ts 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 ) : ( From 68e38e9f1c9ea48012e4e5b7a8e9d8523b055515 Mon Sep 17 00:00:00 2001 From: silentrald Date: Thu, 10 Jul 2025 12:11:51 +0800 Subject: [PATCH 2/2] [fix] properly parse launch option string --- package.json | 2 +- src/__tests__/unit/env.test.ts | 23 ++++ .../unit/launchOptions.helpers.test.ts | 102 ++++++++++++++++++ src/main/helpers/env.helpers.ts | 91 ++++++++-------- src/main/helpers/launchOptions.helper.ts | 31 ++++-- .../bs-launcher/abstract-launcher.service.ts | 7 +- .../bs-launcher/steam-launcher.service.ts | 6 +- 7 files changed, 201 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/unit/launchOptions.helpers.test.ts 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 f6d5cbdb..c512586a 100644 --- a/src/__tests__/unit/env.test.ts +++ b/src/__tests__/unit/env.test.ts @@ -65,4 +65,27 @@ describe("Test parseEnvString", () => { 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 e2112303..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, }; @@ -51,13 +52,13 @@ export function parseEnvString(envString: string): { 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 === "=") { @@ -65,58 +66,65 @@ export function parseEnvString(envString: string): { 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: } - // TODO: Change to an early exit instead + // 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}`, @@ -131,14 +139,11 @@ export function parseEnvString(envString: string): { } if (state === EnvParserState.NAME_START || state === EnvParserState.SPACE) { - return { - env: envVars, - command: envString.substring(index + 1, envString.length) - }; + 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 index ca5fb67e..4e14316a 100644 --- a/src/main/helpers/launchOptions.helper.ts +++ b/src/main/helpers/launchOptions.helper.ts @@ -18,28 +18,37 @@ export function parseLaunchOptions(launchOption: string, options: { cmdlet: string; args: string; } { + const wrappedExe = `"${options.beatSaberExe}"`; if (!launchOption) { - return { env: {}, cmdlet: "", args: "" }; + return { env: {}, cmdlet: wrappedExe, args: "" }; } - // Get the env variables first - const { - env, command - } = parseEnvString(launchOption); + const parsed = parseEnvString(launchOption); + const { env } = parsed; - // Replace the %command% - if (options.beatSaberExe) { - launchOption.replace("%command%", `"${options.beatSaberExe}"`); + // 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(" "); + const index = command.indexOf(" ", offset); if (index === -1) { - return { env, cmdlet: command, args: "" } + return { env, cmdlet: command.trim(), args: "" }; } return { - env, cmdlet: command.substring(index), + env, cmdlet: command.substring(0, index), args: command.substring(index + 1, command.length).trim(), } } diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index 90975424..8653112a 100644 --- a/src/main/services/bs-launcher/abstract-launcher.service.ts +++ b/src/main/services/bs-launcher/abstract-launcher.service.ts @@ -46,10 +46,10 @@ export abstract class AbstractLauncherService { } protected launchBeatSaberProcess(options: LaunchBeatSaberOptions): ChildProcessWithoutNullStreams { - const spawnOptions: SpawnOptionsWithoutStdio = { detached: true, cwd: options.beatSaberFolderPath, + env: options.env, }; if(options.args && options.args.includes("--verbose")){ @@ -57,8 +57,7 @@ export abstract class AbstractLauncherService { } spawnOptions.shell = true; // For windows to spawn properly - // TODO: bsExePath can be another executable here - return bsmSpawn(`"${options.cmdlet}"`, { + return bsmSpawn(options.cmdlet, { args: options.args, options: spawnOptions, log: BsmShellLog.Command, linux: { prefix: options.protonPrefix ?? "" }, flatpak: { @@ -126,7 +125,7 @@ export abstract class AbstractLauncherService { newEnv: Record ): Record { const env = { ...originalEnv }; - for (const [ key, value ] of Object.values(newEnv)) { + for (const [ key, value ] of Object.entries(newEnv)) { log.info( key in env ? "Overriding" : "Injecting", `${key}="${value}"`, diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index f5d47497..d350c394 100644 --- a/src/main/services/bs-launcher/steam-launcher.service.ts +++ b/src/main/services/bs-launcher/steam-launcher.service.ts @@ -169,7 +169,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto cmdlet, args } = parseLaunchOptions(launchOptions.command, { beatSaberExe: bsExePath - }) + }); env = this.mergeEnvVariables(env, parsedEnv); const launchArgs = buildBsLaunchArgs(launchOptions); @@ -181,7 +181,9 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto const launchPromise = !launchOptions.admin ? ( this.launchBeatSaber({ env, cmdlet, - args: [ args, ...launchArgs ], + args: args + ? [ args, ...launchArgs ] + : launchArgs, beatSaberFolderPath: bsFolderPath, protonPrefix }).exit