diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs index 453edfe..dfeaf49 100644 --- a/Bloxstrap/App.xaml.cs +++ b/Bloxstrap/App.xaml.cs @@ -1,10 +1,10 @@ using System; -using System.Diagnostics; using System.Globalization; using System.IO; using System.Net.Http; using System.Net; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using System.Windows; using Microsoft.Win32; @@ -173,12 +173,23 @@ namespace Bloxstrap if (IsMenuLaunch) { + Mutex mutex; + + try + { + mutex = Mutex.OpenExisting("Bloxstrap_MenuMutex"); + Logger.WriteLine("[App::OnStartup] Bloxstrap_MenuMutex mutex exists, aborting menu launch..."); + Terminate(); + } + catch + { + // no mutex exists, continue to opening preferences menu + mutex = new(true, "Bloxstrap_MenuMutex"); + } + #if !DEBUG - if (Process.GetProcessesByName(ProjectName).Length > 1) - { - ShowMessageBox($"{ProjectName} is currently running. Please close any currently open Bloxstrap or Roblox window before opening the menu.", MessageBoxImage.Error); - Environment.Exit(0); - } + if (Utilities.GetProcessCount(ProjectName) > 1) + ShowMessageBox($"{ProjectName} is currently running, likely as a background Roblox process. Please note that not all your changes will immediately apply until you close all currently open Roblox instances.", MessageBoxImage.Information); #endif new MainWindow().ShowDialog(); diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index 1eaafbe..cd6ec41 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -113,15 +113,45 @@ namespace Bloxstrap await CheckForUpdates(); #endif + // ensure only one instance of the bootstrapper is running at the time + // so that we don't have stuff like two updates happening simultaneously + + bool mutexExists = false; + + try + { + Mutex.OpenExisting("Bloxstrap_BootstrapperMutex").Close(); + App.Logger.WriteLine("[Bootstrapper::Run] Bloxstrap_BootstrapperMutex mutex exists, waiting..."); + mutexExists = true; + } + catch + { + // no mutex exists + } + + // wait for mutex to be released if it's not yet + await using AsyncMutex mutex = new("Bloxstrap_BootstrapperMutex"); + await mutex.AcquireAsync(_cancelTokenSource.Token); + + // reload our configs since they've likely changed by now + if (mutexExists) + { + App.Settings.Load(); + App.State.Load(); + } + await CheckLatestVersion(); CheckInstallMigration(); - // if bloxstrap is installing for the first time but is running, prompt to close roblox - // if roblox needs updating but is running, ignore update for now - if (!Directory.Exists(_versionFolder) && CheckIfRunning(true) || App.State.Prop.VersionGuid != _versionGuid && !CheckIfRunning(false)) + // if roblox needs updating but is running and we have multiple instances open, ignore update for now + if (App.IsFirstRun || _versionGuid != App.State.Prop.VersionGuid && Utilities.GetProcessCount("RobloxPlayerBeta") == 0) await InstallLatestVersion(); + // last time the version folder was set, it was set to the latest version guid + // but if we skipped updating because roblox is already running, we want it to be set to our current version + _versionFolder = Path.Combine(Directories.Versions, App.State.Prop.VersionGuid); + if (App.IsFirstRun) App.ShouldSaveConfigs = true; @@ -133,13 +163,17 @@ namespace Bloxstrap CheckInstall(); await RbxFpsUnlocker.CheckInstall(); - + + // at this point we've finished updating our configs App.Settings.Save(); App.State.Save(); + App.ShouldSaveConfigs = false; + + await mutex.ReleaseAsync(); if (App.IsFirstRun && App.IsNoLaunch) Dialog?.ShowSuccess($"{App.ProjectName} has successfully installed"); - else if (!App.IsNoLaunch) + else if (!App.IsNoLaunch && !_cancelFired) await StartRoblox(); } @@ -265,32 +299,20 @@ namespace Bloxstrap App.Logger.WriteLine("[Bootstrapper::CheckInstallMigration] Finished migrating install location!"); } - private bool CheckIfRunning(bool shutdown) + private bool ShutdownIfRobloxRunning() { - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] Checking if Roblox is running... (shutdown={shutdown})"); + App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] Checking if Roblox is running..."); - Process[] processes = Process.GetProcessesByName("RobloxPlayerBeta"); - - if (processes.Length == 0) - { - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] Roblox is not running"); + if (Utilities.GetProcessCount("RobloxPlayerBeta") == 0) return false; - } - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] Roblox is running, found {processes.Length} process(es)"); - - if (!shutdown) - return true; - - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] Attempting to shutdown Roblox..."); + App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] Attempting to shutdown Roblox..."); Dialog?.PromptShutdown(); try { - // try/catch just in case process was closed before prompt was answered - - foreach (Process process in processes) + foreach (Process process in Process.GetProcessesByName("RobloxPlayerBeta")) { process.CloseMainWindow(); process.Close(); @@ -298,10 +320,10 @@ namespace Bloxstrap } catch (Exception ex) { - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] Failed to close process! {ex}"); + App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] Failed to close process! {ex}"); } - App.Logger.WriteLine($"[Bootstrapper::CheckIfRunning] All Roblox processes closed"); + App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] All Roblox processes closed"); return true; } @@ -327,10 +349,27 @@ namespace Bloxstrap // whether we should wait for roblox to exit to handle stuff in the background or clean up after roblox closes bool shouldWait = false; + Mutex singletonMutex; + + if (App.Settings.Prop.MultiInstanceLaunching) + { + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Creating singleton mutex"); + // this might be a bit problematic since this mutex will be released when the first launched instance is closed... + try + { + singletonMutex = Mutex.OpenExisting("ROBLOX_singletonMutex"); + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Warning - singleton mutex already exists"); + } + catch + { + singletonMutex = new Mutex(true, "ROBLOX_singletonMutex"); + } + shouldWait = true; + } + Process gameClient = Process.Start(Path.Combine(_versionFolder, "RobloxPlayerBeta.exe"), _launchCommandLine); List autocloseProcesses = new(); DiscordRichPresence? richPresence = null; - Mutex? singletonMutex = null; App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Started Roblox (PID {gameClient.Id})"); @@ -370,14 +409,6 @@ namespace Bloxstrap shouldWait = true; } - if (App.Settings.Prop.MultiInstanceLaunching) - { - App.Logger.WriteLine("[Bootstrapper::StartRoblox] Creating singleton mutex"); - // this might be a bit problematic since this mutex will be released when the first launched instance is closed... - singletonMutex = new Mutex(true, "ROBLOX_singletonMutex"); - shouldWait = true; - } - // launch custom integrations now foreach (CustomIntegration integration in App.Settings.Prop.CustomIntegrations) { @@ -548,7 +579,7 @@ namespace Bloxstrap private void Uninstall() { - CheckIfRunning(true); + ShutdownIfRobloxRunning(); SetStatus($"Uninstalling {App.ProjectName}..."); diff --git a/Bloxstrap/Helpers/AsyncMutex.cs b/Bloxstrap/Helpers/AsyncMutex.cs new file mode 100644 index 0000000..3691582 --- /dev/null +++ b/Bloxstrap/Helpers/AsyncMutex.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Bloxstrap.Helpers +{ + // https://gist.github.com/dfederm/35c729f6218834b764fa04c219181e4e + public sealed class AsyncMutex : IAsyncDisposable + { + private readonly string _name; + private Task? _mutexTask; + private ManualResetEventSlim? _releaseEvent; + private CancellationTokenSource? _cancellationTokenSource; + + public AsyncMutex(string name) + { + _name = name; + } + + public Task AcquireAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + TaskCompletionSource taskCompletionSource = new(); + + _releaseEvent = new ManualResetEventSlim(); + _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Putting all mutex manipulation in its own task as it doesn't work in async contexts + // Note: this task should not throw. + _mutexTask = Task.Factory.StartNew( + state => + { + try + { + CancellationToken cancellationToken = _cancellationTokenSource.Token; + using var mutex = new Mutex(false, _name); + try + { + // Wait for either the mutex to be acquired, or cancellation + if (WaitHandle.WaitAny(new[] { mutex, cancellationToken.WaitHandle }) != 0) + { + taskCompletionSource.SetCanceled(cancellationToken); + return; + } + } + catch (AbandonedMutexException) + { + // Abandoned by another process, we acquired it. + } + + taskCompletionSource.SetResult(); + + // Wait until the release call + _releaseEvent.Wait(); + + mutex.ReleaseMutex(); + } + catch (OperationCanceledException) + { + taskCompletionSource.TrySetCanceled(cancellationToken); + } + catch (Exception ex) + { + taskCompletionSource.TrySetException(ex); + } + }, + state: null, + cancellationToken, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + + return taskCompletionSource.Task; + } + + public async Task ReleaseAsync() + { + _releaseEvent?.Set(); + + if (_mutexTask != null) + { + await _mutexTask; + } + } + + public async ValueTask DisposeAsync() + { + // Ensure the mutex task stops waiting for any acquire + _cancellationTokenSource?.Cancel(); + + // Ensure the mutex is released + await ReleaseAsync(); + + _releaseEvent?.Dispose(); + _cancellationTokenSource?.Dispose(); + } + } +} diff --git a/Bloxstrap/Helpers/Integrations/RbxFpsUnlocker.cs b/Bloxstrap/Helpers/Integrations/RbxFpsUnlocker.cs index 504614d..d353bfe 100644 --- a/Bloxstrap/Helpers/Integrations/RbxFpsUnlocker.cs +++ b/Bloxstrap/Helpers/Integrations/RbxFpsUnlocker.cs @@ -62,6 +62,10 @@ namespace Bloxstrap.Helpers.Integrations if (!App.Settings.Prop.RFUEnabled) { + // don't delete rbxfpsunlocker if rbxfpsunlocker and roblox is currently running + if (Utilities.GetProcessCount(ApplicationName) > 0 && Utilities.GetProcessCount("RobloxPlayerBeta") > 0) + return; + App.State.Prop.RbxFpsUnlockerVersion = ""; App.State.Save(); diff --git a/Bloxstrap/Helpers/Integrations/ReShade.cs b/Bloxstrap/Helpers/Integrations/ReShade.cs index efe0951..eacc91d 100644 --- a/Bloxstrap/Helpers/Integrations/ReShade.cs +++ b/Bloxstrap/Helpers/Integrations/ReShade.cs @@ -325,6 +325,9 @@ namespace Bloxstrap.Helpers.Integrations if (!App.Settings.Prop.UseReShadeExtraviPresets && !String.IsNullOrEmpty(App.State.Prop.ExtraviReShadePresetsVersion)) { + if (Utilities.GetProcessCount("RobloxPlayerBeta") > 0) + return; + UninstallExtraviPresets(); App.State.Prop.ExtraviReShadePresetsVersion = ""; @@ -333,6 +336,9 @@ namespace Bloxstrap.Helpers.Integrations if (!App.Settings.Prop.UseReShade) { + if (Utilities.GetProcessCount("RobloxPlayerBeta") > 0) + return; + App.Logger.WriteLine("[ReShade::CheckModifications] ReShade is not enabled"); // we should already be uninstalled diff --git a/Bloxstrap/Helpers/Utilities.cs b/Bloxstrap/Helpers/Utilities.cs index b6237df..5b22a84 100644 --- a/Bloxstrap/Helpers/Utilities.cs +++ b/Bloxstrap/Helpers/Utilities.cs @@ -26,6 +26,17 @@ namespace Bloxstrap.Helpers return -1; } + public static int GetProcessCount(string processName) + { + App.Logger.WriteLine($"[Utilities::CheckIfProcessRunning] Checking if '{processName}' is running..."); + + Process[] processes = Process.GetProcessesByName("RobloxPlayerBeta"); + + App.Logger.WriteLine($"[Utilities::CheckIfProcessRunning] Found {processes.Length} process(es) running for '{processName}'"); + + return processes.Length; + } + public static void OpenWebsite(string website) { Process.Start(new ProcessStartInfo { FileName = website, UseShellExecute = true });