diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 56f0166..12e90d4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -8,6 +8,7 @@ body: ### **Preliminary instructions** - Before opening an issue, please [check the Wiki first](https://github.com/pizzaboxer/bloxstrap/wiki/) to see if your problem has been addressed there. - If it isn't, please confirm which pages that you read that were relevant to your issue. + - Your issue ***will*** be closed without warning if there's a Wiki page addressing your problem. - If your problem is with Roblox itself (i.e. it crashes or doesn't launch), [check to see if it happens without Bloxstrap](https://github.com/pizzaboxer/bloxstrap/wiki/Roblox-crashes-or-does-not-launch). - Please only open an issue if your problem happens only with Bloxstrap, and state clearly that this is the case, as anything else is out of my control. - If you are getting a Bloxstrap Exception error, please attach a copy of the provided log file. There is a button on the dialog that locates it for you. @@ -32,3 +33,10 @@ body: description: Provide a comprehensive description of the problem you're facing. Don't forget to attach any additional resources you may have, such as log files and screenshots. validations: required: true + - type: textarea + id: log + attributes: + label: Bloxstrap Log + description: If you're getting a Bloxstrap Exception error, upload your log file here. Otherwise, just leave it empty. + value: "N/A" + #render: text diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs index db72422..373c7ee 100644 --- a/Bloxstrap/App.xaml.cs +++ b/Bloxstrap/App.xaml.cs @@ -15,7 +15,11 @@ namespace Bloxstrap public partial class App : Application { public const string ProjectName = "Bloxstrap"; + public const string ProjectOwner = "pizzaboxer"; public const string ProjectRepository = "pizzaboxer/bloxstrap"; + public const string ProjectDownloadLink = "https://bloxstraplabs.com"; + public const string ProjectHelpLink = "https://github.com/pizzaboxer/bloxstrap/wiki"; + public const string ProjectSupportLink = "https://github.com/pizzaboxer/bloxstrap/issues/new"; public const string RobloxPlayerAppName = "RobloxPlayerBeta"; public const string RobloxStudioAppName = "RobloxStudioBeta"; @@ -29,9 +33,11 @@ namespace Bloxstrap public static string Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[..^2]; - public static readonly MD5 MD5Provider = MD5.Create(); + public static bool IsActionBuild => !String.IsNullOrEmpty(BuildMetadata.CommitRef); - public static NotifyIconWrapper? NotifyIcon { get; set; } + public static bool IsProductionBuild => IsActionBuild && BuildMetadata.CommitRef.StartsWith("tag", StringComparison.Ordinal); + + public static readonly MD5 MD5Provider = MD5.Create(); public static readonly Logger Logger = new(); @@ -49,18 +55,14 @@ namespace Bloxstrap ) ); -#if RELEASE private static bool _showingExceptionDialog = false; -#endif - + public static void Terminate(ErrorCode exitCode = ErrorCode.ERROR_SUCCESS) { int exitCodeNum = (int)exitCode; Logger.WriteLine("App::Terminate", $"Terminating with exit code {exitCodeNum} ({exitCode})"); - NotifyIcon?.Dispose(); - Environment.Exit(exitCodeNum); } @@ -73,24 +75,51 @@ namespace Bloxstrap FinalizeExceptionHandling(e.Exception); } - public static void FinalizeExceptionHandling(Exception exception, bool log = true) + public static void FinalizeExceptionHandling(AggregateException ex) + { + foreach (var innerEx in ex.InnerExceptions) + Logger.WriteException("App::FinalizeExceptionHandling", innerEx); + + FinalizeExceptionHandling(ex.GetBaseException(), false); + } + + public static void FinalizeExceptionHandling(Exception ex, bool log = true) { if (log) - Logger.WriteException("App::FinalizeExceptionHandling", exception); + Logger.WriteException("App::FinalizeExceptionHandling", ex); -#if DEBUG - throw exception; -#else if (_showingExceptionDialog) return; _showingExceptionDialog = true; - if (!LaunchSettings.QuietFlag.Active) - Frontend.ShowExceptionDialog(exception); + Frontend.ShowExceptionDialog(ex); Terminate(ErrorCode.ERROR_INSTALL_FAILURE); -#endif + } + + public static async Task GetLatestRelease() + { + const string LOG_IDENT = "App::GetLatestRelease"; + + try + { + var releaseInfo = await Http.GetJson($"https://api.github.com/repos/{ProjectRepository}/releases/latest"); + + if (releaseInfo is null || releaseInfo.Assets is null) + { + Logger.WriteLine(LOG_IDENT, "Encountered invalid data"); + return null; + } + + return releaseInfo; + } + catch (Exception ex) + { + Logger.WriteException(LOG_IDENT, ex); + } + + return null; } protected override void OnStartup(StartupEventArgs e) @@ -103,10 +132,10 @@ namespace Bloxstrap Logger.WriteLine(LOG_IDENT, $"Starting {ProjectName} v{Version}"); - if (String.IsNullOrEmpty(BuildMetadata.CommitHash)) - Logger.WriteLine(LOG_IDENT, $"Compiled {BuildMetadata.Timestamp.ToFriendlyString()} from {BuildMetadata.Machine}"); - else + if (IsActionBuild) Logger.WriteLine(LOG_IDENT, $"Compiled {BuildMetadata.Timestamp.ToFriendlyString()} from commit {BuildMetadata.CommitHash} ({BuildMetadata.CommitRef})"); + else + Logger.WriteLine(LOG_IDENT, $"Compiled {BuildMetadata.Timestamp.ToFriendlyString()} from {BuildMetadata.Machine}"); Logger.WriteLine(LOG_IDENT, $"Loaded from {Paths.Process}"); @@ -162,6 +191,26 @@ namespace Bloxstrap } } + if (fixInstallLocation && installLocation is not null) + { + var installer = new Installer + { + InstallLocation = installLocation, + IsImplicitInstall = true + }; + + if (installer.CheckInstallLocation()) + { + Logger.WriteLine(LOG_IDENT, $"Changing install location to '{installLocation}'"); + installer.DoInstall(); + } + else + { + // force reinstall + installLocation = null; + } + } + if (installLocation is null) { Logger.Initialize(true); @@ -169,21 +218,6 @@ namespace Bloxstrap } else { - if (fixInstallLocation) - { - var installer = new Installer - { - InstallLocation = installLocation, - IsImplicitInstall = true - }; - - if (installer.CheckInstallLocation()) - { - Logger.WriteLine(LOG_IDENT, $"Changing install location to '{installLocation}'"); - installer.DoInstall(); - } - } - Paths.Initialize(installLocation); // ensure executable is in the install directory @@ -202,10 +236,6 @@ namespace Bloxstrap State.Load(); FastFlags.Load(); - // we can only parse them now as settings need - // to be loaded first to know what our channel is - // LaunchSettings.ParseRoblox(); - if (!Locale.SupportedLocales.ContainsKey(Settings.Prop.Locale)) { Settings.Prop.Locale = "nil"; @@ -214,13 +244,13 @@ namespace Bloxstrap Locale.Set(Settings.Prop.Locale); - if (!LaunchSettings.UninstallFlag.Active) + if (!LaunchSettings.BypassUpdateCheck) Installer.HandleUpgrade(); LaunchHandler.ProcessLaunchArgs(); } - Terminate(); + // you must *explicitly* call terminate when everything is done, it won't be called implicitly } } } diff --git a/Bloxstrap/AppData/CommonAppData.cs b/Bloxstrap/AppData/CommonAppData.cs index 5b202ab..c74003a 100644 --- a/Bloxstrap/AppData/CommonAppData.cs +++ b/Bloxstrap/AppData/CommonAppData.cs @@ -39,8 +39,17 @@ namespace Bloxstrap.AppData { "extracontent-places.zip", @"ExtraContent\places\" }, }; + public virtual string ExecutableName { get; } = null!; + + public virtual string Directory { get; } = null!; + + public string LockFilePath => Path.Combine(Directory, "Bloxstrap.lock"); + + public string ExecutablePath => Path.Combine(Directory, ExecutableName); + public virtual IReadOnlyDictionary PackageDirectoryMap { get; set; } + public CommonAppData() { if (PackageDirectoryMap is null) diff --git a/Bloxstrap/AppData/IAppData.cs b/Bloxstrap/AppData/IAppData.cs index c637027..b8a45c9 100644 --- a/Bloxstrap/AppData/IAppData.cs +++ b/Bloxstrap/AppData/IAppData.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bloxstrap.AppData +namespace Bloxstrap.AppData { internal interface IAppData { @@ -18,6 +12,14 @@ namespace Bloxstrap.AppData string StartEvent { get; } + string Directory { get; } + + string LockFilePath { get; } + + string ExecutablePath { get; } + + AppState State { get; } + IReadOnlyDictionary PackageDirectoryMap { get; set; } } } diff --git a/Bloxstrap/AppData/RobloxPlayerData.cs b/Bloxstrap/AppData/RobloxPlayerData.cs index 3bc8785..923c6a1 100644 --- a/Bloxstrap/AppData/RobloxPlayerData.cs +++ b/Bloxstrap/AppData/RobloxPlayerData.cs @@ -8,15 +8,19 @@ namespace Bloxstrap.AppData { public class RobloxPlayerData : CommonAppData, IAppData { - public string ProductName { get; } = "Roblox"; + public string ProductName => "Roblox"; - public string BinaryType { get; } = "WindowsPlayer"; + public string BinaryType => "WindowsPlayer"; - public string RegistryName { get; } = "RobloxPlayer"; + public string RegistryName => "RobloxPlayer"; - public string ExecutableName { get; } = "RobloxPlayerBeta.exe"; + public override string ExecutableName => "RobloxPlayerBeta.exe"; - public string StartEvent { get; } = "www.roblox.com/robloxStartedEvent"; + public string StartEvent => "www.roblox.com/robloxStartedEvent"; + + public override string Directory => Path.Combine(Paths.Roblox, "Player"); + + public AppState State => App.State.Prop.Player; public override IReadOnlyDictionary PackageDirectoryMap { get; set; } = new Dictionary() { diff --git a/Bloxstrap/AppData/RobloxStudioData.cs b/Bloxstrap/AppData/RobloxStudioData.cs index 2c40ef8..fca5a63 100644 --- a/Bloxstrap/AppData/RobloxStudioData.cs +++ b/Bloxstrap/AppData/RobloxStudioData.cs @@ -1,22 +1,20 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bloxstrap.AppData +namespace Bloxstrap.AppData { public class RobloxStudioData : CommonAppData, IAppData { - public string ProductName { get; } = "Roblox Studio"; + public string ProductName => "Roblox Studio"; - public string BinaryType { get; } = "WindowsStudio64"; + public string BinaryType => "WindowsStudio64"; - public string RegistryName { get; } = "RobloxStudio"; + public string RegistryName => "RobloxStudio"; - public string ExecutableName { get; } = "RobloxStudioBeta.exe"; + public override string ExecutableName => "RobloxStudioBeta.exe"; - public string StartEvent { get; } = "www.roblox.com/robloxStudioStartedEvent"; + public string StartEvent => "www.roblox.com/robloxStudioStartedEvent"; + + public override string Directory => Path.Combine(Paths.Roblox, "Studio"); + + public AppState State => App.State.Prop.Studio; public override IReadOnlyDictionary PackageDirectoryMap { get; set; } = new Dictionary() { diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index a942765..bb5cfcb 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -1,10 +1,21 @@ -using System.Windows; +// To debug the automatic updater: +// - Uncomment the definition below +// - Publish the executable +// - Launch the executable (click no when it asks you to upgrade) +// - Launch Roblox (for testing web launches, run it from the command prompt) +// - To re-test the same executable, delete it from the installation folder + +// #define DEBUG_UPDATER + +#if DEBUG_UPDATER +#warning "Automatic updater debugging is enabled" +#endif + +using System.Windows; using System.Windows.Forms; using Microsoft.Win32; -using Bloxstrap.Integrations; -using Bloxstrap.Resources; using Bloxstrap.AppData; using System.Windows.Shell; using Bloxstrap.UI.Elements.Bootstrapper.Base; @@ -28,59 +39,21 @@ namespace Bloxstrap private readonly CancellationTokenSource _cancelTokenSource = new(); - private bool FreshInstall => String.IsNullOrEmpty(_versionGuid); - - private IAppData AppData; - - private string _playerLocation => Path.Combine(_versionFolder, AppData.ExecutableName); + private readonly IAppData AppData; private string _launchCommandLine = App.LaunchSettings.RobloxLaunchArgs; private LaunchMode _launchMode = App.LaunchSettings.RobloxLaunchMode; - private bool _installWebView2; - - private string _versionGuid - { - get - { - return _launchMode == LaunchMode.Player ? App.State.Prop.PlayerVersionGuid : App.State.Prop.StudioVersionGuid; - } - - set - { - if (_launchMode == LaunchMode.Player) - App.State.Prop.PlayerVersionGuid = value; - else - App.State.Prop.StudioVersionGuid = value; - } - } - - private int _distributionSize - { - get - { - return _launchMode == LaunchMode.Player ? App.State.Prop.PlayerSize : App.State.Prop.StudioSize; - } - - set - { - if (_launchMode == LaunchMode.Player) - App.State.Prop.PlayerSize = value; - else - App.State.Prop.StudioSize = value; - } - } - private string _latestVersionGuid = null!; private PackageManifest _versionPackageManifest = null!; - private string _versionFolder = null!; private bool _isInstalling = false; private double _progressIncrement; private double _taskbarProgressIncrement; private double _taskbarProgressMaximum; private long _totalDownloadedBytes = 0; - private int _packagesExtracted = 0; - private bool _cancelFired = false; + + private bool _mustUpgrade => String.IsNullOrEmpty(AppData.State.VersionGuid) || File.Exists(AppData.LockFilePath) || !File.Exists(AppData.ExecutablePath); + private bool _noConnection = false; public IBootstrapperDialog? Dialog = null; @@ -88,14 +61,9 @@ namespace Bloxstrap #endregion #region Core - public Bootstrapper(bool installWebView2) + public Bootstrapper() { - _installWebView2 = installWebView2; - - if (_launchMode == LaunchMode.Player) - AppData = new RobloxPlayerData(); - else - AppData = new RobloxStudioData(); + AppData = IsStudioLaunch ? new RobloxStudioData() : new RobloxPlayerData(); } private void SetStatus(string message) @@ -128,6 +96,35 @@ namespace Bloxstrap Dialog.TaskbarProgressValue = taskbarProgressValue; } + + private void HandleConnectionError(Exception exception) + { + _noConnection = true; + + string message = Strings.Dialog_Connectivity_Preventing; + + if (exception.GetType() == typeof(AggregateException)) + exception = exception.InnerException!; + + if (exception.GetType() == typeof(HttpRequestException)) + message = String.Format(Strings.Dialog_Connectivity_RobloxDown, "[status.roblox.com](https://status.roblox.com)"); + else if (exception.GetType() == typeof(TaskCanceledException)) + message = Strings.Dialog_Connectivity_TimedOut; + + if (_mustUpgrade) + message += $"\n\n{Strings.Dialog_Connectivity_RobloxUpgradeNeeded}\n\n{Strings.Dialog_Connectivity_TryAgainLater}"; + else + message += $"\n\n{Strings.Dialog_Connectivity_RobloxUpgradeSkip}"; + + Frontend.ShowConnectivityDialog( + String.Format(Strings.Dialog_Connectivity_UnableToConnect, "Roblox"), + message, + _mustUpgrade ? MessageBoxImage.Error : MessageBoxImage.Warning, + exception); + + if (_mustUpgrade) + App.Terminate(ErrorCode.ERROR_CANCELLED); + } public async Task Run() { @@ -135,42 +132,23 @@ namespace Bloxstrap App.Logger.WriteLine(LOG_IDENT, "Running bootstrapper"); - // connectivity check - - App.Logger.WriteLine(LOG_IDENT, "Performing connectivity check..."); - SetStatus(Strings.Bootstrapper_Status_Connecting); var connectionResult = await RobloxDeployment.InitializeConnectivity(); - if (connectionResult is not null) - { - App.Logger.WriteLine(LOG_IDENT, "Connectivity check failed!"); - App.Logger.WriteException(LOG_IDENT, connectionResult); - - string message = Strings.Bootstrapper_Connectivity_Preventing; - - if (connectionResult.GetType() == typeof(HttpResponseException)) - message = Strings.Bootstrapper_Connectivity_RobloxDown; - else if (connectionResult.GetType() == typeof(TaskCanceledException)) - message = Strings.Bootstrapper_Connectivity_TimedOut; - else if (connectionResult.GetType() == typeof(AggregateException)) - connectionResult = connectionResult.InnerException!; - - Frontend.ShowConnectivityDialog(Strings.Dialog_Connectivity_UnableToConnect, message, connectionResult); - - App.Terminate(ErrorCode.ERROR_CANCELLED); - - return; - } - App.Logger.WriteLine(LOG_IDENT, "Connectivity check finished"); - await RobloxDeployment.GetInfo(RobloxDeployment.DefaultChannel); - -#if !DEBUG - if (App.Settings.Prop.CheckForUpdates) - await CheckForUpdates(); + if (connectionResult is not null) + HandleConnectionError(connectionResult); + +#if !DEBUG || DEBUG_UPDATER + if (App.Settings.Prop.CheckForUpdates && !App.LaunchSettings.UpgradeFlag.Active) + { + bool updatePresent = await CheckForUpdates(); + + if (updatePresent) + return; + } #endif // ensure only one instance of the bootstrapper is running at the time @@ -180,8 +158,8 @@ namespace Bloxstrap try { - Mutex.OpenExisting("Bloxstrap_SingletonMutex").Close(); - App.Logger.WriteLine(LOG_IDENT, "Bloxstrap_SingletonMutex mutex exists, waiting..."); + Mutex.OpenExisting("Bloxstrap-Bootstrapper").Close(); + App.Logger.WriteLine(LOG_IDENT, "Bloxstrap-Bootstrapper mutex exists, waiting..."); SetStatus(Strings.Bootstrapper_Status_WaitingOtherInstances); mutexExists = true; } @@ -191,7 +169,7 @@ namespace Bloxstrap } // wait for mutex to be released if it's not yet - await using var mutex = new AsyncMutex(true, "Bloxstrap_SingletonMutex"); + await using var mutex = new AsyncMutex(false, "Bloxstrap-Bootstrapper"); await mutex.AcquireAsync(_cancelTokenSource.Token); // reload our configs since they've likely changed by now @@ -201,39 +179,46 @@ namespace Bloxstrap App.State.Load(); } - await CheckLatestVersion(); + if (!_noConnection) + { + try + { + await GetLatestVersionInfo(); + } + catch (Exception ex) + { + HandleConnectionError(ex); + } + } - // install/update roblox if we're running for the first time, needs updating, or the player location doesn't exist - if (_latestVersionGuid != _versionGuid || !File.Exists(_playerLocation)) - await InstallLatestVersion(); + if (!_noConnection) + { + if (AppData.State.VersionGuid != _latestVersionGuid || _mustUpgrade) + await UpgradeRoblox(); - MigrateIntegrations(); + // we require deployment details for applying modifications for a worst case scenario, + // where we'd need to restore files from a package that isn't present on disk and needs to be redownloaded + await ApplyModifications(); + } - if (_installWebView2) - await InstallWebView2(); + // check registry entries for every launch, just in case the stock bootstrapper changes it back - App.FastFlags.Save(); - await ApplyModifications(); - - // TODO: move this to install/upgrade flow - if (FreshInstall) - RegisterProgramSize(); - - CheckInstall(); - - // at this point we've finished updating our configs - App.Settings.Save(); - App.State.Save(); + if (IsStudioLaunch) + WindowsRegistry.RegisterStudio(); + else + WindowsRegistry.RegisterPlayer(); await mutex.ReleaseAsync(); - if (!App.LaunchSettings.NoLaunchFlag.Active && !_cancelFired) - await StartRoblox(); + if (!App.LaunchSettings.NoLaunchFlag.Active && !_cancelTokenSource.IsCancellationRequested) + StartRoblox(); + + Dialog?.CloseBootstrapper(); } - private async Task CheckLatestVersion() + private async Task GetLatestVersionInfo() { - const string LOG_IDENT = "Bootstrapper::CheckLatestVersion"; + const string LOG_IDENT = "Bootstrapper::GetLatestVersionInfo"; // before we do anything, we need to query our channel // if it's set in the launch uri, we need to use it and set the registry key for it @@ -249,7 +234,7 @@ namespace Bloxstrap { channel = match.Groups[1].Value.ToLowerInvariant(); } - else if (key.GetValue("www.roblox.com") is string value) + else if (key.GetValue("www.roblox.com") is string value && !String.IsNullOrEmpty(value)) { channel = value; } @@ -260,15 +245,14 @@ namespace Bloxstrap { clientVersion = await RobloxDeployment.GetInfo(channel, AppData.BinaryType); } - catch (HttpResponseException ex) + catch (HttpRequestException ex) { - if (ex.ResponseMessage.StatusCode - is not HttpStatusCode.Unauthorized + if (ex.StatusCode is not HttpStatusCode.Unauthorized and not HttpStatusCode.Forbidden and not HttpStatusCode.NotFound) throw; - App.Logger.WriteLine(LOG_IDENT, $"Changing channel from {channel} to {RobloxDeployment.DefaultChannel} because HTTP {(int)ex.ResponseMessage.StatusCode}"); + App.Logger.WriteLine(LOG_IDENT, $"Changing channel from {channel} to {RobloxDeployment.DefaultChannel} because HTTP {(int)ex.StatusCode}"); channel = RobloxDeployment.DefaultChannel; clientVersion = await RobloxDeployment.GetInfo(channel, AppData.BinaryType); @@ -285,99 +269,86 @@ namespace Bloxstrap key.SetValue("www.roblox.com", channel); _latestVersionGuid = clientVersion.VersionGuid; - _versionFolder = Path.Combine(Paths.Versions, _latestVersionGuid); - _versionPackageManifest = await PackageManifest.Get(_latestVersionGuid); + + string pkgManifestUrl = RobloxDeployment.GetLocation($"/{_latestVersionGuid}-rbxPkgManifest.txt"); + var pkgManifestData = await App.HttpClient.GetStringAsync(pkgManifestUrl); + + _versionPackageManifest = new(pkgManifestData); } - private async Task StartRoblox() + private void StartRoblox() { const string LOG_IDENT = "Bootstrapper::StartRoblox"; SetStatus(Strings.Bootstrapper_Status_Starting); - if (App.Settings.Prop.ForceRobloxLanguage) + if (_launchMode == LaunchMode.Player) { - var match = Regex.Match(_launchCommandLine, "gameLocale:([a-z_]+)", RegexOptions.CultureInvariant); + if (App.Settings.Prop.ForceRobloxLanguage) + { + var match = Regex.Match(_launchCommandLine, "gameLocale:([a-z_]+)", RegexOptions.CultureInvariant); - if (match.Groups.Count == 2) - _launchCommandLine = _launchCommandLine.Replace("robloxLocale:en_us", $"robloxLocale:{match.Groups[1].Value}", StringComparison.InvariantCultureIgnoreCase); + if (match.Groups.Count == 2) + _launchCommandLine = _launchCommandLine.Replace("robloxLocale:en_us", $"robloxLocale:{match.Groups[1].Value}", StringComparison.InvariantCultureIgnoreCase); + } + + if (!String.IsNullOrEmpty(_launchCommandLine)) + _launchCommandLine += " "; + + _launchCommandLine += "-isInstallerLaunch"; } - // whether we should wait for roblox to exit to handle stuff in the background or clean up after roblox closes - bool shouldWait = false; - var startInfo = new ProcessStartInfo() { - FileName = _playerLocation, + FileName = AppData.ExecutablePath, Arguments = _launchCommandLine, - WorkingDirectory = _versionFolder + WorkingDirectory = AppData.Directory }; if (_launchMode == LaunchMode.StudioAuth) { Process.Start(startInfo); - Dialog?.CloseBootstrapper(); return; } - // v2.2.0 - byfron will trip if we keep a process handle open for over a minute, so we're doing this now int gameClientPid; - using (Process gameClient = Process.Start(startInfo)!) + bool startEventSignalled; + + // TODO: figure out why this is causing roblox to block for some users + using (var startEvent = new EventWaitHandle(false, EventResetMode.ManualReset, AppData.StartEvent)) { - gameClientPid = gameClient.Id; - } + startEvent.Reset(); - List autocloseProcesses = new(); - ActivityWatcher? activityWatcher = null; - DiscordRichPresence? richPresence = null; - - App.Logger.WriteLine(LOG_IDENT, $"Started Roblox (PID {gameClientPid})"); - - using (var startEvent = new SystemEvent(AppData.StartEvent)) - { - bool startEventFired = await startEvent.WaitForEvent(); - - startEvent.Close(); - - // TODO: this cannot silently exit like this - if (!startEventFired) - return; - } - - if (App.Settings.Prop.EnableActivityTracking && _launchMode == LaunchMode.Player) - App.NotifyIcon?.SetProcessId(gameClientPid); - - if (App.Settings.Prop.EnableActivityTracking) - { - activityWatcher = new(gameClientPid); - shouldWait = true; - - App.NotifyIcon?.SetActivityWatcher(activityWatcher); - - if (App.Settings.Prop.UseDisableAppPatch) + // v2.2.0 - byfron will trip if we keep a process handle open for over a minute, so we're doing this now + using (var process = Process.Start(startInfo)!) { - activityWatcher.OnAppClose += (_, _) => - { - App.Logger.WriteLine(LOG_IDENT, "Received desktop app exit, closing Roblox"); - using var process = Process.GetProcessById(gameClientPid); - process.CloseMainWindow(); - }; + gameClientPid = process.Id; } - if (App.Settings.Prop.UseDiscordRichPresence) - { - App.Logger.WriteLine(LOG_IDENT, "Using Discord Rich Presence"); - richPresence = new(activityWatcher); + App.Logger.WriteLine(LOG_IDENT, $"Started Roblox (PID {gameClientPid}), waiting for start event"); - App.NotifyIcon?.SetRichPresenceHandler(richPresence); - } + startEventSignalled = startEvent.WaitOne(TimeSpan.FromSeconds(30)); } + if (!startEventSignalled) + { + Frontend.ShowPlayerErrorDialog(); + return; + } + + App.Logger.WriteLine(LOG_IDENT, "Start event signalled"); + + if (IsStudioLaunch) + return; + + var autoclosePids = new List(); + // launch custom integrations now - foreach (CustomIntegration integration in App.Settings.Prop.CustomIntegrations) + foreach (var integration in App.Settings.Prop.CustomIntegrations) { App.Logger.WriteLine(LOG_IDENT, $"Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})"); + int pid = 0; try { var process = Process.Start(new ProcessStartInfo @@ -386,175 +357,127 @@ namespace Bloxstrap Arguments = integration.LaunchArgs.Replace("\r\n", " "), WorkingDirectory = Path.GetDirectoryName(integration.Location), UseShellExecute = true - }); + })!; - if (integration.AutoClose) - { - shouldWait = true; - autocloseProcesses.Add(process); - } + pid = process.Id; } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to launch integration '{integration.Name}'!"); App.Logger.WriteLine(LOG_IDENT, $"{ex.Message}"); } + + if (integration.AutoClose && pid != 0) + autoclosePids.Add(pid); } - // event fired, wait for 3 seconds then close - await Task.Delay(3000); - Dialog?.CloseBootstrapper(); + string args = gameClientPid.ToString(); - // keep bloxstrap open in the background if needed - if (!shouldWait) - return; + if (autoclosePids.Any()) + args += $";{String.Join(',', autoclosePids)}"; - activityWatcher?.StartWatcher(); - - App.Logger.WriteLine(LOG_IDENT, "Waiting for Roblox to close"); - - while (Utilities.GetProcessesSafe().Any(x => x.Id == gameClientPid)) - await Task.Delay(1000); - - App.Logger.WriteLine(LOG_IDENT, $"Roblox has exited"); - - richPresence?.Dispose(); - - foreach (var process in autocloseProcesses) + if (App.Settings.Prop.EnableActivityTracking || autoclosePids.Any()) { - if (process is null || process.HasExited) - continue; + using var ipl = new InterProcessLock("Watcher", TimeSpan.FromSeconds(5)); - App.Logger.WriteLine(LOG_IDENT, $"Autoclosing process '{process.ProcessName}' (PID {process.Id})"); - process.Kill(); + // TODO: look into if this needs to be launched *before* roblox starts + if (ipl.IsAcquired) + Process.Start(Paths.Process, $"-watcher \"{args}\""); } } - public void CancelInstall() + // TODO: the bootstrapper dialogs call this function directly. + // this should probably be behind an event invocation. + public void Cancel() { - const string LOG_IDENT = "Bootstrapper::CancelInstall"; + const string LOG_IDENT = "Bootstrapper::Cancel"; if (!_isInstalling) { + // TODO: this sucks and needs to be done better App.Terminate(ErrorCode.ERROR_CANCELLED); return; } - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; - App.Logger.WriteLine(LOG_IDENT, "Cancelling install..."); + App.Logger.WriteLine(LOG_IDENT, "Cancelling launch..."); _cancelTokenSource.Cancel(); - _cancelFired = true; - try + if (_isInstalling) { - // clean up install - if (Directory.Exists(_versionFolder)) - Directory.Delete(_versionFolder, true); - } - catch (Exception ex) - { - App.Logger.WriteLine(LOG_IDENT, "Could not fully clean up installation!"); - App.Logger.WriteException(LOG_IDENT, ex); + try + { + // clean up install + if (Directory.Exists(AppData.Directory)) + Directory.Delete(AppData.Directory, true); + } + catch (Exception ex) + { + App.Logger.WriteLine(LOG_IDENT, "Could not fully clean up installation!"); + App.Logger.WriteException(LOG_IDENT, ex); + } } Dialog?.CloseBootstrapper(); App.Terminate(ErrorCode.ERROR_CANCELLED); } - #endregion +#endregion #region App Install - public void RegisterProgramSize() - { - const string LOG_IDENT = "Bootstrapper::RegisterProgramSize"; - - App.Logger.WriteLine(LOG_IDENT, "Registering approximate program size..."); - - using RegistryKey uninstallKey = Registry.CurrentUser.CreateSubKey($"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{App.ProjectName}"); - - // sum compressed and uncompressed package sizes and convert to kilobytes - int distributionSize = (_versionPackageManifest.Sum(x => x.Size) + _versionPackageManifest.Sum(x => x.PackedSize)) / 1000; - _distributionSize = distributionSize; - - int totalSize = App.State.Prop.PlayerSize + App.State.Prop.StudioSize; - - uninstallKey.SetValue("EstimatedSize", totalSize); - - App.Logger.WriteLine(LOG_IDENT, $"Registered as {totalSize} KB"); - } - - public static void CheckInstall() - { - const string LOG_IDENT = "Bootstrapper::CheckInstall"; - - App.Logger.WriteLine(LOG_IDENT, "Checking install"); - - // check if launch uri is set to our bootstrapper - // this doesn't go under register, so we check every launch - // just in case the stock bootstrapper changes it back - - ProtocolHandler.Register("roblox", "Roblox", Paths.Application, "-player \"%1\""); - ProtocolHandler.Register("roblox-player", "Roblox", Paths.Application, "-player \"%1\""); -#if STUDIO_FEATURES - ProtocolHandler.Register("roblox-studio", "Roblox", Paths.Application); - ProtocolHandler.Register("roblox-studio-auth", "Roblox", Paths.Application); - - ProtocolHandler.RegisterRobloxPlace(Paths.Application); - ProtocolHandler.RegisterExtension(".rbxl"); - ProtocolHandler.RegisterExtension(".rbxlx"); -#endif - } - - private async Task CheckForUpdates() + private async Task CheckForUpdates() { const string LOG_IDENT = "Bootstrapper::CheckForUpdates"; // don't update if there's another instance running (likely running in the background) - if (Process.GetProcessesByName(App.ProjectName).Count() > 1) + // i don't like this, but there isn't much better way of doing it /shrug + if (Process.GetProcessesByName(App.ProjectName).Length > 1) { App.Logger.WriteLine(LOG_IDENT, $"More than one Bloxstrap instance running, aborting update check"); - return; + return false; } - App.Logger.WriteLine(LOG_IDENT, $"Checking for updates..."); + App.Logger.WriteLine(LOG_IDENT, "Checking for updates..."); - GithubRelease? releaseInfo; +#if !DEBUG_UPDATER + var releaseInfo = await App.GetLatestRelease(); - try - { - releaseInfo = await Http.GetJson($"https://api.github.com/repos/{App.ProjectRepository}/releases/latest"); - } - catch (Exception ex) - { - App.Logger.WriteLine(LOG_IDENT, $"Failed to fetch releases: {ex}"); - return; - } - - if (releaseInfo is null || releaseInfo.Assets is null) - { - App.Logger.WriteLine(LOG_IDENT, $"No updates found"); - return; - } + if (releaseInfo is null) + return false; var versionComparison = Utilities.CompareVersions(App.Version, releaseInfo.TagName); // check if we aren't using a deployed build, so we can update to one if a new version comes out - if (versionComparison == VersionComparison.Equal && App.BuildMetadata.CommitRef.StartsWith("tag") || versionComparison == VersionComparison.GreaterThan) + if (App.IsProductionBuild && versionComparison == VersionComparison.Equal || versionComparison == VersionComparison.GreaterThan) { - App.Logger.WriteLine(LOG_IDENT, $"No updates found"); - return; + App.Logger.WriteLine(LOG_IDENT, "No updates found"); + return false; } + string version = releaseInfo.TagName; +#else + string version = App.Version; +#endif + SetStatus(Strings.Bootstrapper_Status_UpgradingBloxstrap); - + try { - // 64-bit is always the first option - GithubReleaseAsset asset = releaseInfo.Assets[0]; - string downloadLocation = Path.Combine(Paths.LocalAppData, "Temp", asset.Name); +#if DEBUG_UPDATER + string downloadLocation = Path.Combine(Paths.TempUpdates, "Bloxstrap.exe"); + + Directory.CreateDirectory(Paths.TempUpdates); + + File.Copy(Paths.Process, downloadLocation, true); +#else + var asset = releaseInfo.Assets![0]; + + string downloadLocation = Path.Combine(Paths.TempUpdates, asset.Name); + + Directory.CreateDirectory(Paths.TempUpdates); App.Logger.WriteLine(LOG_IDENT, $"Downloading {releaseInfo.TagName}..."); @@ -562,25 +485,35 @@ namespace Bloxstrap { var response = await App.HttpClient.GetAsync(asset.BrowserDownloadUrl); - await using var fileStream = new FileStream(downloadLocation, FileMode.CreateNew); + await using var fileStream = new FileStream(downloadLocation, FileMode.OpenOrCreate, FileAccess.Write); await response.Content.CopyToAsync(fileStream); } +#endif - App.Logger.WriteLine(LOG_IDENT, $"Starting {releaseInfo.TagName}..."); + App.Logger.WriteLine(LOG_IDENT, $"Starting {version}..."); ProcessStartInfo startInfo = new() { FileName = downloadLocation, }; + startInfo.ArgumentList.Add("-upgrade"); + foreach (string arg in App.LaunchSettings.Args) startInfo.ArgumentList.Add(arg); - + + if (_launchMode == LaunchMode.Player && !startInfo.ArgumentList.Contains("-player")) + startInfo.ArgumentList.Add("-player"); + else if (_launchMode == LaunchMode.Studio && !startInfo.ArgumentList.Contains("-studio")) + startInfo.ArgumentList.Add("-studio"); + App.Settings.Save(); + + new InterProcessLock("AutoUpdater"); Process.Start(startInfo); - App.Terminate(); + return true; } catch (Exception ex) { @@ -588,44 +521,81 @@ namespace Bloxstrap App.Logger.WriteException(LOG_IDENT, ex); Frontend.ShowMessageBox( - string.Format(Strings.Bootstrapper_AutoUpdateFailed, releaseInfo.TagName), + string.Format(Strings.Bootstrapper_AutoUpdateFailed, version), MessageBoxImage.Information ); + + Utilities.ShellExecute(App.ProjectDownloadLink); } + + return false; } #endregion #region Roblox Install - private async Task InstallLatestVersion() + private async Task UpgradeRoblox() { - const string LOG_IDENT = "Bootstrapper::InstallLatestVersion"; - - _isInstalling = true; + const string LOG_IDENT = "Bootstrapper::UpgradeRoblox"; - SetStatus(FreshInstall ? Strings.Bootstrapper_Status_Installing : Strings.Bootstrapper_Status_Upgrading); + if (String.IsNullOrEmpty(AppData.State.VersionGuid)) + SetStatus(Strings.Bootstrapper_Status_Installing); + else + SetStatus(Strings.Bootstrapper_Status_Upgrading); Directory.CreateDirectory(Paths.Base); Directory.CreateDirectory(Paths.Downloads); - Directory.CreateDirectory(Paths.Versions); + Directory.CreateDirectory(Paths.Roblox); + + if (Directory.Exists(AppData.Directory)) + { + try + { + // gross hack to see if roblox is still running + // i don't want to rely on mutexes because they can change, and will false flag for + // running installations that are not by bloxstrap + File.Delete(AppData.ExecutablePath); + } + catch (Exception ex) + { + App.Logger.WriteLine(LOG_IDENT, "Could not delete executable/folder, Roblox may still be running. Aborting update."); + App.Logger.WriteException(LOG_IDENT, ex); + + Directory.Delete(AppData.Directory); + + return; + } + + Directory.Delete(AppData.Directory, true); + } + + _isInstalling = true; + + Directory.CreateDirectory(AppData.Directory); + + // installer lock, it should only be present while roblox is in the process of upgrading + // if it's present while we're launching, then it's an unfinished install and must be reinstalled + var lockFile = new FileInfo(AppData.LockFilePath); + lockFile.Create().Dispose(); + + var cachedPackageHashes = Directory.GetFiles(Paths.Downloads).Select(x => Path.GetFileName(x)); // package manifest states packed size and uncompressed size in exact bytes + int totalSizeRequired = 0; + // packed size only matters if we don't already have the package cached on disk - string[] cachedPackages = Directory.GetFiles(Paths.Downloads); - int totalSizeRequired = _versionPackageManifest.Where(x => !cachedPackages.Contains(x.Signature)).Sum(x => x.PackedSize) + _versionPackageManifest.Sum(x => x.Size); + totalSizeRequired += _versionPackageManifest.Where(x => !cachedPackageHashes.Contains(x.Signature)).Sum(x => x.PackedSize); + totalSizeRequired += _versionPackageManifest.Sum(x => x.Size); if (Filesystem.GetFreeDiskSpace(Paths.Base) < totalSizeRequired) { - Frontend.ShowMessageBox( - Strings.Bootstrapper_NotEnoughSpace, - MessageBoxImage.Error - ); - + Frontend.ShowMessageBox(Strings.Bootstrapper_NotEnoughSpace, MessageBoxImage.Error); App.Terminate(ErrorCode.ERROR_INSTALL_FAILURE); return; } if (Dialog is not null) { + // TODO: cancelling needs to always be enabled Dialog.CancelEnabled = true; Dialog.ProgressStyle = ProgressBarStyle.Continuous; Dialog.TaskbarProgressState = TaskbarItemProgressState.Normal; @@ -644,9 +614,11 @@ namespace Bloxstrap _taskbarProgressIncrement = _taskbarProgressMaximum / (double)totalSize; } - foreach (Package package in _versionPackageManifest) + var extractionTasks = new List(); + + foreach (var package in _versionPackageManifest) { - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; // download all the packages synchronously @@ -656,108 +628,134 @@ namespace Bloxstrap if (package.Name == "WebView2RuntimeInstaller.zip") continue; - // extract the package immediately after download asynchronously - // discard is just used to suppress the warning - _ = Task.Run(() => ExtractPackage(package).ContinueWith(AsyncHelpers.ExceptionHandler, $"extracting {package.Name}")); + // extract the package async immediately after download + extractionTasks.Add(Task.Run(() => ExtractPackage(package), _cancelTokenSource.Token)); } - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; - // allow progress bar to 100% before continuing (purely ux reasons lol) - await Task.Delay(1000); - if (Dialog is not null) { + // allow progress bar to 100% before continuing (purely ux reasons lol) + // TODO: come up with a better way of handling this that is non-blocking + await Task.Delay(1000); + Dialog.ProgressStyle = ProgressBarStyle.Marquee; Dialog.TaskbarProgressState = TaskbarItemProgressState.Indeterminate; SetStatus(Strings.Bootstrapper_Status_Configuring); } - // wait for all packages to finish extracting, with an exception for the webview2 runtime installer - while (_packagesExtracted < _versionPackageManifest.Where(x => x.Name != "WebView2RuntimeInstaller.zip").Count()) - { - await Task.Delay(100); - } - + await Task.WhenAll(extractionTasks); + App.Logger.WriteLine(LOG_IDENT, "Writing AppSettings.xml..."); - string appSettingsLocation = Path.Combine(_versionFolder, "AppSettings.xml"); - await File.WriteAllTextAsync(appSettingsLocation, AppSettings); + await File.WriteAllTextAsync(Path.Combine(AppData.Directory, "AppSettings.xml"), AppSettings); - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; - if (!FreshInstall) + if (App.State.Prop.PromptWebView2Install) { - // let's take this opportunity to delete any packages we don't need anymore - foreach (string filename in cachedPackages) + using var hklmKey = Registry.LocalMachine.OpenSubKey("SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); + using var hkcuKey = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); + + if (hklmKey is not null || hkcuKey is not null) { - if (!_versionPackageManifest.Exists(package => filename.Contains(package.Signature))) + // reset prompt state if the user has it installed + App.State.Prop.PromptWebView2Install = true; + } + else + { + var result = Frontend.ShowMessageBox(Strings.Bootstrapper_WebView2NotFound, MessageBoxImage.Warning, MessageBoxButton.YesNo, MessageBoxResult.Yes); + + if (result != MessageBoxResult.Yes) { - App.Logger.WriteLine(LOG_IDENT, $"Deleting unused package {filename}"); - - try - { - File.Delete(filename); - } - catch (Exception ex) - { - App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {filename}!"); - App.Logger.WriteException(LOG_IDENT, ex); - } + App.State.Prop.PromptWebView2Install = false; } - } - - string oldVersionFolder = Path.Combine(Paths.Versions, _versionGuid); - - // move old compatibility flags for the old location - using (RegistryKey appFlagsKey = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers")) - { - string oldGameClientLocation = Path.Combine(oldVersionFolder, AppData.ExecutableName); - string? appFlags = (string?)appFlagsKey.GetValue(oldGameClientLocation); - - if (appFlags is not null) + else { - App.Logger.WriteLine(LOG_IDENT, $"Migrating app compatibility flags from {oldGameClientLocation} to {_playerLocation}..."); - appFlagsKey.SetValue(_playerLocation, appFlags); - appFlagsKey.DeleteValue(oldGameClientLocation); + App.Logger.WriteLine(LOG_IDENT, "Installing WebView2 runtime..."); + + var package = _versionPackageManifest.Find(x => x.Name == "WebView2RuntimeInstaller.zip"); + + if (package is null) + { + App.Logger.WriteLine(LOG_IDENT, "Aborted runtime install because package does not exist, has WebView2 been added in this Roblox version yet?"); + return; + } + + string baseDirectory = Path.Combine(AppData.Directory, AppData.PackageDirectoryMap[package.Name]); + + ExtractPackage(package); + + SetStatus(Strings.Bootstrapper_Status_InstallingWebView2); + + var startInfo = new ProcessStartInfo() + { + WorkingDirectory = baseDirectory, + FileName = Path.Combine(baseDirectory, "MicrosoftEdgeWebview2Setup.exe"), + Arguments = "/silent /install" + }; + + await Process.Start(startInfo)!.WaitForExitAsync(); + + App.Logger.WriteLine(LOG_IDENT, "Finished installing runtime"); + + Directory.Delete(baseDirectory, true); } } } - _versionGuid = _latestVersionGuid; + // finishing and cleanup - // delete any old version folders - // we only do this if roblox isnt running just in case an update happened - // while they were launching a second instance or something idk -#if STUDIO_FEATURES - if (!Process.GetProcessesByName(App.RobloxPlayerAppName).Any() && !Process.GetProcessesByName(App.RobloxStudioAppName).Any()) -#else - if (!Process.GetProcessesByName(App.RobloxPlayerAppName).Any()) -#endif + AppData.State.VersionGuid = _latestVersionGuid; + + AppData.State.PackageHashes.Clear(); + + foreach (var package in _versionPackageManifest) + AppData.State.PackageHashes.Add(package.Name, package.Signature); + + var allPackageHashes = new List(); + + allPackageHashes.AddRange(App.State.Prop.Player.PackageHashes.Values); + allPackageHashes.AddRange(App.State.Prop.Studio.PackageHashes.Values); + + foreach (string hash in cachedPackageHashes) { - foreach (DirectoryInfo dir in new DirectoryInfo(Paths.Versions).GetDirectories()) + if (!allPackageHashes.Contains(hash)) { - if (dir.Name == App.State.Prop.PlayerVersionGuid || dir.Name == App.State.Prop.StudioVersionGuid || !dir.Name.StartsWith("version-")) - continue; - - App.Logger.WriteLine(LOG_IDENT, $"Removing old version folder for {dir.Name}"); - + App.Logger.WriteLine(LOG_IDENT, $"Deleting unused package {hash}"); + try { - dir.Delete(true); + File.Delete(Path.Combine(Paths.Downloads, hash)); } catch (Exception ex) { - App.Logger.WriteLine(LOG_IDENT, "Failed to delete version folder!"); + App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {hash}!"); App.Logger.WriteException(LOG_IDENT, ex); } } } - // don't register program size until the program is registered, which will be done after this - if (!FreshInstall) - RegisterProgramSize(); + App.Logger.WriteLine(LOG_IDENT, "Registering approximate program size..."); + + int distributionSize = _versionPackageManifest.Sum(x => x.Size + x.PackedSize) / 1024; + + AppData.State.Size = distributionSize; + + int totalSize = App.State.Prop.Player.Size + App.State.Prop.Studio.Size; + + using (var uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey)) + { + uninstallKey.SetValue("EstimatedSize", totalSize); + } + + App.Logger.WriteLine(LOG_IDENT, $"Registered as {totalSize} KB"); + + App.State.Save(); + + lockFile.Delete(); if (Dialog is not null) Dialog.CancelEnabled = false; @@ -765,121 +763,12 @@ namespace Bloxstrap _isInstalling = false; } - private async Task InstallWebView2() - { - const string LOG_IDENT = "Bootstrapper::InstallWebView2"; - - App.Logger.WriteLine(LOG_IDENT, "Installing runtime..."); - - string baseDirectory = Path.Combine(_versionFolder, "WebView2RuntimeInstaller"); - - if (!Directory.Exists(baseDirectory)) - { - Package? package = _versionPackageManifest.Find(x => x.Name == "WebView2RuntimeInstaller.zip"); - - if (package is null) - { - App.Logger.WriteLine(LOG_IDENT, "Aborted runtime install because package does not exist, has WebView2 been added in this Roblox version yet?"); - return; - } - - await ExtractPackage(package); - } - - SetStatus(Strings.Bootstrapper_Status_InstallingWebView2); - - ProcessStartInfo startInfo = new() - { - WorkingDirectory = baseDirectory, - FileName = Path.Combine(baseDirectory, "MicrosoftEdgeWebview2Setup.exe"), - Arguments = "/silent /install" - }; - - await Process.Start(startInfo)!.WaitForExitAsync(); - - App.Logger.WriteLine(LOG_IDENT, "Finished installing runtime"); - } - - public static void MigrateIntegrations() - { - // v2.2.0 - remove rbxfpsunlocker - string rbxfpsunlocker = Path.Combine(Paths.Integrations, "rbxfpsunlocker"); - - if (Directory.Exists(rbxfpsunlocker)) - Directory.Delete(rbxfpsunlocker, true); - - // v2.3.0 - remove reshade - string injectorLocation = Path.Combine(Paths.Modifications, "dxgi.dll"); - string configLocation = Path.Combine(Paths.Modifications, "ReShade.ini"); - - if (File.Exists(injectorLocation)) - { - Frontend.ShowMessageBox( - Strings.Bootstrapper_HyperionUpdateInfo, - MessageBoxImage.Warning - ); - - File.Delete(injectorLocation); - } - - if (File.Exists(configLocation)) - File.Delete(configLocation); - } - private async Task ApplyModifications() { const string LOG_IDENT = "Bootstrapper::ApplyModifications"; - - if (Process.GetProcessesByName(AppData.ExecutableName[..^4]).Any()) - { - App.Logger.WriteLine(LOG_IDENT, "Roblox is running, aborting mod check"); - return; - } SetStatus(Strings.Bootstrapper_Status_ApplyingModifications); - // set executable flags for fullscreen optimizations - App.Logger.WriteLine(LOG_IDENT, "Checking executable flags..."); - using (RegistryKey appFlagsKey = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers")) - { - string flag = " DISABLEDXMAXIMIZEDWINDOWEDMODE"; - string? appFlags = (string?)appFlagsKey.GetValue(_playerLocation); - - if (App.Settings.Prop.DisableFullscreenOptimizations) - { - if (appFlags is null) - appFlagsKey.SetValue(_playerLocation, $"~{flag}"); - else if (!appFlags.Contains(flag)) - appFlagsKey.SetValue(_playerLocation, appFlags + flag); - } - else if (appFlags is not null && appFlags.Contains(flag)) - { - App.Logger.WriteLine(LOG_IDENT, $"Deleting flag '{flag.Trim()}'"); - - // if there's more than one space, there's more flags set we need to preserve - if (appFlags.Split(' ').Length > 2) - appFlagsKey.SetValue(_playerLocation, appFlags.Remove(appFlags.IndexOf(flag), flag.Length)); - else - appFlagsKey.DeleteValue(_playerLocation); - } - - // hmm, maybe make a unified handler for this? this is just lazily copy pasted from above - - flag = " RUNASADMIN"; - appFlags = (string?)appFlagsKey.GetValue(_playerLocation); - - if (appFlags is not null && appFlags.Contains(flag)) - { - App.Logger.WriteLine(LOG_IDENT, $"Deleting flag '{flag.Trim()}'"); - - // if there's more than one space, there's more flags set we need to preserve - if (appFlags.Split(' ').Length > 2) - appFlagsKey.SetValue(_playerLocation, appFlags.Remove(appFlags.IndexOf(flag), flag.Length)); - else - appFlagsKey.DeleteValue(_playerLocation); - } - } - // handle file mods App.Logger.WriteLine(LOG_IDENT, "Checking file mods..."); @@ -888,8 +777,7 @@ namespace Bloxstrap List modFolderFiles = new(); - if (!Directory.Exists(Paths.Modifications)) - Directory.CreateDirectory(Paths.Modifications); + Directory.CreateDirectory(Paths.Modifications); // check custom font mod // instead of replacing the fonts themselves, we'll just alter the font family manifests @@ -902,7 +790,9 @@ namespace Bloxstrap Directory.CreateDirectory(modFontFamiliesFolder); - foreach (string jsonFilePath in Directory.GetFiles(Path.Combine(_versionFolder, "content\\fonts\\families"))) + const string path = "rbxasset://fonts/CustomFont.ttf"; + + foreach (string jsonFilePath in Directory.GetFiles(Path.Combine(AppData.Directory, "content\\fonts\\families"))) { string jsonFilename = Path.GetFileName(jsonFilePath); string modFilepath = Path.Combine(modFontFamiliesFolder, jsonFilename); @@ -912,15 +802,24 @@ namespace Bloxstrap App.Logger.WriteLine(LOG_IDENT, $"Setting font for {jsonFilename}"); - FontFamily? fontFamilyData = JsonSerializer.Deserialize(File.ReadAllText(jsonFilePath)); + var fontFamilyData = JsonSerializer.Deserialize(File.ReadAllText(jsonFilePath)); if (fontFamilyData is null) continue; - foreach (FontFace fontFace in fontFamilyData.Faces) - fontFace.AssetId = "rbxasset://fonts/CustomFont.ttf"; + bool shouldWrite = false; - File.WriteAllText(modFilepath, JsonSerializer.Serialize(fontFamilyData, new JsonSerializerOptions { WriteIndented = true })); + foreach (var fontFace in fontFamilyData.Faces) + { + if (fontFace.AssetId != path) + { + fontFace.AssetId = path; + shouldWrite = true; + } + } + + if (shouldWrite) + File.WriteAllText(modFilepath, JsonSerializer.Serialize(fontFamilyData, new JsonSerializerOptions { WriteIndented = true })); } App.Logger.WriteLine(LOG_IDENT, "End font check"); @@ -951,7 +850,7 @@ namespace Bloxstrap modFolderFiles.Add(relativeFile); string fileModFolder = Path.Combine(Paths.Modifications, relativeFile); - string fileVersionFolder = Path.Combine(_versionFolder, relativeFile); + string fileVersionFolder = Path.Combine(AppData.Directory, relativeFile); if (File.Exists(fileVersionFolder) && MD5Hash.FromFile(fileModFolder) == MD5Hash.FromFile(fileVersionFolder)) { @@ -971,19 +870,23 @@ namespace Bloxstrap // the manifest is primarily here to keep track of what files have been // deleted from the modifications folder, so that we know when to restore the original files from the downloaded packages // now check for files that have been deleted from the mod folder according to the manifest + + var fileRestoreMap = new Dictionary>(); + foreach (string fileLocation in App.State.Prop.ModManifest) { if (modFolderFiles.Contains(fileLocation)) continue; - var package = AppData.PackageDirectoryMap.SingleOrDefault(x => x.Value != "" && fileLocation.StartsWith(x.Value)); + var packageMapEntry = AppData.PackageDirectoryMap.SingleOrDefault(x => !String.IsNullOrEmpty(x.Value) && fileLocation.StartsWith(x.Value)); + string packageName = packageMapEntry.Key; // package doesn't exist, likely mistakenly placed file - if (String.IsNullOrEmpty(package.Key)) + if (String.IsNullOrEmpty(packageName)) { App.Logger.WriteLine(LOG_IDENT, $"{fileLocation} was removed as a mod but does not belong to a package"); - string versionFileLocation = Path.Combine(_versionFolder, fileLocation); + string versionFileLocation = Path.Combine(AppData.Directory, fileLocation); if (File.Exists(versionFileLocation)) File.Delete(versionFileLocation); @@ -991,11 +894,25 @@ namespace Bloxstrap continue; } - // restore original file - string fileName = fileLocation.Substring(package.Value.Length); - await ExtractFileFromPackage(package.Key, fileName); + string fileName = fileLocation.Substring(packageMapEntry.Value.Length); - App.Logger.WriteLine(LOG_IDENT, $"{fileLocation} was removed as a mod, restored from {package.Key}"); + if (!fileRestoreMap.ContainsKey(packageName)) + fileRestoreMap[packageName] = new(); + + fileRestoreMap[packageName].Add(fileName); + + App.Logger.WriteLine(LOG_IDENT, $"{fileLocation} was removed as a mod, restoring from {packageName}"); + } + + foreach (var entry in fileRestoreMap) + { + var package = _versionPackageManifest.Find(x => x.Name == entry.Key); + + if (package is not null) + { + await DownloadPackage(package); + ExtractPackage(package, entry.Value); + } } App.State.Prop.ModManifest = modFolderFiles; @@ -1008,18 +925,17 @@ namespace Bloxstrap { string LOG_IDENT = $"Bootstrapper::DownloadPackage.{package.Name}"; - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; string packageUrl = RobloxDeployment.GetLocation($"/{_latestVersionGuid}-{package.Name}"); - string packageLocation = Path.Combine(Paths.Downloads, package.Signature); string robloxPackageLocation = Path.Combine(Paths.LocalAppData, "Roblox", "Downloads", package.Signature); - if (File.Exists(packageLocation)) + if (File.Exists(package.DownloadPath)) { - FileInfo file = new(packageLocation); + var file = new FileInfo(package.DownloadPath); - string calculatedMD5 = MD5Hash.FromFile(packageLocation); + string calculatedMD5 = MD5Hash.FromFile(package.DownloadPath); if (calculatedMD5 != package.Signature) { @@ -1042,7 +958,7 @@ namespace Bloxstrap // then we can just copy the one from there App.Logger.WriteLine(LOG_IDENT, $"Found existing copy at '{robloxPackageLocation}'! Copying to Downloads folder..."); - File.Copy(robloxPackageLocation, packageLocation); + File.Copy(robloxPackageLocation, package.DownloadPath); _totalDownloadedBytes += package.PackedSize; UpdateProgressBar(); @@ -1050,9 +966,12 @@ namespace Bloxstrap return; } - if (File.Exists(packageLocation)) + if (File.Exists(package.DownloadPath)) return; + // TODO: telemetry for this. chances are that this is completely unnecessary and that it can be removed. + // but, we need to ensure this doesn't work before we can do that. + const int maxTries = 5; App.Logger.WriteLine(LOG_IDENT, "Downloading..."); @@ -1061,7 +980,7 @@ namespace Bloxstrap for (int i = 1; i <= maxTries; i++) { - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) return; int totalBytesRead = 0; @@ -1070,11 +989,11 @@ namespace Bloxstrap { var response = await App.HttpClient.GetAsync(packageUrl, HttpCompletionOption.ResponseHeadersRead, _cancelTokenSource.Token); await using var stream = await response.Content.ReadAsStreamAsync(_cancelTokenSource.Token); - await using var fileStream = new FileStream(packageLocation, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Delete); + await using var fileStream = new FileStream(package.DownloadPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Delete); while (true) { - if (_cancelFired) + if (_cancelTokenSource.IsCancellationRequested) { stream.Close(); fileStream.Close(); @@ -1112,6 +1031,7 @@ namespace Bloxstrap Frontend.ShowConnectivityDialog( Strings.Dialog_Connectivity_UnableToDownload, String.Format(Strings.Dialog_Connectivity_UnableToDownloadReason, "[https://github.com/pizzaboxer/bloxstrap/wiki/Bloxstrap-is-unable-to-download-Roblox](https://github.com/pizzaboxer/bloxstrap/wiki/Bloxstrap-is-unable-to-download-Roblox)"), + MessageBoxImage.Error, ex ); @@ -1120,8 +1040,8 @@ namespace Bloxstrap else if (i >= maxTries) throw; - if (File.Exists(packageLocation)) - File.Delete(packageLocation); + if (File.Exists(package.DownloadPath)) + File.Delete(package.DownloadPath); _totalDownloadedBytes -= totalBytesRead; UpdateProgressBar(); @@ -1138,47 +1058,31 @@ namespace Bloxstrap } } - private Task ExtractPackage(Package package) + private void ExtractPackage(Package package, List? files = null) { const string LOG_IDENT = "Bootstrapper::ExtractPackage"; - if (_cancelFired) - return Task.CompletedTask; + string packageFolder = Path.Combine(AppData.Directory, AppData.PackageDirectoryMap[package.Name]); + string? fileFilter = null; - string packageLocation = Path.Combine(Paths.Downloads, package.Signature); - string packageFolder = Path.Combine(_versionFolder, AppData.PackageDirectoryMap[package.Name]); + // for sharpziplib, each file in the filter + if (files is not null) + { + var regexList = new List(); + + foreach (string file in files) + regexList.Add("^" + file.Replace("\\", "\\\\") + "$"); + + fileFilter = String.Join(';', regexList); + } App.Logger.WriteLine(LOG_IDENT, $"Extracting {package.Name}..."); var fastZip = new ICSharpCode.SharpZipLib.Zip.FastZip(); - fastZip.ExtractZip(packageLocation, packageFolder, null); + fastZip.ExtractZip(package.DownloadPath, packageFolder, fileFilter); App.Logger.WriteLine(LOG_IDENT, $"Finished extracting {package.Name}"); - - _packagesExtracted += 1; - - return Task.CompletedTask; } - - private async Task ExtractFileFromPackage(string packageName, string fileName) - { - Package? package = _versionPackageManifest.Find(x => x.Name == packageName); - - if (package is null) - return; - - await DownloadPackage(package); - - using ZipArchive archive = ZipFile.OpenRead(Path.Combine(Paths.Downloads, package.Signature)); - - ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(x => x.FullName == fileName); - - if (entry is null) - return; - - string extractionPath = Path.Combine(_versionFolder, AppData.PackageDirectoryMap[package.Name], entry.FullName); - entry.ExtractToFile(extractionPath, true); - } -#endregion + #endregion } } diff --git a/Bloxstrap/Enums/FlagPresets/RenderingMode.cs b/Bloxstrap/Enums/FlagPresets/RenderingMode.cs index 769a430..082d301 100644 --- a/Bloxstrap/Enums/FlagPresets/RenderingMode.cs +++ b/Bloxstrap/Enums/FlagPresets/RenderingMode.cs @@ -4,9 +4,7 @@ { [EnumName(FromTranslation = "Common.Automatic")] Default, - // Vulkan, D3D11, D3D10, - // OpenGL } } diff --git a/Bloxstrap/Exceptions/HttpResponseException.cs b/Bloxstrap/Exceptions/HttpResponseException.cs deleted file mode 100644 index 08404b0..0000000 --- a/Bloxstrap/Exceptions/HttpResponseException.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bloxstrap.Exceptions -{ - internal class HttpResponseException : Exception - { - public HttpResponseMessage ResponseMessage { get; } - - public HttpResponseException(HttpResponseMessage responseMessage) - : base($"Could not connect to {responseMessage.RequestMessage!.RequestUri} because it returned HTTP {(int)responseMessage.StatusCode} ({responseMessage.ReasonPhrase})") - { - ResponseMessage = responseMessage; - } - } -} diff --git a/Bloxstrap/Exceptions/InvalidHTTPResponseException.cs b/Bloxstrap/Exceptions/InvalidHTTPResponseException.cs new file mode 100644 index 0000000..f6b45a0 --- /dev/null +++ b/Bloxstrap/Exceptions/InvalidHTTPResponseException.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Exceptions +{ + internal class InvalidHTTPResponseException : Exception + { + public InvalidHTTPResponseException(string message) : base(message) { } + } +} diff --git a/Bloxstrap/Extensions/IconEx.cs b/Bloxstrap/Extensions/IconEx.cs index 2cd48af..516899d 100644 --- a/Bloxstrap/Extensions/IconEx.cs +++ b/Bloxstrap/Extensions/IconEx.cs @@ -8,11 +8,28 @@ namespace Bloxstrap.Extensions { public static Icon GetSized(this Icon icon, int width, int height) => new(icon, new Size(width, height)); - public static ImageSource GetImageSource(this Icon icon) + public static ImageSource GetImageSource(this Icon icon, bool handleException = true) { using MemoryStream stream = new(); icon.Save(stream); - return BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + + if (handleException) + { + try + { + return BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + } + catch (Exception ex) + { + App.Logger.WriteException("IconEx::GetImageSource", ex); + Frontend.ShowMessageBox(String.Format(Strings.Dialog_IconLoadFailed, ex.Message)); + return BootstrapperIcon.IconBloxstrap.GetIcon().GetImageSource(false); + } + } + else + { + return BitmapFrame.Create(stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + } } } } diff --git a/Bloxstrap/Extensions/ServerTypeEx.cs b/Bloxstrap/Extensions/ServerTypeEx.cs index 88956d0..2b65d8b 100644 --- a/Bloxstrap/Extensions/ServerTypeEx.cs +++ b/Bloxstrap/Extensions/ServerTypeEx.cs @@ -4,9 +4,9 @@ { public static string ToTranslatedString(this ServerType value) => value switch { - ServerType.Public => Resources.Strings.Enums_ServerType_Public, - ServerType.Private => Resources.Strings.Enums_ServerType_Private, - ServerType.Reserved => Resources.Strings.Enums_ServerType_Reserved, + ServerType.Public => Strings.Enums_ServerType_Public, + ServerType.Private => Strings.Enums_ServerType_Private, + ServerType.Reserved => Strings.Enums_ServerType_Reserved, _ => "?" }; } diff --git a/Bloxstrap/Extensions/ThemeEx.cs b/Bloxstrap/Extensions/ThemeEx.cs index 30da539..f5fc70c 100644 --- a/Bloxstrap/Extensions/ThemeEx.cs +++ b/Bloxstrap/Extensions/ThemeEx.cs @@ -9,15 +9,10 @@ namespace Bloxstrap.Extensions if (dialogTheme != Theme.Default) return dialogTheme; - RegistryKey? key = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"); + using var key = Registry.CurrentUser.OpenSubKey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"); - if (key is not null) - { - var value = key.GetValue("AppsUseLightTheme"); - - if (value is not null && (int)value == 0) - return Theme.Dark; - } + if (key?.GetValue("AppsUseLightTheme") is int value && value == 0) + return Theme.Dark; return Theme.Light; } diff --git a/Bloxstrap/FastFlagManager.cs b/Bloxstrap/FastFlagManager.cs index 492bb68..a444b80 100644 --- a/Bloxstrap/FastFlagManager.cs +++ b/Bloxstrap/FastFlagManager.cs @@ -4,8 +4,14 @@ namespace Bloxstrap { public class FastFlagManager : JsonManager> { + public override string ClassName => nameof(FastFlagManager); + + public override string LOG_IDENT_CLASS => ClassName; + public override string FileLocation => Path.Combine(Paths.Modifications, "ClientSettings\\ClientAppSettings.json"); + public bool Changed => !OriginalProp.SequenceEqual(Prop); + public static IReadOnlyDictionary PresetFlags = new Dictionary { { "Network.Log", "FLogNetwork" }, @@ -28,9 +34,6 @@ namespace Bloxstrap { "Rendering.Mode.D3D11", "FFlagDebugGraphicsPreferD3D11" }, { "Rendering.Mode.D3D10", "FFlagDebugGraphicsPreferD3D11FL10" }, - { "Rendering.Mode.Vulkan", "FFlagDebugGraphicsPreferVulkan" }, - { "Rendering.Mode.Vulkan.Fix", "FFlagRenderVulkanFixMinimizeWindow" }, - { "Rendering.Mode.OpenGL", "FFlagDebugGraphicsPreferOpenGL" }, { "Rendering.Lighting.Voxel", "DFFlagDebugRenderForceTechnologyVoxel" }, { "Rendering.Lighting.ShadowMap", "FFlagDebugForceFutureIsBrightPhase2" }, @@ -46,10 +49,9 @@ namespace Bloxstrap { "UI.FlagState", "FStringDebugShowFlagState" }, #endif - { "UI.Menu.GraphicsSlider", "FFlagFixGraphicsQuality" }, { "UI.FullscreenTitlebarDelay", "FIntFullscreenTitleBarTriggerDelayMillis" }, - { "UI.Menu.Style.DisableV2", "FFlagDisableNewIGMinDUA" }, + { "UI.Menu.Style.V2Rollout", "FIntNewInGameMenuPercentRollout3" }, { "UI.Menu.Style.EnableV4.1", "FFlagEnableInGameMenuControls" }, { "UI.Menu.Style.EnableV4.2", "FFlagEnableInGameMenuModernization" }, { "UI.Menu.Style.EnableV4Chrome", "FFlagEnableInGameMenuChrome" }, @@ -59,14 +61,11 @@ namespace Bloxstrap { "UI.Menu.Style.ABTest.3", "FFlagEnableInGameMenuChromeABTest3" } }; - // only one missing here is Metal because lol public static IReadOnlyDictionary RenderingModes => new Dictionary { { RenderingMode.Default, "None" }, - // { RenderingMode.Vulkan, "Vulkan" }, { RenderingMode.D3D11, "D3D11" }, { RenderingMode.D3D10, "D3D10" }, - // { RenderingMode.OpenGL, "OpenGL" } }; public static IReadOnlyDictionary LightingModes => new Dictionary @@ -102,7 +101,7 @@ namespace Bloxstrap InGameMenuVersion.Default, new Dictionary { - { "DisableV2", null }, + { "V2Rollout", null }, { "EnableV4", null }, { "EnableV4Chrome", null }, { "ABTest", null } @@ -113,7 +112,7 @@ namespace Bloxstrap InGameMenuVersion.V1, new Dictionary { - { "DisableV2", "True" }, + { "V2Rollout", "0" }, { "EnableV4", "False" }, { "EnableV4Chrome", "False" }, { "ABTest", "False" } @@ -124,7 +123,7 @@ namespace Bloxstrap InGameMenuVersion.V2, new Dictionary { - { "DisableV2", "False" }, + { "V2Rollout", "100" }, { "EnableV4", "False" }, { "EnableV4Chrome", "False" }, { "ABTest", "False" } @@ -135,7 +134,7 @@ namespace Bloxstrap InGameMenuVersion.V4, new Dictionary { - { "DisableV2", "True" }, + { "V2Rollout", "0" }, { "EnableV4", "True" }, { "EnableV4Chrome", "False" }, { "ABTest", "False" } @@ -146,7 +145,7 @@ namespace Bloxstrap InGameMenuVersion.V4Chrome, new Dictionary { - { "DisableV2", "True" }, + { "V2Rollout", "0" }, { "EnableV4", "True" }, { "EnableV4Chrome", "True" }, { "ABTest", "False" } @@ -228,14 +227,6 @@ namespace Bloxstrap return mapping.First().Key; } - public void CheckManualFullscreenPreset() - { - if (GetPreset("Rendering.Mode.Vulkan") == "True" || GetPreset("Rendering.Mode.OpenGL") == "True") - SetPreset("Rendering.ManualFullscreen", null); - else - SetPreset("Rendering.ManualFullscreen", "False"); - } - public override void Save() { // convert all flag values to strings before saving @@ -244,21 +235,21 @@ namespace Bloxstrap Prop[pair.Key] = pair.Value.ToString()!; base.Save(); + + // clone the dictionary + OriginalProp = new(Prop); } - public override void Load() + public override void Load(bool alertFailure = true) { - base.Load(); + base.Load(alertFailure); - CheckManualFullscreenPreset(); + // clone the dictionary + OriginalProp = new(Prop); // TODO - remove when activity tracking has been revamped if (GetPreset("Network.Log") != "7") SetPreset("Network.Log", "7"); - - string? val = GetPreset("UI.Menu.Style.EnableV4.1"); - if (GetPreset("UI.Menu.Style.EnableV4.2") != val) - SetPreset("UI.Menu.Style.EnableV4.2", val); } } } diff --git a/Bloxstrap/GlobalCache.cs b/Bloxstrap/GlobalCache.cs new file mode 100644 index 0000000..6977224 --- /dev/null +++ b/Bloxstrap/GlobalCache.cs @@ -0,0 +1,7 @@ +namespace Bloxstrap +{ + public static class GlobalCache + { + public static readonly Dictionary ServerLocation = new(); + } +} diff --git a/Bloxstrap/GlobalUsings.cs b/Bloxstrap/GlobalUsings.cs index 9c10516..c04aec2 100644 --- a/Bloxstrap/GlobalUsings.cs +++ b/Bloxstrap/GlobalUsings.cs @@ -3,7 +3,6 @@ global using System.Collections.Generic; global using System.Diagnostics; global using System.Globalization; global using System.IO; -global using System.IO.Compression; global using System.Text; global using System.Text.Json; global using System.Text.Json.Serialization; @@ -18,10 +17,16 @@ global using Bloxstrap.Enums; global using Bloxstrap.Exceptions; global using Bloxstrap.Extensions; global using Bloxstrap.Models; +global using Bloxstrap.Models.APIs.Config; +global using Bloxstrap.Models.APIs.GitHub; +global using Bloxstrap.Models.APIs.Roblox; global using Bloxstrap.Models.Attributes; global using Bloxstrap.Models.BloxstrapRPC; -global using Bloxstrap.Models.RobloxApi; +global using Bloxstrap.Models.Entities; global using Bloxstrap.Models.Manifest; +global using Bloxstrap.Models.Persistable; +global using Bloxstrap.Models.SettingTasks; +global using Bloxstrap.Models.SettingTasks.Base; global using Bloxstrap.Resources; global using Bloxstrap.UI; global using Bloxstrap.Utility; \ No newline at end of file diff --git a/Bloxstrap/Installer.cs b/Bloxstrap/Installer.cs index ccb458c..9e2f6f2 100644 --- a/Bloxstrap/Installer.cs +++ b/Bloxstrap/Installer.cs @@ -1,9 +1,4 @@ -using System.DirectoryServices; -using System.Reflection; -using System.Reflection.Metadata.Ecma335; -using System.Windows; -using System.Windows.Media.Animation; -using Bloxstrap.Resources; +using System.Windows; using Microsoft.Win32; namespace Bloxstrap @@ -16,6 +11,8 @@ namespace Bloxstrap public string InstallLocation = Path.Combine(Paths.LocalAppData, "Bloxstrap"); + public bool ExistingDataPresent => File.Exists(Path.Combine(InstallLocation, "Settings.json")); + public bool CreateDesktopShortcuts = true; public bool CreateStartMenuShortcuts = true; @@ -26,6 +23,10 @@ namespace Bloxstrap public void DoInstall() { + const string LOG_IDENT = "Installer::DoInstall"; + + App.Logger.WriteLine(LOG_IDENT, "Beginning installation"); + // should've been created earlier from the write test anyway Directory.CreateDirectory(InstallLocation); @@ -34,7 +35,19 @@ namespace Bloxstrap if (!IsImplicitInstall) { Filesystem.AssertReadOnly(Paths.Application); - File.Copy(Paths.Process, Paths.Application, true); + + try + { + File.Copy(Paths.Process, Paths.Application, true); + } + catch (Exception ex) + { + App.Logger.WriteLine(LOG_IDENT, "Could not overwrite executable"); + App.Logger.WriteException(LOG_IDENT, ex); + + Frontend.ShowMessageBox(Strings.Installer_Install_CannotOverwrite, MessageBoxImage.Error); + App.Terminate(ErrorCode.ERROR_INSTALL_FAILURE); + } } // TODO: registry access checks, i'll need to look back on issues to see what the error looks like @@ -50,21 +63,19 @@ namespace Bloxstrap uninstallKey.SetValue("InstallLocation", Paths.Base); uninstallKey.SetValue("NoRepair", 1); - uninstallKey.SetValue("Publisher", "pizzaboxer"); + uninstallKey.SetValue("Publisher", App.ProjectOwner); uninstallKey.SetValue("ModifyPath", $"\"{Paths.Application}\" -settings"); uninstallKey.SetValue("QuietUninstallString", $"\"{Paths.Application}\" -uninstall -quiet"); uninstallKey.SetValue("UninstallString", $"\"{Paths.Application}\" -uninstall"); - uninstallKey.SetValue("URLInfoAbout", $"https://github.com/{App.ProjectRepository}"); - uninstallKey.SetValue("URLUpdateInfo", $"https://github.com/{App.ProjectRepository}/releases/latest"); + uninstallKey.SetValue("HelpLink", App.ProjectHelpLink); + uninstallKey.SetValue("URLInfoAbout", App.ProjectSupportLink); + uninstallKey.SetValue("URLUpdateInfo", App.ProjectDownloadLink); } // only register player, for the scenario where the user installs bloxstrap, closes it, // and then launches from the website expecting it to work // studio can be implicitly registered when it's first launched manually - ProtocolHandler.Register("roblox", "Roblox", Paths.Application, "-player \"%1\""); - ProtocolHandler.Register("roblox-player", "Roblox", Paths.Application, "-player \"%1\""); - - // TODO: implicit installation needs to reregister studio + WindowsRegistry.RegisterPlayer(); if (CreateDesktopShortcuts) Shortcut.Create(Paths.Application, "", DesktopShortcut); @@ -73,9 +84,14 @@ namespace Bloxstrap Shortcut.Create(Paths.Application, "", StartMenuShortcut); // existing configuration persisting from an earlier install - App.Settings.Load(); - App.State.Load(); - App.FastFlags.Load(); + App.Settings.Load(false); + App.State.Load(false); + App.FastFlags.Load(false); + + if (!String.IsNullOrEmpty(App.State.Prop.Studio.VersionGuid)) + WindowsRegistry.RegisterStudio(); + + App.Logger.WriteLine(LOG_IDENT, "Installation finished"); } private bool ValidateLocation() @@ -88,6 +104,10 @@ namespace Bloxstrap if (InstallLocation.StartsWith("\\\\")) return false; + if (InstallLocation.StartsWith(Path.GetTempPath(), StringComparison.InvariantCultureIgnoreCase) + || InstallLocation.Contains("\\Temp\\", StringComparison.InvariantCultureIgnoreCase)) + return false; + // prevent from installing to a onedrive folder if (InstallLocation.Contains("OneDrive", StringComparison.InvariantCultureIgnoreCase)) return false; @@ -158,11 +178,12 @@ namespace Bloxstrap const string LOG_IDENT = "Installer::DoUninstall"; var processes = new List(); - processes.AddRange(Process.GetProcessesByName(App.RobloxPlayerAppName)); + + if (!String.IsNullOrEmpty(App.State.Prop.Player.VersionGuid)) + processes.AddRange(Process.GetProcessesByName(App.RobloxPlayerAppName)); -#if STUDIO_FEATURES - processes.AddRange(Process.GetProcessesByName(App.RobloxStudioAppName)); -#endif + if (!String.IsNullOrEmpty(App.State.Prop.Studio.VersionGuid)) + processes.AddRange(Process.GetProcessesByName(App.RobloxStudioAppName)); // prompt to shutdown roblox if its currently running if (processes.Any()) @@ -175,7 +196,10 @@ namespace Bloxstrap ); if (result != MessageBoxResult.OK) + { App.Terminate(ErrorCode.ERROR_CANCELLED); + return; + } try { @@ -203,44 +227,38 @@ namespace Bloxstrap { playerStillInstalled = false; - ProtocolHandler.Unregister("roblox"); - ProtocolHandler.Unregister("roblox-player"); + WindowsRegistry.Unregister("roblox"); + WindowsRegistry.Unregister("roblox-player"); } else { - // revert launch uri handler to stock bootstrapper string playerPath = Path.Combine((string)playerFolder, "RobloxPlayerBeta.exe"); - ProtocolHandler.Register("roblox", "Roblox", playerPath); - ProtocolHandler.Register("roblox-player", "Roblox", playerPath); + WindowsRegistry.RegisterPlayer(playerPath, "%1"); } - using RegistryKey? studioBootstrapperKey = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\roblox-studio"); - if (studioBootstrapperKey is null) + using var studioKey = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\roblox-studio"); + var studioFolder = studioKey?.GetValue("InstallLocation"); + + if (studioKey is null || studioFolder is not string) { studioStillInstalled = false; -#if STUDIO_FEATURES - ProtocolHandler.Unregister("roblox-studio"); - ProtocolHandler.Unregister("roblox-studio-auth"); + WindowsRegistry.Unregister("roblox-studio"); + WindowsRegistry.Unregister("roblox-studio-auth"); - ProtocolHandler.Unregister("Roblox.Place"); - ProtocolHandler.Unregister(".rbxl"); - ProtocolHandler.Unregister(".rbxlx"); -#endif + WindowsRegistry.Unregister("Roblox.Place"); + WindowsRegistry.Unregister(".rbxl"); + WindowsRegistry.Unregister(".rbxlx"); } -#if STUDIO_FEATURES else { - string studioLocation = (string?)studioBootstrapperKey.GetValue("InstallLocation") + "RobloxStudioBeta.exe"; // points to studio exe instead of bootstrapper - ProtocolHandler.Register("roblox-studio", "Roblox", studioLocation); - ProtocolHandler.Register("roblox-studio-auth", "Roblox", studioLocation); + string studioPath = Path.Combine((string)studioFolder, "RobloxStudioBeta.exe"); + string studioLauncherPath = Path.Combine((string)studioFolder, "RobloxStudioLauncherBeta.exe"); - ProtocolHandler.RegisterRobloxPlace(studioLocation); + WindowsRegistry.RegisterStudioProtocol(studioPath, "%1"); + WindowsRegistry.RegisterStudioFileClass(studioPath, "-ide \"%1\""); } -#endif - - var cleanupSequence = new List { @@ -257,8 +275,10 @@ namespace Bloxstrap () => File.Delete(StartMenuShortcut), - () => Directory.Delete(Paths.Versions, true), () => Directory.Delete(Paths.Downloads, true), + () => Directory.Delete(Paths.Roblox, true), + + () => File.Delete(App.State.FileLocation) }; if (!keepData) @@ -268,8 +288,7 @@ namespace Bloxstrap () => Directory.Delete(Paths.Modifications, true), () => Directory.Delete(Paths.Logs, true), - () => File.Delete(App.Settings.FileLocation), - () => File.Delete(App.State.FileLocation), // TODO: maybe this should always be deleted? not sure yet + () => File.Delete(App.Settings.FileLocation) }); } @@ -331,8 +350,9 @@ namespace Bloxstrap return; // 2.0.0 downloads updates to /Updates so lol - // TODO: 2.8.0 will download them to /Bloxstrap/Updates - bool isAutoUpgrade = Paths.Process.StartsWith(Path.Combine(Paths.Base, "Updates")) || Paths.Process.StartsWith(Path.Combine(Paths.LocalAppData, "Temp")); + bool isAutoUpgrade = App.LaunchSettings.UpgradeFlag.Active + || Paths.Process.StartsWith(Path.Combine(Paths.Base, "Updates")) + || Paths.Process.StartsWith(Paths.Temp); var existingVer = FileVersionInfo.GetVersionInfo(Paths.Application).ProductVersion; var currentVer = FileVersionInfo.GetVersionInfo(Paths.Process).ProductVersion; @@ -340,8 +360,20 @@ namespace Bloxstrap if (MD5Hash.FromFile(Paths.Process) == MD5Hash.FromFile(Paths.Application)) return; + if (currentVer is not null && existingVer is not null && Utilities.CompareVersions(currentVer, existingVer) == VersionComparison.LessThan) + { + var result = Frontend.ShowMessageBox( + Strings.InstallChecker_VersionLessThanInstalled, + MessageBoxImage.Question, + MessageBoxButton.YesNo + ); + + if (result != MessageBoxResult.Yes) + return; + } + // silently upgrade version if the command line flag is set or if we're launching from an auto update - if (!App.LaunchSettings.UpgradeFlag.Active && !isAutoUpgrade) + if (!isAutoUpgrade) { var result = Frontend.ShowMessageBox( Strings.InstallChecker_VersionDifferentThanInstalled, @@ -353,47 +385,94 @@ namespace Bloxstrap return; } + App.Logger.WriteLine(LOG_IDENT, "Doing upgrade"); + Filesystem.AssertReadOnly(Paths.Application); - // TODO: make this use a mutex somehow - // yes, this is EXTREMELY hacky, but the updater process that launched the - // new version may still be open and so we have to wait for it to close - int attempts = 0; - while (attempts < 10) + using (var ipl = new InterProcessLock("AutoUpdater", TimeSpan.FromSeconds(5))) { - attempts++; + if (!ipl.IsAcquired) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to update! (Could not obtain singleton mutex)"); + return; + } + } + // prior to 2.8.0, auto-updating was handled with this... bruteforce method + // now it's handled with the system mutex you see above, but we need to keep this logic for <2.8.0 versions + for (int i = 1; i <= 10; i++) + { try { - File.Delete(Paths.Application); + File.Copy(Paths.Process, Paths.Application, true); break; } - catch (Exception) + catch (Exception ex) { - if (attempts == 1) + if (i == 1) + { App.Logger.WriteLine(LOG_IDENT, "Waiting for write permissions to update version"); + } + else if (i == 10) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to update! (Could not get write permissions after 10 tries/5 seconds)"); + App.Logger.WriteException(LOG_IDENT, ex); + return; + } Thread.Sleep(500); } } - if (attempts == 10) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to update! (Could not get write permissions after 5 seconds)"); - return; - } - - File.Copy(Paths.Process, Paths.Application); - using (var uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey)) { uninstallKey.SetValue("DisplayVersion", App.Version); + + uninstallKey.SetValue("Publisher", App.ProjectOwner); + uninstallKey.SetValue("HelpLink", App.ProjectHelpLink); + uninstallKey.SetValue("URLInfoAbout", App.ProjectSupportLink); + uninstallKey.SetValue("URLUpdateInfo", App.ProjectDownloadLink); } // update migrations if (existingVer is not null) { + if (Utilities.CompareVersions(existingVer, "2.2.0") == VersionComparison.LessThan) + { + string path = Path.Combine(Paths.Integrations, "rbxfpsunlocker"); + + try + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + catch (Exception ex) + { + App.Logger.WriteException(LOG_IDENT, ex); + } + } + + if (Utilities.CompareVersions(existingVer, "2.3.0") == VersionComparison.LessThan) + { + string injectorLocation = Path.Combine(Paths.Modifications, "dxgi.dll"); + string configLocation = Path.Combine(Paths.Modifications, "ReShade.ini"); + + if (File.Exists(injectorLocation)) + { + Frontend.ShowMessageBox( + Strings.Bootstrapper_HyperionUpdateInfo, + MessageBoxImage.Warning + ); + + File.Delete(injectorLocation); + } + + if (File.Exists(configLocation)) + File.Delete(configLocation); + } + + if (Utilities.CompareVersions(existingVer, "2.5.0") == VersionComparison.LessThan) { App.FastFlags.SetValue("DFFlagDisableDPIScale", null); @@ -408,6 +487,13 @@ namespace Bloxstrap App.FastFlags.SetPreset("UI.Menu.Style.ABTest", false); } + if (Utilities.CompareVersions(existingVer, "2.5.3") == VersionComparison.LessThan) + { + string? val = App.FastFlags.GetPreset("UI.Menu.Style.EnableV4.1"); + if (App.FastFlags.GetPreset("UI.Menu.Style.EnableV4.2") != val) + App.FastFlags.SetPreset("UI.Menu.Style.EnableV4.2", val); + } + if (Utilities.CompareVersions(existingVer, "2.6.0") == VersionComparison.LessThan) { if (App.Settings.Prop.UseDisableAppPatch) @@ -429,9 +515,7 @@ namespace Bloxstrap _ = int.TryParse(App.FastFlags.GetPreset("Rendering.Framerate"), out int x); if (x == 0) - { App.FastFlags.SetPreset("Rendering.Framerate", null); - } } if (Utilities.CompareVersions(existingVer, "2.8.0") == VersionComparison.LessThan) @@ -440,7 +524,7 @@ namespace Bloxstrap string oldStartPath = Path.Combine(Paths.WindowsStartMenu, "Bloxstrap"); if (File.Exists(oldDesktopPath)) - File.Move(oldDesktopPath, DesktopShortcut); + File.Move(oldDesktopPath, DesktopShortcut, true); if (Directory.Exists(oldStartPath)) { @@ -458,14 +542,32 @@ namespace Bloxstrap Registry.CurrentUser.DeleteSubKeyTree("Software\\Bloxstrap", false); - ProtocolHandler.Register("roblox", "Roblox", Paths.Application, "-player \"%1\""); - ProtocolHandler.Register("roblox-player", "Roblox", Paths.Application, "-player \"%1\""); + WindowsRegistry.RegisterPlayer(); + + string? oldV2Val = App.FastFlags.GetValue("FFlagDisableNewIGMinDUA"); + + if (oldV2Val is not null) + { + if (oldV2Val == "True") + App.FastFlags.SetPreset("UI.Menu.Style.V2Rollout", "0"); + else + App.FastFlags.SetPreset("UI.Menu.Style.V2Rollout", "100"); + + App.FastFlags.SetValue("FFlagDisableNewIGMinDUA", null); + } + + App.FastFlags.SetValue("FFlagFixGraphicsQuality", null); + + Directory.Delete(Path.Combine(Paths.Base, "Versions")); } App.Settings.Save(); App.FastFlags.Save(); } + if (currentVer is null) + return; + if (isAutoUpgrade) { Utilities.ShellExecute($"https://github.com/{App.ProjectRepository}/wiki/Release-notes-for-Bloxstrap-v{currentVer}"); diff --git a/Bloxstrap/Integrations/ActivityWatcher.cs b/Bloxstrap/Integrations/ActivityWatcher.cs index e15cd2d..b1d13d4 100644 --- a/Bloxstrap/Integrations/ActivityWatcher.cs +++ b/Bloxstrap/Integrations/ActivityWatcher.cs @@ -2,59 +2,58 @@ { public class ActivityWatcher : IDisposable { - // i'm thinking the functionality for parsing roblox logs could be broadened for more features than just rich presence, - // like checking the ping and region of the current connected server. maybe that's something to add? - private const string GameJoiningEntry = "[FLog::Output] ! Joining game"; - private const string GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer"; - private const string GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer"; - private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = "; - private const string GameJoinedEntry = "[FLog::Network] serverId:"; - private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:"; - private const string GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport"; - private const string GameMessageEntry = "[FLog::Output] [BloxstrapRPC]"; - private const string GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal"; + private const string GameMessageEntry = "[FLog::Output] [BloxstrapRPC]"; + private const string GameJoiningEntry = "[FLog::Output] ! Joining game"; - private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)"; - private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+"; - private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+"; - private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)"; + // these entries are technically volatile! + // they only get printed depending on their configured FLog level, which could change at any time + // while levels being changed is fairly rare, please limit the number of varying number of FLog types you have to use, if possible + + private const string GameJoiningPrivateServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::joinGamePostPrivateServer"; + private const string GameJoiningReservedServerEntry = "[FLog::GameJoinUtil] GameJoinUtil::initiateTeleportToReservedServer"; + private const string GameJoiningUniverseEntry = "[FLog::GameJoinLoadTime] Report game_join_loadtime:"; + private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = "; + private const string GameJoinedEntry = "[FLog::Network] serverId:"; + private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:"; + private const string GameTeleportingEntry = "[FLog::SingleSurfaceApp] initiateTeleport"; + private const string GameLeavingEntry = "[FLog::SingleSurfaceApp] leaveUGCGameInternal"; + + private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)"; + private const string GameJoiningPrivateServerPattern = @"""accessCode"":""([0-9a-f\-]{36})"""; + private const string GameJoiningUniversePattern = @"universeid:([0-9]+)"; + private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+"; + private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+"; + private const string GameMessageEntryPattern = @"\[BloxstrapRPC\] (.*)"; - private int _gameClientPid; private int _logEntriesRead = 0; private bool _teleportMarker = false; private bool _reservedTeleportMarker = false; - + public event EventHandler? OnLogEntry; public event EventHandler? OnGameJoin; public event EventHandler? OnGameLeave; + public event EventHandler? OnLogOpen; public event EventHandler? OnAppClose; public event EventHandler? OnRPCMessage; - private readonly Dictionary GeolocationCache = new(); private DateTime LastRPCRequest; public string LogLocation = null!; - // these are values to use assuming the player isn't currently in a game - // hmm... do i move this to a model? - public bool ActivityInGame = false; - public long ActivityPlaceId = 0; - public string ActivityJobId = ""; - public string ActivityMachineAddress = ""; - public bool ActivityMachineUDMUX = false; - public bool ActivityIsTeleport = false; - public ServerType ActivityServerType = ServerType.Public; + public bool InGame = false; + + public ActivityData Data { get; private set; } = new(); + + /// + /// Ordered by newest to oldest + /// + public List History = new(); public bool IsDisposed = false; - public ActivityWatcher(int gameClientPid) + public async void Start() { - _gameClientPid = gameClientPid; - } - - public async void StartWatcher() - { - const string LOG_IDENT = "ActivityWatcher::StartWatcher"; + const string LOG_IDENT = "ActivityWatcher::Start"; // okay, here's the process: // @@ -84,7 +83,7 @@ { logFileInfo = new DirectoryInfo(logDirectory) .GetFiles() - .Where(x => x.CreationTime <= DateTime.Now) + .Where(x => x.Name.Contains("Player", StringComparison.OrdinalIgnoreCase) && x.CreationTime <= DateTime.Now) .OrderByDescending(x => x.CreationTime) .First(); @@ -95,12 +94,14 @@ await Task.Delay(1000); } + OnLogOpen?.Invoke(this, EventArgs.Empty); + LogLocation = logFileInfo.FullName; FileStream logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); App.Logger.WriteLine(LOG_IDENT, $"Opened {LogLocation}"); - AutoResetEvent logUpdatedEvent = new(false); - FileSystemWatcher logWatcher = new() + var logUpdatedEvent = new AutoResetEvent(false); + var logWatcher = new FileSystemWatcher() { Path = logDirectory, Filter = Path.GetFileName(logFileInfo.FullName), @@ -108,7 +109,7 @@ }; logWatcher.Changed += (s, e) => logUpdatedEvent.Set(); - using StreamReader sr = new(logFileStream); + using var sr = new StreamReader(logFileStream); while (!IsDisposed) { @@ -117,13 +118,13 @@ if (log is null) logUpdatedEvent.WaitOne(250); else - ExamineLogEntry(log); + ReadLogEntry(log); } } - private void ExamineLogEntry(string entry) + private void ReadLogEntry(string entry) { - const string LOG_IDENT = "ActivityWatcher::ExamineLogEntry"; + const string LOG_IDENT = "ActivityWatcher::ReadLogEntry"; OnLogEntry?.Invoke(this, entry); @@ -137,14 +138,38 @@ App.Logger.WriteLine(LOG_IDENT, $"Read {_logEntriesRead} log entries"); if (entry.Contains(GameLeavingEntry)) - OnAppClose?.Invoke(this, new EventArgs()); - - if (!ActivityInGame && ActivityPlaceId == 0) { + App.Logger.WriteLine(LOG_IDENT, "User is back into the desktop app"); + + OnAppClose?.Invoke(this, EventArgs.Empty); + + if (Data.PlaceId != 0 && !InGame) + { + App.Logger.WriteLine(LOG_IDENT, "User appears to be leaving from a cancelled/errored join"); + Data = new(); + } + } + + if (!InGame && Data.PlaceId == 0) + { + // We are not in a game, nor are in the process of joining one + if (entry.Contains(GameJoiningPrivateServerEntry)) { // we only expect to be joining a private server if we're not already in a game - ActivityServerType = ServerType.Private; + + Data.ServerType = ServerType.Private; + + var match = Regex.Match(entry, GameJoiningPrivateServerPattern); + + if (match.Groups.Count != 2) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join private server entry"); + App.Logger.WriteLine(LOG_IDENT, entry); + return; + } + + Data.AccessCode = match.Groups[1].Value; } else if (entry.Contains(GameJoiningEntry)) { @@ -157,80 +182,111 @@ return; } - ActivityInGame = false; - ActivityPlaceId = long.Parse(match.Groups[2].Value); - ActivityJobId = match.Groups[1].Value; - ActivityMachineAddress = match.Groups[3].Value; + InGame = false; + Data.PlaceId = long.Parse(match.Groups[2].Value); + Data.JobId = match.Groups[1].Value; + Data.MachineAddress = match.Groups[3].Value; + + if (App.Settings.Prop.ShowServerDetails && Data.MachineAddressValid) + _ = Data.QueryServerLocation(); if (_teleportMarker) { - ActivityIsTeleport = true; + Data.IsTeleport = true; _teleportMarker = false; } if (_reservedTeleportMarker) { - ActivityServerType = ServerType.Reserved; + Data.ServerType = ServerType.Reserved; _reservedTeleportMarker = false; } - App.Logger.WriteLine(LOG_IDENT, $"Joining Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + App.Logger.WriteLine(LOG_IDENT, $"Joining Game ({Data})"); } } - else if (!ActivityInGame && ActivityPlaceId != 0) + else if (!InGame && Data.PlaceId != 0) { - if (entry.Contains(GameJoiningUDMUXEntry)) - { - Match match = Regex.Match(entry, GameJoiningUDMUXPattern); + // We are not confirmed to be in a game, but we are in the process of joining one - if (match.Groups.Count != 3 || match.Groups[2].Value != ActivityMachineAddress) + if (entry.Contains(GameJoiningUniverseEntry)) + { + var match = Regex.Match(entry, GameJoiningUniversePattern); + + if (match.Groups.Count != 2) { - App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game join UDMUX entry"); + App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join universe entry"); App.Logger.WriteLine(LOG_IDENT, entry); return; } - ActivityMachineAddress = match.Groups[1].Value; - ActivityMachineUDMUX = true; + Data.UniverseId = long.Parse(match.Groups[1].Value); - App.Logger.WriteLine(LOG_IDENT, $"Server is UDMUX protected ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + if (History.Any()) + { + var lastActivity = History.First(); + + if (Data.UniverseId == lastActivity.UniverseId && Data.IsTeleport) + Data.RootActivity = lastActivity.RootActivity ?? lastActivity; + } + } + else if (entry.Contains(GameJoiningUDMUXEntry)) + { + var match = Regex.Match(entry, GameJoiningUDMUXPattern); + + if (match.Groups.Count != 3 || match.Groups[2].Value != Data.MachineAddress) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to assert format for game join UDMUX entry"); + App.Logger.WriteLine(LOG_IDENT, entry); + return; + } + + Data.MachineAddress = match.Groups[1].Value; + + if (App.Settings.Prop.ShowServerDetails) + _ = Data.QueryServerLocation(); + + App.Logger.WriteLine(LOG_IDENT, $"Server is UDMUX protected ({Data})"); } else if (entry.Contains(GameJoinedEntry)) { Match match = Regex.Match(entry, GameJoinedEntryPattern); - if (match.Groups.Count != 2 || match.Groups[1].Value != ActivityMachineAddress) + if (match.Groups.Count != 2 || match.Groups[1].Value != Data.MachineAddress) { App.Logger.WriteLine(LOG_IDENT, $"Failed to assert format for game joined entry"); App.Logger.WriteLine(LOG_IDENT, entry); return; } - App.Logger.WriteLine(LOG_IDENT, $"Joined Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + App.Logger.WriteLine(LOG_IDENT, $"Joined Game ({Data})"); + + InGame = true; + Data.TimeJoined = DateTime.Now; - ActivityInGame = true; OnGameJoin?.Invoke(this, new EventArgs()); } } - else if (ActivityInGame && ActivityPlaceId != 0) + else if (InGame && Data.PlaceId != 0) { + // We are confirmed to be in a game + if (entry.Contains(GameDisconnectedEntry)) { - App.Logger.WriteLine(LOG_IDENT, $"Disconnected from Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + App.Logger.WriteLine(LOG_IDENT, $"Disconnected from Game ({Data})"); - ActivityInGame = false; - ActivityPlaceId = 0; - ActivityJobId = ""; - ActivityMachineAddress = ""; - ActivityMachineUDMUX = false; - ActivityIsTeleport = false; - ActivityServerType = ServerType.Public; + Data.TimeLeft = DateTime.Now; + History.Insert(0, Data); + + InGame = false; + + Data = new(); OnGameLeave?.Invoke(this, new EventArgs()); } else if (entry.Contains(GameTeleportingEntry)) { - App.Logger.WriteLine(LOG_IDENT, $"Initiating teleport to server ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + App.Logger.WriteLine(LOG_IDENT, $"Initiating teleport to server ({Data})"); _teleportMarker = true; } else if (_teleportMarker && entry.Contains(GameJoiningReservedServerEntry)) @@ -282,6 +338,35 @@ return; } + if (message.Command == "SetLaunchData") + { + string? data; + + try + { + data = message.Data.Deserialize(); + } + catch (Exception) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); + return; + } + + if (data is null) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); + return; + } + + if (data.Length > 200) + { + App.Logger.WriteLine(LOG_IDENT, "Data cannot be longer than 200 characters"); + return; + } + + Data.RPCLaunchData = data; + } + OnRPCMessage?.Invoke(this, message); LastRPCRequest = DateTime.Now; @@ -289,44 +374,6 @@ } } - public async Task GetServerLocation() - { - const string LOG_IDENT = "ActivityWatcher::GetServerLocation"; - - if (GeolocationCache.ContainsKey(ActivityMachineAddress)) - return GeolocationCache[ActivityMachineAddress]; - - try - { - string location = ""; - var ipInfo = await Http.GetJson($"https://ipinfo.io/{ActivityMachineAddress}/json"); - - if (ipInfo is null) - return $"? ({Resources.Strings.ActivityTracker_LookupFailed})"; - - if (string.IsNullOrEmpty(ipInfo.Country)) - location = "?"; - else if (ipInfo.City == ipInfo.Region) - location = $"{ipInfo.Region}, {ipInfo.Country}"; - else - location = $"{ipInfo.City}, {ipInfo.Region}, {ipInfo.Country}"; - - if (!ActivityInGame) - return $"? ({Resources.Strings.ActivityTracker_LeftGame})"; - - GeolocationCache[ActivityMachineAddress] = location; - - return location; - } - catch (Exception ex) - { - App.Logger.WriteLine(LOG_IDENT, $"Failed to get server location for {ActivityMachineAddress}"); - App.Logger.WriteException(LOG_IDENT, ex); - - return $"? ({Resources.Strings.ActivityTracker_LookupFailed})"; - } - } - public void Dispose() { IsDisposed = true; diff --git a/Bloxstrap/Integrations/DiscordRichPresence.cs b/Bloxstrap/Integrations/DiscordRichPresence.cs index ab920d1..ba1f719 100644 --- a/Bloxstrap/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Integrations/DiscordRichPresence.cs @@ -1,4 +1,6 @@ -using DiscordRPC; +using System.Windows; + +using DiscordRPC; namespace Bloxstrap.Integrations { @@ -6,18 +8,16 @@ namespace Bloxstrap.Integrations { private readonly DiscordRpcClient _rpcClient = new("1005469189907173486"); private readonly ActivityWatcher _activityWatcher; - + private readonly Queue _messageQueue = new(); + private DiscordRPC.RichPresence? _currentPresence; - private DiscordRPC.RichPresence? _currentPresenceCopy; - private Message? _stashedRPCMessage; + private DiscordRPC.RichPresence? _originalPresence; private bool _visible = true; - private long _currentUniverseId; - private DateTime? _timeStartedUniverse; public DiscordRichPresence(ActivityWatcher activityWatcher) { - const string LOG_IDENT = "DiscordRichPresence::DiscordRichPresence"; + const string LOG_IDENT = "DiscordRichPresence"; _activityWatcher = activityWatcher; @@ -47,119 +47,121 @@ namespace Bloxstrap.Integrations _rpcClient.Initialize(); } - public void ProcessRPCMessage(Message message) + public void ProcessRPCMessage(Message message, bool implicitUpdate = true) { const string LOG_IDENT = "DiscordRichPresence::ProcessRPCMessage"; - if (message.Command != "SetRichPresence") + if (message.Command != "SetRichPresence" && message.Command != "SetLaunchData") return; - if (_currentPresence is null || _currentPresenceCopy is null) + if (_currentPresence is null || _originalPresence is null) { - if (_activityWatcher.ActivityInGame) + App.Logger.WriteLine(LOG_IDENT, "Presence is not set, enqueuing message"); + _messageQueue.Enqueue(message); + return; + } + + // a lot of repeated code here, could this somehow be cleaned up? + + if (message.Command == "SetLaunchData") + { + _currentPresence.Buttons = GetButtons(); + } + else if (message.Command == "SetRichPresence") + { + Models.BloxstrapRPC.RichPresence? presenceData; + + try { - App.Logger.WriteLine(LOG_IDENT, "Presence is not yet set, but is currently in game, stashing presence set request"); - _stashedRPCMessage = message; + presenceData = message.Data.Deserialize(); + } + catch (Exception) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); return; } - App.Logger.WriteLine(LOG_IDENT, "Presence is not set, aborting"); - return; - } - - Models.BloxstrapRPC.RichPresence? presenceData; - - // a lot of repeated code here, could this somehow be cleaned up? - - try - { - presenceData = message.Data.Deserialize(); - } - catch (Exception) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); - return; - } - - if (presenceData is null) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); - return; - } - - if (presenceData.Details is not null) - { - if (presenceData.Details.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); - else if (presenceData.Details == "") - _currentPresence.Details = _currentPresenceCopy.Details; - else - _currentPresence.Details = presenceData.Details; - } - - if (presenceData.State is not null) - { - if (presenceData.State.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); - else if (presenceData.State == "") - _currentPresence.State = _currentPresenceCopy.State; - else - _currentPresence.State = presenceData.State; - } - - if (presenceData.TimestampStart == 0) - _currentPresence.Timestamps.Start = null; - else if (presenceData.TimestampStart is not null) - _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; - - if (presenceData.TimestampEnd == 0) - _currentPresence.Timestamps.End = null; - else if (presenceData.TimestampEnd is not null) - _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; - - if (presenceData.SmallImage is not null) - { - if (presenceData.SmallImage.Clear) + if (presenceData is null) { - _currentPresence.Assets.SmallImageKey = ""; + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); + return; } - else if (presenceData.SmallImage.Reset) - { - _currentPresence.Assets.SmallImageText = _currentPresenceCopy.Assets.SmallImageText; - _currentPresence.Assets.SmallImageKey = _currentPresenceCopy.Assets.SmallImageKey; - } - else - { - if (presenceData.SmallImage.AssetId is not null) - _currentPresence.Assets.SmallImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.SmallImage.AssetId}"; - if (presenceData.SmallImage.HoverText is not null) - _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; + if (presenceData.Details is not null) + { + if (presenceData.Details.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); + else if (presenceData.Details == "") + _currentPresence.Details = _originalPresence.Details; + else + _currentPresence.Details = presenceData.Details; + } + + if (presenceData.State is not null) + { + if (presenceData.State.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); + else if (presenceData.State == "") + _currentPresence.State = _originalPresence.State; + else + _currentPresence.State = presenceData.State; + } + + if (presenceData.TimestampStart == 0) + _currentPresence.Timestamps.Start = null; + else if (presenceData.TimestampStart is not null) + _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; + + if (presenceData.TimestampEnd == 0) + _currentPresence.Timestamps.End = null; + else if (presenceData.TimestampEnd is not null) + _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; + + if (presenceData.SmallImage is not null) + { + if (presenceData.SmallImage.Clear) + { + _currentPresence.Assets.SmallImageKey = ""; + } + else if (presenceData.SmallImage.Reset) + { + _currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText; + _currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey; + } + else + { + if (presenceData.SmallImage.AssetId is not null) + _currentPresence.Assets.SmallImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.SmallImage.AssetId}"; + + if (presenceData.SmallImage.HoverText is not null) + _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; + } + } + + if (presenceData.LargeImage is not null) + { + if (presenceData.LargeImage.Clear) + { + _currentPresence.Assets.LargeImageKey = ""; + } + else if (presenceData.LargeImage.Reset) + { + _currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText; + _currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey; + } + else + { + if (presenceData.LargeImage.AssetId is not null) + _currentPresence.Assets.LargeImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.LargeImage.AssetId}"; + + if (presenceData.LargeImage.HoverText is not null) + _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; + } } } - if (presenceData.LargeImage is not null) - { - if (presenceData.LargeImage.Clear) - { - _currentPresence.Assets.LargeImageKey = ""; - } - else if (presenceData.LargeImage.Reset) - { - _currentPresence.Assets.LargeImageText = _currentPresenceCopy.Assets.LargeImageText; - _currentPresence.Assets.LargeImageKey = _currentPresenceCopy.Assets.LargeImageKey; - } - else - { - if (presenceData.LargeImage.AssetId is not null) - _currentPresence.Assets.LargeImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.LargeImage.AssetId}"; - - if (presenceData.LargeImage.HoverText is not null) - _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; - } - } - - UpdatePresence(); + if (implicitUpdate) + UpdatePresence(); } public void SetVisibility(bool visible) @@ -178,124 +180,131 @@ namespace Bloxstrap.Integrations { const string LOG_IDENT = "DiscordRichPresence::SetCurrentGame"; - if (!_activityWatcher.ActivityInGame) + if (!_activityWatcher.InGame) { App.Logger.WriteLine(LOG_IDENT, "Not in game, clearing presence"); - _currentPresence = _currentPresenceCopy = null; - _stashedRPCMessage = null; + _currentPresence = _originalPresence = null; + _messageQueue.Clear(); UpdatePresence(); return true; } string icon = "roblox"; - long placeId = _activityWatcher.ActivityPlaceId; + + var activity = _activityWatcher.Data; + long placeId = activity.PlaceId; App.Logger.WriteLine(LOG_IDENT, $"Setting presence for Place ID {placeId}"); - var universeIdResponse = await Http.GetJson($"https://apis.roblox.com/universes/v1/places/{placeId}/universe"); - if (universeIdResponse is null) - { - App.Logger.WriteLine(LOG_IDENT, "Could not get Universe ID!"); - return false; - } - - long universeId = universeIdResponse.UniverseId; - App.Logger.WriteLine(LOG_IDENT, $"Got Universe ID as {universeId}"); - // preserve time spent playing if we're teleporting between places in the same universe - if (_timeStartedUniverse is null || !_activityWatcher.ActivityIsTeleport || universeId != _currentUniverseId) - _timeStartedUniverse = DateTime.UtcNow; + var timeStarted = activity.TimeJoined; - _currentUniverseId = universeId; + if (activity.RootActivity is not null) + timeStarted = activity.RootActivity.TimeJoined; - var gameDetailResponse = await Http.GetJson>($"https://games.roblox.com/v1/games?universeIds={universeId}"); - if (gameDetailResponse is null || !gameDetailResponse.Data.Any()) + if (activity.UniverseDetails is null) { - App.Logger.WriteLine(LOG_IDENT, "Could not get Universe info!"); - return false; - } - - GameDetailResponse universeDetails = gameDetailResponse.Data.ToArray()[0]; - App.Logger.WriteLine(LOG_IDENT, "Got Universe details"); - - var universeThumbnailResponse = await Http.GetJson>($"https://thumbnails.roblox.com/v1/games/icons?universeIds={universeId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false"); - if (universeThumbnailResponse is null || !universeThumbnailResponse.Data.Any()) - { - App.Logger.WriteLine(LOG_IDENT, "Could not get Universe thumbnail info!"); - } - else - { - icon = universeThumbnailResponse.Data.ToArray()[0].ImageUrl; - App.Logger.WriteLine(LOG_IDENT, $"Got Universe thumbnail as {icon}"); - } - - List