diff --git a/electron-builder.ts b/electron-builder.ts index 2ed49fd5..1869829e 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -53,6 +53,25 @@ const config: Configuration = { 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=home", + // 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", diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index b9b5fdaa..263b61c4 100644 --- a/src/main/services/bs-launcher/abstract-launcher.service.ts +++ b/src/main/services/bs-launcher/abstract-launcher.service.ts @@ -4,12 +4,15 @@ import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, spawn } from import path from "path"; import log from "electron-log"; import { sToMs } from "../../../shared/helpers/time.helpers"; +import { LinuxService } from "../linux.service"; 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 +47,12 @@ export abstract class AbstractLauncherService { spawnOptions.windowsVerbatimArguments = true; } - log.info(`Launch BS exe at ${bsExePath} with args ${args?.join(" ")}`); + if (process.platform === "linux") { + return this.linux.spawnBsProcess(bsExePath, args, spawnOptions) + } + log.info("Windows launch BS command\n>" ,bsExePath, args?.join(" ")); return spawn(bsExePath, args, spawnOptions); - } protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise} { diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index 5154d2e2..926f5392 100644 --- a/src/main/services/bs-launcher/steam-launcher.service.ts +++ b/src/main/services/bs-launcher/steam-launcher.service.ts @@ -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,10 +67,10 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto return new Observable(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 @@ -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(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) diff --git a/src/main/services/linux.service.ts b/src/main/services/linux.service.ts index 2336229a..6acd3d62 100644 --- a/src/main/services/linux.service.ts +++ b/src/main/services/linux.service.ts @@ -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 { SpawnOptionsWithoutStdio, spawn } from "child_process"; +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"; export class LinuxService { private static instance: LinuxService; @@ -15,10 +19,104 @@ export class LinuxService { private readonly staticConfig: StaticConfigurationService; + public readonly isFlatpak = process.env.container === "flatpak"; + private constructor() { this.staticConfig = StaticConfigurationService.getInstance(); } + // === Launching === // + + public setupLaunch( + launchOptions: LaunchOption, + steamPath: string, + bsFolderPath: string, + env: Record + ) { + if (launchOptions.admin) { + log.warn("Launching as admin is not supported on Linux! Starting the game as a normal user."); + launchOptions.admin = false; + } + + // 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 + ); + } + + // 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 spawnBsProcess(bsExePath: string, args: string[], spawnOptions: SpawnOptionsWithoutStdio) { + // Already checked in setupLaunch + const protonPath = path.join(this.staticConfig.get("proton-folder"), PROTON_BINARY_PREFIX); + + // "/bin/sh" does not see flatpak-spawn + // Most Debian and Arch should also support "/bin/bash" + spawnOptions.shell = "/bin/bash"; + + const command = this.isFlatpak + ? this.createFlatpakCommand(protonPath, bsExePath, args, spawnOptions) + : `"${protonPath}" run "${bsExePath}" ${args.join(" ")}`; + + log.info("Linux launch BS command\n>", command); + return spawn(command, spawnOptions); + } + + private createFlatpakCommand(protonPath: string, bsExePath: string, args: string[], spawnOptions: SpawnOptionsWithoutStdio): string { + + // DON'T REMOVE: Good for injecting commands while debugging with flatpak + // return args.slice(1).join(" "); + + // The env vars are hidden to flatpak-spawn, need to set them manually in --env arg + // Minimal copy of the env, don't need to copy them all + const envArgs = [ + "SteamAppId", + "SteamOverlayGameId", + "SteamGameId", + "WINEDLLOVERRIDES", + "STEAM_COMPAT_DATA_PATH", + "STEAM_COMPAT_INSTALL_PATH", + "STEAM_COMPAT_CLIENT_INSTALL_PATH", + "STEAM_COMPAT_APP_ID", + "SteamEnv", + ].map(envName => { + return `--env=${envName}="${spawnOptions.env[envName]}"`; + }).join(" "); + + return `flatpak-spawn --host ${envArgs} "${protonPath}" run "${bsExePath}" ${args.join(" ")}`; + } + public verifyProtonPath(protonFolder: string = ""): boolean { if (protonFolder === "") { if (!this.staticConfig.has("proton-folder")) { @@ -30,7 +128,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,7 +140,7 @@ 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`); }