Merge pull request #451 from Insprill/feature/linux-launch

Add support for launching Beat Saber on Linux
This commit is contained in:
MathieuG-P
2024-07-10 19:38:39 +02:00
committed by GitHub
12 changed files with 128 additions and 12 deletions
+8 -1
View File
@@ -134,6 +134,11 @@
"description": "Change the default folder for Beat Saber versions and other upcoming features.",
"choose-folder": "Choose folder"
},
"proton-path": {
"title": "Proton path",
"description": "Change the path to the Proton binary",
"choose-file": "Choose file"
},
"additional-content": {
"title": "Additional content",
"description": "Additional content that allows you to customize Beat Saber!",
@@ -376,6 +381,7 @@
"OCULUS_NOT_RUNNING": "Oculus is not running",
"BS_ALREADY_RUNNING": "Beat Saber already running",
"EXE_NOT_FINDED": "Missing files",
"PROTON_NOT_SET": "Proton path not set",
"EXIT": "Abrupt stop",
"OCULUS_LIB_NOT_FOUND": "Oculus library not found"
},
@@ -386,7 +392,8 @@
"BS_ALREADY_RUNNING": "Close BeatSaber before launching it again.",
"EXE_NOT_FINDED": "Some files seem to be missing, try to verify the files.",
"EXIT": "BeatSaber stopped abruptly, tries to check the files.",
"OCULUS_LIB_NOT_FOUND": "Check that the Oculus application is properly installed, and that the libraries are correctly defined in Oculus."
"OCULUS_LIB_NOT_FOUND": "Check that the Oculus application is properly installed, and that the libraries are correctly defined in Oculus.",
"PROTON_NOT_SET": "Set the Proton path in settings"
},
"actions": {
"STEAM_NOT_RUNNING": "Launch Steam"
+4
View File
@@ -16,6 +16,10 @@ ipc.on("choose-folder", (args, reply) => {
reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: args ?? "" })));
});
ipc.on<string>("choose-file", async (args, reply) => {
reply(from(dialog.showOpenDialog({ properties: ["openFile"], defaultPath: args ?? "" })));
});
ipc.on("window.progression",(args, reply, sender) => {
BrowserWindow.fromWebContents(sender)?.setProgressBar(args / 100);
reply(of(undefined));
+1
View File
@@ -4,6 +4,7 @@ import { ProviderPlatform } from "shared/models/provider-platform.enum";
const sep = process.platform === ProviderPlatform.WINDOWS ? "\\" : "/";
contextBridge.exposeInMainWorld("electron", {
platform: process.platform,
ipcRenderer: {
sendMessage(channel: string, args: unknown[]) {
ipcRenderer.send(channel, args);
@@ -53,6 +53,13 @@ export abstract class AbstractLauncherService {
let timoutId: NodeJS.Timeout;
const exit = new Promise<number>((resolve, reject) => {
// Don't remove, useful for debugging!
// process.stdout.on("data", (data) => {
// log.info(`BS stdout: ${data}`);
// });
// process.stderr.on("data", (data) => {
// log.error(`BS stderr: ${data}`);
// });
process.on("error", (err) => {
log.error(`Error while launching BS`, err);
@@ -11,6 +11,7 @@ import { CustomError } from "../../../shared/models/exceptions/custom-error.clas
import isElevated from "is-elevated";
import { UtilsService } from "../utils.service";
import { exec } from "child_process";
import fs from 'fs';
export class SteamLauncherService extends AbstractLauncherService implements StoreLauncherInterface{
@@ -75,7 +76,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
return new Observable<BSLaunchEventData>(obs => {(async () => {
const bsFolderPath = await this.localVersions.getInstalledVersionPath(launchOptions.version);
const exePath = path.join(bsFolderPath, BS_EXECUTABLE);
let exePath = path.join(bsFolderPath, BS_EXECUTABLE);
if(!(await pathExists(exePath))){
throw CustomError.fromError(new Error(`Path not exist : ${exePath}`), BSLaunchError.BS_NOT_FOUND);
@@ -102,15 +103,66 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
await this.restoreSteamVR().catch(log.error);
}
const launchArgs = this.buildBsLaunchArgs(launchOptions);
let launchArgs = this.buildBsLaunchArgs(launchOptions);
const steamPath = await this.steam.getSteamPath();
const env = {
...process.env,
"SteamAppId": BS_APP_ID,
"SteamOverlayGameId": BS_APP_ID,
"SteamGameId": BS_APP_ID,
};
// Linux setup
if (process.platform === "linux") {
if (launchOptions.admin == true) {
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,
];
exePath = launchOptions.protonPath;
if (!exePath) {
throw CustomError.fromError(new Error("Proton path not set"), BSLaunchError.PROTON_NOT_SET);
}
// 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,
// Uncomment these to create a proton log file in the Beat Saber install directory.
// "PROTON_LOG": 1,
// "PROTON_LOG_DIR": bsFolderPath,
});
}
obs.next({type: BSLaunchEvent.BS_LAUNCHING});
const spawnOpts = { env, cwd: bsFolderPath };
const launchPromise = !launchOptions.admin ? (
this.launchBs(exePath, launchArgs, { env: {...process.env, "SteamAppId": BS_APP_ID} }).exit
this.launchBs(exePath, launchArgs, spawnOpts).exit
) : (
new Promise<number>(resolve => {
const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${exePath}" ${launchArgs.join(" ")}`, { env: {...process.env, "SteamAppId": BS_APP_ID} });
const adminProcess = exec(`"${this.getStartBsAsAdminExePath()}" "${exePath}" ${launchArgs.join(" ")}`, spawnOpts);
adminProcess.on("error", err => {
log.error("Error while starting BS as Admin", err);
resolve(-1)
@@ -8,15 +8,20 @@ type Props = {
minorTitle?: string;
description?: string;
children?: ReactNode;
os?: string;
};
export function SettingContainer({ id, className, title, minorTitle, description, children }: Props) {
export function SettingContainer({ id, className, title, minorTitle, description, children, os }: Props) {
const t = useTranslation();
if (os && os !== window.electron.platform) {
return <></>;
}
return (
<div id={id} className={className || "relative mb-5"}>
{title && <h1 className="font-bold text-2xl mb-1 tracking-wide">{t(title)}</h1>}
{minorTitle && <h2 className="font-bold mb-1 tracking-wide text-gray-600 dark:text-gray-300">{t(minorTitle)}</h2>}
{title && <h1 className="mb-1 text-2xl font-bold tracking-wide">{t(title)}</h1>}
{minorTitle && <h2 className="mb-1 font-bold tracking-wide text-gray-600 dark:text-gray-300">{t(minorTitle)}</h2>}
{description && <p className="mb-3 text-sm text-gray-600 dark:text-gray-400">{t(description)}</p>}
{children}
</div>
@@ -68,7 +68,8 @@ export function LaunchSlide({ version }: Props) {
oculus: version.oculus ? false : oculusMode,
desktop: desktopMode,
debug: debugMode,
additionalArgs
additionalArgs,
protonPath: bsLauncherService.getProtonPath(),
});
return lastValueFrom(launch$).catch(() => {});
+27 -2
View File
@@ -42,7 +42,7 @@ import { BsDownloaderService } from "renderer/services/bs-version-download/bs-do
import { AutoUpdaterService } from "renderer/services/auto-updater.service";
import BeatWaitingImg from "../../../assets/images/apngs/beat-waiting.png";
import { logRenderError } from "renderer";
import { BSLauncherService } from "renderer/services/bs-launcher.service";
export function SettingsPage() {
@@ -51,6 +51,7 @@ export function SettingsPage() {
const ipcService = useService(IpcService);
const modalService = useService(ModalService);
const bsDownloader = useService(BsDownloaderService);
const bsLauncher = useService(BSLauncherService);
const steamDownloader = useService(SteamDownloaderService);
const progressBarService = useService(ProgressBarService);
const notificationService = useService(NotificationService);
@@ -85,6 +86,7 @@ export function SettingsPage() {
const downloadStore = useObservable(() => bsDownloader.defaultStore$);
const [installationFolder, setInstallationFolder] = useState(null);
const [protonPath, setProtonPath] = useState(bsLauncher.getProtonPath());
const [showSupporters, setShowSupporters] = useState(false);
const [mapDeepLinksEnabled, setMapDeepLinksEnabled] = useState(false);
const [playlistsDeepLinkEnabled, setPlaylistsDeepLinkEnabled] = useState(false);
@@ -156,6 +158,20 @@ export function SettingsPage() {
clearTimeout(timeoutId);
};
const setDefaultProtonPath = () => {
if (!progressBarService.require()) {
return;
}
ipcService.sendV2<{ canceled: boolean; filePaths: string[] }>("choose-file").toPromise().then(res => {
if (!res.canceled && res.filePaths?.length) {
const protonPath = res.filePaths[0];
setProtonPath(protonPath);
bsLauncher.setProtonPath(protonPath);
}
});
};
const setDefaultInstallationFolder = () => {
if (!progressBarService.require()) {
return;
@@ -282,7 +298,16 @@ export function SettingsPage() {
<span className="block text-ellipsis overflow-hidden min-w-0" title={installationFolder}>
{installationFolder}
</span>
<BsmButton onClick={setDefaultInstallationFolder} className="shrink-0 whitespace-nowrap mr-2 px-2 font-bold italic text-sm rounded-md" text="pages.settings.installation-folder.choose-folder" withBar={false} />
<BsmButton onClick={setDefaultInstallationFolder} className="shrink-1 whitespace-nowrap mr-2 px-2 font-bold italic text-sm rounded-md" text="pages.settings.installation-folder.choose-folder" withBar={false} />
</div>
</SettingContainer>
<SettingContainer os="linux" title="pages.settings.proton-path.title" description="pages.settings.proton-path.description">
<div className="relative flex items-center justify-between w-full h-8 bg-light-main-color-1 dark:bg-main-color-1 rounded-md pl-2 py-1">
<span className="block text-ellipsis overflow-hidden min-w-0 whitespace-nowrap" title={protonPath}>
{protonPath}
</span>
<BsmButton onClick={setDefaultProtonPath} className="shrink-0 whitespace-nowrap mr-2 px-2 font-bold italic text-sm rounded-md" text="pages.settings.proton-path.choose-file" withBar={false} />
</div>
</SettingContainer>
+1
View File
@@ -1,6 +1,7 @@
declare global {
interface Window {
electron: {
platform: "win32"|"linux"|"darwin",
ipcRenderer: {
sendMessage(channel: string, args: any): void;
on(channel: string, func: (...args: any) => void): (() => void) | undefined;
@@ -23,6 +23,8 @@ export class BSLauncherService {
public readonly versionRunning$: BehaviorSubject<BSVersion> = new BehaviorSubject(null);
private readonly PROTON_PATH_KEY = "protonPath";
public static getInstance(){
if(!BSLauncherService.instance){ BSLauncherService.instance = new BSLauncherService(); }
return BSLauncherService.instance;
@@ -36,6 +38,14 @@ export class BSLauncherService {
this.modals = ModalService.getInstance();
}
public setProtonPath(protonPath: string|undefined): void {
this.config.set(this.PROTON_PATH_KEY, protonPath);
}
public getProtonPath(): string|undefined {
return this.config.get<string>(this.PROTON_PATH_KEY);
}
private notRewindBackupOculus(): boolean{
return this.config.get<boolean>("not-rewind-backup-oculus");
}
@@ -83,6 +93,7 @@ export class BSLauncherService {
}
public doLaunch(launchOptions: LaunchOption): Observable<BSLaunchEventData>{
launchOptions.protonPath = this.getProtonPath();
return this.ipcService.sendV2("bs-launch.launch", launchOptions);
}
@@ -14,6 +14,7 @@ export enum BSLaunchError{
OCULUS_NOT_RUNNING = "OCULUS_NOT_RUNNING",
BS_EXIT_ERROR = "EXIT",
OCULUS_LIB_NOT_FOUND = "OCULUS_LIB_NOT_FOUND",
PROTON_NOT_SET = "PROTON_NOT_SET",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
@@ -27,4 +28,4 @@ export enum BSLaunchWarning{
UNABLE_TO_LAUNCH_STEAM = "UNABLE_TO_LAUNCH_STEAM",
}
export type BSLaunchEventType = BSLaunchEvent | BSLaunchWarning;
export type BSLaunchEventType = BSLaunchEvent | BSLaunchWarning;
@@ -7,4 +7,5 @@ export interface LaunchOption {
debug?: boolean,
additionalArgs?: string[],
admin?: boolean,
protonPath?: string,
}