Merge pull request #629 from silentrald/bugfix/627

[bugfix-627] build BSM with deb and flatpak for linux
This commit is contained in:
MathieuG-P
2024-11-30 20:02:27 +01:00
committed by GitHub
25 changed files with 744 additions and 186 deletions
+1
View File
@@ -60,6 +60,7 @@ module.exports = {
"jsx-a11y/control-has-associated-label": "off",
"react/button-has-type": "off",
"max-classes-per-file": "off",
"jest/no-standalone-expect": "off",
},
parserOptions: {
ecmaVersion: 2020,
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 22.11.0
cache: "npm"
- run: npm ci
- run: npm run package
+3 -3
View File
@@ -14,11 +14,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 22.11.0
cache: "npm"
- run: npm ci
- run: npm run lint
+3 -3
View File
@@ -18,11 +18,11 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 22.11.0
cache: "npm"
- run: npm ci
- run: npm run build
+16 -2
View File
@@ -20,13 +20,27 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install flatpak packages
run: sudo apt-get install -y flatpak flatpak-builder
- name: Setup flatpak repo
run: |
flatpak remote-add --if-not-exists --user \
flathub https://flathub.org/repo/flathub.flatpakrepo
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 22.11.0
cache: "npm"
- run: npm ci
- run: npm run publish:linux
- run: npm run build
- name: Build deb
run: npx electron-builder --config electron-builder.config.js --publish always --linux deb --x64
- name: Build flatpak
run: env DEBUG="@malept/flatpak-bundler" npx electron-builder --config electron-builder.config.js --publish always --linux flatpak
- name: Upload artifact
uses: actions/upload-artifact@v4
+10
View File
@@ -226,6 +226,10 @@
-->
<div>
<h2><b>How to install?</b></h2>
</div>
<div>
<h3><b>Windows Installation</b></h3>
<ul>
<li>Download the <a href="https://github.com/Zagrios/bs-manager/releases/latest">latest release</a> from <a
href="https://github.com/Zagrios/bs-manager/releases">Releases</a>.</li>
@@ -239,6 +243,10 @@
<video src="https://github.com/Zagrios/bs-manager/assets/40648115/4215384e-eb68-40da-884c-f21df491ef75" />
</div>
<div>
<h3><b>Linux installation</b></h3>
<p>Refer to <a href="https://github.com/Zagrios/bs-manager/wiki/Linux">Linux wiki</a></p>
</div>
<!--
----------------------------------------
@@ -550,6 +558,8 @@
<li><a href="https://github.com/Iluhadesu">Iluhadesu</a> - Co-Developer & Co-Founder, Discord Bot Developer.</li>
<li><a href="https://github.com/GaetanGrd">GaetanGrd</a> - Co-Developer & Co-Founder, Documentation Lead.</li>
<li><a href="https://github.com/cheddZy">cheddZy</a> - Icon Creator.</li>
<li><a href="https://github.com/Insprill">Insprill</a> - Co-Developer, Linux Developer, AUR Maintainer.</li>
<li><a href="https://github.com/silentrald">silentrald</a> - Co-Developer, Linux Developer, deb and flatpak Maintainer.</li>
</ul>
</div>
+6
View File
@@ -0,0 +1,6 @@
# Add write permissions to anybody so that this can be sync with the
# github's bs-versions.json when starting bsmanager
/usr/bin/chmod +002 /opt/BSManager/resources/assets/jsons/bs-versions.json
# https://github.com/electron/electron/issues/42510
/usr/bin/chmod 4755 /opt/BSManager/chrome-sandbox
+86
View File
@@ -0,0 +1,86 @@
# Linux Guide
## Installation
Go to [Releases](https://github.com/Zagrios/bs-manager/releases) page and go to the latest release. Download the necessary build installer for your distro (see below).
### Ubuntu, Debian (deb)
Download the `.deb` file in the releases and run the following command:
```bash
dpkg -i ./bsmanager.deb
```
### Arch (AUR)
Refer to [bs-manager-git](https://aur.archlinux.org/packages/bs-manager-git).
To install AUR packages, you need to install [yay](https://github.com/Jguer/yay).
### Universal (flatpak)
This should work on any linux distribution. You are only required to have `flatpak` installed in your system. If it is not installed, then go to [flatpak](https://flatpak.org/setup/) to look for a guide on how to install it on your distro.
After installing, download the `.flatpak` file in the releases and run the following command:
```bash
flatpak install --user ./bsmanager.flatpak
```
If you are getting errors like packages not existing, run the command below so that it finds the correct packages.
```bash
sudo flatpak remote-add --if-not-exists --system flathub https://flathub.org/repo/flathub.flatpakrepo
# or
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
```
Flatpak also supports sandboxing which gives the minimal access to your machine. To configure this, you can download [Flatseal](https://flathub.org/apps/com.github.tchx84.Flatseal) which has a GUI to edit your permissions. You can also this with the `flatpak` executable but it will not be discussed here.
## Proton Setup
[Proton](https://github.com/ValveSoftware/Proton) is needed to run the Beat Saber executable under Linux. You need to download this from either from Steam or building it from their GitHub repo.
Once Proton is installed, when you open your BSManager application for the first time, it will ask you to link the _Proton Folder_. The _Proton Folder_ also verifies if the `proton` and `files/bin/wine64` binaries exists. Once set, you should be able to launch the Beat Saber (using `proton`) and install mods (using `files/bin/wine64`). You can still change the _Proton Folder_ in the **settings page** if any new version of Steam Proton is downloaded.
# Troubleshooting
## Permission denied on "bs-versions.json"
<pre>
Unhandled Exception UnhandledRejection Error: EACCES: permission denied, open '/opt/BSManager/resources/assets/jsons/bs-versions.json'
</pre>
To fix this issue, the current user must have write permissions to the "bs-versions.json". To correct the permissions do command below:
```bash
chmod +002 /opt/BSManager/resources/assets/jsons/bs-versions.json
# or
chown $(whoami) /opt/BSManager/resources/assets/jsons/bs-versions.json
```
## [deb] The SUID sandbox helper binary was found.
This is encountered when running the app file or executing the app in the terminal. This is due to a change to Ubuntu 24.04. In order to fix the issue, take a look into the path of "chrome-sandbox" described in the error log and give the correct permissions within the terminal, for example:
```bash
chmod 4755 /opt/BSManager/chrome-sandbox
```
ref: https://github.com/electron/electron/issues/42510
## [Flatpak] Steam Beat Saber version not showing / Proton not detected
Flatpak should have permissions with the steam games folder. By default, the minimum permissions are `~/.steam/steam/steamapps/common:ro` and `~/.steam/steam/steamapps/common:ro`. If you changed the steam installation path, add that path instead into the permissions.
## [Flatpak] Changing installation folder
To change the installation path of the **BSManager** folder, you have to edit the flatpak permissions to destination folder.
- In flatpak or Flatseal, add the destination folder with `:create` permissions.
- In BSM, move the folder to the destination folder.
- [Optional] In flatpak or Flatseal, remove the original folder permissions.
+94
View File
@@ -0,0 +1,94 @@
const config = {
extraResources: [
"./assets/jsons/bs-versions.json",
"./assets/jsons/patreons.json",
"./assets/proto/song_details_cache_v1.proto"
],
productName: "BSManager",
appId: "org.erb.BSManager",
asarUnpack: "**\\*.{node,dll}",
files: [
"dist/**/*",
"node_modules",
"package.json"
],
afterSign: ".erb/scripts/notarize.js",
afterPack: ".erb/scripts/after-pack.js",
win: {
signingHashAlgorithms: ["sha256"],
target: [
"nsis",
"nsis-web"
],
icon: "./build/icons/win/favicon.ico",
extraResources: [
"./build/icons/win",
"./assets/scripts/*.exe"
],
},
linux: {
target: [
"deb",
],
icon: "./build/icons/png",
category: "Utility;Game;",
extraResources: [
"./build/icons/png",
"./assets/scripts/DepotDownloader"
],
protocols: {
name: "BSManager",
schemes: [
"bsmanager",
"beatsaver",
"bsplaylist",
"modelsaber",
"web+bsmap",
],
},
},
deb: {
fpm: ["--after-install=build/after-install.sh"],
},
flatpak: {
finishArgs: [
// Wayland/X11 Rendering
"--socket=wayland",
"--socket=x11",
"--share=ipc",
// Open GL
"--device=dri",
// Audio output
"--socket=pulseaudio",
// Read/write home directory access
"--filesystem=~/BSManager:create", // Default BSManager installation folder
"--filesystem=~/.steam/steam/steamapps:ro", // for the libraryfolders.vdf
"--filesystem=~/.steam/steam/steamapps/common:ro", // Steam game folder
"--filesystem=~/.steam/steam/steamapps/common/Beat Saber:create", // For installing mods/maps to original Beat Saber version
// Allow communication with network
"--share=network",
// System notifications with libnotify
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.freedesktop.Flatpak",
]
},
directories: {
app: "release/app",
buildResources: "assets",
output: "release/build",
},
publish: {
provider: "github",
owner: "Zagrios",
},
fileAssociations: [
{
ext: "bplist",
description: "Beat Saber Playlist (BSManager)",
icon: "./assets/bsm_file.ico",
role: "Viewer",
},
],
};
module.exports = config;
+4 -56
View File
@@ -13,15 +13,15 @@
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --config electron-builder.config.js && npm run build:dll",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"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:unit": "jest ./src/__tests__/unit",
"publish": "npm run build && electron-builder -c.win.certificateSha1=2164d6a7d641ecf6ad57852f665a518ca2bf960f --publish always --win --x64",
"publish:linux": "npm run build && electron-builder --publish always --linux --x64"
"publish": "npm run build && electron-builder -c.win.certificateSha1=2164d6a7d641ecf6ad57852f665a518ca2bf960f --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"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -38,63 +38,11 @@
]
},
"build": {
"extraResources": [
"./assets/jsons/bs-versions.json",
"./assets/jsons/patreons.json",
"./assets/proto/song_details_cache_v1.proto"
],
"productName": "BSManager",
"appId": "org.erb.BSManager",
"asarUnpack": "**\\*.{node,dll}",
"files": [
"dist/**/*",
"node_modules",
"package.json"
],
"afterSign": ".erb/scripts/notarize.js",
"afterPack": ".erb/scripts/after-pack.js",
"win": {
"signingHashAlgorithms": [
"sha256"
],
"target": [
"nsis",
"nsis-web"
],
"icon": "./build/icons/win/favicon.ico",
"extraResources": [
"./build/icons/win",
"./assets/scripts/*.exe"
]
},
"linux": {
"target": [
"AppImage"
],
"icon": "./build/icons/png",
"category": "Utility;Game;",
"extraResources": [
"./build/icons/png",
"./assets/scripts/DepotDownloader"
]
},
"directories": {
"app": "release/app",
"buildResources": "assets",
"output": "release/build"
},
"publish": {
"provider": "github",
"owner": "Zagrios"
},
"fileAssociations": [
{
"ext": "bplist",
"description": "Beat Saber Playlist (BSManager)",
"icon": "./assets/bsm_file.ico",
"role": "Viewer"
}
]
}
},
"repository": {
"type": "git",
+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();
}
@@ -44,10 +49,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,14 +67,14 @@ 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);
}
// Open Steam if not running
if(!(await this.steam.steamRunning())){
if(!(await this.steam.isSteamRunning())){
obs.next({type: BSLaunchEvent.STEAM_LAUNCHING});
await this.steam.openSteam().then(() => {
@@ -98,7 +94,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 = {
@@ -110,52 +106,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});
@@ -163,10 +114,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[]> {
+81 -4
View File
@@ -1,7 +1,11 @@
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";
export class LinuxService {
private static instance: LinuxService;
@@ -14,11 +18,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 +91,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 +103,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", "org.erb.BSManager",
"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,
}));
@@ -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 (