diff --git a/package.json b/package.json index af86049e..ae13385c 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 fc6c80d2..c512586a 100644 --- a/src/__tests__/unit/env.test.ts +++ b/src/__tests__/unit/env.test.ts @@ -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%"); + }) + }); diff --git a/src/__tests__/unit/launchOptions.helpers.test.ts b/src/__tests__/unit/launchOptions.helpers.test.ts new file mode 100644 index 00000000..50265e09 --- /dev/null +++ b/src/__tests__/unit/launchOptions.helpers.test.ts @@ -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`); + }); + +}); diff --git a/src/__tests__/unit/os.test.ts b/src/__tests__/unit/os.test.ts index 664de857..7fc82075 100644 --- a/src/__tests__/unit/os.test.ts +++ b/src/__tests__/unit/os.test.ts @@ -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, diff --git a/src/main/helpers/env.helpers.ts b/src/main/helpers/env.helpers.ts index bb4dd8c6..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, }; @@ -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 { +/** + * 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; @@ -39,13 +52,13 @@ export function parseEnvString(envString: string): Record { 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 { 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 { 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(), + } } diff --git a/src/main/helpers/launchOptions.helper.ts b/src/main/helpers/launchOptions.helper.ts new file mode 100644 index 00000000..452b09d3 --- /dev/null +++ b/src/main/helpers/launchOptions.helper.ts @@ -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; + 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(), + } +} + diff --git a/src/main/helpers/os.helpers.ts b/src/main/helpers/os.helpers.ts index cdafd0ac..57789c5c 100644 --- a/src/main/helpers/os.helpers.ts +++ b/src/main/helpers/os.helpers.ts @@ -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 { - 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; 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 diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index 8369eef9..5734f826 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 = []; @@ -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} { - const process = this.launchBSProcess(bsExePath, args, options); + protected launchBeatSaber(options: LaunchBeatSaberOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { + 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 - ) { - 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.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; + beatSaberFolderPath: string; + + args?: string[]; // Appended to the cmdlet string + + // Timeout value (in ms) to unref the Beat Saber process to BSM unrefAfter?: number; -} & SpawnOptionsWithoutStdio; +} + diff --git a/src/main/services/bs-launcher/oculus-launcher.service.ts b/src/main/services/bs-launcher/oculus-launcher.service.ts index 072ef7bc..a301104e 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, { + 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); diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index 68100b31..cf9476fd 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,24 +147,35 @@ 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, "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(resolve => { diff --git a/src/main/services/linux.service.ts b/src/main/services/linux.service.ts index d5c91007..780c353f 100644 --- a/src/main/services/linux.service.ts +++ b/src/main/services/linux.service.ts @@ -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; - }> { - 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 { @@ -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, - launchOptions: LaunchOption - ): string { + private async getCommand( + launchOptions: LaunchOption, + steamPath: string, + beatSaberFolderPath: string + ): Promise { + 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 { 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 { - 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 }; } diff --git a/src/main/services/mods/bs-mods-manager.service.ts b/src/main/services/mods/bs-mods-manager.service.ts index fdf2f570..2562120a 100644 --- a/src/main/services/mods/bs-mods-manager.service.ts +++ b/src/main/services/mods/bs-mods-manager.service.ts @@ -156,38 +156,20 @@ export class BsModsManagerService { return false; } - const env: Record = { ...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(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; + 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` }