[fix] handle launch option string properly (#895)

* [fix] refactored launch functions to handle wrapper executables

* [fix] properly parse launch option string

* [fix] support complex launch options for linux

* [fix] applied sonarcube comments

* [fix] apply launch option parsing to shortcut creation

* Change log message

* fix beat saber not launching on windows

---------

Co-authored-by: Zagrios <40181755+Zagrios@users.noreply.github.com>
This commit is contained in:
silentrald
2026-01-02 20:08:01 +08:00
committed by GitHub
parent 25047b6c0a
commit 92a1232f85
12 changed files with 456 additions and 225 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",
+44 -14
View File
@@ -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%");
})
});
@@ -0,0 +1,114 @@
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
const SAMPLE_EXE = `"Beat Saber.exe"`;
const PROTON_EXE = `"proton" run ${SAMPLE_EXE}`;
describe("Test parseLaunchOptions", () => {
it("Empty", () => {
const {
env, cmdlet, args
} = parseLaunchOptions("", { commandReplacement: SAMPLE_EXE });
expect(env).toEqual({});
expect(cmdlet).toBe(SAMPLE_EXE);
expect(args).toBe("");
});
it("Envs", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`HELLO=World! DOUBLE_QUOTE="Two Words" SINGLE_QUOTE='' EMPTY=`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
HELLO: "World!",
DOUBLE_QUOTE: "Two Words",
SINGLE_QUOTE: "",
EMPTY: ""
}));
expect(cmdlet).toEqual(SAMPLE_EXE);
expect(args).toEqual("");
});
it("Env with %command%", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`TEST=TEST %command%`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
TEST: "TEST",
}));
expect(cmdlet).toEqual(SAMPLE_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`,
{ commandReplacement: SAMPLE_EXE }
);
expect(env).toEqual(expect.objectContaining({
HELLO: "World!",
DOUBLE_QUOTE: "Two Words",
SINGLE_QUOTE: "",
EMPTY: ""
}));
expect(cmdlet).toEqual(SAMPLE_EXE);
expect(args).toEqual("--vr-mode");
});
it("Linux Command 1", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"gamemoderun %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("gamemoderun");
expect(args).toBe(PROTON_EXE);
});
it("Linux Command 2", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"mangohud %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("mangohud");
expect(args).toBe(PROTON_EXE);
});
it("Linux Command 3", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"gamescope -h 720 -H 1440 -S integer -- %command%",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({});
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`-h 720 -H 1440 -S integer -- ${PROTON_EXE}`);
});
it("Linux Command 4", () => {
const { env, cmdlet, args } = parseLaunchOptions(
`LD_PRELOAD="" gamescope --hdr-enabled -f -h 1440 -r 144 --force-grab-cursor --framerate-limit 144 --mangoapp -- %command%`,
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual({
LD_PRELOAD: ""
});
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`--hdr-enabled -f -h 1440 -r 144 --force-grab-cursor --framerate-limit 144 --mangoapp -- ${PROTON_EXE}`);
});
it("Complex Linux Command", () => {
const { env, cmdlet, args } = parseLaunchOptions(
"WINEPREFIX=some-path HELLO=World gamescope -h 720 -H 1440 -S integer -- %command% --debug",
{ commandReplacement: PROTON_EXE }
);
expect(env).toEqual(expect.objectContaining({
WINEPREFIX: "some-path",
HELLO: "World",
}));
expect(cmdlet).toBe("gamescope");
expect(args).toBe(`-h 720 -H 1440 -S integer -- ${PROTON_EXE} --debug`);
});
});
+3 -11
View File
@@ -27,7 +27,6 @@ jest.mock("electron-log", () => ({
jest.mock("ps-list", () => (): unknown[] => []);
const IS_WINDOWS = process.platform === "win32";
const IS_LINUX = process.platform === "linux";
describe("Test os.helpers bsmSpawn", () => {
@@ -90,14 +89,11 @@ describe("Test os.helpers bsmSpawn", () => {
it("Complex spawn command call (Mods install)", () => {
bsmSpawn(`"./BSIPA.exe" "./Beat Saber.exe" -n`, {
log: BsmShellLog.Command,
linux: { prefix: `"./wine64"` },
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith(
process.platform === "win32"
? `"./BSIPA.exe" "./Beat Saber.exe" -n`
: `"./wine64" "./BSIPA.exe" "./Beat Saber.exe" -n`,
`"./BSIPA.exe" "./Beat Saber.exe" -n`,
expect.anything()
);
@@ -113,14 +109,11 @@ describe("Test os.helpers bsmSpawn", () => {
env: BS_ENV,
},
log: BsmShellLog.Command,
linux: { prefix: `"./proton" run` },
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith(
IS_WINDOWS
? `"./Beat Saber.exe" --no-yeet fpfc`
: `"./proton" run "./Beat Saber.exe" --no-yeet fpfc`,
`"./Beat Saber.exe" --no-yeet fpfc`,
expect.objectContaining({
cwd: "/",
detached: true,
@@ -149,7 +142,7 @@ describe("Test os.helpers bsmSpawn", () => {
something: "else",
more: "tests",
};
bsmSpawn(`"./Beat Saber.exe"`, {
bsmSpawn(`"./proton" run "./Beat Saber.exe"`, {
args: ["--no-yeet", "fpfc"],
options: {
cwd: "/",
@@ -157,7 +150,6 @@ describe("Test os.helpers bsmSpawn", () => {
env: newEnv,
},
log: BsmShellLog.Command,
linux: { prefix: `"./proton" run` },
flatpak: {
host: true,
env: flatpakEnv,
+62 -41
View File
@@ -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<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;
@@ -39,13 +52,13 @@ export function parseEnvString(envString: string): Record<string, 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 === "=") {
@@ -53,57 +66,65 @@ export function parseEnvString(envString: string): Record<string, 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:
}
// 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<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: "" };
}
throw new CustomError(
"parseEnvString failed: invalid ending state",
"generic.env.parse"
);
return {
env: envVars,
command: envString.substring(index + 1).trim(),
}
}
+59
View File
@@ -0,0 +1,59 @@
import { parseEnvString } from "./env.helpers";
const COMMAND_KEYWORD = "%command%";
/**
* Parses the launch options command into parts to be used for bsmSpawn
*
* @params command
* @params options.commandReplacement - Replaces the %command% string
* @params options.linux - If the application is running under linux. Can be toggled in testing to check if the logic works.
* @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: {
commandReplacement: string;
}): {
env: Record<string, string>;
cmdlet: string;
args: string;
} {
if (!launchOption) {
return { env: {}, cmdlet: options.commandReplacement, args: "" };
}
const parsed = parseEnvString(launchOption);
const { env } = parsed;
// If launch options only contains env strings
if (!parsed.command) {
return { env, cmdlet: options.commandReplacement, args: "" };
}
const command = parsed.command.indexOf(COMMAND_KEYWORD) === -1
? `${options.commandReplacement} ${parsed.command}`
: parsed.command.replace(COMMAND_KEYWORD, options.commandReplacement);
// 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).trim(),
}
}
+4 -14
View File
@@ -3,13 +3,6 @@ import log from "electron-log";
import psList from "ps-list";
import { IS_FLATPAK } from "main/constants";
type LinuxOptions = {
// Add the prefix to the command
// eg. command - "./Beat Saber.exe" --no-yeet, prefix - "path/to/proton" run
// = "path/to/proton" run "./Beat Saber.exe" --no-yeet
prefix: string;
};
// Only applied if package as flatpak
type FlatpakOptions = {
// Force to use "flatpak-spawn --host" to run commands outside of the sandbox
@@ -24,11 +17,10 @@ export enum BsmShellLog {
};
interface BsmShellOptions<OptionsType> {
args?: string[];
args?: string[] | string;
options?: OptionsType;
// Look into BsmShellLog values
log?: number;
linux?: LinuxOptions;
flatpak?: FlatpakOptions;
};
@@ -37,7 +29,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") {
@@ -45,10 +39,6 @@ function updateCommand(command: string, options: BsmSpawnOptions) {
// All distros should support "bash" by default
options.options.shell = "bash";
if (options.linux?.prefix) {
command = `${options.linux.prefix} ${command}`;
}
if (options?.flatpak?.host) {
const envArgs = (options?.flatpak?.env && options?.options?.env)
&& options.flatpak.env
@@ -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 = [];
@@ -30,10 +28,6 @@ export function buildBsLaunchArgs(launchOptions: LaunchOption): string[] {
launchArgs.push("editor");
}
if (launchOptions.command) {
launchArgs.push(launchOptions.command);
}
return Array.from(new Set(launchArgs).values());
}
@@ -47,20 +41,20 @@ 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?.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,
flatpak: {
host: IS_FLATPAK,
env: [
@@ -81,8 +75,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;
@@ -121,23 +115,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.entries(newEnv)) {
log.info(
key in env ? "Overriding" : "Injecting",
`${key}="${value}"`,
@@ -145,13 +129,21 @@ 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;
}
@@ -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, {
commandReplacement: 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,24 +147,35 @@ 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,
"SteamGameId": BS_APP_ID,
};
let protonPrefix = "";
// Linux setup
if (process.platform === "linux") {
const linuxSetup = await this.linux.setupLaunch(
if (launchOptions.admin) {
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
launchOptions.admin = false;
}
Object.assign(env, await this.linux.buildEnvVariables(
launchOptions, steamPath, bsFolderPath
);
protonPrefix = linuxSetup.protonPrefix;
Object.assign(env, linuxSetup.env);
));
}
this.injectAdditionalArgsEnvs(launchOptions, env);
const {
env: parsedEnv,
cmdlet, args
} = parseLaunchOptions(launchOptions.command, {
commandReplacement: process.platform === "win32"
? `"${bsExePath}"`
: `${await this.linux.getProtonPrefix()} "${bsExePath}"`,
});
env = this.mergeEnvVariables(env, parsedEnv);
const launchArgs = buildBsLaunchArgs(launchOptions);
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
@@ -171,9 +183,12 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
const spawnOpts = { env, cwd: bsFolderPath };
const launchPromise = !launchOptions.admin ? (
this.launchBs(bsExePath, launchArgs, {
...spawnOpts,
protonPrefix
this.launchBeatSaber({
env, cmdlet,
args: args
? [ args, ...launchArgs ]
: launchArgs,
beatSaberFolderPath: bsFolderPath,
}).exit
) : (
new Promise<number>(resolve => {
+50 -62
View File
@@ -10,6 +10,7 @@ import { BsmShellLog, bsmExec } from "main/helpers/os.helpers";
import { LaunchMods } from "shared/models/bs-launch/launch-option.interface";
import { SteamShortcutData } from "shared/models/steam/shortcut.model";
import { buildBsLaunchArgs } from "./bs-launcher/abstract-launcher.service";
import { parseLaunchOptions } from "main/helpers/launchOptions.helper";
export class LinuxService {
private static instance: LinuxService;
@@ -38,26 +39,11 @@ export class LinuxService {
return path.resolve(sharedFolder, "compatdata");
}
public async setupLaunch(
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
): Promise<{
protonPrefix: string;
env: Record<string, string>;
}> {
if (launchOptions.admin) {
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
launchOptions.admin = false;
}
public async getProtonPrefix() {
const protonPath = await this.getProtonPath();
return {
protonPrefix: await this.isNixOS()
? `steam-run "${protonPath}" run`
: `"${protonPath}" run`,
env: await this.buildEnvVariables(launchOptions, steamPath, bsFolderPath)
};
return await this.isNixOS()
? `steam-run "${protonPath}" run`
: `"${protonPath}" run`;
}
private async getProtonPath(): Promise<string> {
@@ -81,7 +67,7 @@ export class LinuxService {
return protonPath;
}
private async buildEnvVariables(
public async buildEnvVariables(
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
@@ -176,18 +162,42 @@ export class LinuxService {
// === Shortcuts === //
private getCommand(
protonPrefix: string,
bsFolderPath: string,
env: Record<string, string>,
launchOptions: LaunchOption
): string {
private async getCommand(
launchOptions: LaunchOption,
steamPath: string,
beatSaberFolderPath: string
): Promise<string> {
const protonPrefix = await this.getProtonPrefix();
const launchEnv = await this.buildEnvVariables(
launchOptions, steamPath, beatSaberFolderPath
);
const beatSaberExePath = path.join(beatSaberFolderPath, BS_EXECUTABLE);
const {
env: parsedEnv,
args: parsedArgs,
cmdlet,
} = parseLaunchOptions(launchOptions.command, {
commandReplacement: `${protonPrefix} ${beatSaberExePath}`,
});
const args = buildBsLaunchArgs(launchOptions);
log.debug("Launch arguments:", args, "Parsed arguments:", parsedArgs);
if (parsedArgs) {
args.unshift(parsedArgs);
}
const env = {
...launchEnv, ...parsedEnv,
SteamAppId: BS_APP_ID,
SteamOverlayGameId: BS_APP_ID,
SteamGameId: BS_APP_ID,
};
const envString = Object.entries(env)
.map(([ key, value ]) => `${key}="${value}"`)
.join(" ");
const bsExe = path.join(bsFolderPath, BS_EXECUTABLE);
const args = buildBsLaunchArgs(launchOptions).join(" ");
return `${envString} ${protonPrefix} "${bsExe}" ${args}`;
return `${envString} ${cmdlet} ${args.join(" ")}`;
}
public async createDesktopShortcut(
@@ -196,22 +206,11 @@ export class LinuxService {
icon: string,
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
beatSaberFolderPath: string
): Promise<boolean> {
try {
const {
protonPrefix, env
} = await this.setupLaunch(launchOptions, steamPath, bsFolderPath);
Object.assign(env, {
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
});
const command = this.getCommand(
protonPrefix, bsFolderPath,
env, launchOptions
const command = await this.getCommand(
launchOptions, steamPath, beatSaberFolderPath
);
const desktopEntry = [
@@ -219,7 +218,7 @@ export class LinuxService {
"Type=Application",
`Name=${name}`,
`Icon=${icon}`,
`Path=${bsFolderPath}`,
`Path=${beatSaberFolderPath}`,
`Exec=${command}`
].join("\n");
@@ -237,31 +236,20 @@ export class LinuxService {
icon: string,
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: string
beatSaberFolderPath: string
): Promise<SteamShortcutData> {
const env = await this.buildEnvVariables(
launchOptions, steamPath, bsFolderPath
const protonPath = await this.getProtonPath();
const command = await this.getCommand(
launchOptions, steamPath, beatSaberFolderPath
);
Object.assign(env, {
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
});
const protonPrefix = await this.isNixOS()
? "steam-run %command% run"
: "%command% run";
return {
AppName: shortcutName,
Exe: await this.getProtonPath(),
StartDir: bsFolderPath,
Exe: protonPath,
StartDir: beatSaberFolderPath,
icon,
OpenVR: "\x01",
LaunchOptions: this.getCommand(
protonPrefix, bsFolderPath,
env, launchOptions
)
LaunchOptions: command
};
}
@@ -156,38 +156,20 @@ export class BsModsManagerService {
return false;
}
const env: Record<string, string> = { ...process.env };
const cmd = `"${ipaPath}" "${bsExePath}" ${args.join(" ")}`;
let winePath: string = "";
if (process.platform === "linux") {
const { error: winePathError, result: winePathResult } =
tryit(() => this.linuxService.getWinePath());
if (winePathError) {
log.error(winePathError);
return false;
}
winePath = await this.linuxService.isNixOS()
? `steam-run "${winePathResult}"`
: `"${winePathResult}"`;
const winePrefix = this.linuxService.getWinePrefixPath();
if (!winePrefix) {
throw new CustomError("Could not find BSManager WINEPREFIX path", "no-wineprefix");
}
env.WINEPREFIX = winePrefix;
const command = await this.getCommand(ipaPath, bsExePath, args);
if (!command) {
return false;
}
return new Promise<boolean>(resolve => {
const processIPA = bsmSpawn(cmd, {
const processIPA = bsmSpawn(command.command, {
log: BsmShellLog.Command | BsmShellLog.EnvVariables,
options: {
cwd: versionPath,
detached: true,
shell: true,
env
env: command.env
},
linux: { prefix: winePath },
});
const timeout = setTimeout(() => {
@@ -213,6 +195,47 @@ export class BsModsManagerService {
});
}
private async getCommand(
ipaPath: string,
beatSaberExePath: string,
args: string[]
): Promise<{
env: Record<string, string>;
command: string;
} | null> {
const command = `"${ipaPath}" "${beatSaberExePath}" ${args.join(" ")}`;
if (process.platform === "win32") {
return {
env: { ...process.env },
command,
};
}
const { error: winePathError, result: winePathResult } =
tryit(() => this.linuxService.getWinePath());
if (winePathError) {
log.error(winePathError);
return null;
}
const winePath = await this.linuxService.isNixOS()
? `steam-run "${winePathResult}"`
: `"${winePathResult}"`;
const winePrefix = this.linuxService.getWinePrefixPath();
if (!winePrefix) {
throw new CustomError("Could not find BSManager WINEPREFIX path", "no-wineprefix");
}
return {
env: {
...process.env,
WINEPREFIX: winePrefix
},
command: `${winePath} ${command}`,
};
}
private getModDownload(modVersion: BbmModVersion): string {
return `/cdn/mod/${modVersion.zipHash}.zip`
}