mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 127a538509 | |||
| 043e300f42 | |||
| 6e3d8852de | |||
| e2c2aa7a84 | |||
| 2a0de5f2ad | |||
| 214f0494c1 | |||
| 19f2a57c37 | |||
| 922bcf30b4 | |||
| f31ade5754 | |||
| c8e4d8c359 | |||
| fc44dcba65 | |||
| b02311456f | |||
| 5462c8821e | |||
| 4afad63753 | |||
| 438611f4ca | |||
| 1fdf1f0247 | |||
| 5721891e28 | |||
| cba001dc97 | |||
| 38ea360ee6 | |||
| 8096b3ec76 | |||
| 8844e7cec3 | |||
| 403995612c | |||
| 58656294df | |||
| 84ea7a26b6 | |||
| 82a19c0c8b | |||
| 15f8d83c8d | |||
| 196bdc04f5 | |||
| e6bdf56c6d | |||
| 7a85171cd3 | |||
| 38e0cc56d0 | |||
| a345a3ee7c | |||
| cb121a9442 |
@@ -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,
|
||||
|
||||
@@ -22,11 +22,29 @@ 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
|
||||
|
||||
# Install and setup Flatpak for Ubuntu
|
||||
- name: Install flatpak packages (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: sudo apt-get install -y flatpak flatpak-builder
|
||||
|
||||
- name: Setup flatpak repo (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
flatpak remote-add --if-not-exists --user \
|
||||
flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
# Build Flatpak package
|
||||
- name: Build Flatpak (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
env DEBUG="@malept/flatpak-bundler" \
|
||||
npx electron-builder --config electron-builder.config.js --publish never --linux flatpak
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "io.bsmanager.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;
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+5
-57
@@ -2,7 +2,7 @@
|
||||
"name": "bs-manager",
|
||||
"description": "Manage maps, mods and more for Beat Saber",
|
||||
"main": "./.erb/dll/main.bundle.dev.js",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"scripts": {
|
||||
"build-rust-scripts": "ts-node ./.erb/scripts/build-rust-scripts.js",
|
||||
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
|
||||
@@ -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 --publish never --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=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"
|
||||
},
|
||||
"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",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bs-manager",
|
||||
"version": "1.5.0-alpha.4",
|
||||
"version": "1.5.0-alpha.5",
|
||||
"description": "BSManager",
|
||||
"main": "./dist/main/main.js",
|
||||
"author": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
export const ifDescribe = (condition: boolean) => condition ? describe : describe.skip;
|
||||
|
||||
export const ifIt = (condition: boolean) => condition ? it : it.skip;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ export class LocalPlaylistsManagerService {
|
||||
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
|
||||
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
|
||||
const url = new URL(link);
|
||||
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") : "";
|
||||
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${bplistUrl}`);
|
||||
const bplistUrl = url.host === "playlist" ? url.pathname.replace("/", "") + url.search : "";
|
||||
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${encodeURIComponent(bplistUrl)}`);
|
||||
});
|
||||
|
||||
this.fileAssociation.registerFileAssociation(".bplist", filePath => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user