From 0ad65f87bf613bc950e2a4db8eba9ec308c80f92 Mon Sep 17 00:00:00 2001 From: Nikhil Narayana Date: Mon, 16 Oct 2023 18:05:52 -0700 Subject: [PATCH] feat: add Dolphin Release Channels (#388) --- .github/workflows/build.yml | 5 + FAQ.md | 14 +- package.json | 2 + src/dolphin/config/config.ts | 37 ++- src/dolphin/install/extractDmg.ts | 4 +- src/dolphin/install/fetchLatestVersion.ts | 10 +- .../{installation.ts => ishiiInstallation.ts} | 122 ++++--- src/dolphin/install/linux.ts | 11 +- src/dolphin/install/macos.ts | 38 ++- src/dolphin/install/mainlineInstallation.ts | 298 ++++++++++++++++++ src/dolphin/manager.ts | 102 +++++- src/dolphin/playkey.ts | 35 +- src/dolphin/setup.ts | 17 +- src/dolphin/types.ts | 29 ++ src/dolphin/util.ts | 2 +- src/main/newsFeed.ts | 2 +- .../containers/Settings/DolphinSettings.tsx | 60 ++-- src/renderer/lib/hooks/useSettings.ts | 38 +-- src/settings/api.ts | 13 +- src/settings/defaultSettings.ts | 6 +- src/settings/ipc.ts | 19 +- src/settings/settingsManager.ts | 64 +++- src/settings/setup.ts | 18 +- src/settings/types.ts | 6 +- yarn.lock | 12 + 25 files changed, 755 insertions(+), 209 deletions(-) rename src/dolphin/install/{installation.ts => ishiiInstallation.ts} (68%) create mode 100644 src/dolphin/install/mainlineInstallation.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1255364ab..bdc50c39d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -137,6 +137,11 @@ jobs: - name: Prepare artifacts shell: bash run: | + msg="$(git log -1 --no-merges --pretty=%B)" + if [[ ! $msg =~ "^release.*" ]]; + then + rm release/build/*-mac.zip || true; + fi mkdir artifacts mv release/build/{*.exe*,*.deb,*.AppImage,*.dmg*,*.zip,*.yml} artifacts || true - name: Upload artifacts diff --git a/FAQ.md b/FAQ.md index 489525b0a..3d1a7b52e 100644 --- a/FAQ.md +++ b/FAQ.md @@ -65,10 +65,9 @@ A single delay frame is equal to 4 buffer in traditional Dolphin netplay. Our re If you want to change this value, first open Dolphin (if using the Launcher go to Settings -> Netplay -> Configure Dolphin) and go to `Config` -> `GameCube`. At the bottom you will see the option to update the Delay Frames. You can update this value at any time but it will only update for the next game you play. -## Can I use Widescreen when playing Slippi Online? - -Yes. To enable Widescreen for Slippi Online follow these steps. Open Dolphin (if using the Launcher go to Settings -> Netplay -> Configure Dolphin), right click on your Melee in the games list, go to Properties -> Gecko Codes. Then enable the Widescreen gecko code and set the Dolphin aspect ratio to 16:9 under the Graphics settings. Do not use the dolphin widescreen hack, it does not have the same effect as the gecko code. +## Can I use Widescreen (16:9) when playing Slippi Online? +Yes, to enable widescreen go to the Netplay in the Launcher settings depending > `Manage Gecko Codes` > and enable the Widescreen gecko code. You can do the same for replays by going to the Playback tab instead. ## Is UCF included in Slippi Online? Yes, we currently ship with UCF 0.8 and it is applied everywhere by default. @@ -88,3 +87,12 @@ Some computers will have issues polling the adapter at the correct rate on some ## Where are my replays? Replays are stored by default in `Documents/Slippi` on Windows and `~/Slippi` on macOS and Linux. The replay directory is configurable in the `Replays` settings tab of the Launcher. + +## Mainline Slippi Dolphin (Beta) + +### Updated OS Requirements +- Windows 10 or newer +- macOS Catalina (10.15) or newer +- Ubuntu 22.04 or newer and the following packages + - libfuse2 + - qt6-qpa-plugins diff --git a/package.json b/package.json index 7b04ad3fa..1d7a2ff80 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@mui/material": "^5.12.1", "@slippi/slippi-js": "^6.7.0", "@xmcl/nat-api": "^0.4.1", + "async-mutex": "^0.4.0", "compare-func": "^2.0.0", "cross-fetch": "^3.1.5", "default-gateway": "^7.2.2", @@ -195,6 +196,7 @@ "react-virtualized-auto-sizer": "^1.0.3", "react-window": "^1.8.6", "semver": "^7.5.4", + "semver-regex": "^3.1.2", "stun": "^2.1.0", "wget-improved": "^3.3.1", "zustand": "^3.2.0" diff --git a/src/dolphin/config/config.ts b/src/dolphin/config/config.ts index 0a807acba..87c0530c9 100644 --- a/src/dolphin/config/config.ts +++ b/src/dolphin/config/config.ts @@ -18,7 +18,28 @@ export async function addGamePath(iniFile: IniFile, gameDir: string): Promise): Promise { +export async function setSlippiMainlineSettings( + iniFile: IniFile, + options: Partial, +): Promise { + const useMonthlySubfolders = options.useMonthlySubfolders ? "True" : "False"; + const enableJukebox = options.enableJukebox ? "True" : "False"; + const slippiSection = iniFile.getOrCreateSection("Slippi"); + + if (options.replayPath !== undefined) { + slippiSection.set("ReplayDir", options.replayPath); + } + if (options.useMonthlySubfolders !== undefined) { + slippiSection.set("ReplayMonthlyFolders", useMonthlySubfolders); + } + if (options.enableJukebox !== undefined) { + slippiSection.set("EnableJukebox", enableJukebox); + } + + await iniFile.save(); +} + +export async function setSlippiIshiiSettings(iniFile: IniFile, options: Partial): Promise { const useMonthlySubfolders = options.useMonthlySubfolders ? "True" : "False"; const enableJukebox = options.enableJukebox ? "True" : "False"; const coreSection = iniFile.getOrCreateSection("Core"); @@ -34,11 +55,21 @@ export async function setSlippiSettings(iniFile: IniFile, options: Partial { +export async function getSlippiMainlineSettings(iniFile: IniFile): Promise { + const slippiSection = iniFile.getOrCreateSection("Slippi"); + + const replayPath = slippiSection.get("ReplayDir", defaultAppSettings.settings.rootSlpPath); + const useMonthlySubfolders = slippiSection.get("ReplayMonthlyFolders", "True") === "True"; + const enableJukebox = slippiSection.get("EnableJukebox", "True") === "True"; + + return { useMonthlySubfolders, replayPath, enableJukebox }; +} + +export async function getSlippiIshiiSettings(iniFile: IniFile): Promise { const coreSection = iniFile.getOrCreateSection("Core"); - const useMonthlySubfolders = coreSection.get("SlippiReplayMonthFolders", "False") === "True"; const replayPath = coreSection.get("SlippiReplayDir", defaultAppSettings.settings.rootSlpPath); + const useMonthlySubfolders = coreSection.get("SlippiReplayMonthFolders", "False") === "True"; const enableJukebox = coreSection.get("SlippiJukeboxEnabled", "True") === "True"; return { useMonthlySubfolders, replayPath, enableJukebox }; diff --git a/src/dolphin/install/extractDmg.ts b/src/dolphin/install/extractDmg.ts index a9f71eef7..88a56e1d7 100644 --- a/src/dolphin/install/extractDmg.ts +++ b/src/dolphin/install/extractDmg.ts @@ -17,7 +17,7 @@ export async function extractDmg(filename: string, destination: string): Promise return files; } -async function mountDmg(filename: string): Promise { +export async function mountDmg(filename: string): Promise { return new Promise((resolve, reject) => { dmg.mount(filename, (err, value) => { if (err) { @@ -29,7 +29,7 @@ async function mountDmg(filename: string): Promise { }); } -async function unmountDmg(mountPath: string): Promise { +export async function unmountDmg(mountPath: string): Promise { return new Promise((resolve, reject) => { dmg.unmount(mountPath, (err) => { if (err) { diff --git a/src/dolphin/install/fetchLatestVersion.ts b/src/dolphin/install/fetchLatestVersion.ts index 3670cfe74..59788830b 100644 --- a/src/dolphin/install/fetchLatestVersion.ts +++ b/src/dolphin/install/fetchLatestVersion.ts @@ -17,7 +17,7 @@ export type DolphinVersionResponse = { }; }; -const log = electronLog.scope("dolphin/checkVersion"); +const log = electronLog.scope("dolphin/fetchLatestVersion"); const isDevelopment = process.env.NODE_ENV !== "production"; const httpLink = new HttpLink({ uri: process.env.SLIPPI_GRAPHQL_ENDPOINT, fetch }); @@ -73,16 +73,20 @@ const handleErrors = (errors: readonly GraphQLError[] | undefined) => { } }; +// this function is relied by getInstallation in DolphinManager to decide which dolphin (folder) to use +// it isn't the prettiest execution but will suffice since we want to be able to let users play even if +// the stable dolphin updates before the beta dolphin. The backend will interleave the versions from github +// and return the version that is most recently published if includeBeta is true. export async function fetchLatestVersion( dolphinType: DolphinLaunchType, - beta = false, + includeBeta = false, ): Promise { const res = await client.query({ query: getLatestDolphinQuery, fetchPolicy: "network-only", variables: { purpose: dolphinType.toUpperCase(), - includeBeta: beta, + includeBeta: includeBeta, }, }); diff --git a/src/dolphin/install/installation.ts b/src/dolphin/install/ishiiInstallation.ts similarity index 68% rename from src/dolphin/install/installation.ts rename to src/dolphin/install/ishiiInstallation.ts index e3180b848..2f6e2719b 100644 --- a/src/dolphin/install/installation.ts +++ b/src/dolphin/install/ishiiInstallation.ts @@ -1,7 +1,6 @@ import type { SyncedDolphinSettings } from "@dolphin/config/config"; -import { addGamePath, getSlippiSettings, setSlippiSettings } from "@dolphin/config/config"; +import { addGamePath, getSlippiIshiiSettings, setSlippiIshiiSettings } from "@dolphin/config/config"; import { IniFile } from "@dolphin/config/iniFile"; -import { findDolphinExecutable } from "@dolphin/util"; import { spawnSync } from "child_process"; import { app } from "electron"; import electronLog from "electron-log"; @@ -9,22 +8,23 @@ import * as fs from "fs-extra"; import os from "os"; import path from "path"; import { lt } from "semver"; +import semverRegex from "semver-regex"; +import type { DolphinInstallation } from "../types"; import { DolphinLaunchType } from "../types"; import { downloadLatestDolphin } from "./download"; import type { DolphinVersionResponse } from "./fetchLatestVersion"; -import { fetchLatestVersion } from "./fetchLatestVersion"; -const log = electronLog.scope("dolphin/installation"); +const log = electronLog.scope("dolphin/ishiiInstallation"); const isLinux = process.platform === "linux"; -// taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string -const semverRegex = - /(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/; - -export class DolphinInstallation { - constructor(private dolphinLaunchType: DolphinLaunchType, private installationFolder: string) {} +export class IshiirukaDolphinInstallation implements DolphinInstallation { + public readonly installationFolder: string; + constructor(private readonly dolphinLaunchType: DolphinLaunchType) { + const dolphinFolder = dolphinLaunchType === DolphinLaunchType.NETPLAY ? "netplay" : "playback"; + this.installationFolder = path.join(app.getPath("userData"), dolphinFolder); + } public get userFolder(): string { switch (process.platform) { @@ -33,7 +33,8 @@ export class DolphinInstallation { } case "darwin": { const configPath = path.join(os.homedir(), "Library", "Application Support", "com.project-slippi.dolphin"); - const userFolderName = this.dolphinLaunchType === DolphinLaunchType.NETPLAY ? "netplay/User" : "playback/User"; + const dolphinFolder = this.dolphinLaunchType === DolphinLaunchType.NETPLAY ? "netplay" : "playback"; + const userFolderName = `${dolphinFolder}/User`; return path.join(configPath, userFolderName); } @@ -49,17 +50,14 @@ export class DolphinInstallation { public get sysFolder(): string { const dolphinPath = this.installationFolder; - const type = this.dolphinLaunchType; switch (process.platform) { + case "linux": case "win32": { return path.join(dolphinPath, "Sys"); } case "darwin": { return path.join(dolphinPath, "Slippi Dolphin.app", "Contents", "Resources", "Sys"); } - case "linux": { - return path.join(app.getPath("userData"), type, "Sys"); - } default: throw new Error(`Unsupported operating system: ${process.platform}`); } @@ -68,8 +66,40 @@ export class DolphinInstallation { public async findDolphinExecutable(): Promise { const dolphinPath = this.installationFolder; const type = this.dolphinLaunchType; + // Check the directory contents + const files = await fs.readdir(dolphinPath); + const result = files.find((filename) => { + switch (process.platform) { + case "win32": + return filename.endsWith("Dolphin.exe"); + case "darwin": + return filename.endsWith("Dolphin.app"); + case "linux": { + const appimagePrefix = type === DolphinLaunchType.NETPLAY ? "Slippi_Online" : "Slippi_Playback"; + const isAppimage = filename.startsWith(appimagePrefix) && filename.endsWith("AppImage"); + return isAppimage || filename.endsWith("dolphin-emu"); + } + default: + return false; + } + }); + + if (!result) { + throw new Error( + `No ${type} Dolphin found in: ${dolphinPath}, try restarting the launcher. Ask in the Slippi Discord's support channels for further help`, + ); + } + + if (process.platform === "darwin") { + const dolphinBinaryPath = path.join(dolphinPath, result, "Contents", "MacOS", "Slippi Dolphin"); + const dolphinExists = await fs.pathExists(dolphinBinaryPath); + if (!dolphinExists) { + throw new Error(`No ${type} Dolphin found in: ${dolphinPath}, try resetting dolphin`); + } + return dolphinBinaryPath; + } - return findDolphinExecutable(type, dolphinPath); + return path.join(dolphinPath, result); } public async clearCache() { @@ -90,40 +120,26 @@ export class DolphinInstallation { // we shouldn't keep the old cache folder since it might be out of date await this.clearCache(); - - // read the settings from the ini and update any settings - if (this.dolphinLaunchType === DolphinLaunchType.NETPLAY) { - const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); - const iniFile = await IniFile.init(iniPath); - await getSlippiSettings(iniFile); - } } public async validate({ onStart, onProgress, onComplete, + dolphinDownloadInfo, }: { onStart: () => void; onProgress: (current: number, total: number) => void; onComplete: () => void; + dolphinDownloadInfo: DolphinVersionResponse; }): Promise { const type = this.dolphinLaunchType; - let dolphinDownloadInfo: DolphinVersionResponse | undefined = undefined; try { await this.findDolphinExecutable(); log.info(`Found existing ${type} Dolphin executable.`); log.info(`Checking if we need to update ${type} Dolphin`); - try { - dolphinDownloadInfo = await fetchLatestVersion(type); - } catch (err) { - log.error(`Failed to fetch latest Dolphin version: ${err}`); - onComplete(); - return; - } - const latestVersion = dolphinDownloadInfo?.version; const isOutdated = !latestVersion || (await this._isOutOfDate(latestVersion)); if (!isOutdated) { @@ -141,28 +157,24 @@ export class DolphinInstallation { // Start the download await this.downloadAndInstall({ - releaseInfo: dolphinDownloadInfo, + dolphinDownloadInfo, onProgress, onComplete, }); } public async downloadAndInstall({ - releaseInfo, + dolphinDownloadInfo, onProgress, onComplete, cleanInstall, }: { - releaseInfo?: DolphinVersionResponse; + dolphinDownloadInfo: DolphinVersionResponse; onProgress?: (current: number, total: number) => void; onComplete?: () => void; cleanInstall?: boolean; }): Promise { const type = this.dolphinLaunchType; - let dolphinDownloadInfo = releaseInfo; - if (!dolphinDownloadInfo) { - dolphinDownloadInfo = await fetchLatestVersion(type); - } const downloadUrl = dolphinDownloadInfo.downloadUrls[process.platform]; if (!downloadUrl) { @@ -189,13 +201,13 @@ export class DolphinInstallation { public async getSettings(): Promise { const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); const iniFile = await IniFile.init(iniPath); - return await getSlippiSettings(iniFile); + return await getSlippiIshiiSettings(iniFile); } public async updateSettings(options: Partial): Promise { const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); const iniFile = await IniFile.init(iniPath); - await setSlippiSettings(iniFile, options); + await setSlippiIshiiSettings(iniFile, options); } private async _isOutOfDate(latestVersion: string): Promise { @@ -207,13 +219,33 @@ export class DolphinInstallation { try { const dolphinPath = await this.findDolphinExecutable(); const dolphinVersionOut = spawnSync(dolphinPath, ["--version"]).stdout.toString(); - const match = dolphinVersionOut.match(semverRegex); + const match = dolphinVersionOut.match(semverRegex()); return match?.[0] ?? null; } catch (err) { return null; } } + public async findPlayKey(): Promise { + let slippiDir = ""; + switch (process.platform) { + case "linux": + case "win32": { + slippiDir = path.join(this.userFolder, "Slippi"); + break; + } + case "darwin": { + slippiDir = path.join(os.homedir(), "Library", "Application Support", "com.project-slippi.dolphin", "Slippi"); + break; + } + default: { + break; + } + } + await fs.ensureDir(slippiDir); + return path.resolve(slippiDir, "user.json"); + } + private async _uninstallDolphin() { await fs.remove(this.installationFolder); if (isLinux) { @@ -237,8 +269,8 @@ export class DolphinInstallation { break; } case "darwin": { - const { installDolphinOnMac } = await import("./macos"); - await installDolphinOnMac({ + const { installIshiirukaDolphinOnMac } = await import("./macos"); + await installIshiirukaDolphinOnMac({ assetPath, destinationFolder: dolphinPath, }); @@ -247,9 +279,9 @@ export class DolphinInstallation { case "linux": { const { installDolphinOnLinux } = await import("./linux"); await installDolphinOnLinux({ - type: this.dolphinLaunchType, assetPath, destinationFolder: dolphinPath, + installation: this, }); break; } diff --git a/src/dolphin/install/linux.ts b/src/dolphin/install/linux.ts index d54499007..3fc414d93 100644 --- a/src/dolphin/install/linux.ts +++ b/src/dolphin/install/linux.ts @@ -1,22 +1,21 @@ -import type { DolphinLaunchType } from "@dolphin/types"; -import { findDolphinExecutable } from "@dolphin/util"; +import type { DolphinInstallation } from "@dolphin/types"; import * as fs from "fs-extra"; import { async as AsyncStreamZip } from "node-stream-zip"; // TODO: Figure out how to make this not depend on DolphinLaunchType export async function installDolphinOnLinux({ - type, assetPath, destinationFolder, + installation, log = console.log, }: { - type: DolphinLaunchType; assetPath: string; destinationFolder: string; + installation: DolphinInstallation; log?: (message: string) => void; }) { try { - const dolphinAppImagePath = await findDolphinExecutable(type, destinationFolder); + const dolphinAppImagePath = await installation.findDolphinExecutable(); log(`${dolphinAppImagePath} already exists. Deleting...`); await fs.remove(dolphinAppImagePath); } catch (err) { @@ -28,7 +27,7 @@ export async function installDolphinOnLinux({ await zip.close(); // make the appimage executable because sometimes it doesn't have the right perms out the gate - const dolphinAppImagePath = await findDolphinExecutable(type, destinationFolder); + const dolphinAppImagePath = await installation.findDolphinExecutable(); log(`Setting executable permissions...`); await fs.chmod(dolphinAppImagePath, "755"); } diff --git a/src/dolphin/install/macos.ts b/src/dolphin/install/macos.ts index e39bb9a74..25c3283a5 100644 --- a/src/dolphin/install/macos.ts +++ b/src/dolphin/install/macos.ts @@ -2,9 +2,9 @@ import * as fs from "fs-extra"; import os from "os"; import path from "path"; -import { extractDmg } from "./extractDmg"; +import { extractDmg, mountDmg, unmountDmg } from "./extractDmg"; -export async function installDolphinOnMac({ +export async function installIshiirukaDolphinOnMac({ assetPath, destinationFolder, log = console.log, @@ -32,3 +32,37 @@ export async function installDolphinOnMac({ await fs.chmod(binaryLocation, "777"); await fs.chown(binaryLocation, userInfo.uid, userInfo.gid); } + +export async function installMainlineDolphinOnMac({ + assetPath, + destinationFolder, + log = console.log, +}: { + assetPath: string; + destinationFolder: string; + log?: (message: string) => void; +}) { + log(`Extracting to: ${destinationFolder}`); + const mountPath = await mountDmg(assetPath); + try { + const appMountPath = path.join(mountPath, "Slippi_Dolphin.app"); + const destPath = path.join(destinationFolder, "Slippi_Dolphin.app"); + await fs.copy(appMountPath, destPath, { recursive: true }); + } catch { + log("Failed to copy files from DMG"); + } finally { + await unmountDmg(mountPath); + } + + try { + // sometimes permissions aren't set properly after the extraction so we will forcibly set them on install + const binaryLocation = path.join(destinationFolder, "Slippi_Dolphin.app", "Contents", "MacOS", "Slippi_Dolphin"); + const userInfo = os.userInfo(); + await fs.chmod(path.join(destinationFolder, "Slippi_Dolphin.app"), "777"); + await fs.chown(path.join(destinationFolder, "Slippi_Dolphin.app"), userInfo.uid, userInfo.gid); + await fs.chmod(binaryLocation, "777"); + await fs.chown(binaryLocation, userInfo.uid, userInfo.gid); + } catch { + log("could not chown/chmod Dolphin"); + } +} diff --git a/src/dolphin/install/mainlineInstallation.ts b/src/dolphin/install/mainlineInstallation.ts new file mode 100644 index 000000000..230837975 --- /dev/null +++ b/src/dolphin/install/mainlineInstallation.ts @@ -0,0 +1,298 @@ +import type { SyncedDolphinSettings } from "@dolphin/config/config"; +import { addGamePath, getSlippiMainlineSettings, setSlippiMainlineSettings } from "@dolphin/config/config"; +import { IniFile } from "@dolphin/config/iniFile"; +import { spawnSync } from "child_process"; +import { app } from "electron"; +import electronLog from "electron-log"; +import * as fs from "fs-extra"; +import os from "os"; +import path from "path"; +import { lt } from "semver"; +import semverRegex from "semver-regex"; + +import type { DolphinInstallation } from "../types"; +import { DolphinLaunchType } from "../types"; +import { downloadLatestDolphin } from "./download"; +import type { DolphinVersionResponse } from "./fetchLatestVersion"; + +const log = electronLog.scope("dolphin/mainlineInstallation"); + +const isLinux = process.platform === "linux"; + +export class MainlineDolphinInstallation implements DolphinInstallation { + public readonly installationFolder: string; + constructor(private readonly dolphinLaunchType: DolphinLaunchType, private readonly betaSuffix: string) { + const dolphinFolder = dolphinLaunchType === DolphinLaunchType.NETPLAY ? "netplay" : "playback"; + this.installationFolder = path.join(app.getPath("userData"), `${dolphinFolder}${this.betaSuffix}`); + } + + public get userFolder(): string { + switch (process.platform) { + case "win32": { + return path.join(this.installationFolder, "User"); + } + case "darwin": { + const configPath = path.join(os.homedir(), "Library", "Application Support", `com.project-slippi.dolphin`); + const userFolderName = + this.dolphinLaunchType === DolphinLaunchType.NETPLAY + ? `netplay${this.betaSuffix}/User` + : `playback${this.betaSuffix}/User`; + + return path.join(configPath, userFolderName); + } + case "linux": { + const configPath = path.join(os.homedir(), ".config"); + const userFolderName = + this.dolphinLaunchType === DolphinLaunchType.NETPLAY + ? `slippi-dolphin/netplay${this.betaSuffix}` + : `slippi-dolphin/playback${this.betaSuffix}`; + return path.join(configPath, userFolderName); + } + default: + throw new Error(`Unsupported operating system: ${process.platform}`); + } + } + + public get sysFolder(): string { + const dolphinPath = this.installationFolder; + switch (process.platform) { + case "linux": + case "win32": { + return path.join(dolphinPath, "Sys"); + } + case "darwin": { + const dolphinApp = "Slippi_Dolphin.app"; + return path.join(dolphinPath, dolphinApp, "Contents", "Resources", "Sys"); + } + default: + throw new Error(`Unsupported operating system: ${process.platform}`); + } + } + + public async findDolphinExecutable(): Promise { + const dolphinPath = this.installationFolder; + const type = this.dolphinLaunchType; + + // Make sure the directory actually exists + await fs.ensureDir(dolphinPath); + + // Check the directory contents + const files = await fs.readdir(dolphinPath); + const result = files.find((filename) => { + switch (process.platform) { + case "win32": + return filename.endsWith("Dolphin.exe"); + case "darwin": + return filename.endsWith("Dolphin.app"); + case "linux": { + const appimagePrefix = type === DolphinLaunchType.NETPLAY ? "Slippi_Netplay" : "Slippi_Playback"; + const isAppimage = filename.startsWith(appimagePrefix) && filename.endsWith("AppImage"); + return isAppimage || filename.endsWith("dolphin-emu"); + } + default: + return false; + } + }); + + if (!result) { + throw new Error( + `No ${type} Dolphin found in: ${dolphinPath}, try restarting the launcher. Ask in the Slippi Discord's support channels for further help`, + ); + } + + if (process.platform === "darwin") { + const dolphinBinaryPath = path.join(dolphinPath, result, "Contents", "MacOS", "Slippi_Dolphin"); + const dolphinExists = await fs.pathExists(dolphinBinaryPath); + if (!dolphinExists) { + throw new Error(`No ${type} Dolphin found in: ${dolphinPath}, try resetting dolphin`); + } + return dolphinBinaryPath; + } + + return path.join(dolphinPath, result); + } + + public async clearCache() { + const cacheFolder = path.join(this.userFolder, "Cache"); + await fs.remove(cacheFolder); + } + + public async importConfig(fromPath: string) { + const newUserFolder = this.userFolder; + await fs.ensureDir(this.userFolder); + const oldUserFolder = path.join(fromPath, "User"); + + if (!(await fs.pathExists(oldUserFolder))) { + return; + } + + await fs.copy(oldUserFolder, newUserFolder, { overwrite: true }); + + // we shouldn't keep the old cache folder since it might be out of date + await this.clearCache(); + } + + public async validate({ + onStart, + onProgress, + onComplete, + dolphinDownloadInfo, + }: { + onStart: () => void; + onProgress: (current: number, total: number) => void; + onComplete: () => void; + dolphinDownloadInfo: DolphinVersionResponse; + }): Promise { + const type = this.dolphinLaunchType; + + try { + await this.findDolphinExecutable(); + log.info(`Found existing ${type} Dolphin executable.`); + log.info(`Checking if we need to update ${type} Dolphin`); + + const latestVersion = dolphinDownloadInfo?.version; + const isOutdated = !latestVersion || (await this._isOutOfDate(latestVersion)); + log.warn(`latest version = ${latestVersion}`); + if (!isOutdated) { + log.info("No update found..."); + onComplete(); + return; + } + + onStart(); + + log.info(`${type} Dolphin installation is outdated. Downloading latest...`); + } catch (err) { + log.info(`Could not find ${type} Dolphin installation. Downloading...`); + } + + // Start the download + await this.downloadAndInstall({ + dolphinDownloadInfo, + onProgress, + onComplete, + }); + } + + public async downloadAndInstall({ + dolphinDownloadInfo, + onProgress, + onComplete, + cleanInstall, + }: { + dolphinDownloadInfo: DolphinVersionResponse; + onProgress?: (current: number, total: number) => void; + onComplete?: () => void; + cleanInstall?: boolean; + }): Promise { + const type = this.dolphinLaunchType; + + const downloadUrl = dolphinDownloadInfo.downloadUrls[process.platform]; + if (!downloadUrl) { + throw new Error(`Could not find latest Dolphin download url for ${process.platform}`); + } + + const downloadDir = path.join(app.getPath("userData"), "temp"); + const downloadedAsset = await downloadLatestDolphin(downloadUrl, downloadDir, onProgress); + log.info(`Installing v${dolphinDownloadInfo.version} ${type} Dolphin...`); + await this._installDolphin(downloadedAsset, cleanInstall); + log.info(`Finished v${dolphinDownloadInfo.version} ${type} Dolphin install`); + + if (onComplete) { + onComplete(); + } + } + + public async addGamePath(gameDir: string): Promise { + const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); + const iniFile = await IniFile.init(iniPath); + await addGamePath(iniFile, gameDir); + } + + public async getSettings(): Promise { + const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); + const iniFile = await IniFile.init(iniPath); + return await getSlippiMainlineSettings(iniFile); + } + + public async updateSettings(options: Partial): Promise { + const iniPath = path.join(this.userFolder, "Config", "Dolphin.ini"); + const iniFile = await IniFile.init(iniPath); + await setSlippiMainlineSettings(iniFile, options); + } + + private async _isOutOfDate(latestVersion: string): Promise { + const dolphinVersion = await this.getDolphinVersion(); + log.info(`dolphin version = ${dolphinVersion}`); + return !dolphinVersion || lt(dolphinVersion, latestVersion); + } + + public async getDolphinVersion(): Promise { + try { + const dolphinPath = await this.findDolphinExecutable(); + log.warn(`dolphinPath = ${dolphinPath}`); + const dolphinVersionOut = spawnSync(dolphinPath, ["--version"]).stdout.toString(); + log.warn(`dolphinVersionOut = ${dolphinVersionOut}`); + const match = dolphinVersionOut.match(semverRegex()); + return match?.[0] ?? null; + } catch (err) { + return null; + } + } + + public async findPlayKey(): Promise { + const slippiDir = path.join(this.userFolder, "Slippi"); + await fs.ensureDir(slippiDir); + return path.resolve(slippiDir, "user.json"); + } + + private async _uninstallDolphin() { + await fs.remove(this.installationFolder); + if (isLinux) { + await fs.remove(this.userFolder); + } + } + + private async _installDolphin(assetPath: string, cleanInstall = false) { + const dolphinPath = this.installationFolder; + + if (cleanInstall) { + await this._uninstallDolphin(); + } else { + await this.clearCache(); // clear cache to avoid shader issues on new versions + } + + switch (process.platform) { + case "win32": { + const { installDolphinOnWindows } = await import("./windows"); + await installDolphinOnWindows({ assetPath, destinationFolder: dolphinPath }); + break; + } + case "darwin": { + const { installMainlineDolphinOnMac } = await import("./macos"); + await installMainlineDolphinOnMac({ + assetPath, + destinationFolder: dolphinPath, + }); + break; + } + case "linux": { + const { installDolphinOnLinux } = await import("./linux"); + await installDolphinOnLinux({ + assetPath, + destinationFolder: dolphinPath, + installation: this, + }); + break; + } + default: { + throw new Error( + `Installing ${this.dolphinLaunchType} Dolphin is not supported on this platform: ${process.platform}`, + ); + } + } + await fs.remove(assetPath).catch((err) => { + log.error(`Could not delete dolphin asset: ${err}`); + }); + } +} diff --git a/src/dolphin/manager.ts b/src/dolphin/manager.ts index ee37a1f66..967376f52 100644 --- a/src/dolphin/manager.ts +++ b/src/dolphin/manager.ts @@ -1,12 +1,17 @@ import type { SettingsManager } from "@settings/settingsManager"; +import { app } from "electron"; import electronLog from "electron-log"; +import { move, remove } from "fs-extra"; import { Observable, Subject } from "observable-fns"; +import os from "os"; import path from "path"; import { fileExists } from "utils/fileExists"; -import { DolphinInstallation } from "./install/installation"; +import { type DolphinVersionResponse, fetchLatestVersion } from "./install/fetchLatestVersion"; +import { IshiirukaDolphinInstallation } from "./install/ishiiInstallation"; +import { MainlineDolphinInstallation } from "./install/mainlineInstallation"; import { DolphinInstance, PlaybackDolphinInstance } from "./instance"; -import type { DolphinEvent, ReplayCommunication } from "./types"; +import type { DolphinEvent, DolphinInstallation, ReplayCommunication } from "./types"; import { DolphinEventType, DolphinLaunchType } from "./types"; const log = electronLog.scope("dolphin/manager"); @@ -14,19 +19,51 @@ const log = electronLog.scope("dolphin/manager"); // DolphinManager should be in control of all dolphin instances that get opened for actual use. // This includes playing netplay, viewing replays, watching broadcasts (spectating), and configuring Dolphin. export class DolphinManager { + private betaFlags = { + [DolphinLaunchType.NETPLAY]: { + betaAvailable: false, + promotedToStable: false, + }, + [DolphinLaunchType.PLAYBACK]: { + betaAvailable: false, + promotedToStable: false, + }, + }; + private playbackDolphinInstances = new Map(); private netplayDolphinInstance: DolphinInstance | null = null; private eventSubject = new Subject(); public events = Observable.from(this.eventSubject); - constructor(private settingsManager: SettingsManager) {} + constructor(private settingsManager: SettingsManager) { + this.betaFlags[DolphinLaunchType.NETPLAY].promotedToStable = settingsManager.getDolphinPromotedToStable( + DolphinLaunchType.NETPLAY, + ); + this.betaFlags[DolphinLaunchType.PLAYBACK].promotedToStable = settingsManager.getDolphinPromotedToStable( + DolphinLaunchType.PLAYBACK, + ); + } public getInstallation(launchType: DolphinLaunchType): DolphinInstallation { - const dolphinPath = this.settingsManager.getDolphinPath(launchType); - return new DolphinInstallation(launchType, dolphinPath); + const { betaAvailable, promotedToStable } = this.betaFlags[launchType]; + if (betaAvailable || promotedToStable) { + const betaSuffix = promotedToStable ? "" : "-beta"; + return new MainlineDolphinInstallation(launchType, betaSuffix); + } + return new IshiirukaDolphinInstallation(launchType); } public async installDolphin(dolphinType: DolphinLaunchType): Promise { + const useBeta = this.settingsManager.getUseDolphinBeta(dolphinType); + let dolphinDownloadInfo: DolphinVersionResponse | undefined = undefined; + try { + dolphinDownloadInfo = await fetchLatestVersion(dolphinType, useBeta); + await this._updateDolphinFlags(dolphinDownloadInfo, dolphinType); + } catch (err) { + log.error(`Failed to fetch latest Dolphin version: ${err}`); + return; + } + const dolphinInstall = this.getInstallation(dolphinType); await dolphinInstall.validate({ onStart: () => this._onStart(dolphinType), @@ -35,6 +72,7 @@ export class DolphinManager { dolphinInstall.getDolphinVersion().then((version) => { this._onComplete(dolphinType, version); }), + dolphinDownloadInfo, }); const isoPath = this.settingsManager.get().settings.isoPath; @@ -185,9 +223,19 @@ export class DolphinManager { } } + const useBeta = this.settingsManager.getUseDolphinBeta(launchType); + let dolphinDownloadInfo: DolphinVersionResponse | undefined = undefined; + try { + dolphinDownloadInfo = await fetchLatestVersion(launchType, useBeta); + await this._updateDolphinFlags(dolphinDownloadInfo, launchType); + } catch (err) { + log.error(`Failed to fetch latest Dolphin version: ${err}`); + return; + } const installation = this.getInstallation(launchType); this._onStart(launchType); await installation.downloadAndInstall({ + dolphinDownloadInfo, cleanInstall, onProgress: (current, total) => this._onProgress(launchType, current, total), }); @@ -212,6 +260,14 @@ export class DolphinManager { return meleeIsoPath; } + public async importConfig(launchType: DolphinLaunchType, dolphinPath: string): Promise { + const installation = this.getInstallation(launchType); + await installation.importConfig(dolphinPath); + if (launchType === DolphinLaunchType.NETPLAY) { + await this._updateLauncherSettings(launchType); + } + } + private async _updateDolphinSettings(launchType: DolphinLaunchType) { const installation = this.getInstallation(launchType); await installation.updateSettings({ @@ -270,4 +326,40 @@ export class DolphinManager { dolphinVersion, }); } + + // Run after fetchLatestVersion to update the necessary flags + private async _updateDolphinFlags(downloadInfo: DolphinVersionResponse, dolphinType: DolphinLaunchType) { + const isBeta = (downloadInfo.version as string).includes("-beta"); + const isMainline = downloadInfo.downloadUrls.win32.includes("project-slippi/dolphin"); + + if (!this.betaFlags[dolphinType].promotedToStable && !isBeta && isMainline) { + // if this is the first time we're handling the promotion then delete {dolphinType}-beta and move {dolphinType} + // we want to delete the beta folder so that any defaults that got changed during the beta are properly updated + const dolphinFolder = dolphinType === DolphinLaunchType.NETPLAY ? "netplay" : "playback"; + const betaPath = path.join(app.getPath("userData"), `${dolphinFolder}-beta`); + const stablePath = path.join(app.getPath("userData"), dolphinFolder); + const legacyPath = path.join(app.getPath("userData"), `${dolphinFolder}-legacy`); + try { + await remove(betaPath); + await move(stablePath, legacyPath, { overwrite: true }); + if (process.platform === "darwin") { + // mainline on macOS will take over the old user folder so move it on promotion + // windows keeps everything contained in the install dir + // linux will be using a new user folder path + const configPath = path.join(os.homedir(), "Library", "Application Support", "com.project-slippi.dolphin"); + const oldUserFolderName = `${dolphinFolder}/User`; + const legacyUserFolderName = `${dolphinFolder}/User-legacy`; + const oldPath = path.join(configPath, oldUserFolderName); + const newPath = path.join(configPath, legacyUserFolderName); + await move(oldPath, newPath, { overwrite: true }); + } + this.betaFlags[dolphinType].promotedToStable = true; + await this.settingsManager.setDolphinPromotedToStable(dolphinType, true); + } catch (err) { + log.warn(`could not handle promotion: ${err}`); + } + } + + this.betaFlags[dolphinType].betaAvailable = isBeta; + } } diff --git a/src/dolphin/playkey.ts b/src/dolphin/playkey.ts index 5388478f6..538ef65c1 100644 --- a/src/dolphin/playkey.ts +++ b/src/dolphin/playkey.ts @@ -1,44 +1,15 @@ -import type { PlayKey } from "@dolphin/types"; +import type { DolphinInstallation, PlayKey } from "@dolphin/types"; import * as fs from "fs-extra"; -import os from "os"; -import path from "path"; import { fileExists } from "utils/fileExists"; -import type { DolphinInstallation } from "./install/installation"; - export async function writePlayKeyFile(installation: DolphinInstallation, playKey: PlayKey): Promise { - const keyPath = await findPlayKey(installation); + const keyPath = await installation.findPlayKey(); const contents = JSON.stringify(playKey, null, 2); await fs.writeFile(keyPath, contents); } -export async function findPlayKey(installation: DolphinInstallation): Promise { - let slippiDir = ""; - switch (process.platform) { - case "win32": { - const dolphinPath = await installation.findDolphinExecutable(); - const dolphinFolder = path.dirname(dolphinPath); - slippiDir = path.join(dolphinFolder, "User", "Slippi"); - break; - } - case "darwin": { - slippiDir = path.join(os.homedir(), "Library", "Application Support", "com.project-slippi.dolphin", "Slippi"); - break; - } - case "linux": { - slippiDir = path.join(os.homedir(), ".config", "SlippiOnline", "Slippi"); - break; - } - default: { - break; - } - } - await fs.ensureDir(slippiDir); - return path.resolve(slippiDir, "user.json"); -} - export async function deletePlayKeyFile(installation: DolphinInstallation): Promise { - const keyPath = await findPlayKey(installation); + const keyPath = await installation.findPlayKey(); const playKeyExists = await fileExists(keyPath); if (playKeyExists) { await fs.unlink(keyPath); diff --git a/src/dolphin/setup.ts b/src/dolphin/setup.ts index 29f4e98b1..48a0bf14e 100644 --- a/src/dolphin/setup.ts +++ b/src/dolphin/setup.ts @@ -23,7 +23,7 @@ import { ipc_viewSlpReplay, } from "./ipc"; import type { DolphinManager } from "./manager"; -import { deletePlayKeyFile, findPlayKey, writePlayKeyFile } from "./playkey"; +import { deletePlayKeyFile, writePlayKeyFile } from "./playkey"; import { DolphinLaunchType } from "./types"; import { fetchGeckoCodes, findDolphinExecutable, saveGeckoCodes, updateBootToCssCode } from "./util"; @@ -53,8 +53,14 @@ export default function setupDolphinIpc({ dolphinManager }: { dolphinManager: Do }); ipc_openDolphinSettingsFolder.main!.handle(async ({ dolphinType }) => { - const path = await dolphinManager.getInstallation(dolphinType).userFolder; - await shell.openPath(path); + const dolphinInstall = dolphinManager.getInstallation(dolphinType); + if (process.platform === "win32") { + const path = dolphinInstall.installationFolder; + await shell.openPath(path); + } else { + const path = dolphinInstall.userFolder; + await shell.openPath(path); + } return { success: true }; }); @@ -72,7 +78,7 @@ export default function setupDolphinIpc({ dolphinManager }: { dolphinManager: Do ipc_checkPlayKeyExists.main!.handle(async ({ key }) => { const installation = dolphinManager.getInstallation(DolphinLaunchType.NETPLAY); - const keyPath = await findPlayKey(installation); + const keyPath = await installation.findPlayKey(); const exists = await fileExists(keyPath); if (!exists) { return { exists: false }; @@ -121,8 +127,7 @@ export default function setupDolphinIpc({ dolphinManager }: { dolphinManager: Do dolphinPath = path.dirname(dolphinPath); } - const installation = dolphinManager.getInstallation(dolphinType); - await installation.importConfig(dolphinPath); + await dolphinManager.importConfig(dolphinType, dolphinPath); return { success: true }; }); diff --git a/src/dolphin/types.ts b/src/dolphin/types.ts index b1ee7f76f..97fa6d10b 100644 --- a/src/dolphin/types.ts +++ b/src/dolphin/types.ts @@ -1,4 +1,6 @@ +import type { SyncedDolphinSettings } from "./config/config"; import type { GeckoCode } from "./config/geckoCode"; +import type { DolphinVersionResponse } from "./install/fetchLatestVersion"; export type ReplayCommunication = { mode: "normal" | "mirror" | "queue"; // default normal @@ -108,3 +110,30 @@ export interface DolphinService { saveGeckoCodes(dolphinLaunchType: DolphinLaunchType, geckoCodes: GeckoCode[]): Promise; onEvent(eventType: T, handle: (event: DolphinEventMap[T]) => void): () => void; } + +export interface DolphinInstallation { + readonly installationFolder: string; + get userFolder(): string; + get sysFolder(): string; + + findDolphinExecutable(): Promise; + clearCache(): Promise; + importConfig(fromPath: string): Promise; + validate(options: { + onStart: () => void; + onProgress: (current: number, total: number) => void; + onComplete: () => void; + dolphinDownloadInfo: DolphinVersionResponse; + }): Promise; + downloadAndInstall(options: { + dolphinDownloadInfo: DolphinVersionResponse; + onProgress?: (current: number, total: number) => void; + onComplete?: () => void; + cleanInstall?: boolean; + }): Promise; + addGamePath(gameDir: string): Promise; + getSettings(): Promise; + updateSettings(options: Partial): Promise; + getDolphinVersion(): Promise; + findPlayKey(): Promise; +} diff --git a/src/dolphin/util.ts b/src/dolphin/util.ts index d253cbb67..931a08935 100644 --- a/src/dolphin/util.ts +++ b/src/dolphin/util.ts @@ -5,7 +5,7 @@ import { setBootToCss } from "./config/config"; import type { GeckoCode } from "./config/geckoCode"; import { loadGeckoCodes, setCodes } from "./config/geckoCode"; import { IniFile } from "./config/iniFile"; -import type { DolphinInstallation } from "./install/installation"; +import type { DolphinInstallation } from "./types"; import { DolphinLaunchType } from "./types"; export async function findDolphinExecutable(type: DolphinLaunchType, dolphinPath: string): Promise { diff --git a/src/main/newsFeed.ts b/src/main/newsFeed.ts index 71cc03e6e..0cae03c3e 100644 --- a/src/main/newsFeed.ts +++ b/src/main/newsFeed.ts @@ -5,7 +5,7 @@ import { getAllReleases } from "./github"; export async function fetchNewsFeedData(): Promise { const mediumNews = fetchMediumNews(); - const githubNews = fetchGithubReleaseNews(["Ishiiruka", "slippi-launcher"]); + const githubNews = fetchGithubReleaseNews(["Ishiiruka", "slippi-launcher", "dolphin"]); const allNews = (await Promise.all([mediumNews, githubNews])).flat(); return allNews.sort((a, b) => { // Sort all news item by reverse chronological order diff --git a/src/renderer/containers/Settings/DolphinSettings.tsx b/src/renderer/containers/Settings/DolphinSettings.tsx index 8af4e3b4f..1f5af66ba 100644 --- a/src/renderer/containers/Settings/DolphinSettings.tsx +++ b/src/renderer/containers/Settings/DolphinSettings.tsx @@ -2,23 +2,26 @@ import { DolphinLaunchType } from "@dolphin/types"; import { css } from "@emotion/react"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; import Typography from "@mui/material/Typography"; import log from "electron-log"; import capitalize from "lodash/capitalize"; import React from "react"; import { ConfirmationModal } from "@/components/ConfirmationModal"; -import { DevGuard } from "@/components/DevGuard"; -import { PathInput } from "@/components/PathInput"; import { useDolphinActions } from "@/lib/dolphin/useDolphinActions"; import { DolphinStatus, useDolphinStore } from "@/lib/dolphin/useDolphinStore"; -import { useDolphinPath } from "@/lib/hooks/useSettings"; +import { useDolphinBeta } from "@/lib/hooks/useSettings"; +import { useToasts } from "@/lib/hooks/useToasts"; import { useServices } from "@/services"; import { GeckoCodes } from "./GeckoCodes/GeckoCodes"; import { SettingItem } from "./SettingItem"; -const { isLinux, isMac } = window.electron.bootstrap; +const { isMac, isWindows } = window.electron.bootstrap; +const { enableMainlineDolphin } = window.electron.bootstrap.flags; enum ResetType { SOFT, @@ -35,23 +38,30 @@ export const DolphinSettings = ({ dolphinType }: { dolphinType: DolphinLaunchTyp const dolphinVersion = useDolphinStore((store) => dolphinType === DolphinLaunchType.NETPLAY ? store.netplayDolphinVersion : store.playbackDolphinVersion, ); - const [dolphinPath, setDolphinPath] = useDolphinPath(dolphinType); + const [dolphinBeta, setDolphinBeta] = useDolphinBeta(dolphinType); const [resetModalOpen, setResetModalOpen] = React.useState(false); const [isResetType, setResetType] = React.useState(null); const { dolphinService } = useServices(); const { openConfigureDolphin, hardResetDolphin, softResetDolphin, importDolphin } = useDolphinActions(dolphinService); - + const { showWarning } = useToasts(); const dolphinIsReady = dolphinStatus === DolphinStatus.READY && !dolphinIsOpen && isResetType === null; const versionString: string = dolphinStatus === DolphinStatus.UNKNOWN ? "Not found" : !dolphinVersion ? "Unknown" : dolphinVersion; - const openDolphinDirectoryHandler = React.useCallback(async () => { - if (isMac || isLinux) { - await dolphinService.openDolphinSettingsFolder(dolphinType); - } else { - await window.electron.shell.openPath(dolphinPath); + const onDolphinBetaChange = async (value: string) => { + setResetType(ResetType.SOFT); + const useBeta = value === "true"; + if (useBeta) { + showWarning("Mainline Slippi Dolphin has updated OS requirements, check the Help Section for more info"); } - }, [dolphinPath, dolphinService, dolphinType]); + await setDolphinBeta(useBeta); + await softResetDolphin(dolphinType); + setResetType(null); + }; + + const openDolphinDirectoryHandler = React.useCallback(async () => { + await dolphinService.openDolphinSettingsFolder(dolphinType); + }, [dolphinService, dolphinType]); const configureDolphinHandler = async () => { openConfigureDolphin(dolphinType); @@ -97,19 +107,6 @@ export const DolphinSettings = ({ dolphinType }: { dolphinType: DolphinLaunchTyp - - - - - @@ -174,7 +171,18 @@ export const DolphinSettings = ({ dolphinType }: { dolphinType: DolphinLaunchTyp - {!isLinux && ( + {enableMainlineDolphin && dolphinType === DolphinLaunchType.NETPLAY && ( + + onDolphinBetaChange(value)}> + } /> + } /> + + + )} + {isWindows && ( { return [extraSlpPaths, setExtraSlpDirs] as const; }; -export const useDolphinPath = (dolphinType: DolphinLaunchType) => { - const netplayDolphinPath = useSettings((store) => store.settings.netplayDolphinPath); - const setNetplayPath = async (path: string) => { - await window.electron.settings.setNetplayDolphinPath(path); - }; - - const playbackDolphinPath = useSettings((store) => store.settings.playbackDolphinPath); - const setDolphinPath = async (path: string) => { - await window.electron.settings.setPlaybackDolphinPath(path); - }; - - switch (dolphinType) { - case DolphinLaunchType.NETPLAY: { - return [netplayDolphinPath, setNetplayPath] as const; - } - case DolphinLaunchType.PLAYBACK: { - return [playbackDolphinPath, setDolphinPath] as const; - } - } -}; - export const useLaunchMeleeOnPlay = () => { const launchMeleeOnPlay = useSettings((store) => store.settings.launchMeleeOnPlay); const setLaunchMelee = async (launchMelee: boolean) => { @@ -104,3 +83,20 @@ export const useAutoUpdateLauncher = () => { return [autoUpdateLauncher, setAutoUpdateLauncher] as const; }; + +export const useDolphinBeta = (dolphinType: DolphinLaunchType) => { + const netplayBeta = useSettings((state) => state.settings.useNetplayBeta); + const playbackBeta = useSettings((state) => state.settings.usePlaybackBeta); + const setDolphinBeta = useCallback(async (useBeta: boolean) => { + await window.electron.settings.setUseDolphinBeta(dolphinType, useBeta); + }, []); + + switch (dolphinType) { + case DolphinLaunchType.NETPLAY: { + return [netplayBeta, setDolphinBeta] as const; + } + case DolphinLaunchType.PLAYBACK: { + return [playbackBeta, setDolphinBeta] as const; + } + } +}; diff --git a/src/settings/api.ts b/src/settings/api.ts index d9a515e58..4c0f941cd 100644 --- a/src/settings/api.ts +++ b/src/settings/api.ts @@ -1,4 +1,5 @@ /* eslint-disable import/no-default-export */ +import type { DolphinLaunchType } from "@dolphin/types"; import { ipcRenderer } from "electron"; import { @@ -11,11 +12,10 @@ import { ipc_setExtraSlpPaths, ipc_setIsoPath, ipc_setLaunchMeleeOnPlay, - ipc_setNetplayDolphinPath, - ipc_setPlaybackDolphinPath, ipc_setRootSlpPath, ipc_setSpectateSlpPath, ipc_settingsUpdatedEvent, + ipc_setUseDolphinBeta, ipc_setUseMonthlySubfolders, } from "./ipc"; import type { AppSettings, StoredConnection } from "./types"; @@ -54,18 +54,15 @@ export default { async setExtraSlpPaths(paths: string[]): Promise { await ipc_setExtraSlpPaths.renderer!.trigger({ paths }); }, - async setNetplayDolphinPath(netplayDolphinPath: string): Promise { - await ipc_setNetplayDolphinPath.renderer!.trigger({ path: netplayDolphinPath }); - }, - async setPlaybackDolphinPath(playbackDolphinPath: string): Promise { - await ipc_setPlaybackDolphinPath.renderer!.trigger({ path: playbackDolphinPath }); - }, async setLaunchMeleeOnPlay(launchMelee: boolean): Promise { await ipc_setLaunchMeleeOnPlay.renderer!.trigger({ launchMelee }); }, async setAutoUpdateLauncher(autoUpdateLauncher: boolean): Promise { await ipc_setAutoUpdateLauncher.renderer!.trigger({ autoUpdateLauncher }); }, + async setUseDolphinBeta(dolphinType: DolphinLaunchType, installBeta: boolean): Promise { + await ipc_setUseDolphinBeta.renderer!.trigger({ dolphinType, useBeta: installBeta }); + }, async addNewConnection(connection: Omit): Promise { await ipc_addNewConnection.renderer!.trigger({ connection }); }, diff --git a/src/settings/defaultSettings.ts b/src/settings/defaultSettings.ts index a7908aea7..4afe101ae 100644 --- a/src/settings/defaultSettings.ts +++ b/src/settings/defaultSettings.ts @@ -26,9 +26,11 @@ export const defaultAppSettings: AppSettings = { enableJukebox: true, spectateSlpPath: path.join(getDefaultRootSlpPath(), "Spectate"), extraSlpPaths: [], - netplayDolphinPath: path.join(app.getPath("userData"), "netplay"), - playbackDolphinPath: path.join(app.getPath("userData"), "playback"), launchMeleeOnPlay: true, autoUpdateLauncher: true, + useNetplayBeta: false, + usePlaybackBeta: false, }, + netplayPromotedToStable: false, + playbackPromotedToStable: false, }; diff --git a/src/settings/ipc.ts b/src/settings/ipc.ts index 21bc3dfc8..44afb5d5c 100644 --- a/src/settings/ipc.ts +++ b/src/settings/ipc.ts @@ -1,3 +1,4 @@ +import type { DolphinLaunchType } from "@dolphin/types"; import type { SuccessPayload } from "utils/ipc"; import { _, makeEndpoint } from "utils/ipc"; @@ -21,18 +22,6 @@ export const ipc_setSpectateSlpPath = makeEndpoint.main("setSpectateSlpPath", <{ export const ipc_setExtraSlpPaths = makeEndpoint.main("setExtraSlpPaths", <{ paths: string[] }>_, _); -export const ipc_setNetplayDolphinPath = makeEndpoint.main( - "setNetplayDolphinPath", - <{ path: string }>_, - _, -); - -export const ipc_setPlaybackDolphinPath = makeEndpoint.main( - "setPlaybackDolphinPath", - <{ path: string }>_, - _, -); - export const ipc_setLaunchMeleeOnPlay = makeEndpoint.main( "setLaunchMeleeOnPlay", <{ launchMelee: boolean }>_, @@ -45,6 +34,12 @@ export const ipc_setAutoUpdateLauncher = makeEndpoint.main( _, ); +export const ipc_setUseDolphinBeta = makeEndpoint.main( + "setUseNetplayBeta", + <{ dolphinType: DolphinLaunchType; useBeta: boolean }>_, + _, +); + export const ipc_addNewConnection = makeEndpoint.main( "addNewConnection", <{ connection: Omit }>_, diff --git a/src/settings/settingsManager.ts b/src/settings/settingsManager.ts index f406f3fc2..260a90d91 100644 --- a/src/settings/settingsManager.ts +++ b/src/settings/settingsManager.ts @@ -1,4 +1,5 @@ import { DolphinLaunchType } from "@dolphin/types"; +import { Mutex } from "async-mutex"; import electronSettings from "electron-settings"; import fs from "fs"; import merge from "lodash/merge"; @@ -16,8 +17,10 @@ electronSettings.configure({ export class SettingsManager { // This only stores the actually modified settings private appSettings: Partial; + private setMutex: Mutex; constructor() { + this.setMutex = new Mutex(); const restoredSettings = electronSettings.getSync() as Partial; // If the ISO file no longer exists, don't restore it @@ -36,15 +39,6 @@ export class SettingsManager { return merge({}, defaultAppSettings, this.appSettings); } - public getDolphinPath(type: DolphinLaunchType): string { - switch (type) { - case DolphinLaunchType.NETPLAY: - return this.get().settings.netplayDolphinPath; - case DolphinLaunchType.PLAYBACK: - return this.get().settings.playbackDolphinPath; - } - } - public getRootSlpPath(): string { return this.get().settings.rootSlpPath; } @@ -53,6 +47,26 @@ export class SettingsManager { return this.get().settings.useMonthlySubfolders; } + public getUseDolphinBeta(type: DolphinLaunchType): boolean { + const settings = this.get().settings; + switch (type) { + case DolphinLaunchType.NETPLAY: + return settings.useNetplayBeta; + case DolphinLaunchType.PLAYBACK: + return settings.usePlaybackBeta; + } + } + + public getDolphinPromotedToStable(type: DolphinLaunchType): boolean { + const settings = this.get(); + switch (type) { + case DolphinLaunchType.NETPLAY: + return settings.netplayPromotedToStable; + case DolphinLaunchType.PLAYBACK: + return settings.playbackPromotedToStable; + } + } + public async setIsoPath(isoPath: string | null): Promise { await this._set("settings.isoPath", isoPath); } @@ -77,14 +91,6 @@ export class SettingsManager { await this._set("settings.extraSlpPaths", slpPaths); } - public async setNetplayDolphinPath(dolphinPath: string): Promise { - await this._set("settings.netplayDolphinPath", dolphinPath); - } - - public async setPlaybackDolphinPath(dolphinPath: string): Promise { - await this._set("settings.playbackDolphinPath", dolphinPath); - } - public async setLaunchMeleeOnPlay(launchMelee: boolean): Promise { await this._set("settings.launchMeleeOnPlay", launchMelee); } @@ -93,6 +99,28 @@ export class SettingsManager { await this._set("settings.autoUpdateLauncher", autoUpdateLauncher); } + public async setUseDolphinBeta(dolphinType: DolphinLaunchType, useBeta: boolean): Promise { + switch (dolphinType) { + case DolphinLaunchType.NETPLAY: + await this._set("settings.useNetplayBeta", useBeta); + break; + case DolphinLaunchType.PLAYBACK: + await this._set("settings.usePlaybackBeta", useBeta); + break; + } + } + + public async setDolphinPromotedToStable(dolphinType: DolphinLaunchType, promotedToStable: boolean): Promise { + switch (dolphinType) { + case DolphinLaunchType.NETPLAY: + await this._set("netplayPromotedToStable", promotedToStable); + break; + case DolphinLaunchType.PLAYBACK: + await this._set("playbackPromotedToStable", promotedToStable); + break; + } + } + public async addConsoleConnection(conn: Omit): Promise { const connections = this.get().connections; // Auto-generate an ID @@ -121,8 +149,10 @@ export class SettingsManager { } private async _set(objectPath: string, value: any) { + await this.setMutex.acquire(); await electronSettings.set(objectPath, value); set(this.appSettings, objectPath, value); await ipc_settingsUpdatedEvent.main!.trigger(this.get()); + this.setMutex.release(); } } diff --git a/src/settings/setup.ts b/src/settings/setup.ts index bf56f0dc4..90fb2cf86 100644 --- a/src/settings/setup.ts +++ b/src/settings/setup.ts @@ -13,10 +13,9 @@ import { ipc_setExtraSlpPaths, ipc_setIsoPath, ipc_setLaunchMeleeOnPlay, - ipc_setNetplayDolphinPath, - ipc_setPlaybackDolphinPath, ipc_setRootSlpPath, ipc_setSpectateSlpPath, + ipc_setUseDolphinBeta, ipc_setUseMonthlySubfolders, } from "./ipc"; import type { SettingsManager } from "./settingsManager"; @@ -80,16 +79,6 @@ export default function setupSettingsIpc({ return { success: true }; }); - ipc_setNetplayDolphinPath.main!.handle(async ({ path }) => { - await settingsManager.setNetplayDolphinPath(path); - return { success: true }; - }); - - ipc_setPlaybackDolphinPath.main!.handle(async ({ path }) => { - await settingsManager.setPlaybackDolphinPath(path); - return { success: true }; - }); - ipc_addNewConnection.main!.handle(async ({ connection }) => { await settingsManager.addConsoleConnection(connection); return { success: true }; @@ -115,4 +104,9 @@ export default function setupSettingsIpc({ autoUpdater.autoInstallOnAppQuit = autoUpdateLauncher; return { success: true }; }); + + ipc_setUseDolphinBeta.main!.handle(async ({ dolphinType, useBeta }) => { + await settingsManager.setUseDolphinBeta(dolphinType, useBeta); + return { success: true }; + }); } diff --git a/src/settings/types.ts b/src/settings/types.ts index c999c771d..308bbb982 100644 --- a/src/settings/types.ts +++ b/src/settings/types.ts @@ -24,9 +24,11 @@ export type AppSettings = { enableJukebox: boolean; spectateSlpPath: string; extraSlpPaths: string[]; - netplayDolphinPath: string; - playbackDolphinPath: string; launchMeleeOnPlay: boolean; autoUpdateLauncher: boolean; + useNetplayBeta: boolean; + usePlaybackBeta: boolean; }; + netplayPromotedToStable: boolean; + playbackPromotedToStable: boolean; }; diff --git a/yarn.lock b/yarn.lock index 1ddfeec4b..2e81a9bed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5819,6 +5819,13 @@ async-exit-hook@^2.0.1: resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw== +async-mutex@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.0.tgz#ae8048cd4d04ace94347507504b3cf15e631c25f" + integrity sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA== + dependencies: + tslib "^2.4.0" + async@^3.2.3: version "3.2.4" resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" @@ -16887,6 +16894,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.4.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tsutils@^3.17.1, tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"