[fix] refactored launch functions to handle wrapper executables

This commit is contained in:
silentrald
2025-07-08 09:05:31 +08:00
parent 5b5a4a0817
commit 2e2fb60050
7 changed files with 156 additions and 67 deletions
+21 -14
View File
@@ -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("");
});
});
+19 -3
View File
@@ -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<string, string> {
/**
* 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<string, string>;
command: string;
} {
const envVars: Record<string, string> = {};
let state: EnvParserState = EnvParserState.NAME_START;
@@ -104,6 +116,7 @@ export function parseEnvString(envString: string): Record<string, string> {
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<string, string> {
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(
+46
View File
@@ -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<string, string>;
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(),
}
}
+4 -2
View File
@@ -24,7 +24,7 @@ export enum BsmShellLog {
};
interface BsmShellOptions<OptionsType> {
args?: string[];
args?: string[] | string;
options?: OptionsType;
// Look into BsmShellLog values
log?: number;
@@ -37,7 +37,9 @@ export type BsmExecOptions = BsmShellOptions<cp.ExecOptions>;
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") {
@@ -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<number>} {
const process = this.launchBSProcess(bsExePath, args, options);
protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
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<string, string>
) {
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<string, string>,
newEnv: Record<string, string>
): Record<string, string> {
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<string, string>;
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;
}
@@ -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<string, string> = {
let env: Record<string, string> = {
...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);
@@ -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<number>} {
const process = this.launchBSProcess(bsExePath, args, options);
protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
const process = this.launchBeatSaberProcess(options);
const exit = new Promise<number>((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<string, string> = {
...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
) : (