From 68e38e9f1c9ea48012e4e5b7a8e9d8523b055515 Mon Sep 17 00:00:00 2001 From: silentrald Date: Thu, 10 Jul 2025 12:11:51 +0800 Subject: [PATCH] [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