[fix] properly parse launch option string

This commit is contained in:
silentrald
2025-07-10 12:11:51 +08:00
parent 2e2fb60050
commit 68e38e9f1c
7 changed files with 201 additions and 61 deletions
+1 -1
View File
@@ -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",
+23
View File
@@ -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%");
})
});
@@ -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`);
});
});
+48 -43
View File
@@ -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(),
}
}
+20 -11
View File
@@ -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(),
}
}
@@ -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<string, string>
): Record<string, string> {
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}"`,
@@ -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