diff --git a/assets/jsons/translations/en.json b/assets/jsons/translations/en.json index 0815be3d..786e27c2 100644 --- a/assets/jsons/translations/en.json +++ b/assets/jsons/translations/en.json @@ -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" diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index 1de9cfe4..110c3de7 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -16,6 +16,10 @@ ipc.on("choose-folder", (args, reply) => { reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: args ?? "" }))); }); +ipc.on("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)); diff --git a/src/main/preload.ts b/src/main/preload.ts index 61ced3c1..243f5e76 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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); diff --git a/src/main/services/bs-launcher/abstract-launcher.service.ts b/src/main/services/bs-launcher/abstract-launcher.service.ts index df583d1b..5382ce99 100644 --- a/src/main/services/bs-launcher/abstract-launcher.service.ts +++ b/src/main/services/bs-launcher/abstract-launcher.service.ts @@ -53,6 +53,13 @@ export abstract class AbstractLauncherService { let timoutId: NodeJS.Timeout; const exit = new Promise((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); diff --git a/src/main/services/bs-launcher/steam-launcher.service.ts b/src/main/services/bs-launcher/steam-launcher.service.ts index a9d1c76c..c1f38cd8 100644 --- a/src/main/services/bs-launcher/steam-launcher.service.ts +++ b/src/main/services/bs-launcher/steam-launcher.service.ts @@ -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(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(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) diff --git a/src/renderer/components/settings/setting-container.component.tsx b/src/renderer/components/settings/setting-container.component.tsx index c57adb3e..abb41b38 100644 --- a/src/renderer/components/settings/setting-container.component.tsx +++ b/src/renderer/components/settings/setting-container.component.tsx @@ -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 (
- {title &&

{t(title)}

} - {minorTitle &&

{t(minorTitle)}

} + {title &&

{t(title)}

} + {minorTitle &&

{t(minorTitle)}

} {description &&

{t(description)}

} {children}
diff --git a/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx b/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx index bbe412af..af517204 100644 --- a/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx +++ b/src/renderer/components/version-viewer/slides/launch/launch-slide.component.tsx @@ -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(() => {}); diff --git a/src/renderer/pages/settings-page.component.tsx b/src/renderer/pages/settings-page.component.tsx index 453efb72..fb1d84c1 100644 --- a/src/renderer/pages/settings-page.component.tsx +++ b/src/renderer/pages/settings-page.component.tsx @@ -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() { {installationFolder} - + + + + + +
+ + {protonPath} + +
diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index ab023814..29aace8b 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -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; diff --git a/src/renderer/services/bs-launcher.service.ts b/src/renderer/services/bs-launcher.service.ts index 8bc89ec8..248870b9 100644 --- a/src/renderer/services/bs-launcher.service.ts +++ b/src/renderer/services/bs-launcher.service.ts @@ -23,6 +23,8 @@ export class BSLauncherService { public readonly versionRunning$: BehaviorSubject = 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(this.PROTON_PATH_KEY); + } + private notRewindBackupOculus(): boolean{ return this.config.get("not-rewind-backup-oculus"); } @@ -83,6 +93,7 @@ export class BSLauncherService { } public doLaunch(launchOptions: LaunchOption): Observable{ + launchOptions.protonPath = this.getProtonPath(); return this.ipcService.sendV2("bs-launch.launch", launchOptions); } diff --git a/src/shared/models/bs-launch/launch-event.model.ts b/src/shared/models/bs-launch/launch-event.model.ts index c653feb8..29663132 100644 --- a/src/shared/models/bs-launch/launch-event.model.ts +++ b/src/shared/models/bs-launch/launch-event.model.ts @@ -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; \ No newline at end of file +export type BSLaunchEventType = BSLaunchEvent | BSLaunchWarning; diff --git a/src/shared/models/bs-launch/launch-option.interface.ts b/src/shared/models/bs-launch/launch-option.interface.ts index 21056c19..3731143b 100644 --- a/src/shared/models/bs-launch/launch-option.interface.ts +++ b/src/shared/models/bs-launch/launch-option.interface.ts @@ -7,4 +7,5 @@ export interface LaunchOption { debug?: boolean, additionalArgs?: string[], admin?: boolean, + protonPath?: string, }