Merge branch 'master' into feat/663

This commit is contained in:
MathieuG-P
2024-12-01 15:31:27 +01:00
committed by GitHub
26 changed files with 824 additions and 191 deletions
+203
View File
@@ -0,0 +1,203 @@
import {
bsmSpawn,
// bsmExec,
isProcessRunning,
// getProcessId,
} from "main/helpers/os.helpers";
import cp from "child_process";
import crypto from "crypto";
import log from "electron-log";
import { ifDescribe, ifIt } from "__tests__/utils";
import { BS_APP_ID } from "main/constants";
Object.defineProperty(global, "crypto", {
value: {
randomUUID: () => crypto.webcrypto.randomUUID(),
}
});
jest.mock("electron", () => ({
app: { getPath: () => "" },
}));
jest.mock("electron-log", () => ({
info: jest.fn(),
error: jest.fn(),
}));
jest.mock("ps-list", () => () => []);
const IS_WINDOWS = process.platform === "win32";
const IS_LINUX = process.platform === "linux";
describe("Test os.helpers bsmSpawn", () => {
const spawnSpy: jest.SpyInstance = jest.spyOn(cp, "spawn")
.mockImplementation();
const logSpy: jest.SpyInstance = jest.spyOn(log, "info");
const originalContainer = process.env.container;
const BS_ENV = {
SteamAppId: BS_APP_ID,
SteamOverlayGameId: BS_APP_ID,
SteamGameId: BS_APP_ID,
};
beforeAll(() => {
if (IS_LINUX) {
Object.assign(BS_ENV, {
WINEDLLOVERRIDES: "winhttp=n,b",
STEAM_COMPAT_DATA_PATH: "/compatdata",
STEAM_COMPAT_INSTALL_PATH: "/BSInstance",
STEAM_COMPAT_CLIENT_INSTALL_PATH: "/steam",
STEAM_COMPAT_APP_ID: BS_APP_ID,
SteamEnv: "1",
});
}
});
afterAll(() => {
spawnSpy.mockRestore();
});
afterEach(() => {
spawnSpy.mockClear();
logSpy.mockClear();
process.env.container = originalContainer;
});
it("Simple spawn command", () => {
bsmSpawn("cd", {
args: ["folder1", "folder2"],
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith("cd folder1 folder2", expect.anything());
expect(logSpy).toHaveBeenCalledTimes(0);
});
it("Simple spawn command with logging", () => {
bsmSpawn("mkdir", {
args: ["new_folder"],
log: true,
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
expect(spawnSpy).toHaveBeenCalledWith("mkdir new_folder", expect.anything());
expect(logSpy).toHaveBeenCalledTimes(1);
});
it("Complex spawn command call (Mods install)", () => {
bsmSpawn(`"./BSIPA.exe" "./Beat Saber.exe" -n`, {
log: true,
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`,
expect.anything()
);
expect(logSpy).toHaveBeenCalledTimes(1);
});
it("Complex spawn command call (BS launch)", () => {
bsmSpawn(`"./Beat Saber.exe"`, {
args: ["--no-yeet", "fpfc"],
options: {
cwd: "/",
detached: true,
env: BS_ENV,
},
log: true,
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`,
expect.objectContaining({
cwd: "/",
detached: true,
env: BS_ENV,
})
);
expect(logSpy).toHaveBeenCalledTimes(1);
});
ifIt(IS_LINUX)("Complex spawn command call (BS launch flatpak)", () => {
const flatpakEnv = [
"SteamAppId",
"SteamOverlayGameId",
"SteamGameId",
"WINEDLLOVERRIDES",
"STEAM_COMPAT_DATA_PATH",
"STEAM_COMPAT_INSTALL_PATH",
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
"STEAM_COMPAT_APP_ID",
"SteamEnv"
];
const newEnv = {
...BS_ENV,
something: "else",
more: "tests",
};
bsmSpawn(`"./Beat Saber.exe"`, {
args: ["--no-yeet", "fpfc"],
options: {
cwd: "/",
detached: true,
env: newEnv,
},
log: true,
linux: { prefix: `"./proton" run` },
flatpak: {
host: true,
env: flatpakEnv,
},
});
expect(spawnSpy).toHaveBeenCalledTimes(1);
const envArgs = flatpakEnv.map(argName =>
`--env=${argName}="${(BS_ENV as any)[argName]}"`
).join(" ");
expect(spawnSpy).toHaveBeenCalledWith(
`flatpak-spawn --host ${envArgs} "./proton" run "./Beat Saber.exe" --no-yeet fpfc`,
expect.objectContaining({
cwd: "/",
detached: true,
env: newEnv,
})
);
expect(logSpy).toHaveBeenCalledTimes(1);
});
});
ifDescribe(IS_LINUX)("Test os.helpers isProcessRunning", () => {
const logSpy: jest.SpyInstance = jest.spyOn(log, "error");
afterEach(() => {
logSpy.mockClear();
});
it("Process is running", async () => {
// There will always a node process running
const running = await isProcessRunning("node");
expect(running).toBe(true);
// No errors received
expect(logSpy).toHaveBeenCalledTimes(0);
});
it("Process is not running", async () => {
const running = await isProcessRunning(`bs-manager-${crypto.randomUUID()}`);
expect(running).toBe(false);
// No errors received
expect(logSpy).toHaveBeenCalledTimes(0);
});
});
+5
View File
@@ -0,0 +1,5 @@
export const ifDescribe = (condition: boolean) => condition ? describe : describe.skip;
export const ifIt = (condition: boolean) => condition ? it : it.skip;
+1
View File
@@ -21,4 +21,5 @@ export const HTTP_STATUS_CODES = constants;
export const PROTON_BINARY_PREFIX = "proton";
export const WINE_BINARY_PREFIX = path.join("files", "bin", "wine64");
export const IS_FLATPAK = process.env.container === "flatpak";
+151 -10
View File
@@ -1,25 +1,166 @@
import cp from "child_process";
import log from "electron-log";
import psList from "ps-list";
import { IS_FLATPAK } from "main/constants";
export async function taskRunning(task: string): Promise<boolean> {
// There are 2 erroneous lines ps | grep which is both the ps and grep calls themselves
const MIN_PROCESS_COUNT_LINUX = 2;
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
host: boolean;
// Only copy the keys from options.env from bsmSpawn/bsmExec
env?: string[];
};
export type BsmSpawnOptions = {
args?: string[];
options?: cp.SpawnOptions;
log?: boolean;
linux?: LinuxOptions;
flatpak?: FlatpakOptions;
};
export type BsmExecOptions = {
args?: string[];
options?: cp.ExecOptions;
log?: boolean;
linux?: LinuxOptions;
flatpak?: FlatpakOptions;
};
function updateCommand(command: string, options: BsmSpawnOptions) {
if (options?.args) {
command += ` ${options.args.join(" ")}`;
}
if (process.platform === "linux") {
// "/bin/sh" does not see flatpak-spawn
// Most Debian and Arch should also support "/bin/bash"
options.options.shell = "/bin/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
.filter(envName => options.options.env[envName])
.map(envName =>
`--env=${envName}="${options.options.env[envName]}"`
)
.join(" ");
command = `flatpak-spawn --host ${envArgs || ""} ${command}`;
}
}
return command;
}
export function bsmSpawn(command: string, options?: BsmSpawnOptions) {
options = options || {};
options.options = options.options || {};
command = updateCommand(command, options);
if (options?.log) {
log.info(process.platform === "win32" ? "Windows" : "Linux", "spawn command\n>", command);
}
return cp.spawn(command, options.options);
}
export function bsmExec(command: string, options?: BsmExecOptions): Promise<{
stdout: string;
stderr: string;
}> {
options = options || {};
options.options = options.options || {};
command = updateCommand(command, options);
if (options?.log) {
log.info(
process.platform === "win32" ? "Windows" : "Linux",
"exec command\n>", command
);
}
return new Promise((resolve, reject) => {
cp.exec(command, options?.options || {}, (error: Error, stdout: string, stderr: string) => {
if (error) { return reject(error); }
resolve({ stdout, stderr });
});
});
}
async function isProcessRunningLinux(name: string): Promise<boolean> {
try {
const { stdout: count } = await bsmExec(`ps awwxo args | grep -c "${name}"`, {
log: true,
flatpak: { host: IS_FLATPAK },
});
return +count.trim() > MIN_PROCESS_COUNT_LINUX;
} catch(error) {
log.error(error);
return false;
};
}
async function getProcessIdWindows(name: string): Promise<number | null> {
try {
const processes = await psList();
return processes.some(process => process.name?.includes(task) || process.cmd?.includes(task));
const process = processes.find(process => process.name?.includes(name) || process.cmd?.includes(name));
return process?.pid;
} catch (error) {
log.error(error);
return null;
}
catch(error){
}
export const isProcessRunning = process.platform === "win32"
? isProcessRunningWindows
: isProcessRunningLinux;
async function isProcessRunningWindows(name: string): Promise<boolean> {
try {
const processes = await psList();
return processes.some(process =>
process.name?.includes(name) || process.cmd?.includes(name)
);
} catch (error) {
log.error(error);
return false;
}
}
export async function getProcessPid(task: string): Promise<number> {
async function getProcessIdLinux(name: string): Promise<number | null> {
try {
const processes = await psList();
const process = processes.find(process => process.name?.includes(task) || process.cmd?.includes(task));
return process?.pid;
}
catch(error){
const { stdout } = await bsmExec(`ps awwxo pid,args | grep "${name}"`, {
log: true,
flatpak: { host: IS_FLATPAK },
});
const line = stdout.split("\n")
.slice(0, -MIN_PROCESS_COUNT_LINUX)
.map(line => line.trimStart())
.find(line => line.includes(name) && !line.includes("grep"));
return line ? +line.split(" ").at(0) : null;
} catch(error) {
log.error(error);
return null;
}
};
}
export const getProcessId = process.platform === "win32"
? getProcessIdWindows
: getProcessIdLinux;
@@ -1,15 +1,20 @@
import { LaunchOption } from "shared/models/bs-launch";
import { BSLocalVersionService } from "../bs-local-version.service";
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, spawn } from "child_process";
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 { bsmSpawn } from "main/helpers/os.helpers";
import { IS_FLATPAK } from "main/constants";
export abstract class AbstractLauncherService {
protected readonly linux = LinuxService.getInstance();
protected readonly localVersions = BSLocalVersionService.getInstance();
constructor(){
this.linux = LinuxService.getInstance();
this.localVersions = BSLocalVersionService.getInstance();
}
@@ -47,10 +52,25 @@ export abstract class AbstractLauncherService {
spawnOptions.windowsVerbatimArguments = true;
}
log.info(`Launch BS exe at ${bsExePath} with args ${args?.join(" ")}`);
return spawn(bsExePath, args, spawnOptions);
spawnOptions.shell = true; // For windows to spawn properly
return bsmSpawn(`"${bsExePath}"`, {
args, options: spawnOptions, log: true,
linux: { prefix: this.linux.getProtonCommand() },
flatpak: {
host: IS_FLATPAK,
env: [
"SteamAppId",
"SteamOverlayGameId",
"SteamGameId",
"WINEDLLOVERRIDES",
"STEAM_COMPAT_DATA_PATH",
"STEAM_COMPAT_INSTALL_PATH",
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
"STEAM_COMPAT_APP_ID",
"SteamEnv",
],
},
});
}
protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {
@@ -8,7 +8,7 @@ import log from "electron-log";
import { sToMs } from "../../../shared/helpers/time.helpers";
import { lstat, pathExists, readdir, readlink, rename, symlink, unlink } from "fs-extra";
import { AbstractLauncherService } from "./abstract-launcher.service";
import { taskRunning } from "../../helpers/os.helpers";
import { isProcessRunning } from "../../helpers/os.helpers";
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
import { InstallationLocationService } from "../installation-location.service";
import { ensurePathNotAlreadyExist } from "../../helpers/fs.helpers";
@@ -154,7 +154,7 @@ export class OculusLauncherService extends AbstractLauncherService implements St
(async () => {
// Cannot start multiple instances of Beat Saber with Oculus
const bsRunning = await taskRunning(BS_EXECUTABLE).catch(() => false);
const bsRunning = await isProcessRunning(BS_EXECUTABLE).catch(() => false);
if(bsRunning){
throw CustomError.fromError(new Error("Cannot start two instance of Beat Saber for Oculus"), BSLaunchError.BS_ALREADY_RUNNING);
}
@@ -1,17 +1,15 @@
import { Observable } from "rxjs";
import { BSLaunchError, BSLaunchEvent, BSLaunchEventData, BSLaunchWarning, LaunchOption } from "../../../shared/models/bs-launch";
import { StoreLauncherInterface } from "./store-launcher.interface";
import { pathExists, pathExistsSync, rename } from "fs-extra";
import { pathExists, rename } from "fs-extra";
import { SteamService } from "../steam.service";
import path from "path";
import { BS_APP_ID, BS_EXECUTABLE, PROTON_BINARY_PREFIX, STEAMVR_APP_ID } from "../../constants";
import { BS_APP_ID, BS_EXECUTABLE, STEAMVR_APP_ID } from "../../constants";
import log from "electron-log";
import { AbstractLauncherService } from "./abstract-launcher.service";
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
import { UtilsService } from "../utils.service";
import { exec } from "child_process";
import fs from 'fs';
import { StaticConfigurationService } from "../static-configuration.service";
export class SteamLauncherService extends AbstractLauncherService implements StoreLauncherInterface{
@@ -24,13 +22,11 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
return SteamLauncherService.instance;
}
private readonly staticConfig: StaticConfigurationService;
private readonly steam: SteamService;
private readonly util: UtilsService;
private constructor(){
super();
this.staticConfig = StaticConfigurationService.getInstance();
this.steam = SteamService.getInstance();
this.util = UtilsService.getInstance();
}
@@ -71,16 +67,17 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
return new Observable<BSLaunchEventData>(obs => {(async () => {
const bsFolderPath = await this.localVersions.getInstalledVersionPath(launchOptions.version);
let exePath = path.join(bsFolderPath, BS_EXECUTABLE);
const bsExePath = path.join(bsFolderPath, BS_EXECUTABLE);
if(!(await pathExists(exePath))){
throw CustomError.fromError(new Error(`Path not exist : ${exePath}`), BSLaunchError.BS_NOT_FOUND);
if(!(await pathExists(bsExePath))){
throw CustomError.fromError(new Error(`Path not exist : ${bsExePath}`), BSLaunchError.BS_NOT_FOUND);
}
const skipVR: boolean = launchOptions.skipVR ?? false;
// Open Steam if not running
if(!skipVR && !(await this.steam.steamRunning())){
obs.next({type: BSLaunchEvent.STEAM_LAUNCHING});
await this.steam.openSteam().then(() => {
@@ -103,7 +100,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
await this.restoreSteamVR().catch(log.error);
}
let launchArgs = this.buildBsLaunchArgs(launchOptions);
const launchArgs = this.buildBsLaunchArgs(launchOptions);
const steamPath = await this.steam.getSteamPath();
const env = {
@@ -115,52 +112,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
// Linux setup
if (process.platform === "linux") {
if (launchOptions.admin) {
log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user.");
launchOptions.admin = false;
}
// Create the compat data path if it doesn't exist.
// If the user never ran Beat Saber through steam before
// using bsmanager, it won't exist, and proton will fail
// to launch the game.
const compatDataPath = `${steamPath}/steamapps/compatdata/${BS_APP_ID}`;
if (!fs.existsSync(compatDataPath)) {
log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`);
fs.mkdirSync(compatDataPath);
}
// proton run BeatSaber.exe
launchArgs = [
"run",
`${exePath}`,
...launchArgs,
];
if (!this.staticConfig.has("proton-folder")) {
throw CustomError.fromError(new Error("Proton folder not set"), BSLaunchError.PROTON_NOT_SET);
}
exePath = path.join(this.staticConfig.get("proton-folder"), PROTON_BINARY_PREFIX);
if (!pathExistsSync(exePath)) {
throw CustomError.fromError(
new Error("Could not locate proton binary"),
BSLaunchError.PROTON_NOT_FOUND
);
}
// Setup Proton environment variables
Object.assign(env, {
"WINEDLLOVERRIDES": "winhttp=n,b", // Required for mods to work
"STEAM_COMPAT_DATA_PATH": compatDataPath,
"STEAM_COMPAT_INSTALL_PATH": bsFolderPath,
"STEAM_COMPAT_CLIENT_INSTALL_PATH": steamPath,
"STEAM_COMPAT_APP_ID": BS_APP_ID,
// Run game in steam environment; fixes #585 for unicode song titles
"SteamEnv": "1",
// Uncomment these to create a proton log file in the Beat Saber install directory.
// "PROTON_LOG": 1,
// "PROTON_LOG_DIR": bsFolderPath,
});
this.linux.setupLaunch(launchOptions, steamPath, bsFolderPath, env);
}
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
@@ -168,10 +120,10 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
const spawnOpts = { env, cwd: bsFolderPath };
const launchPromise = !launchOptions.admin ? (
this.launchBs(exePath, launchArgs, spawnOpts).exit
this.launchBs(bsExePath, launchArgs, spawnOpts).exit
) : (
new Promise<number>(resolve => {
const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${exePath}" ${launchArgs.join(" ")}`, spawnOpts);
const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${bsExePath}" ${launchArgs.join(" ")}`, spawnOpts);
adminProcess.on("error", err => {
log.error("Error while starting BS as Admin", err);
resolve(-1)
+18 -21
View File
@@ -3,9 +3,10 @@ import path from "path";
import { writeFileSync } from "fs";
import { BSVersion } from "shared/bs-version.interface";
import { RequestService } from "./request.service";
import { readJSON } from "fs-extra";
import { pathExistsSync, readJSON } from "fs-extra";
import { allSettled } from "../../shared/helpers/promise.helpers";
import { StaticConfigurationService } from "./static-configuration.service";
import { LinuxService } from "./linux.service";
import { IS_FLATPAK } from "main/constants";
export class BSVersionLibService {
private readonly REMOTE_BS_VERSIONS_URL: string = "https://raw.githubusercontent.com/Zagrios/bs-manager/master/assets/jsons/bs-versions.json";
@@ -13,16 +14,16 @@ export class BSVersionLibService {
private static instance: BSVersionLibService;
private linuxService: LinuxService;
private utilsService: UtilsService;
private requestService: RequestService;
private staticConfigurationService: StaticConfigurationService;
private bsVersions: BSVersion[];
private constructor() {
this.linuxService = LinuxService.getInstance();
this.utilsService = UtilsService.getInstance();
this.requestService = RequestService.getInstance();
this.staticConfigurationService = StaticConfigurationService.getInstance();
}
public static getInstance(): BSVersionLibService {
@@ -32,34 +33,30 @@ export class BSVersionLibService {
return BSVersionLibService.instance;
}
private getRemoteVersions(): Promise<BSVersion[]> {
private async getRemoteVersions(): Promise<BSVersion[]> {
return this.requestService.getJSON<BSVersion[]>(this.REMOTE_BS_VERSIONS_URL).then(res => res.data);
}
private async getLocalVersions(): Promise<BSVersion[]> {
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
if (process.platform === "linux") {
let versions = this.staticConfigurationService.get("versions");
if (!versions) {
versions = (await readJSON(localVersionsPath)) as BSVersion[];
this.staticConfigurationService.set("versions", versions);
if (IS_FLATPAK) {
const flatpakVersionsPath = path.join(this.linuxService.getFlatpakLocalVersionFolder(), this.VERSIONS_FILE);
if (pathExistsSync(flatpakVersionsPath)) {
return readJSON(flatpakVersionsPath);
}
return versions;
}
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
return readJSON(localVersionsPath);
}
private async updateLocalVersions(versions: BSVersion[]): Promise<void> {
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
// Do not write on readonly memory in linux when running on AppImage
if (process.platform === "linux") {
this.staticConfigurationService.set("versions", versions);
} else {
writeFileSync(localVersionsPath, JSON.stringify(versions, null, "\t"), { encoding: "utf-8", flag: "w" });
}
const localVersionsPath = path.join(
IS_FLATPAK
? this.linuxService.getFlatpakLocalVersionFolder()
: this.utilsService.getAssestsJsonsPath(),
this.VERSIONS_FILE
);
writeFileSync(localVersionsPath, JSON.stringify(versions, null, "\t"), { encoding: "utf-8", flag: "w" });
}
private async loadBsVersions(): Promise<BSVersion[]> {
+82 -4
View File
@@ -1,7 +1,12 @@
import fs from "fs-extra";
import log from "electron-log";
import path from "path";
import { pathExistsSync } from "fs-extra";
import { PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
import { BS_APP_ID, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
import { StaticConfigurationService } from "./static-configuration.service";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { BSLaunchError, LaunchOption } from "shared/models/bs-launch";
import { app } from "electron";
import config from "../../../electron-builder.config";
export class LinuxService {
private static instance: LinuxService;
@@ -14,11 +19,68 @@ export class LinuxService {
}
private readonly staticConfig: StaticConfigurationService;
private protonCommand = "";
private constructor() {
this.staticConfig = StaticConfigurationService.getInstance();
}
// === Launching === //
public setupLaunch(
launchOptions: LaunchOption,
steamPath: string,
bsFolderPath: 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;
}
// Create the compat data path if it doesn't exist.
// If the user never ran Beat Saber through steam before
// using bsmanager, it won't exist, and proton will fail
// to launch the game.
const compatDataPath = `${steamPath}/steamapps/compatdata/${BS_APP_ID}`;
if (!fs.existsSync(compatDataPath)) {
log.info(`Proton compat data path not found at '${compatDataPath}', creating directory`);
fs.mkdirSync(compatDataPath);
}
if (!this.staticConfig.has("proton-folder")) {
throw CustomError.fromError(
new Error("Proton folder not set"),
BSLaunchError.PROTON_NOT_SET
);
}
const protonPath = path.join(
this.staticConfig.get("proton-folder"),
PROTON_BINARY_PREFIX
);
if (!fs.pathExistsSync(protonPath)) {
throw CustomError.fromError(
new Error("Could not locate proton binary"),
BSLaunchError.PROTON_NOT_FOUND
);
}
this.protonCommand = `"${protonPath}" run`;
// Setup Proton environment variables
Object.assign(env, {
"WINEDLLOVERRIDES": "winhttp=n,b", // Required for mods to work
"STEAM_COMPAT_DATA_PATH": compatDataPath,
"STEAM_COMPAT_INSTALL_PATH": bsFolderPath,
"STEAM_COMPAT_CLIENT_INSTALL_PATH": steamPath,
"STEAM_COMPAT_APP_ID": BS_APP_ID,
// Run game in steam environment; fixes #585 for unicode song titles
"SteamEnv": "1",
// Uncomment these to create a proton log file in the Beat Saber install directory.
// "PROTON_LOG": 1,
// "PROTON_LOG_DIR": bsFolderPath,
});
}
public verifyProtonPath(protonFolder: string = ""): boolean {
if (protonFolder === "") {
if (!this.staticConfig.has("proton-folder")) {
@@ -30,7 +92,7 @@ export class LinuxService {
const protonPath = path.join(protonFolder, PROTON_BINARY_PREFIX);
const winePath = path.join(protonFolder, WINE_BINARY_PREFIX);
return pathExistsSync(protonPath) && pathExistsSync(winePath);
return fs.pathExistsSync(protonPath) && fs.pathExistsSync(winePath);
}
public getWinePath(): string {
@@ -42,10 +104,26 @@ export class LinuxService {
this.staticConfig.get("proton-folder"),
WINE_BINARY_PREFIX
);
if (!pathExistsSync(winePath)) {
if (!fs.pathExistsSync(winePath)) {
throw new Error(`"${winePath}" binary file not found`);
}
return winePath;
}
public getProtonCommand(): string {
// Set in setupLaunch
return this.protonCommand;
}
// === Flatpak Specific === //
public getFlatpakLocalVersionFolder(): string {
return path.join(
app.getPath("home"),
".var", "app", config.appId,
"resources", "assets", "jsons"
);
}
}
@@ -5,7 +5,6 @@ import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
import md5File from "md5-file";
import { RequestService } from "../request.service";
import { spawn } from "child_process";
import { BS_EXECUTABLE } from "../../constants";
import log from "electron-log";
import { deleteFolder, pathExist, Progression, unlinkPath } from "../../helpers/fs.helpers";
@@ -17,9 +16,9 @@ import { CustomError } from "shared/models/exceptions/custom-error.class";
import { popElement } from "shared/helpers/array.helpers";
import { LinuxService } from "../linux.service";
import { tryit } from "shared/helpers/error.helpers";
import { UtilsService } from "../utils.service";
import crypto from "crypto";
import { BsmZipExtractor } from "main/models/bsm-zip-extractor.class";
import { bsmSpawn } from "main/helpers/os.helpers";
export class BsModsManagerService {
private static instance: BsModsManagerService;
@@ -28,7 +27,6 @@ export class BsModsManagerService {
private readonly bsLocalService: BSLocalVersionService;
private readonly linuxService: LinuxService;
private readonly requestService: RequestService;
private readonly utilsService: UtilsService;
private manifestMatches: Mod[];
@@ -44,7 +42,6 @@ export class BsModsManagerService {
this.bsLocalService = BSLocalVersionService.getInstance();
this.linuxService = LinuxService.getInstance();
this.requestService = RequestService.getInstance();
this.utilsService = UtilsService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
@@ -144,19 +141,28 @@ export class BsModsManagerService {
return false;
}
let cmd = `"${ipaPath}" "${bsExePath}" ${args.join(" ")}`;
const cmd = `"${ipaPath}" "${bsExePath}" ${args.join(" ")}`;
let winePath: string = "";
if (process.platform === "linux") {
const { error, result: winePath } = tryit(() => this.linuxService.getWinePath());
const { error, result } = tryit(() => this.linuxService.getWinePath());
if (error) {
log.error(error);
return false;
}
cmd = `"${winePath}" ${cmd}`;
winePath = `"${result}"`;
}
return new Promise<boolean>(resolve => {
log.info("START IPA PROCESS", cmd);
const processIPA = spawn(cmd, { cwd: versionPath, detached: true, shell: true });
const processIPA = bsmSpawn(cmd, {
log: true,
options: {
cwd: versionPath,
detached: true,
shell: true
},
linux: { prefix: winePath },
});
const timeout = setTimeout(() => {
log.info("Ipa process timeout");
+2 -2
View File
@@ -4,7 +4,7 @@ import log from "electron-log";
import { lstat } from "fs-extra";
import { tryit } from "../../shared/helpers/error.helpers";
import { shell } from "electron";
import { taskRunning } from "../helpers/os.helpers";
import { isProcessRunning } from "../helpers/os.helpers";
import { sToMs } from "../../shared/helpers/time.helpers";
import { execOnOs } from "../helpers/env.helpers";
@@ -99,7 +99,7 @@ export class OculusService {
}
public oculusRunning(): Promise<boolean> {
return taskRunning("OculusClient");
return isProcessRunning("OculusClient");
}
public async startOculus(): Promise<void>{
@@ -3,7 +3,6 @@ import { pathExistsSync } from "fs-extra";
import path from "path";
import { PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
import { Observable, Subject } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { CustomError } from "shared/models/exceptions/custom-error.class";
export class StaticConfigurationService {
@@ -90,7 +89,6 @@ export interface StaticConfigKeyValues {
"use-symlinks": boolean;
// Linux Specific static configs
"versions": BSVersion[];
"proton-folder": string;
};
+6 -6
View File
@@ -5,7 +5,7 @@ import { readFile } from "fs/promises";
import { pathExist } from "../helpers/fs.helpers";
import log from "electron-log";
import { app, shell } from "electron";
import { getProcessPid, taskRunning } from "../helpers/os.helpers";
import { getProcessId, isProcessRunning } from "main/helpers/os.helpers";
import { isElevated } from "query-process";
import { execOnOs } from "../helpers/env.helpers";
@@ -15,7 +15,7 @@ export class SteamService {
private static readonly PROCESS_NAME: string = process.platform === "linux"
? "steam-runtime-launcher-service"
: "steam";
: "steam.exe";
private static instance: SteamService;
@@ -37,15 +37,15 @@ export class SteamService {
return registryValue.value;
}
public async steamRunning(): Promise<boolean>{
const steamProcessRunning = await taskRunning(SteamService.PROCESS_NAME);
public async isSteamRunning(): Promise<boolean>{
const steamProcessRunning = await isProcessRunning(SteamService.PROCESS_NAME);
if(process.platform === "linux") { return steamProcessRunning; }
const activeUser = await this.getActiveUser().catch(err => log.error(err));
return steamProcessRunning && !!activeUser;
}
public async getSteamPid(): Promise<number>{
return getProcessPid(SteamService.PROCESS_NAME);
return getProcessId(SteamService.PROCESS_NAME);
}
/**
@@ -123,7 +123,7 @@ export class SteamService {
return new Promise((resolve, reject) => {
// Every 3 seconds check if steam is running
const interval = setInterval(() => {
const steamRunning = this.steamRunning().catch(() => false);
const steamRunning = this.isSteamRunning().catch(() => false);
steamRunning.then(running => {
if(!running){ return; }
clearInterval(interval);
@@ -22,7 +22,7 @@ export const ChooseProtonFolderModal: ModalComponent<{}, {}> = ({ resolver }) =>
const selectProtonPath = async () => {
const response = await lastValueFrom(ipcService.sendV2("choose-folder", {
parent: "home",
defaultPath: ".local/share/Steam/steamapps/common",
defaultPath: ".steam/steam/steamapps/common",
showHidden: true,
}));
@@ -82,22 +82,77 @@ export function ModsSlide({ version, onDisclamerDecline }: { version: BSVersion;
}
linkOpener.open(moreInfoMod.link);
};
// private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]) {
// return selectedMods.some(m => {
// const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name));
// if (deps.some(depMod => depMod.name === mod.name)) {
// return true;
// }
// return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
// });
// }
// private async resolveDependencies(mods: Mod[], version: BSVersion): Promise<Mod[]> {
// const availableMods = await this.beatModsApi.getVersionMods(version);
// return Array.from(
// new Map<string, Mod>(
// availableMods.reduce((res, mod) => {
// if (mod.required || this.isDependency(mod, mods, availableMods)) {
// res.push([mod.name, mod]);
// }
// return res;
// }, [])
// ).values()
// );
// }
const getAllDependencies = (mods: Mod[], availableMods: Mod[]): Mod[] => {
const collectedDependencies = new Set<Mod>();
const collectDependencies = (mod: Mod) => {
if (!mod.dependencies) { return; }
for (const dependency of mod.dependencies) {
const dependencyMod = availableMods.find(avMod => avMod.name === dependency.name);
if (dependencyMod && !collectedDependencies.has(dependencyMod)) {
collectedDependencies.add(dependencyMod);
collectDependencies(dependencyMod);
}
}
};
mods.forEach(collectDependencies);
availableMods.forEach(mod => {
if(mod.required){
collectedDependencies.add(mod);
}
});
return Array.from(collectedDependencies);
};
const installMods = (reinstallAll: boolean): void => {
setReinstallAllMods(() => false);
setReinstallAllMods(false);
if (installing) {
return;
}
const modsToInstall = modsSelected.filter(mod => {
const installedMod = modsInstalled.get(mod.category)?.find(installedMod => installedMod.name === mod.name);
let modsToInstall = [
...modsSelected,
...getAllDependencies(modsSelected, Array.from(modsAvailable.values()).flat())
]
if(reinstallAll || !installedMod){ return true; }
modsToInstall = reinstallAll ? (
modsToInstall // If reinstalling all, we install all selected mods
) : (
modsToInstall.filter(mod => { // Else we only install the mods that are not installed or have a newer version
const installedMod = modsInstalled.get(mod.category)?.find(installedMod => installedMod.name === mod.name);
return !installedMod || lt(installedMod.version, mod.version);
})
);
return lt(installedMod.version, mod.version);
});
modsToInstall = Array.from(new Set(modsToInstall)); // Remove duplicates
if (!modsToInstall.length) {
notification.notifyInfo({ title: "pages.version-viewer.mods.notifications.all-mods-already-installed.title", desc: "pages.version-viewer.mods.notifications.all-mods-already-installed.description" });
@@ -164,7 +164,7 @@ export function SettingsPage() {
try {
const pathResponse = await lastValueFrom(ipcService.sendV2("choose-folder", {
parent: "home",
defaultPath: ".local/share/Steam/steamapps/common",
defaultPath: ".steam/steam/steamapps/common",
showHidden: true,
}));
if (