diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1921310c..eb38b99c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,28 +6,33 @@ jobs: strategy: matrix: configuration: [Debug, Release] - platform: [x64] + runs-on: windows-latest steps: - uses: actions/checkout@v3 with: submodules: true + - uses: actions/setup-dotnet@v3 with: - dotnet-version: '6.x' + dotnet-version: '6.0.x' + - name: Restore dependencies run: dotnet restore + - name: Build run: dotnet build --no-restore + - name: Publish - run: dotnet publish -p:PublishSingleFile=true -r win-${{ matrix.platform }} -c ${{ matrix.configuration }} --self-contained false .\Bloxstrap\Bloxstrap.csproj + run: dotnet publish -p:PublishSingleFile=true -p:CommitHash=${{ github.sha }} -p:CommitRef=${{ github.ref_type }}/${{ github.ref_name }} -r win-x64 -c ${{ matrix.configuration }} --self-contained false .\Bloxstrap\Bloxstrap.csproj + - name: Upload Artifact uses: actions/upload-artifact@v3 with: - name: Bloxstrap (${{ matrix.configuration }}, ${{ matrix.platform }}) + name: Bloxstrap (${{ matrix.configuration }}) path: | - .\Bloxstrap\bin\${{ matrix.configuration }}\net6.0-windows\win-${{ matrix.platform }}\publish\* + .\Bloxstrap\bin\${{ matrix.configuration }}\net6.0-windows\win-x64\publish\* release: needs: build @@ -38,15 +43,17 @@ jobs: - name: Download x64 release artifact uses: actions/download-artifact@v3 with: - name: Bloxstrap (Release, x64) + name: Bloxstrap (Release) path: x64 + - name: Rename binaries run: | - mv x64/Bloxstrap.exe Bloxstrap-${{ github.ref_name }}-x64.exe + mv x64/Bloxstrap.exe Bloxstrap-${{ github.ref_name }}.exe + - name: Release uses: softprops/action-gh-release@v1 with: draft: true files: | - Bloxstrap-${{ github.ref_name }}-x64.exe + Bloxstrap-${{ github.ref_name }}.exe name: Bloxstrap ${{ github.ref_name }} diff --git a/Bloxstrap/App.xaml b/Bloxstrap/App.xaml index 8f4642f9..c46ad487 100644 --- a/Bloxstrap/App.xaml +++ b/Bloxstrap/App.xaml @@ -11,6 +11,8 @@ + + pack://application:,,,/Resources/Fonts/#Rubik Light diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs index 98a37e29..13ed82b1 100644 --- a/Bloxstrap/App.xaml.cs +++ b/Bloxstrap/App.xaml.cs @@ -1,349 +1,343 @@ -using System; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net; -using System.Reflection; -using System.Threading; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Threading; - -using Microsoft.Win32; - -using Bloxstrap.Dialogs; -using Bloxstrap.Extensions; -using Bloxstrap.Models; -using Bloxstrap.Singletons; -using Bloxstrap.Views; - -namespace Bloxstrap -{ - /// - /// Interaction logic for App.xaml - /// - public partial class App : Application - { - public static readonly CultureInfo CultureFormat = CultureInfo.InvariantCulture; - - public const string ProjectName = "Bloxstrap"; - public const string ProjectRepository = "pizzaboxer/bloxstrap"; - - // used only for communicating between app and menu - use Directories.Base for anything else - public static string BaseDirectory = null!; - public static bool ShouldSaveConfigs { get; set; } = false; - public static bool IsSetupComplete { get; set; } = true; - public static bool IsFirstRun { get; private set; } = true; - public static bool IsQuiet { get; private set; } = false; - public static bool IsUninstall { get; private set; } = false; - public static bool IsNoLaunch { get; private set; } = false; - public static bool IsUpgrade { get; private set; } = false; - public static bool IsMenuLaunch { get; private set; } = false; - public static string[] LaunchArgs { get; private set; } = null!; - - public static string Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[..^2]; - - // singletons - public static readonly Logger Logger = new(); - public static readonly JsonManager Settings = new(); - public static readonly JsonManager State = new(); - public static readonly FastFlagManager FastFlags = new(); - public static readonly HttpClient HttpClient = new(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }); - - public static System.Windows.Forms.NotifyIcon Notification { get; private set; } = null!; - - // shorthand - public static MessageBoxResult ShowMessageBox(string message, MessageBoxImage icon = MessageBoxImage.None, MessageBoxButton buttons = MessageBoxButton.OK) - { - if (IsQuiet) - return MessageBoxResult.None; - - return MessageBox.Show(message, ProjectName, buttons, icon); - } - - public static void Terminate(int code = Bootstrapper.ERROR_SUCCESS) - { - Logger.WriteLine($"[App::Terminate] Terminating with exit code {code}"); - Settings.Save(); - State.Save(); - Environment.Exit(code); - } - - private void InitLog() - { - // if we're running for the first time or uninstalling, log to temp folder - // else, log to bloxstrap folder - - bool isUsingTempDir = IsFirstRun || IsUninstall; - string logdir = isUsingTempDir ? Path.Combine(Directories.LocalAppData, "Temp") : Path.Combine(Directories.Base, "Logs"); - string timestamp = DateTime.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'"); - - Logger.Initialize(Path.Combine(logdir, $"{ProjectName}_{timestamp}.log")); - - // clean up any logs older than a week - if (!isUsingTempDir) - { - foreach (FileInfo log in new DirectoryInfo(logdir).GetFiles()) - { - if (log.LastWriteTimeUtc.AddDays(7) > DateTime.UtcNow) - continue; - - Logger.WriteLine($"[App::InitLog] Cleaning up old log file '{log.Name}'"); - log.Delete(); - } - } - } - - void GlobalExceptionHandler(object sender, DispatcherUnhandledExceptionEventArgs e) - { - e.Handled = true; - - Logger.WriteLine("[App::OnStartup] An exception occurred when running the main thread"); - Logger.WriteLine($"[App::OnStartup] {e.Exception}"); - - if (!IsQuiet) - Settings.Prop.BootstrapperStyle.GetNew().ShowError($"{e.Exception.GetType()}: {e.Exception.Message}"); - - Terminate(Bootstrapper.ERROR_INSTALL_FAILURE); - } - - protected override void OnStartup(StartupEventArgs e) - { - base.OnStartup(e); - - Logger.WriteLine($"[App::OnStartup] Starting {ProjectName} v{Version}"); - - // To customize application configuration such as set high DPI settings or default font, - // see https://aka.ms/applicationconfiguration. - ApplicationConfiguration.Initialize(); - - LaunchArgs = e.Args; - - HttpClient.Timeout = TimeSpan.FromMinutes(5); - HttpClient.DefaultRequestHeaders.Add("User-Agent", ProjectRepository); - - if (LaunchArgs.Length > 0) - { - if (Array.IndexOf(LaunchArgs, "-preferences") != -1 || Array.IndexOf(LaunchArgs, "-menu") != -1) - { - Logger.WriteLine("[App::OnStartup] Started with IsMenuLaunch flag"); - IsMenuLaunch = true; - } - - if (Array.IndexOf(LaunchArgs, "-quiet") != -1) - { - Logger.WriteLine("[App::OnStartup] Started with IsQuiet flag"); - IsQuiet = true; - } - - if (Array.IndexOf(LaunchArgs, "-uninstall") != -1) - { - Logger.WriteLine("[App::OnStartup] Started with IsUninstall flag"); - IsUninstall = true; - } - - if (Array.IndexOf(LaunchArgs, "-nolaunch") != -1) - { - Logger.WriteLine("[App::OnStartup] Started with IsNoLaunch flag"); - IsNoLaunch = true; - } - - if (Array.IndexOf(LaunchArgs, "-upgrade") != -1) - { - Logger.WriteLine("[App::OnStartup] Bloxstrap started with IsUpgrade flag"); - IsUpgrade = true; - } - } - - // so this needs to be here because winforms moment - // onclick events will not fire unless this is defined here in the main thread so uhhhhh - // we'll show the icon if we're launching roblox since we're likely gonna be showing a - // bunch of notifications, and always showing it just makes the most sense i guess since it - // indicates that bloxstrap is running, even in the background - Notification = new() - { - Icon = Bloxstrap.Properties.Resources.IconBloxstrap, - Text = ProjectName, - Visible = !IsMenuLaunch - }; - - // check if installed - using (RegistryKey? registryKey = Registry.CurrentUser.OpenSubKey($@"Software\{ProjectName}")) - { - if (registryKey is null) - { - Logger.WriteLine("[App::OnStartup] Running first-time install"); - - BaseDirectory = Path.Combine(Directories.LocalAppData, ProjectName); - InitLog(); - - if (!IsQuiet) - { - IsSetupComplete = false; - FastFlags.Load(); - new MainWindow().ShowDialog(); - } - } - else - { - IsFirstRun = false; - BaseDirectory = (string)registryKey.GetValue("InstallLocation")!; - } - } - - // exit if we don't click the install button on installation - if (!IsSetupComplete) - { - Logger.WriteLine("[App::OnStartup] Installation cancelled!"); - Environment.Exit(Bootstrapper.ERROR_INSTALL_USEREXIT); - } - - Directories.Initialize(BaseDirectory); - - // we shouldn't save settings on the first run until the first installation is finished, - // just in case the user decides to cancel the install - if (!IsFirstRun) - { - InitLog(); - Settings.Load(); - State.Load(); - FastFlags.Load(); - } - -#if !DEBUG - if (!IsUninstall && !IsFirstRun) - Updater.CheckInstalledVersion(); -#endif - - string commandLine = ""; - - if (IsMenuLaunch) - { - Mutex mutex; - - try - { - mutex = Mutex.OpenExisting("Bloxstrap_MenuMutex"); - Logger.WriteLine("[App::OnStartup] Bloxstrap_MenuMutex mutex exists, aborting menu launch..."); - Terminate(); - } - catch - { - // no mutex exists, continue to opening preferences menu - mutex = new(true, "Bloxstrap_MenuMutex"); - } - - if (Utilities.GetProcessCount(ProjectName) > 1) - ShowMessageBox($"{ProjectName} is currently running, likely as a background Roblox process. Please note that not all your changes will immediately apply until you close all currently open Roblox instances.", MessageBoxImage.Information); - - new MainWindow().ShowDialog(); - } - else if (LaunchArgs.Length > 0) - { - if (LaunchArgs[0].StartsWith("roblox-player:")) - { - commandLine = ProtocolHandler.ParseUri(LaunchArgs[0]); - } - else if (LaunchArgs[0].StartsWith("roblox:")) - { - commandLine = $"--app --deeplink {LaunchArgs[0]}"; - } - else - { - commandLine = "--app"; - } - } - else - { - commandLine = "--app"; - } - - if (!String.IsNullOrEmpty(commandLine)) - { - if (!IsFirstRun) - ShouldSaveConfigs = true; - - // start bootstrapper and show the bootstrapper modal if we're not running silently - Logger.WriteLine($"[App::OnStartup] Initializing bootstrapper"); - Bootstrapper bootstrapper = new(commandLine); - IBootstrapperDialog? dialog = null; - - if (!IsQuiet) - { - Logger.WriteLine($"[App::OnStartup] Initializing bootstrapper dialog"); - dialog = Settings.Prop.BootstrapperStyle.GetNew(); - bootstrapper.Dialog = dialog; - dialog.Bootstrapper = bootstrapper; - } - - // handle roblox singleton mutex for multi-instance launching - // note we're handling it here in the main thread and NOT in the - // bootstrapper as handling mutexes in async contexts suuuuuucks - - Mutex? singletonMutex = null; - - if (Settings.Prop.MultiInstanceLaunching) - { - Logger.WriteLine("[App::OnStartup] Creating singleton mutex"); - - try - { - Mutex.OpenExisting("ROBLOX_singletonMutex"); - Logger.WriteLine("[App::OnStartup] Warning - singleton mutex already exists!"); - } - catch - { - // create the singleton mutex before the game client does - singletonMutex = new Mutex(true, "ROBLOX_singletonMutex"); - } - } - - // there's a bug here that i have yet to fix! - // sometimes the task just terminates when the bootstrapper hasn't - // actually finished, causing the bootstrapper to hang indefinitely - // i have no idea how the fuck this happens, but it happens like VERY - // rarely so i'm not too concerned by it - // maybe one day ill find out why it happens - Task bootstrapperTask = Task.Run(() => bootstrapper.Run()).ContinueWith(t => - { - Logger.WriteLine("[App::OnStartup] Bootstrapper task has finished"); - - if (t.IsFaulted) - Logger.WriteLine("[App::OnStartup] An exception occurred when running the bootstrapper"); - - if (t.Exception is null) - return; - - Logger.WriteLine($"[App::OnStartup] {t.Exception}"); - -#if DEBUG - throw t.Exception; -#else - var exception = t.Exception.InnerExceptions.Count >= 1 ? t.Exception.InnerExceptions[0] : t.Exception; - dialog?.ShowError($"{exception.GetType()}: {exception.Message}"); - Terminate(Bootstrapper.ERROR_INSTALL_FAILURE); -#endif - }); - - dialog?.ShowBootstrapper(); - bootstrapperTask.Wait(); - - if (singletonMutex is not null) - { - Logger.WriteLine($"[App::OnStartup] We have singleton mutex ownership! Running in background until all Roblox processes are closed"); - - // we've got ownership of the roblox singleton mutex! - // if we stop running, everything will screw up once any more roblox instances launched - while (Process.GetProcessesByName("RobloxPlayerBeta").Any()) - Thread.Sleep(5000); - } - } - - Logger.WriteLine($"[App::OnStartup] Successfully reached end of main thread. Terminating..."); - - Terminate(); - } - } -} +using System.Reflection; +using System.Windows; +using System.Windows.Threading; + +using Microsoft.Win32; + +namespace Bloxstrap +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + public const string ProjectName = "Bloxstrap"; + public const string ProjectRepository = "pizzaboxer/bloxstrap"; + public const string RobloxAppName = "RobloxPlayerBeta"; + + // used only for communicating between app and menu - use Directories.Base for anything else + public static string BaseDirectory = null!; + + public static bool ShouldSaveConfigs { get; set; } = false; + public static bool IsSetupComplete { get; set; } = true; + public static bool IsFirstRun { get; private set; } = true; + public static bool IsQuiet { get; private set; } = false; + public static bool IsUninstall { get; private set; } = false; + public static bool IsNoLaunch { get; private set; } = false; + public static bool IsUpgrade { get; private set; } = false; + public static bool IsMenuLaunch { get; private set; } = false; + public static string[] LaunchArgs { get; private set; } = null!; + + public static BuildMetadataAttribute BuildMetadata = Assembly.GetExecutingAssembly().GetCustomAttribute()!; + public static string Version = Assembly.GetExecutingAssembly().GetName().Version!.ToString()[..^2]; + + public static NotifyIconWrapper? NotifyIcon { get; private set; } + + public static readonly Logger Logger = new(); + + public static readonly JsonManager Settings = new(); + public static readonly JsonManager State = new(); + public static readonly FastFlagManager FastFlags = new(); + + public static readonly HttpClient HttpClient = new(new HttpClientLoggingHandler(new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All })); + + public static void Terminate(ErrorCode exitCode = ErrorCode.ERROR_SUCCESS) + { + if (IsFirstRun) + { + if (exitCode == ErrorCode.ERROR_CANCELLED) + exitCode = ErrorCode.ERROR_INSTALL_USEREXIT; + } + + int exitCodeNum = (int)exitCode; + + Logger.WriteLine($"[App::Terminate] Terminating with exit code {exitCodeNum} ({exitCode})"); + + Settings.Save(); + State.Save(); + NotifyIcon?.Dispose(); + + Environment.Exit(exitCodeNum); + } + + void GlobalExceptionHandler(object sender, DispatcherUnhandledExceptionEventArgs e) + { + e.Handled = true; + + Logger.WriteLine("[App::OnStartup] An exception occurred when running the main thread"); + Logger.WriteLine($"[App::OnStartup] {e.Exception}"); + + FinalizeExceptionHandling(e.Exception); + } + + void FinalizeExceptionHandling(Exception exception) + { +#if DEBUG + throw exception; +#else + if (!IsQuiet) + Controls.ShowExceptionDialog(exception); + + Terminate(ErrorCode.ERROR_INSTALL_FAILURE); +#endif + } + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + Logger.WriteLine($"[App::OnStartup] Starting {ProjectName} v{Version}"); + + if (String.IsNullOrEmpty(BuildMetadata.CommitHash)) + Logger.WriteLine($"[App::OnStartup] Compiled {BuildMetadata.Timestamp.ToFriendlyString()} from {BuildMetadata.Machine}"); + else + Logger.WriteLine($"[App::OnStartup] Compiled {BuildMetadata.Timestamp.ToFriendlyString()} from commit {BuildMetadata.CommitHash} ({BuildMetadata.CommitRef})"); + + // To customize application configuration such as set high DPI settings or default font, + // see https://aka.ms/applicationconfiguration. + ApplicationConfiguration.Initialize(); + + LaunchArgs = e.Args; + + HttpClient.Timeout = TimeSpan.FromMinutes(5); + HttpClient.DefaultRequestHeaders.Add("User-Agent", ProjectRepository); + + if (LaunchArgs.Length > 0) + { + if (Array.IndexOf(LaunchArgs, "-preferences") != -1 || Array.IndexOf(LaunchArgs, "-menu") != -1) + { + Logger.WriteLine("[App::OnStartup] Started with IsMenuLaunch flag"); + IsMenuLaunch = true; + } + + if (Array.IndexOf(LaunchArgs, "-quiet") != -1) + { + Logger.WriteLine("[App::OnStartup] Started with IsQuiet flag"); + IsQuiet = true; + } + + if (Array.IndexOf(LaunchArgs, "-uninstall") != -1) + { + Logger.WriteLine("[App::OnStartup] Started with IsUninstall flag"); + IsUninstall = true; + } + + if (Array.IndexOf(LaunchArgs, "-nolaunch") != -1) + { + Logger.WriteLine("[App::OnStartup] Started with IsNoLaunch flag"); + IsNoLaunch = true; + } + + if (Array.IndexOf(LaunchArgs, "-upgrade") != -1) + { + Logger.WriteLine("[App::OnStartup] Bloxstrap started with IsUpgrade flag"); + IsUpgrade = true; + } + } + + // check if installed + using (RegistryKey? registryKey = Registry.CurrentUser.OpenSubKey($@"Software\{ProjectName}")) + { + string? installLocation = null; + + if (registryKey is not null) + installLocation = (string?)registryKey.GetValue("InstallLocation"); + + if (registryKey is null || installLocation is null) + { + Logger.WriteLine("[App::OnStartup] Running first-time install"); + + BaseDirectory = Path.Combine(Directories.LocalAppData, ProjectName); + Logger.Initialize(true); + + if (!IsQuiet) + { + IsSetupComplete = false; + FastFlags.Load(); + Controls.ShowMenu(); + } + } + else + { + IsFirstRun = false; + BaseDirectory = installLocation; + } + } + + // exit if we don't click the install button on installation + if (!IsSetupComplete) + { + Logger.WriteLine("[App::OnStartup] Installation cancelled!"); + Terminate(ErrorCode.ERROR_CANCELLED); + } + + Directories.Initialize(BaseDirectory); + + // we shouldn't save settings on the first run until the first installation is finished, + // just in case the user decides to cancel the install + if (!IsFirstRun) + { + Logger.Initialize(IsUninstall); + + if (!Logger.Initialized) + { + Logger.WriteLine("[App::OnStartup] Possible duplicate launch detected, terminating."); + Terminate(); + } + + Settings.Load(); + State.Load(); + FastFlags.Load(); + } + + if (!IsUninstall && !IsMenuLaunch) + NotifyIcon = new(); + +#if !DEBUG + if (!IsUninstall && !IsFirstRun) + Updater.CheckInstalledVersion(); +#endif + + string commandLine = ""; + + if (IsMenuLaunch) + { + Process? menuProcess = Process.GetProcesses().Where(x => x.MainWindowTitle == $"{ProjectName} Menu").FirstOrDefault(); + + if (menuProcess is not null) + { + IntPtr handle = menuProcess.MainWindowHandle; + Logger.WriteLine($"[App::OnStartup] Found an already existing menu window with handle {handle}"); + NativeMethods.SetForegroundWindow(handle); + } + else + { + if (Process.GetProcessesByName(ProjectName).Length > 1 && !IsQuiet) + Controls.ShowMessageBox( + $"{ProjectName} is currently running, likely as a background Roblox process. Please note that not all your changes will immediately apply until you close all currently open Roblox instances.", + MessageBoxImage.Information + ); + + Controls.ShowMenu(); + } + } + else if (LaunchArgs.Length > 0) + { + if (LaunchArgs[0].StartsWith("roblox-player:")) + { + commandLine = ProtocolHandler.ParseUri(LaunchArgs[0]); + } + else if (LaunchArgs[0].StartsWith("roblox:")) + { + if (Settings.Prop.UseDisableAppPatch) + Controls.ShowMessageBox( + "Roblox was launched via a deeplink, however the desktop app is required for deeplink launching to work. Because you've opted to disable the desktop app, it will temporarily be re-enabled for this launch only.", + MessageBoxImage.Information + ); + + commandLine = $"--app --deeplink {LaunchArgs[0]}"; + } + else + { + commandLine = "--app"; + } + } + else + { + commandLine = "--app"; + } + + if (!String.IsNullOrEmpty(commandLine)) + { + if (!IsFirstRun) + ShouldSaveConfigs = true; + + // start bootstrapper and show the bootstrapper modal if we're not running silently + Logger.WriteLine($"[App::OnStartup] Initializing bootstrapper"); + Bootstrapper bootstrapper = new(commandLine); + IBootstrapperDialog? dialog = null; + + if (!IsQuiet) + { + Logger.WriteLine($"[App::OnStartup] Initializing bootstrapper dialog"); + dialog = Settings.Prop.BootstrapperStyle.GetNew(); + bootstrapper.Dialog = dialog; + dialog.Bootstrapper = bootstrapper; + } + + // handle roblox singleton mutex for multi-instance launching + // note we're handling it here in the main thread and NOT in the + // bootstrapper as handling mutexes in async contexts suuuuuucks + + Mutex? singletonMutex = null; + + if (Settings.Prop.MultiInstanceLaunching) + { + Logger.WriteLine("[App::OnStartup] Creating singleton mutex"); + + try + { + Mutex.OpenExisting("ROBLOX_singletonMutex"); + Logger.WriteLine("[App::OnStartup] Warning - singleton mutex already exists!"); + } + catch + { + // create the singleton mutex before the game client does + singletonMutex = new Mutex(true, "ROBLOX_singletonMutex"); + } + } + + Task bootstrapperTask = Task.Run(() => bootstrapper.Run()); + + bootstrapperTask.ContinueWith(t => + { + Logger.WriteLine("[App::OnStartup] Bootstrapper task has finished"); + + // notifyicon is blocking main thread, must be disposed here + NotifyIcon?.Dispose(); + + if (t.IsFaulted) + Logger.WriteLine("[App::OnStartup] An exception occurred when running the bootstrapper"); + + if (t.Exception is null) + return; + + Logger.WriteLine($"[App::OnStartup] {t.Exception}"); + + Exception exception = t.Exception; + +#if !DEBUG + if (t.Exception.GetType().ToString() == "System.AggregateException") + exception = t.Exception.InnerException!; +#endif + + FinalizeExceptionHandling(exception); + }); + + // this ordering is very important as all wpf windows are shown as modal dialogs, mess it up and you'll end up blocking input to one of them + dialog?.ShowBootstrapper(); + + if (!IsNoLaunch && Settings.Prop.EnableActivityTracking) + NotifyIcon?.InitializeContextMenu(); + + Logger.WriteLine($"[App::OnStartup] Waiting for bootstrapper task to finish"); + + bootstrapperTask.Wait(); + + if (singletonMutex is not null) + { + Logger.WriteLine($"[App::OnStartup] We have singleton mutex ownership! Running in background until all Roblox processes are closed"); + + // we've got ownership of the roblox singleton mutex! + // if we stop running, everything will screw up once any more roblox instances launched + while (Process.GetProcessesByName("RobloxPlayerBeta").Any()) + Thread.Sleep(5000); + } + } + + Logger.WriteLine($"[App::OnStartup] Successfully reached end of main thread. Terminating..."); + + Terminate(); + } + } +} diff --git a/Bloxstrap/Bloxstrap.csproj b/Bloxstrap/Bloxstrap.csproj index 05de60f8..d7399537 100644 --- a/Bloxstrap/Bloxstrap.csproj +++ b/Bloxstrap/Bloxstrap.csproj @@ -7,25 +7,39 @@ true True Bloxstrap.ico - 2.3.0 - 2.3.0.0 + 2.4.0 + 2.4.0.0 app.manifest + + + + + + + + - - - + + + + + + + + + + - - - + + @@ -33,4 +47,13 @@ + + + <_Parameter1>$([System.DateTime]::UtcNow.ToString("s"))Z + <_Parameter2>$(COMPUTERNAME)\$(USERNAME) + <_Parameter3>$(CommitHash) + <_Parameter4>$(CommitRef) + + + diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index 9ecfb0d2..6824e312 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -1,1149 +1,1327 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; -using System.Windows; - -using Microsoft.Win32; - -using Bloxstrap.Dialogs; -using Bloxstrap.Enums; -using Bloxstrap.Integrations; -using Bloxstrap.Models; -using Bloxstrap.Tools; -using System.Globalization; - -namespace Bloxstrap -{ - public class Bootstrapper - { - #region Properties - - // https://learn.microsoft.com/en-us/windows/win32/msi/error-codes - public const int ERROR_SUCCESS = 0; - public const int ERROR_INSTALL_USEREXIT = 1602; - public const int ERROR_INSTALL_FAILURE = 1603; - - // in case a new package is added, you can find the corresponding directory - // by opening the stock bootstrapper in a hex editor - // TODO - there ideally should be a less static way to do this that's not hardcoded? - private static readonly IReadOnlyDictionary PackageDirectories = new Dictionary() - { - { "RobloxApp.zip", @"" }, - { "shaders.zip", @"shaders\" }, - { "ssl.zip", @"ssl\" }, - - // the runtime installer is only extracted if it needs installing - { "WebView2.zip", @"" }, - { "WebView2RuntimeInstaller.zip", @"WebView2RuntimeInstaller\" }, - - { "content-avatar.zip", @"content\avatar\" }, - { "content-configs.zip", @"content\configs\" }, - { "content-fonts.zip", @"content\fonts\" }, - { "content-sky.zip", @"content\sky\" }, - { "content-sounds.zip", @"content\sounds\" }, - { "content-textures2.zip", @"content\textures\" }, - { "content-models.zip", @"content\models\" }, - - { "content-textures3.zip", @"PlatformContent\pc\textures\" }, - { "content-terrain.zip", @"PlatformContent\pc\terrain\" }, - { "content-platform-fonts.zip", @"PlatformContent\pc\fonts\" }, - - { "extracontent-luapackages.zip", @"ExtraContent\LuaPackages\" }, - { "extracontent-translations.zip", @"ExtraContent\translations\" }, - { "extracontent-models.zip", @"ExtraContent\models\" }, - { "extracontent-textures.zip", @"ExtraContent\textures\" }, - { "extracontent-places.zip", @"ExtraContent\places\" }, - }; - - private const string AppSettings = - "\r\n" + - "\r\n" + - " content\r\n" + - " http://www.roblox.com\r\n" + - "\r\n"; - - private readonly CancellationTokenSource _cancelTokenSource = new(); - - private static bool FreshInstall => String.IsNullOrEmpty(App.State.Prop.VersionGuid); - private static string DesktopShortcutLocation => Path.Combine(Directories.Desktop, "Play Roblox.lnk"); - private static bool ShouldInstallWebView2 = false; - - private string _playerLocation => Path.Combine(_versionFolder, "RobloxPlayerBeta.exe"); - - private string _launchCommandLine; - - private string _latestVersionGuid = null!; - private PackageManifest _versionPackageManifest = null!; - private string _versionFolder = null!; - - private bool _isInstalling = false; - private double _progressIncrement; - private long _totalDownloadedBytes = 0; - private int _packagesExtracted = 0; - private bool _cancelFired = false; - - public IBootstrapperDialog? Dialog = null; - #endregion - - #region Core - public Bootstrapper(string launchCommandLine) - { - _launchCommandLine = launchCommandLine; - - // check if the webview2 runtime needs to be installed - // webview2 can either be installed be per-user or globally, so we need to check in both hklm and hkcu - // https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#detect-if-a-suitable-webview2-runtime-is-already-installed - - string hklmLocation = "SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; - string hkcuLocation = "Software\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"; - - ShouldInstallWebView2 = Registry.LocalMachine.OpenSubKey(hklmLocation) is null && Registry.CurrentUser.OpenSubKey(hkcuLocation) is null; - } - - private void SetStatus(string message) - { - App.Logger.WriteLine($"[Bootstrapper::SetStatus] {message}"); - - if (Dialog is not null) - Dialog.Message = message; - } - - private void UpdateProgressbar() - { - int newProgress = (int)Math.Floor(_progressIncrement * _totalDownloadedBytes); - - // bugcheck: if we're restoring a file from a package, it'll incorrectly increment the progress beyond 100 - // too lazy to fix properly so lol - if (newProgress > 100) - return; - - if (Dialog is not null) - Dialog.ProgressValue = newProgress; - } - - public async Task Run() - { - App.Logger.WriteLine("[Bootstrapper::Run] Running bootstrapper"); - - if (App.IsUninstall) - { - Uninstall(); - return; - } - -#if !DEBUG - if (!App.IsFirstRun && App.Settings.Prop.CheckForUpdates) - await CheckForUpdates(); -#endif - - // ensure only one instance of the bootstrapper is running at the time - // so that we don't have stuff like two updates happening simultaneously - - bool mutexExists = false; - - try - { - Mutex.OpenExisting("Bloxstrap_BootstrapperMutex").Close(); - App.Logger.WriteLine("[Bootstrapper::Run] Bloxstrap_BootstrapperMutex mutex exists, waiting..."); - mutexExists = true; - } - catch (Exception) - { - // no mutex exists - } - - // wait for mutex to be released if it's not yet - await using AsyncMutex mutex = new("Bloxstrap_BootstrapperMutex"); - await mutex.AcquireAsync(_cancelTokenSource.Token); - - // reload our configs since they've likely changed by now - if (mutexExists) - { - App.Settings.Load(); - App.State.Load(); - } - - await CheckLatestVersion(); - - CheckInstallMigration(); - - // only update roblox if we're running for the first time, or if - // roblox isn't running and our version guid is out of date, or the player exe doesn't exist - if (App.IsFirstRun || !Utilities.CheckIfRobloxRunning() && (_latestVersionGuid != App.State.Prop.VersionGuid || !File.Exists(_playerLocation))) - await InstallLatestVersion(); - - // last time the version folder was set, it was set to the latest version guid - // but if we skipped updating because roblox is already running, we want it to be set to our current version - _versionFolder = Path.Combine(Directories.Versions, App.State.Prop.VersionGuid); - - if (App.IsFirstRun) - App.ShouldSaveConfigs = true; - - MigrateIntegrations(); - - if (ShouldInstallWebView2) - await InstallWebView2(); - - App.FastFlags.Save(); - await ApplyModifications(); - - if (App.IsFirstRun || FreshInstall) - Register(); - - CheckInstall(); - - // at this point we've finished updating our configs - App.Settings.Save(); - App.State.Save(); - App.ShouldSaveConfigs = false; - - await mutex.ReleaseAsync(); - - if (App.IsFirstRun && App.IsNoLaunch) - Dialog?.ShowSuccess($"{App.ProjectName} has successfully installed"); - else if (!App.IsNoLaunch && !_cancelFired) - await StartRoblox(); - } - - private async Task CheckLatestVersion() - { - SetStatus("Connecting to Roblox..."); - - ClientVersion clientVersion = await RobloxDeployment.GetInfo(App.Settings.Prop.Channel); - - // briefly check if current channel is suitable to use - if (App.Settings.Prop.Channel.ToLower() != RobloxDeployment.DefaultChannel.ToLower() && App.Settings.Prop.ChannelChangeMode != ChannelChangeMode.Ignore) - { - string? switchDefaultPrompt = null; - ClientVersion? defaultChannelInfo = null; - - App.Logger.WriteLine($"[Bootstrapper::CheckLatestVersion] Checking if current channel is suitable to use..."); - - if (String.IsNullOrEmpty(switchDefaultPrompt)) - { - // this SUCKS - defaultChannelInfo = await RobloxDeployment.GetInfo(RobloxDeployment.DefaultChannel); - int defaultChannelVersion = Int32.Parse(defaultChannelInfo.Version.Split('.')[1]); - int currentChannelVersion = Int32.Parse(clientVersion.Version.Split('.')[1]); - - if (currentChannelVersion < defaultChannelVersion) - switchDefaultPrompt = $"Your current preferred channel ({App.Settings.Prop.Channel}) appears to no longer be receiving updates. Would you like to switch to {RobloxDeployment.DefaultChannel}?"; - } - - if (!String.IsNullOrEmpty(switchDefaultPrompt)) - { - MessageBoxResult result = App.Settings.Prop.ChannelChangeMode == ChannelChangeMode.Automatic ? MessageBoxResult.Yes : App.ShowMessageBox(switchDefaultPrompt, MessageBoxImage.Question, MessageBoxButton.YesNo); - - if (result == MessageBoxResult.Yes) - { - App.Settings.Prop.Channel = RobloxDeployment.DefaultChannel; - App.Logger.WriteLine($"[DeployManager::SwitchToDefault] Changed Roblox release channel from {App.Settings.Prop.Channel} to {RobloxDeployment.DefaultChannel}"); - - if (defaultChannelInfo is null) - defaultChannelInfo = await RobloxDeployment.GetInfo(RobloxDeployment.DefaultChannel); - - clientVersion = defaultChannelInfo; - } - } - } - - _latestVersionGuid = clientVersion.VersionGuid; - _versionFolder = Path.Combine(Directories.Versions, _latestVersionGuid); - _versionPackageManifest = await PackageManifest.Get(_latestVersionGuid); - } - - private async Task StartRoblox() - { - SetStatus("Starting Roblox..."); - - if (_launchCommandLine == "--app" && App.Settings.Prop.UseDisableAppPatch) - { - Utilities.OpenWebsite("https://www.roblox.com/games"); - Dialog?.CloseBootstrapper(); - return; - } - - _launchCommandLine = _launchCommandLine.Replace("LAUNCHTIMEPLACEHOLDER", DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString()); - - if (App.Settings.Prop.Channel.ToLower() != RobloxDeployment.DefaultChannel.ToLower()) - _launchCommandLine += " -channel " + App.Settings.Prop.Channel.ToLower(); - - // whether we should wait for roblox to exit to handle stuff in the background or clean up after roblox closes - bool shouldWait = false; - - // 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(_playerLocation, _launchCommandLine)) - { - gameClientPid = gameClient.Id; - } - - List autocloseProcesses = new(); - RobloxActivity? activityWatcher = null; - DiscordRichPresence? richPresence = null; - ServerNotifier? serverNotifier = null; - - App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Started Roblox (PID {gameClientPid})"); - - using (SystemEvent startEvent = new("www.roblox.com/robloxStartedEvent")) - { - bool startEventFired = await startEvent.WaitForEvent(); - - startEvent.Close(); - - if (!startEventFired) - return; - } - - if (App.Settings.Prop.UseDiscordRichPresence || App.Settings.Prop.ShowServerDetails) - { - activityWatcher = new(); - shouldWait = true; - } - - if (App.Settings.Prop.UseDiscordRichPresence) - { - App.Logger.WriteLine("[Bootstrapper::StartRoblox] Using Discord Rich Presence"); - richPresence = new(activityWatcher!); - } - - if (App.Settings.Prop.ShowServerDetails) - { - App.Logger.WriteLine("[Bootstrapper::StartRoblox] Using server details notifier"); - serverNotifier = new(activityWatcher!); - } - - // launch custom integrations now - foreach (CustomIntegration integration in App.Settings.Prop.CustomIntegrations) - { - App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})"); - - try - { - Process process = Process.Start(integration.Location, integration.LaunchArgs); - - if (integration.AutoClose) - { - shouldWait = true; - autocloseProcesses.Add(process); - } - } - catch (Exception ex) - { - App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Failed to launch integration '{integration.Name}'! ({ex.Message})"); - } - } - - // event fired, wait for 3 seconds then close - await Task.Delay(3000); - Dialog?.CloseBootstrapper(); - - // keep bloxstrap open in the background if needed - if (!shouldWait) - return; - - activityWatcher?.StartWatcher(); - - App.Logger.WriteLine("[Bootstrapper::StartRoblox] Waiting for Roblox to close"); - - while (Process.GetProcesses().Any(x => x.Id == gameClientPid)) - await Task.Delay(1000); - - App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Roblox has exited"); - - richPresence?.Dispose(); - - foreach (Process process in autocloseProcesses) - { - if (process.HasExited) - continue; - - App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Autoclosing process '{process.ProcessName}' (PID {process.Id})"); - process.Kill(); - } - } - - public void CancelInstall() - { - if (!_isInstalling) - { - App.Terminate(ERROR_INSTALL_USEREXIT); - return; - } - - App.Logger.WriteLine("[Bootstrapper::CancelInstall] Cancelling install..."); - - _cancelTokenSource.Cancel(); - _cancelFired = true; - - try - { - // clean up install - if (App.IsFirstRun) - Directory.Delete(Directories.Base, true); - else if (Directory.Exists(_versionFolder)) - Directory.Delete(_versionFolder, true); - } - catch (Exception ex) - { - App.Logger.WriteLine("[Bootstrapper::CancelInstall] Could not fully clean up installation!"); - App.Logger.WriteLine($"[Bootstrapper::CancelInstall] {ex}"); - } - - App.Terminate(ERROR_INSTALL_USEREXIT); - } - #endregion - - #region App Install - public static void Register() - { - using (RegistryKey applicationKey = Registry.CurrentUser.CreateSubKey($@"Software\{App.ProjectName}")) - { - applicationKey.SetValue("InstallLocation", Directories.Base); - } - - // set uninstall key - using (RegistryKey uninstallKey = Registry.CurrentUser.CreateSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{App.ProjectName}")) - { - uninstallKey.SetValue("DisplayIcon", $"{Directories.Application},0"); - uninstallKey.SetValue("DisplayName", App.ProjectName); - uninstallKey.SetValue("DisplayVersion", App.Version); - - if (uninstallKey.GetValue("InstallDate") is null) - uninstallKey.SetValue("InstallDate", DateTime.Now.ToString("yyyyMMdd")); - - uninstallKey.SetValue("InstallLocation", Directories.Base); - uninstallKey.SetValue("NoRepair", 1); - uninstallKey.SetValue("Publisher", "pizzaboxer"); - uninstallKey.SetValue("ModifyPath", $"\"{Directories.Application}\" -menu"); - uninstallKey.SetValue("QuietUninstallString", $"\"{Directories.Application}\" -uninstall -quiet"); - uninstallKey.SetValue("UninstallString", $"\"{Directories.Application}\" -uninstall"); - uninstallKey.SetValue("URLInfoAbout", $"https://github.com/{App.ProjectRepository}"); - uninstallKey.SetValue("URLUpdateInfo", $"https://github.com/{App.ProjectRepository}/releases/latest"); - } - - App.Logger.WriteLine("[Bootstrapper::StartRoblox] Registered application"); - } - - private void CheckInstallMigration() - { - // check if we've changed our install location since the last time we started - // in which case, we'll have to copy over all our folders so we don't lose any mods and stuff - - using RegistryKey? applicationKey = Registry.CurrentUser.OpenSubKey($@"Software\{App.ProjectName}", true); - - string? oldInstallLocation = (string?)applicationKey?.GetValue("OldInstallLocation"); - - if (applicationKey is null || oldInstallLocation is null || oldInstallLocation == Directories.Base) - return; - - SetStatus("Migrating install location..."); - - if (Directory.Exists(oldInstallLocation)) - { - App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Moving all files in {oldInstallLocation} to {Directories.Base}..."); - - foreach (string oldFileLocation in Directory.GetFiles(oldInstallLocation, "*.*", SearchOption.AllDirectories)) - { - string relativeFile = oldFileLocation.Substring(oldInstallLocation.Length + 1); - string newFileLocation = Path.Combine(Directories.Base, relativeFile); - string? newDirectory = Path.GetDirectoryName(newFileLocation); - - try - { - if (!String.IsNullOrEmpty(newDirectory)) - Directory.CreateDirectory(newDirectory); - - File.Move(oldFileLocation, newFileLocation, true); - } - catch (Exception ex) - { - App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Failed to move {oldFileLocation} to {newFileLocation}! {ex}"); - } - } - - try - { - Directory.Delete(oldInstallLocation, true); - App.Logger.WriteLine("[Bootstrapper::CheckInstallMigration] Deleted old install location"); - } - catch (Exception ex) - { - App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Failed to delete old install location! {ex}"); - } - } - - applicationKey.DeleteValue("OldInstallLocation"); - - // allow shortcuts to be re-registered - if (Directory.Exists(Directories.StartMenu)) - Directory.Delete(Directories.StartMenu, true); - - if (File.Exists(DesktopShortcutLocation)) - { - File.Delete(DesktopShortcutLocation); - App.Settings.Prop.CreateDesktopIcon = true; - } - - App.Logger.WriteLine("[Bootstrapper::CheckInstallMigration] Finished migrating install location!"); - } - - public static void CheckInstall() - { - App.Logger.WriteLine("[Bootstrapper::StartRoblox] 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", Directories.Application); - ProtocolHandler.Register("roblox-player", "Roblox", Directories.Application); - - // in case the user is reinstalling - if (File.Exists(Directories.Application) && App.IsFirstRun) - File.Delete(Directories.Application); - - // check to make sure bootstrapper is in the install folder - if (!File.Exists(Directories.Application) && Environment.ProcessPath is not null) - File.Copy(Environment.ProcessPath, Directories.Application); - - // this SHOULD go under Register(), - // but then people who have Bloxstrap v1.0.0 installed won't have this without a reinstall - // maybe in a later version? - if (!Directory.Exists(Directories.StartMenu)) - { - Directory.CreateDirectory(Directories.StartMenu); - - ShellLink.Shortcut.CreateShortcut(Directories.Application, "", Directories.Application, 0) - .WriteToFile(Path.Combine(Directories.StartMenu, "Play Roblox.lnk")); - - ShellLink.Shortcut.CreateShortcut(Directories.Application, "-menu", Directories.Application, 0) - .WriteToFile(Path.Combine(Directories.StartMenu, $"{App.ProjectName} Menu.lnk")); - } - else - { - // v2.0.0 - rebadge configuration menu as just "Bloxstrap Menu" - string oldMenuShortcut = Path.Combine(Directories.StartMenu, $"Configure {App.ProjectName}.lnk"); - string newMenuShortcut = Path.Combine(Directories.StartMenu, $"{App.ProjectName} Menu.lnk"); - - if (File.Exists(oldMenuShortcut)) - File.Delete(oldMenuShortcut); - - if (!File.Exists(newMenuShortcut)) - ShellLink.Shortcut.CreateShortcut(Directories.Application, "-menu", Directories.Application, 0) - .WriteToFile(newMenuShortcut); - } - - if (App.Settings.Prop.CreateDesktopIcon) - { - if (!File.Exists(DesktopShortcutLocation)) - { - ShellLink.Shortcut.CreateShortcut(Directories.Application, "", Directories.Application, 0) - .WriteToFile(DesktopShortcutLocation); - } - - // one-time toggle, set it back to false - App.Settings.Prop.CreateDesktopIcon = false; - } - } - - private async Task CheckForUpdates() - { - // don't update if there's another instance running (likely running in the background) - if (Utilities.GetProcessCount(App.ProjectName) > 1) - { - App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] More than one Bloxstrap instance running, aborting update check"); - return; - } - - string currentVersion = $"{App.ProjectName} v{App.Version}"; - - App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Checking for {App.ProjectName} updates..."); - - var releaseInfo = await Utilities.GetJson($"https://api.github.com/repos/{App.ProjectRepository}/releases/latest"); - - if (releaseInfo?.Assets is null || currentVersion == releaseInfo.Name) - { - App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] No updates found"); - return; - } - - SetStatus($"Getting the latest {App.ProjectName}..."); - - // 64-bit is always the first option - GithubReleaseAsset asset = releaseInfo.Assets[0]; - string downloadLocation = Path.Combine(Directories.LocalAppData, "Temp", asset.Name); - - App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Downloading {releaseInfo.Name}..."); - - if (!File.Exists(downloadLocation)) - { - var response = await App.HttpClient.GetAsync(asset.BrowserDownloadUrl); - - await using var fileStream = new FileStream(downloadLocation, FileMode.CreateNew); - await response.Content.CopyToAsync(fileStream); - } - - App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Starting {releaseInfo.Name}..."); - - ProcessStartInfo startInfo = new() - { - FileName = downloadLocation, - }; - - foreach (string arg in App.LaunchArgs) - startInfo.ArgumentList.Add(arg); - - App.Settings.Save(); - - Process.Start(startInfo); - - Environment.Exit(0); - } - - private void Uninstall() - { - // prompt to shutdown roblox if its currently running - if (Utilities.CheckIfRobloxRunning()) - { - App.Logger.WriteLine($"[Bootstrapper::Uninstall] Prompting to shut down all open Roblox instances"); - - Dialog?.PromptShutdown(); - - try - { - foreach (Process process in Process.GetProcessesByName("RobloxPlayerBeta")) - { - process.CloseMainWindow(); - process.Close(); - } - } - catch (Exception ex) - { - App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] Failed to close process! {ex}"); - } - - App.Logger.WriteLine($"[Bootstrapper::Uninstall] All Roblox processes closed"); - } - - SetStatus($"Uninstalling {App.ProjectName}..."); - - //App.Settings.ShouldSave = false; - App.ShouldSaveConfigs = false; - - // check if stock bootstrapper is still installed - RegistryKey? bootstrapperKey = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\roblox-player"); - if (bootstrapperKey is null) - { - ProtocolHandler.Unregister("roblox"); - ProtocolHandler.Unregister("roblox-player"); - } - else - { - // revert launch uri handler to stock bootstrapper - - string bootstrapperLocation = (string?)bootstrapperKey.GetValue("InstallLocation") + "RobloxPlayerLauncher.exe"; - - ProtocolHandler.Register("roblox", "Roblox", bootstrapperLocation); - ProtocolHandler.Register("roblox-player", "Roblox", bootstrapperLocation); - } - - try - { - // delete application key - Registry.CurrentUser.DeleteSubKey($@"Software\{App.ProjectName}"); - - // delete start menu folder - Directory.Delete(Directories.StartMenu, true); - - // delete desktop shortcut - File.Delete(Path.Combine(Directories.Desktop, "Play Roblox.lnk")); - - // delete uninstall key - Registry.CurrentUser.DeleteSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{App.ProjectName}"); - - // delete installation folder - // (should delete everything except bloxstrap itself) - Directory.Delete(Directories.Base, true); - } - catch (Exception ex) - { - App.Logger.WriteLine($"Could not fully uninstall! ({ex})"); - } - - Dialog?.ShowSuccess($"{App.ProjectName} has succesfully uninstalled"); - } - #endregion - - #region Roblox Install - private async Task InstallLatestVersion() - { - _isInstalling = true; - - SetStatus(FreshInstall ? "Installing Roblox..." : "Upgrading Roblox..."); - - Directory.CreateDirectory(Directories.Base); - Directory.CreateDirectory(Directories.Downloads); - Directory.CreateDirectory(Directories.Versions); - - // package manifest states packed size and uncompressed size in exact bytes - // packed size only matters if we don't already have the package cached on disk - string[] cachedPackages = Directory.GetFiles(Directories.Downloads); - int totalSizeRequired = _versionPackageManifest.Where(x => !cachedPackages.Contains(x.Signature)).Sum(x => x.PackedSize) + _versionPackageManifest.Sum(x => x.Size); - - if (Utilities.GetFreeDiskSpace(Directories.Base) < totalSizeRequired) - { - App.ShowMessageBox($"{App.ProjectName} does not have enough disk space to download and install Roblox. Please free up some disk space and try again.", MessageBoxImage.Error); - App.Terminate(ERROR_INSTALL_FAILURE); - return; - } - - if (Dialog is not null) - { - Dialog.CancelEnabled = true; - Dialog.ProgressStyle = ProgressBarStyle.Continuous; - } - - // compute total bytes to download - _progressIncrement = (double)100 / _versionPackageManifest.Sum(package => package.PackedSize); - - foreach (Package package in _versionPackageManifest) - { - if (_cancelFired) - return; - - // download all the packages synchronously - await DownloadPackage(package); - - // we'll extract the runtime installer later if we need to - if (package.Name == "WebView2RuntimeInstaller.zip") - continue; - - // extract the package immediately after download asynchronously - // discard is just used to suppress the warning - Task _ = ExtractPackage(package); - } - - if (_cancelFired) - return; - - // allow progress bar to 100% before continuing (purely ux reasons lol) - await Task.Delay(1000); - - if (Dialog is not null) - { - Dialog.ProgressStyle = ProgressBarStyle.Marquee; - SetStatus("Configuring Roblox..."); - } - - // 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); - } - - string appSettingsLocation = Path.Combine(_versionFolder, "AppSettings.xml"); - await File.WriteAllTextAsync(appSettingsLocation, AppSettings); - - if (_cancelFired) - return; - - if (!FreshInstall) - { - // let's take this opportunity to delete any packages we don't need anymore - foreach (string filename in cachedPackages) - { - if (!_versionPackageManifest.Exists(package => filename.Contains(package.Signature))) - { - App.Logger.WriteLine($"Deleting unused package {filename}"); - File.Delete(filename); - } - } - - string oldVersionFolder = Path.Combine(Directories.Versions, App.State.Prop.VersionGuid); - - if (_latestVersionGuid != App.State.Prop.VersionGuid && Directory.Exists(oldVersionFolder)) - { - // and also to delete our old version folder - Directory.Delete(oldVersionFolder, true); - } - - // 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, "RobloxPlayerBeta.exe"); - string? appFlags = (string?)appFlagsKey.GetValue(oldGameClientLocation); - - if (appFlags is not null) - { - App.Logger.WriteLine($"[Bootstrapper::InstallLatestVersion] Migrating app compatibility flags from {oldGameClientLocation} to {_playerLocation}..."); - appFlagsKey.SetValue(_playerLocation, appFlags); - appFlagsKey.DeleteValue(oldGameClientLocation); - } - } - } - - if (Dialog is not null) - Dialog.CancelEnabled = false; - - App.State.Prop.VersionGuid = _latestVersionGuid; - - _isInstalling = false; - } - - private async Task InstallWebView2() - { - if (!ShouldInstallWebView2) - return; - - App.Logger.WriteLine($"[Bootstrapper::InstallWebView2] 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($"[Bootstrapper::InstallWebView2] Aborted runtime install because package does not exist, has WebView2 been added in this Roblox version yet?"); - return; - } - - await ExtractPackage(package); - } - - SetStatus("Installing WebView2, please wait..."); - - ProcessStartInfo startInfo = new() - { - WorkingDirectory = baseDirectory, - FileName = Path.Combine(baseDirectory, "MicrosoftEdgeWebview2Setup.exe"), - Arguments = "/silent /install" - }; - - await Process.Start(startInfo)!.WaitForExitAsync(); - - App.Logger.WriteLine($"[Bootstrapper::InstallWebView2] Finished installing runtime"); - } - - public static void MigrateIntegrations() - { - // v2.2.0 - remove rbxfpsunlocker - string rbxfpsunlocker = Path.Combine(Directories.Integrations, "rbxfpsunlocker"); - - if (Directory.Exists(rbxfpsunlocker)) - Directory.Delete(rbxfpsunlocker, true); - - // v2.3.0 - remove reshade - string injectorLocation = Path.Combine(Directories.Modifications, "dxgi.dll"); - string configLocation = Path.Combine(Directories.Modifications, "ReShade.ini"); - - if (File.Exists(injectorLocation)) - { - App.ShowMessageBox( - "Roblox has now completeted rollout of the new client update, featuring 64-bit support and the Hyperion anticheat. ReShade does not work with this update, and so it has now been removed from Bloxstrap.\n\n"+ - "Your ReShade configuration files will still be saved, and you can locate them by opening the folder where Bloxstrap is installed to, and navigating to the Integrations folder. You can choose to delete these if you want.", - MessageBoxImage.Warning - ); - - File.Delete(injectorLocation); - } - - if (File.Exists(configLocation)) - File.Delete(configLocation); - } - - private async Task ApplyModifications() - { - SetStatus("Applying Roblox modifications..."); - - // set executable flags for fullscreen optimizations - App.Logger.WriteLine("[Bootstrapper::ApplyModifications] Checking executable flags..."); - using (RegistryKey appFlagsKey = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers")) - { - const 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)) - { - // 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("[Bootstrapper::ApplyModifications] Checking file mods..."); - string modFolder = Path.Combine(Directories.Modifications); - - // manifest has been moved to State.json - File.Delete(Path.Combine(Directories.Base, "ModManifest.txt")); - - List modFolderFiles = new(); - - if (!Directory.Exists(modFolder)) - Directory.CreateDirectory(modFolder); - - await CheckModPreset(App.Settings.Prop.UseOldDeathSound, @"content\sounds\ouch.ogg", "OldDeath.ogg"); - await CheckModPreset(App.Settings.Prop.UseOldMouseCursor, @"content\textures\Cursors\KeyboardMouse\ArrowCursor.png", "OldCursor.png"); - await CheckModPreset(App.Settings.Prop.UseOldMouseCursor, @"content\textures\Cursors\KeyboardMouse\ArrowFarCursor.png", "OldFarCursor.png"); - await CheckModPreset(App.Settings.Prop.UseDisableAppPatch, @"ExtraContent\places\Mobile.rbxl", ""); - - foreach (string file in Directory.GetFiles(modFolder, "*.*", SearchOption.AllDirectories)) - { - // get relative directory path - string relativeFile = file.Substring(modFolder.Length + 1); - - // v1.7.0 - README has been moved to the preferences menu now - if (relativeFile == "README.txt") - { - File.Delete(file); - continue; - } - - modFolderFiles.Add(relativeFile); - } - - // copy and overwrite - foreach (string file in modFolderFiles) - { - string fileModFolder = Path.Combine(modFolder, file); - string fileVersionFolder = Path.Combine(_versionFolder, file); - - if (File.Exists(fileVersionFolder)) - { - if (Utilities.MD5File(fileModFolder) == Utilities.MD5File(fileVersionFolder)) - continue; - } - - string? directory = Path.GetDirectoryName(fileVersionFolder); - - if (directory is null) - continue; - - Directory.CreateDirectory(directory); - - File.Copy(fileModFolder, fileVersionFolder, true); - File.SetAttributes(fileVersionFolder, File.GetAttributes(fileModFolder) & ~FileAttributes.ReadOnly); - } - - // 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 - foreach (string fileLocation in App.State.Prop.ModManifest) - { - if (modFolderFiles.Contains(fileLocation)) - continue; - - KeyValuePair packageDirectory; - - try - { - packageDirectory = PackageDirectories.First(x => x.Value != "" && fileLocation.StartsWith(x.Value)); - } - catch (InvalidOperationException) - { - // package doesn't exist, likely mistakenly placed file - string versionFileLocation = Path.Combine(_versionFolder, fileLocation); - - if (File.Exists(versionFileLocation)) - File.Delete(versionFileLocation); - - continue; - } - - // restore original file - string fileName = fileLocation.Substring(packageDirectory.Value.Length); - ExtractFileFromPackage(packageDirectory.Key, fileName); - } - - App.State.Prop.ModManifest = modFolderFiles; - App.State.Save(); - } - - private static async Task CheckModPreset(bool condition, string location, string name) - { - string modFolderLocation = Path.Combine(Directories.Modifications, location); - byte[] binaryData = string.IsNullOrEmpty(name) ? Array.Empty() : await Resource.Get(name); - - if (condition) - { - if (!File.Exists(modFolderLocation)) - { - string? directory = Path.GetDirectoryName(modFolderLocation); - - if (directory is null) - return; - - Directory.CreateDirectory(directory); - - await File.WriteAllBytesAsync(modFolderLocation, binaryData); - } - } - else if (File.Exists(modFolderLocation) && Utilities.MD5File(modFolderLocation) == Utilities.MD5Data(binaryData)) - { - File.Delete(modFolderLocation); - } - } - - private async Task DownloadPackage(Package package) - { - if (_cancelFired) - return; - - string packageUrl = RobloxDeployment.GetLocation($"/{_latestVersionGuid}-{package.Name}"); - string packageLocation = Path.Combine(Directories.Downloads, package.Signature); - string robloxPackageLocation = Path.Combine(Directories.LocalAppData, "Roblox", "Downloads", package.Signature); - - if (File.Exists(packageLocation)) - { - FileInfo file = new(packageLocation); - - string calculatedMD5 = Utilities.MD5File(packageLocation); - if (calculatedMD5 != package.Signature) - { - App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] {package.Name} is corrupted ({calculatedMD5} != {package.Signature})! Deleting and re-downloading..."); - file.Delete(); - } - else - { - App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] {package.Name} is already downloaded, skipping..."); - _totalDownloadedBytes += package.PackedSize; - UpdateProgressbar(); - return; - } - } - else if (File.Exists(robloxPackageLocation)) - { - // let's cheat! if the stock bootstrapper already previously downloaded the file, - // then we can just copy the one from there - - App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Found existing version of {package.Name} ({robloxPackageLocation})! Copying to Downloads folder..."); - File.Copy(robloxPackageLocation, packageLocation); - _totalDownloadedBytes += package.PackedSize; - UpdateProgressbar(); - return; - } - - if (!File.Exists(packageLocation)) - { - App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Downloading {package.Name} ({package.Signature})..."); - - { - var response = await App.HttpClient.GetAsync(packageUrl, HttpCompletionOption.ResponseHeadersRead, _cancelTokenSource.Token); - var buffer = new byte[4096]; - - await using var stream = await response.Content.ReadAsStreamAsync(_cancelTokenSource.Token); - await using var fileStream = new FileStream(packageLocation, FileMode.CreateNew, FileAccess.Write, FileShare.Delete); - - while (true) - { - if (_cancelFired) - { - stream.Close(); - fileStream.Close(); - return; - } - - var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, _cancelTokenSource.Token); - - if (bytesRead == 0) - break; // we're done - - await fileStream.WriteAsync(buffer, 0, bytesRead, _cancelTokenSource.Token); - - _totalDownloadedBytes += bytesRead; - UpdateProgressbar(); - } - } - - App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Finished downloading {package.Name}!"); - } - } - - private async Task ExtractPackage(Package package) - { - if (_cancelFired) - return; - - string packageLocation = Path.Combine(Directories.Downloads, package.Signature); - string packageFolder = Path.Combine(_versionFolder, PackageDirectories[package.Name]); - string extractPath; - - App.Logger.WriteLine($"[Bootstrapper::ExtractPackage] Extracting {package.Name} to {packageFolder}..."); - - using (ZipArchive archive = await Task.Run(() => ZipFile.OpenRead(packageLocation))) - { - foreach (ZipArchiveEntry entry in archive.Entries) - { - if (_cancelFired) - return; - - if (entry.FullName.EndsWith('\\')) - continue; - - extractPath = Path.Combine(packageFolder, entry.FullName); - - //App.Logger.WriteLine($"[{package.Name}] Writing {extractPath}..."); - - string? directory = Path.GetDirectoryName(extractPath); - - if (directory is null) - continue; - - Directory.CreateDirectory(directory); - - await Task.Run(() => entry.ExtractToFile(extractPath, true)); - } - } - - App.Logger.WriteLine($"[Bootstrapper::ExtractPackage] Finished extracting {package.Name}"); - - _packagesExtracted += 1; - } - - private void ExtractFileFromPackage(string packageName, string fileName) - { - Package? package = _versionPackageManifest.Find(x => x.Name == packageName); - - if (package is null) - return; - - DownloadPackage(package).GetAwaiter().GetResult(); - - string packageLocation = Path.Combine(Directories.Downloads, package.Signature); - string packageFolder = Path.Combine(_versionFolder, PackageDirectories[package.Name]); - - using ZipArchive archive = ZipFile.OpenRead(packageLocation); - - ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(x => x.FullName == fileName); - - if (entry is null) - return; - - string fileLocation = Path.Combine(packageFolder, entry.FullName); - - File.Delete(fileLocation); - - entry.ExtractToFile(fileLocation); - } - #endregion - } -} +using System.Windows; +using System.Windows.Forms; + +using Microsoft.Win32; + +using Bloxstrap.Integrations; + +namespace Bloxstrap +{ + public class Bootstrapper + { + #region Properties + // in case a new package is added, you can find the corresponding directory + // by opening the stock bootstrapper in a hex editor + // TODO - there ideally should be a less static way to do this that's not hardcoded? + private static readonly IReadOnlyDictionary PackageDirectories = new Dictionary() + { + { "RobloxApp.zip", @"" }, + { "shaders.zip", @"shaders\" }, + { "ssl.zip", @"ssl\" }, + + // the runtime installer is only extracted if it needs installing + { "WebView2.zip", @"" }, + { "WebView2RuntimeInstaller.zip", @"WebView2RuntimeInstaller\" }, + + { "content-avatar.zip", @"content\avatar\" }, + { "content-configs.zip", @"content\configs\" }, + { "content-fonts.zip", @"content\fonts\" }, + { "content-sky.zip", @"content\sky\" }, + { "content-sounds.zip", @"content\sounds\" }, + { "content-textures2.zip", @"content\textures\" }, + { "content-models.zip", @"content\models\" }, + + { "content-textures3.zip", @"PlatformContent\pc\textures\" }, + { "content-terrain.zip", @"PlatformContent\pc\terrain\" }, + { "content-platform-fonts.zip", @"PlatformContent\pc\fonts\" }, + + { "extracontent-luapackages.zip", @"ExtraContent\LuaPackages\" }, + { "extracontent-translations.zip", @"ExtraContent\translations\" }, + { "extracontent-models.zip", @"ExtraContent\models\" }, + { "extracontent-textures.zip", @"ExtraContent\textures\" }, + { "extracontent-places.zip", @"ExtraContent\places\" }, + }; + + private const string AppSettings = + "\r\n" + + "\r\n" + + " content\r\n" + + " http://www.roblox.com\r\n" + + "\r\n"; + + private readonly CancellationTokenSource _cancelTokenSource = new(); + + private static bool FreshInstall => String.IsNullOrEmpty(App.State.Prop.VersionGuid); + private static string DesktopShortcutLocation => Path.Combine(Directories.Desktop, "Play Roblox.lnk"); + + private string _playerLocation => Path.Combine(_versionFolder, "RobloxPlayerBeta.exe"); + + private string _launchCommandLine; + + private string _latestVersionGuid = null!; + private PackageManifest _versionPackageManifest = null!; + private string _versionFolder = null!; + + private bool _isInstalling = false; + private double _progressIncrement; + private long _totalDownloadedBytes = 0; + private int _packagesExtracted = 0; + private bool _cancelFired = false; + + public IBootstrapperDialog? Dialog = null; + #endregion + + #region Core + public Bootstrapper(string launchCommandLine) + { + _launchCommandLine = launchCommandLine; + } + + private void SetStatus(string message) + { + App.Logger.WriteLine($"[Bootstrapper::SetStatus] {message}"); + + // yea idk + if (App.Settings.Prop.BootstrapperStyle == BootstrapperStyle.ByfronDialog) + message = message.Replace("...", ""); + + if (Dialog is not null) + Dialog.Message = message; + } + + private void UpdateProgressBar() + { + int newProgress = (int)Math.Floor(_progressIncrement * _totalDownloadedBytes); + + // bugcheck: if we're restoring a file from a package, it'll incorrectly increment the progress beyond 100 + // too lazy to fix properly so lol + if (newProgress > 100) + return; + + if (Dialog is not null) + Dialog.ProgressValue = newProgress; + } + + public async Task Run() + { + App.Logger.WriteLine("[Bootstrapper::Run] Running bootstrapper"); + + if (App.IsUninstall) + { + Uninstall(); + return; + } + +#if !DEBUG + if (!App.IsFirstRun && App.Settings.Prop.CheckForUpdates) + await CheckForUpdates(); +#endif + + // ensure only one instance of the bootstrapper is running at the time + // so that we don't have stuff like two updates happening simultaneously + + bool mutexExists = false; + + try + { + Mutex.OpenExisting("Bloxstrap_BootstrapperMutex").Close(); + App.Logger.WriteLine("[Bootstrapper::Run] Bloxstrap_BootstrapperMutex mutex exists, waiting..."); + mutexExists = true; + } + catch (Exception) + { + // no mutex exists + } + + // wait for mutex to be released if it's not yet + await using AsyncMutex mutex = new("Bloxstrap_BootstrapperMutex"); + await mutex.AcquireAsync(_cancelTokenSource.Token); + + // reload our configs since they've likely changed by now + if (mutexExists) + { + App.Settings.Load(); + App.State.Load(); + } + + await CheckLatestVersion(); + + CheckInstallMigration(); + + // install/update roblox if we're running for the first time, needs updating, or the player location doesn't exist + if (App.IsFirstRun || _latestVersionGuid != App.State.Prop.VersionGuid || !File.Exists(_playerLocation)) + await InstallLatestVersion(); + + if (App.IsFirstRun) + App.ShouldSaveConfigs = true; + + MigrateIntegrations(); + + await InstallWebView2(); + + App.FastFlags.Save(); + await ApplyModifications(); + + if (App.IsFirstRun || FreshInstall) + { + Register(); + RegisterProgramSize(); + } + + CheckInstall(); + + // at this point we've finished updating our configs + App.Settings.Save(); + App.State.Save(); + App.ShouldSaveConfigs = false; + + await mutex.ReleaseAsync(); + + if (App.IsFirstRun && App.IsNoLaunch) + Dialog?.ShowSuccess($"{App.ProjectName} has successfully installed"); + else if (!App.IsNoLaunch && !_cancelFired) + await StartRoblox(); + } + + private async Task CheckLatestVersion() + { + SetStatus("Connecting to Roblox..."); + + ClientVersion clientVersion = await RobloxDeployment.GetInfo(App.Settings.Prop.Channel); + + _latestVersionGuid = clientVersion.VersionGuid; + _versionFolder = Path.Combine(Directories.Versions, _latestVersionGuid); + _versionPackageManifest = await PackageManifest.Get(_latestVersionGuid); + } + + private async Task StartRoblox() + { + SetStatus("Starting Roblox..."); + + if (_launchCommandLine == "--app" && App.Settings.Prop.UseDisableAppPatch) + { + Utilities.ShellExecute("https://www.roblox.com/games"); + Dialog?.CloseBootstrapper(); + return; + } + + if (!File.Exists("C:\\Windows\\System32\\mfplat.dll")) + { + Controls.ShowMessageBox( + "Roblox requires the use of Windows Media Foundation components. You appear to be missing them, likely because you are using an N edition of Windows. Please install them first, and then launch Roblox.", + MessageBoxImage.Error + ); + Utilities.ShellExecute("https://support.microsoft.com/en-us/topic/media-feature-pack-list-for-windows-n-editions-c1c6fffa-d052-8338-7a79-a4bb980a700a"); + Dialog?.CloseBootstrapper(); + return; + } + + _launchCommandLine = _launchCommandLine.Replace("LAUNCHTIMEPLACEHOLDER", DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString()); + + if (App.Settings.Prop.Channel.ToLowerInvariant() != RobloxDeployment.DefaultChannel.ToLowerInvariant()) + _launchCommandLine += " -channel " + App.Settings.Prop.Channel.ToLowerInvariant(); + + // whether we should wait for roblox to exit to handle stuff in the background or clean up after roblox closes + bool shouldWait = false; + + // 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(_playerLocation, _launchCommandLine)) + { + gameClientPid = gameClient.Id; + } + + List autocloseProcesses = new(); + RobloxActivity? activityWatcher = null; + DiscordRichPresence? richPresence = null; + + App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Started Roblox (PID {gameClientPid})"); + + using (SystemEvent startEvent = new("www.roblox.com/robloxStartedEvent")) + { + bool startEventFired = await startEvent.WaitForEvent(); + + startEvent.Close(); + + if (!startEventFired) + return; + } + + if (App.Settings.Prop.EnableActivityTracking) + { + activityWatcher = new(); + shouldWait = true; + + App.NotifyIcon?.SetActivityWatcher(activityWatcher); + + if (App.Settings.Prop.UseDiscordRichPresence) + { + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Using Discord Rich Presence"); + richPresence = new(activityWatcher); + + App.NotifyIcon?.SetRichPresenceHandler(richPresence); + } + } + + // launch custom integrations now + foreach (CustomIntegration integration in App.Settings.Prop.CustomIntegrations) + { + App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})"); + + try + { + Process process = Process.Start(integration.Location, integration.LaunchArgs); + + if (integration.AutoClose) + { + shouldWait = true; + autocloseProcesses.Add(process); + } + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Failed to launch integration '{integration.Name}'! ({ex.Message})"); + } + } + + // event fired, wait for 3 seconds then close + await Task.Delay(3000); + Dialog?.CloseBootstrapper(); + + // keep bloxstrap open in the background if needed + if (!shouldWait) + return; + + activityWatcher?.StartWatcher(); + + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Waiting for Roblox to close"); + + while (Process.GetProcesses().Any(x => x.Id == gameClientPid)) + await Task.Delay(1000); + + App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Roblox has exited"); + + richPresence?.Dispose(); + + foreach (Process process in autocloseProcesses) + { + if (process.HasExited) + continue; + + App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Autoclosing process '{process.ProcessName}' (PID {process.Id})"); + process.Kill(); + } + } + + public void CancelInstall() + { + if (!_isInstalling) + { + App.Terminate(ErrorCode.ERROR_CANCELLED); + return; + } + + App.Logger.WriteLine("[Bootstrapper::CancelInstall] Cancelling install..."); + + _cancelTokenSource.Cancel(); + _cancelFired = true; + + try + { + // clean up install + if (App.IsFirstRun) + Directory.Delete(Directories.Base, true); + else if (Directory.Exists(_versionFolder)) + Directory.Delete(_versionFolder, true); + } + catch (Exception ex) + { + App.Logger.WriteLine("[Bootstrapper::CancelInstall] Could not fully clean up installation!"); + App.Logger.WriteLine($"[Bootstrapper::CancelInstall] {ex}"); + } + + App.Terminate(ErrorCode.ERROR_CANCELLED); + } + #endregion + + #region App Install + public static void Register() + { + using (RegistryKey applicationKey = Registry.CurrentUser.CreateSubKey($@"Software\{App.ProjectName}")) + { + applicationKey.SetValue("InstallLocation", Directories.Base); + } + + // set uninstall key + using (RegistryKey uninstallKey = Registry.CurrentUser.CreateSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{App.ProjectName}")) + { + uninstallKey.SetValue("DisplayIcon", $"{Directories.Application},0"); + uninstallKey.SetValue("DisplayName", App.ProjectName); + uninstallKey.SetValue("DisplayVersion", App.Version); + + if (uninstallKey.GetValue("InstallDate") is null) + uninstallKey.SetValue("InstallDate", DateTime.Now.ToString("yyyyMMdd")); + + uninstallKey.SetValue("InstallLocation", Directories.Base); + uninstallKey.SetValue("NoRepair", 1); + uninstallKey.SetValue("Publisher", "pizzaboxer"); + uninstallKey.SetValue("ModifyPath", $"\"{Directories.Application}\" -menu"); + uninstallKey.SetValue("QuietUninstallString", $"\"{Directories.Application}\" -uninstall -quiet"); + uninstallKey.SetValue("UninstallString", $"\"{Directories.Application}\" -uninstall"); + uninstallKey.SetValue("URLInfoAbout", $"https://github.com/{App.ProjectRepository}"); + uninstallKey.SetValue("URLUpdateInfo", $"https://github.com/{App.ProjectRepository}/releases/latest"); + } + + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Registered application"); + } + + public void RegisterProgramSize() + { + App.Logger.WriteLine("[Bootstrapper::RegisterProgramSize] 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 totalSize = (_versionPackageManifest.Sum(x => x.Size) + _versionPackageManifest.Sum(x => x.PackedSize)) / 1000; + + uninstallKey.SetValue("EstimatedSize", totalSize); + + App.Logger.WriteLine($"[Bootstrapper::RegisterProgramSize] Registered as {totalSize} KB"); + } + + private void CheckInstallMigration() + { + // check if we've changed our install location since the last time we started + // in which case, we'll have to copy over all our folders so we don't lose any mods and stuff + + using RegistryKey? applicationKey = Registry.CurrentUser.OpenSubKey($@"Software\{App.ProjectName}", true); + + string? oldInstallLocation = (string?)applicationKey?.GetValue("OldInstallLocation"); + + if (applicationKey is null || oldInstallLocation is null || oldInstallLocation == Directories.Base) + return; + + SetStatus("Migrating install location..."); + + if (Directory.Exists(oldInstallLocation)) + { + App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Moving all files in {oldInstallLocation} to {Directories.Base}..."); + + foreach (string oldFileLocation in Directory.GetFiles(oldInstallLocation, "*.*", SearchOption.AllDirectories)) + { + string relativeFile = oldFileLocation.Substring(oldInstallLocation.Length + 1); + string newFileLocation = Path.Combine(Directories.Base, relativeFile); + string? newDirectory = Path.GetDirectoryName(newFileLocation); + + try + { + if (!String.IsNullOrEmpty(newDirectory)) + Directory.CreateDirectory(newDirectory); + + File.Move(oldFileLocation, newFileLocation, true); + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Failed to move {oldFileLocation} to {newFileLocation}! {ex}"); + } + } + + try + { + Directory.Delete(oldInstallLocation, true); + App.Logger.WriteLine("[Bootstrapper::CheckInstallMigration] Deleted old install location"); + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::CheckInstallMigration] Failed to delete old install location! {ex}"); + } + } + + applicationKey.DeleteValue("OldInstallLocation"); + + // allow shortcuts to be re-registered + if (Directory.Exists(Directories.StartMenu)) + Directory.Delete(Directories.StartMenu, true); + + if (File.Exists(DesktopShortcutLocation)) + { + File.Delete(DesktopShortcutLocation); + App.Settings.Prop.CreateDesktopIcon = true; + } + + App.Logger.WriteLine("[Bootstrapper::CheckInstallMigration] Finished migrating install location!"); + } + + public static void CheckInstall() + { + App.Logger.WriteLine("[Bootstrapper::CheckInstall] 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", Directories.Application); + ProtocolHandler.Register("roblox-player", "Roblox", Directories.Application); + + // in case the user is reinstalling + if (File.Exists(Directories.Application) && App.IsFirstRun) + File.Delete(Directories.Application); + + // check to make sure bootstrapper is in the install folder + if (!File.Exists(Directories.Application) && Environment.ProcessPath is not null) + File.Copy(Environment.ProcessPath, Directories.Application); + + // this SHOULD go under Register(), + // but then people who have Bloxstrap v1.0.0 installed won't have this without a reinstall + // maybe in a later version? + if (!Directory.Exists(Directories.StartMenu)) + { + Directory.CreateDirectory(Directories.StartMenu); + + ShellLink.Shortcut.CreateShortcut(Directories.Application, "", Directories.Application, 0) + .WriteToFile(Path.Combine(Directories.StartMenu, "Play Roblox.lnk")); + + ShellLink.Shortcut.CreateShortcut(Directories.Application, "-menu", Directories.Application, 0) + .WriteToFile(Path.Combine(Directories.StartMenu, $"{App.ProjectName} Menu.lnk")); + } + else + { + // v2.0.0 - rebadge configuration menu as just "Bloxstrap Menu" + string oldMenuShortcut = Path.Combine(Directories.StartMenu, $"Configure {App.ProjectName}.lnk"); + string newMenuShortcut = Path.Combine(Directories.StartMenu, $"{App.ProjectName} Menu.lnk"); + + if (File.Exists(oldMenuShortcut)) + File.Delete(oldMenuShortcut); + + if (!File.Exists(newMenuShortcut)) + ShellLink.Shortcut.CreateShortcut(Directories.Application, "-menu", Directories.Application, 0) + .WriteToFile(newMenuShortcut); + } + + if (App.Settings.Prop.CreateDesktopIcon) + { + if (!File.Exists(DesktopShortcutLocation)) + { + try + { + ShellLink.Shortcut.CreateShortcut(Directories.Application, "", Directories.Application, 0) + .WriteToFile(DesktopShortcutLocation); + } + catch (Exception ex) + { + App.Logger.WriteLine("[Bootstrapper::CheckInstall] Could not create desktop shortcut, aborting"); + App.Logger.WriteLine($"[Bootstrapper::CheckInstall] {ex}"); + } + } + + // one-time toggle, set it back to false + App.Settings.Prop.CreateDesktopIcon = false; + } + } + + private async Task CheckForUpdates() + { + // don't update if there's another instance running (likely running in the background) + if (Process.GetProcessesByName(App.ProjectName).Count() > 1) + { + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] More than one Bloxstrap instance running, aborting update check"); + return; + } + + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Checking for updates..."); + + GithubRelease? releaseInfo; + try + { + releaseInfo = await Http.GetJson($"https://api.github.com/repos/{App.ProjectRepository}/releases/latest"); + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Failed to fetch releases: {ex}"); + return; + } + + if (releaseInfo is null || releaseInfo.Assets is null) + { + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] No updates found"); + return; + } + + int 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 == 0 && App.BuildMetadata.CommitRef.StartsWith("tag") || versionComparison == 1) + { + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] No updates found"); + return; + } + + + SetStatus($"Getting the latest {App.ProjectName}..."); + + // 64-bit is always the first option + GithubReleaseAsset asset = releaseInfo.Assets[0]; + string downloadLocation = Path.Combine(Directories.LocalAppData, "Temp", asset.Name); + + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Downloading {releaseInfo.TagName}..."); + + if (!File.Exists(downloadLocation)) + { + var response = await App.HttpClient.GetAsync(asset.BrowserDownloadUrl); + + await using var fileStream = new FileStream(downloadLocation, FileMode.CreateNew); + await response.Content.CopyToAsync(fileStream); + } + + App.Logger.WriteLine($"[Bootstrapper::CheckForUpdates] Starting {releaseInfo.TagName}..."); + + ProcessStartInfo startInfo = new() + { + FileName = downloadLocation, + }; + + foreach (string arg in App.LaunchArgs) + startInfo.ArgumentList.Add(arg); + + App.Settings.Save(); + App.ShouldSaveConfigs = false; + + Process.Start(startInfo); + + App.Terminate(); + } + + private void Uninstall() + { + // prompt to shutdown roblox if its currently running + if (Process.GetProcessesByName(App.RobloxAppName).Any()) + { + App.Logger.WriteLine($"[Bootstrapper::Uninstall] Prompting to shut down all open Roblox instances"); + + MessageBoxResult result = Controls.ShowMessageBox( + "Roblox is currently running, but must be closed before uninstalling Bloxstrap. Would you like close Roblox now?", + MessageBoxImage.Information, + MessageBoxButton.OKCancel + ); + + if (result != MessageBoxResult.OK) + App.Terminate(ErrorCode.ERROR_CANCELLED); + + try + { + foreach (Process process in Process.GetProcessesByName("RobloxPlayerBeta")) + { + process.CloseMainWindow(); + process.Close(); + } + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::ShutdownIfRobloxRunning] Failed to close process! {ex}"); + } + + App.Logger.WriteLine($"[Bootstrapper::Uninstall] All Roblox processes closed"); + } + + SetStatus($"Uninstalling {App.ProjectName}..."); + + App.ShouldSaveConfigs = false; + + // check if stock bootstrapper is still installed + RegistryKey? bootstrapperKey = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\roblox-player"); + if (bootstrapperKey is null) + { + ProtocolHandler.Unregister("roblox"); + ProtocolHandler.Unregister("roblox-player"); + } + else + { + // revert launch uri handler to stock bootstrapper + + string bootstrapperLocation = (string?)bootstrapperKey.GetValue("InstallLocation") + "RobloxPlayerLauncher.exe"; + + ProtocolHandler.Register("roblox", "Roblox", bootstrapperLocation); + ProtocolHandler.Register("roblox-player", "Roblox", bootstrapperLocation); + } + + // if the folder we're installed to does not end with "Bloxstrap", we're installed to a user-selected folder + // in which case, chances are they chose to install to somewhere they didn't really mean to (prior to the added warning in 2.4.0) + // if so, we're walking on eggshells and have to ensure we only clean up what we need to clean up + bool cautiousUninstall = !Directories.Base.EndsWith(App.ProjectName); + + var cleanupSequence = new List + { + () => Registry.CurrentUser.DeleteSubKey($@"Software\{App.ProjectName}"), + () => Directory.Delete(Directories.StartMenu, true), + () => File.Delete(Path.Combine(Directories.Desktop, "Play Roblox.lnk")), + () => Registry.CurrentUser.DeleteSubKey($@"Software\Microsoft\Windows\CurrentVersion\Uninstall\{App.ProjectName}") + }; + + if (cautiousUninstall) + { + cleanupSequence.Add(() => Directory.Delete(Directories.Downloads, true)); + cleanupSequence.Add(() => Directory.Delete(Directories.Modifications, true)); + cleanupSequence.Add(() => Directory.Delete(Directories.Versions, true)); + cleanupSequence.Add(() => Directory.Delete(Directories.Logs, true)); + + cleanupSequence.Add(() => File.Delete(App.Settings.FileLocation)); + cleanupSequence.Add(() => File.Delete(App.State.FileLocation)); + } + else + { + cleanupSequence.Add(() => Directory.Delete(Directories.Base, true)); + } + + foreach (var process in cleanupSequence) + { + try + { + process(); + } + catch (Exception ex) + { + App.Logger.WriteLine($"[Bootstrapper::Uninstall] Encountered exception when running cleanup sequence (#{cleanupSequence.IndexOf(process)})"); + App.Logger.WriteLine($"[Bootstrapper::Uninstall] {ex}"); + } + } + + Action? callback = null; + + if (Directory.Exists(Directories.Base)) + { + callback = delegate + { + // this is definitely one of the workaround hacks of all time + // could antiviruses falsely detect this as malicious behaviour though? + // "hmm whats this program doing running a cmd command chain quietly in the background that auto deletes an entire folder" + + string deleteCommand; + + if (cautiousUninstall) + deleteCommand = $"del /Q \"{Directories.Application}\""; + else + deleteCommand = $"del /Q \"{Directories.Base}\\*\" && rmdir \"{Directories.Base}\""; + + Process.Start(new ProcessStartInfo() + { + FileName = "cmd.exe", + Arguments = $"/c timeout 5 && {deleteCommand}", + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden + }); + }; + } + + Dialog?.ShowSuccess($"{App.ProjectName} has succesfully uninstalled", callback); + } + #endregion + + #region Roblox Install + private async Task InstallLatestVersion() + { + _isInstalling = true; + + SetStatus(FreshInstall ? "Installing Roblox..." : "Upgrading Roblox..."); + + Directory.CreateDirectory(Directories.Base); + Directory.CreateDirectory(Directories.Downloads); + Directory.CreateDirectory(Directories.Versions); + + // package manifest states packed size and uncompressed size in exact bytes + // packed size only matters if we don't already have the package cached on disk + string[] cachedPackages = Directory.GetFiles(Directories.Downloads); + int totalSizeRequired = _versionPackageManifest.Where(x => !cachedPackages.Contains(x.Signature)).Sum(x => x.PackedSize) + _versionPackageManifest.Sum(x => x.Size); + + if (Utilities.GetFreeDiskSpace(Directories.Base) < totalSizeRequired) + { + Controls.ShowMessageBox( + $"{App.ProjectName} does not have enough disk space to download and install Roblox. Please free up some disk space and try again.", + MessageBoxImage.Error + ); + + App.Terminate(ErrorCode.ERROR_INSTALL_FAILURE); + return; + } + + if (Dialog is not null) + { + Dialog.CancelEnabled = true; + Dialog.ProgressStyle = ProgressBarStyle.Continuous; + } + + // compute total bytes to download + _progressIncrement = (double)100 / _versionPackageManifest.Sum(package => package.PackedSize); + + foreach (Package package in _versionPackageManifest) + { + if (_cancelFired) + return; + + // download all the packages synchronously + await DownloadPackage(package); + + // we'll extract the runtime installer later if we need to + if (package.Name == "WebView2RuntimeInstaller.zip") + continue; + + // extract the package immediately after download asynchronously + // discard is just used to suppress the warning + Task _ = ExtractPackage(package); + } + + if (_cancelFired) + return; + + // allow progress bar to 100% before continuing (purely ux reasons lol) + await Task.Delay(1000); + + if (Dialog is not null) + { + Dialog.ProgressStyle = ProgressBarStyle.Marquee; + SetStatus("Configuring Roblox..."); + } + + // 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); + } + + string appSettingsLocation = Path.Combine(_versionFolder, "AppSettings.xml"); + await File.WriteAllTextAsync(appSettingsLocation, AppSettings); + + if (_cancelFired) + return; + + if (!FreshInstall) + { + // let's take this opportunity to delete any packages we don't need anymore + foreach (string filename in cachedPackages) + { + if (!_versionPackageManifest.Exists(package => filename.Contains(package.Signature))) + { + App.Logger.WriteLine($"[Bootstrapper::InstallLatestVersion] Deleting unused package {filename}"); + File.Delete(filename); + } + } + + string oldVersionFolder = Path.Combine(Directories.Versions, App.State.Prop.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, "RobloxPlayerBeta.exe"); + string? appFlags = (string?)appFlagsKey.GetValue(oldGameClientLocation); + + if (appFlags is not null) + { + App.Logger.WriteLine($"[Bootstrapper::InstallLatestVersion] Migrating app compatibility flags from {oldGameClientLocation} to {_playerLocation}..."); + appFlagsKey.SetValue(_playerLocation, appFlags); + appFlagsKey.DeleteValue(oldGameClientLocation); + } + } + + // 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 (!Process.GetProcessesByName(App.RobloxAppName).Any()) + { + foreach (DirectoryInfo dir in new DirectoryInfo(Directories.Versions).GetDirectories()) + { + if (dir.Name == _latestVersionGuid || !dir.Name.StartsWith("version-")) + continue; + + App.Logger.WriteLine($"[Bootstrapper::InstallLatestVersion] Removing old version folder for {dir.Name}"); + dir.Delete(true); + } + } + } + + App.State.Prop.VersionGuid = _latestVersionGuid; + + // don't register program size until the program is registered, which will be done after this + if (!App.IsFirstRun && !FreshInstall) + RegisterProgramSize(); + + if (Dialog is not null) + Dialog.CancelEnabled = false; + + _isInstalling = false; + } + + private async Task InstallWebView2() + { + // check if the webview2 runtime needs to be installed + // webview2 can either be installed be per-user or globally, so we need to check in both hklm and hkcu + // https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#detect-if-a-suitable-webview2-runtime-is-already-installed + + using RegistryKey? hklmKey = Registry.LocalMachine.OpenSubKey("SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); + using RegistryKey? hkcuKey = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); + + if (hklmKey is not null || hkcuKey is not null) + return; + + App.Logger.WriteLine($"[Bootstrapper::InstallWebView2] 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($"[Bootstrapper::InstallWebView2] Aborted runtime install because package does not exist, has WebView2 been added in this Roblox version yet?"); + return; + } + + await ExtractPackage(package); + } + + SetStatus("Installing WebView2, please wait..."); + + ProcessStartInfo startInfo = new() + { + WorkingDirectory = baseDirectory, + FileName = Path.Combine(baseDirectory, "MicrosoftEdgeWebview2Setup.exe"), + Arguments = "/silent /install" + }; + + await Process.Start(startInfo)!.WaitForExitAsync(); + + App.Logger.WriteLine($"[Bootstrapper::InstallWebView2] Finished installing runtime"); + } + + public static void MigrateIntegrations() + { + // v2.2.0 - remove rbxfpsunlocker + string rbxfpsunlocker = Path.Combine(Directories.Integrations, "rbxfpsunlocker"); + + if (Directory.Exists(rbxfpsunlocker)) + Directory.Delete(rbxfpsunlocker, true); + + // v2.3.0 - remove reshade + string injectorLocation = Path.Combine(Directories.Modifications, "dxgi.dll"); + string configLocation = Path.Combine(Directories.Modifications, "ReShade.ini"); + + if (File.Exists(injectorLocation)) + { + Controls.ShowMessageBox( + "Roblox has now finished rolling out the new game client update, featuring 64-bit support and the Hyperion anticheat. ReShade does not work with this update, and so it has now been disabled and removed from Bloxstrap.\n\n"+ + "Your ReShade configuration files will still be saved, and you can locate them by opening the folder where Bloxstrap is installed to, and navigating to the Integrations folder. You can choose to delete these if you want.", + MessageBoxImage.Warning + ); + + File.Delete(injectorLocation); + } + + if (File.Exists(configLocation)) + File.Delete(configLocation); + } + + private async Task ApplyModifications() + { + SetStatus("Applying Roblox modifications..."); + + // set executable flags for fullscreen optimizations + App.Logger.WriteLine("[Bootstrapper::ApplyModifications] 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($"[Bootstrapper::ApplyModifications] 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($"[Bootstrapper::ApplyModifications] 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("[Bootstrapper::ApplyModifications] Checking file mods..."); + string modFolder = Path.Combine(Directories.Modifications); + + // manifest has been moved to State.json + File.Delete(Path.Combine(Directories.Base, "ModManifest.txt")); + + List modFolderFiles = new(); + + if (!Directory.Exists(modFolder)) + Directory.CreateDirectory(modFolder); + + bool appDisabled = App.Settings.Prop.UseDisableAppPatch && !_launchCommandLine.Contains("--deeplink"); + + // cursors + + await CheckModPreset(App.Settings.Prop.CursorType == CursorType.From2006, new Dictionary + { + { @"content\textures\Cursors\KeyboardMouse\ArrowCursor.png", "Cursor.From2006.ArrowCursor.png" }, + { @"content\textures\Cursors\KeyboardMouse\ArrowFarCursor.png", "Cursor.From2006.ArrowFarCursor.png" } + }); + + await CheckModPreset(App.Settings.Prop.CursorType == CursorType.From2013, new Dictionary + { + { @"content\textures\Cursors\KeyboardMouse\ArrowCursor.png", "Cursor.From2013.ArrowCursor.png" }, + { @"content\textures\Cursors\KeyboardMouse\ArrowFarCursor.png", "Cursor.From2013.ArrowFarCursor.png" } + }); + + // character sounds + await CheckModPreset(App.Settings.Prop.UseOldDeathSound, @"content\sounds\ouch.ogg", "Sounds.OldDeath.ogg"); + + await CheckModPreset(App.Settings.Prop.UseOldCharacterSounds, new Dictionary + { + { @"content\sounds\action_footsteps_plastic.mp3", "Sounds.OldWalk.mp3" }, + { @"content\sounds\action_jump.mp3", "Sounds.OldJump.mp3" }, + { @"content\sounds\action_get_up.mp3", "Sounds.OldGetUp.mp3" }, + { @"content\sounds\action_falling.mp3", "Sounds.Empty.mp3" }, + { @"content\sounds\action_jump_land.mp3", "Sounds.Empty.mp3" }, + { @"content\sounds\action_swim.mp3", "Sounds.Empty.mp3" }, + { @"content\sounds\impact_water.mp3", "Sounds.Empty.mp3" } + }); + + // Mobile.rbxl + await CheckModPreset(appDisabled, @"ExtraContent\places\Mobile.rbxl", ""); + await CheckModPreset(App.Settings.Prop.UseOldAvatarBackground && !appDisabled, @"ExtraContent\places\Mobile.rbxl", "OldAvatarBackground.rbxl"); + + // emoji presets are downloaded remotely from github due to how large they are + string contentFonts = Path.Combine(Directories.Modifications, "content\\fonts"); + string emojiFontLocation = Path.Combine(contentFonts, "TwemojiMozilla.ttf"); + string emojiFontHash = File.Exists(emojiFontLocation) ? Utility.MD5Hash.FromFile(emojiFontLocation) : ""; + + if (App.Settings.Prop.EmojiType == EmojiType.Default && EmojiTypeEx.Hashes.Values.Contains(emojiFontHash)) + { + File.Delete(emojiFontLocation); + } + else if (App.Settings.Prop.EmojiType != EmojiType.Default && emojiFontHash != App.Settings.Prop.EmojiType.GetHash()) + { + if (emojiFontHash != "") + File.Delete(emojiFontLocation); + + Directory.CreateDirectory(contentFonts); + + var response = await App.HttpClient.GetAsync(App.Settings.Prop.EmojiType.GetUrl()); + await using var fileStream = new FileStream(emojiFontLocation, FileMode.CreateNew); + await response.Content.CopyToAsync(fileStream); + } + + // check custom font mod + // instead of replacing the fonts themselves, we'll just alter the font family manifests + + string modFontFamiliesFolder = Path.Combine(Directories.Modifications, "content\\fonts\\families"); + string customFontLocation = Path.Combine(Directories.Modifications, "content\\fonts\\CustomFont.ttf"); + + if (File.Exists(customFontLocation)) + { + App.Logger.WriteLine("[Bootstrapper::ApplyModifications] Begin font check"); + + Directory.CreateDirectory(modFontFamiliesFolder); + + foreach (string jsonFilePath in Directory.GetFiles(Path.Combine(_versionFolder, "content\\fonts\\families"))) + { + string jsonFilename = Path.GetFileName(jsonFilePath); + string modFilepath = Path.Combine(modFontFamiliesFolder, jsonFilename); + + if (File.Exists(modFilepath)) + continue; + + FontFamily? fontFamilyData = JsonSerializer.Deserialize(File.ReadAllText(jsonFilePath)); + + if (fontFamilyData is null) + continue; + + foreach (FontFace fontFace in fontFamilyData.Faces) + fontFace.AssetId = "rbxasset://fonts/CustomFont.ttf"; + + File.WriteAllText(modFilepath, JsonSerializer.Serialize(fontFamilyData, new JsonSerializerOptions { WriteIndented = true })); + } + + App.Logger.WriteLine("[Bootstrapper::ApplyModifications] End font check"); + } + else if (Directory.Exists(modFontFamiliesFolder)) + { + Directory.Delete(modFontFamiliesFolder, true); + } + + foreach (string file in Directory.GetFiles(modFolder, "*.*", SearchOption.AllDirectories)) + { + // get relative directory path + string relativeFile = file.Substring(modFolder.Length + 1); + + // v1.7.0 - README has been moved to the preferences menu now + if (relativeFile == "README.txt") + { + File.Delete(file); + continue; + } + + modFolderFiles.Add(relativeFile); + } + + // copy and overwrite + foreach (string file in modFolderFiles) + { + string fileModFolder = Path.Combine(modFolder, file); + string fileVersionFolder = Path.Combine(_versionFolder, file); + + if (File.Exists(fileVersionFolder)) + { + if (Utility.MD5Hash.FromFile(fileModFolder) == Utility.MD5Hash.FromFile(fileVersionFolder)) + continue; + } + + string? directory = Path.GetDirectoryName(fileVersionFolder); + + if (directory is null) + continue; + + Directory.CreateDirectory(directory); + + File.Copy(fileModFolder, fileVersionFolder, true); + File.SetAttributes(fileVersionFolder, File.GetAttributes(fileModFolder) & ~FileAttributes.ReadOnly); + } + + // 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 + foreach (string fileLocation in App.State.Prop.ModManifest) + { + if (modFolderFiles.Contains(fileLocation)) + continue; + + KeyValuePair packageDirectory; + + try + { + packageDirectory = PackageDirectories.First(x => x.Value != "" && fileLocation.StartsWith(x.Value)); + } + catch (InvalidOperationException) + { + // package doesn't exist, likely mistakenly placed file + string versionFileLocation = Path.Combine(_versionFolder, fileLocation); + + if (File.Exists(versionFileLocation)) + File.Delete(versionFileLocation); + + continue; + } + + // restore original file + string fileName = fileLocation.Substring(packageDirectory.Value.Length); + ExtractFileFromPackage(packageDirectory.Key, fileName); + } + + App.State.Prop.ModManifest = modFolderFiles; + App.State.Save(); + } + + private static async Task CheckModPreset(bool condition, string location, string name) + { + string fullLocation = Path.Combine(Directories.Modifications, location); + string fileHash = File.Exists(fullLocation) ? MD5Hash.FromFile(fullLocation) : ""; + + if (!condition && fileHash == "") + return; + + byte[] embeddedData = string.IsNullOrEmpty(name) ? Array.Empty() : await Resource.Get(name); + string embeddedHash = MD5Hash.FromBytes(embeddedData); + + if (!condition) + { + if (fileHash != "" && fileHash == embeddedHash) + File.Delete(fullLocation); + + return; + } + + if (fileHash != embeddedHash) + { + Directory.CreateDirectory(Path.GetDirectoryName(fullLocation)!); + File.Delete(fullLocation); + + await File.WriteAllBytesAsync(fullLocation, embeddedData); + } + } + + private static async Task CheckModPreset(bool condition, Dictionary mapping) + { + foreach (var pair in mapping) + await CheckModPreset(condition, pair.Key, pair.Value); + } + + private async Task DownloadPackage(Package package) + { + if (_cancelFired) + return; + + string packageUrl = RobloxDeployment.GetLocation($"/{_latestVersionGuid}-{package.Name}"); + string packageLocation = Path.Combine(Directories.Downloads, package.Signature); + string robloxPackageLocation = Path.Combine(Directories.LocalAppData, "Roblox", "Downloads", package.Signature); + + if (File.Exists(packageLocation)) + { + FileInfo file = new(packageLocation); + + string calculatedMD5 = Utility.MD5Hash.FromFile(packageLocation); + + if (calculatedMD5 != package.Signature) + { + App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] {package.Name} is corrupted ({calculatedMD5} != {package.Signature})! Deleting and re-downloading..."); + file.Delete(); + } + else + { + App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] {package.Name} is already downloaded, skipping..."); + _totalDownloadedBytes += package.PackedSize; + UpdateProgressBar(); + return; + } + } + else if (File.Exists(robloxPackageLocation)) + { + // let's cheat! if the stock bootstrapper already previously downloaded the file, + // then we can just copy the one from there + + App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Found existing version of {package.Name} ({robloxPackageLocation})! Copying to Downloads folder..."); + File.Copy(robloxPackageLocation, packageLocation); + _totalDownloadedBytes += package.PackedSize; + UpdateProgressBar(); + return; + } + + if (!File.Exists(packageLocation)) + { + App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Downloading {package.Name} ({package.Signature})..."); + + { + var response = await App.HttpClient.GetAsync(packageUrl, HttpCompletionOption.ResponseHeadersRead, _cancelTokenSource.Token); + var buffer = new byte[4096]; + + await using var stream = await response.Content.ReadAsStreamAsync(_cancelTokenSource.Token); + await using var fileStream = new FileStream(packageLocation, FileMode.CreateNew, FileAccess.Write, FileShare.Delete); + + while (true) + { + if (_cancelFired) + { + stream.Close(); + fileStream.Close(); + return; + } + + var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, _cancelTokenSource.Token); + + if (bytesRead == 0) + break; // we're done + + await fileStream.WriteAsync(buffer, 0, bytesRead, _cancelTokenSource.Token); + + _totalDownloadedBytes += bytesRead; + UpdateProgressBar(); + } + } + + App.Logger.WriteLine($"[Bootstrapper::DownloadPackage] Finished downloading {package.Name}!"); + } + } + + private async Task ExtractPackage(Package package) + { + if (_cancelFired) + return; + + string packageLocation = Path.Combine(Directories.Downloads, package.Signature); + string packageFolder = Path.Combine(_versionFolder, PackageDirectories[package.Name]); + string extractPath; + + App.Logger.WriteLine($"[Bootstrapper::ExtractPackage] Extracting {package.Name} to {packageFolder}..."); + + using ZipArchive archive = await Task.Run(() => ZipFile.OpenRead(packageLocation)); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (_cancelFired) + return; + + if (entry.FullName.EndsWith('\\')) + continue; + + extractPath = Path.Combine(packageFolder, entry.FullName); + + //App.Logger.WriteLine($"[{package.Name}] Writing {extractPath}..."); + + string? directory = Path.GetDirectoryName(extractPath); + + if (directory is null) + continue; + + Directory.CreateDirectory(directory); + + using var fileStream = new FileStream(extractPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 0x1000, useAsync: true); + using var dataStream = entry.Open(); + + await dataStream.CopyToAsync(fileStream); + + File.SetLastWriteTime(extractPath, entry.LastWriteTime.DateTime); + } + + App.Logger.WriteLine($"[Bootstrapper::ExtractPackage] Finished extracting {package.Name}"); + + _packagesExtracted += 1; + } + + private void ExtractFileFromPackage(string packageName, string fileName) + { + Package? package = _versionPackageManifest.Find(x => x.Name == packageName); + + if (package is null) + return; + + DownloadPackage(package).GetAwaiter().GetResult(); + + string packageLocation = Path.Combine(Directories.Downloads, package.Signature); + string packageFolder = Path.Combine(_versionFolder, PackageDirectories[package.Name]); + + using ZipArchive archive = ZipFile.OpenRead(packageLocation); + + ZipArchiveEntry? entry = archive.Entries.FirstOrDefault(x => x.FullName == fileName); + + if (entry is null) + return; + + string fileLocation = Path.Combine(packageFolder, entry.FullName); + + File.Delete(fileLocation); + + entry.ExtractToFile(fileLocation); + } + #endregion + } +} diff --git a/Bloxstrap/Dialogs/BootstrapperDialogForm.cs b/Bloxstrap/Dialogs/BootstrapperDialogForm.cs deleted file mode 100644 index 64e5c5f3..00000000 --- a/Bloxstrap/Dialogs/BootstrapperDialogForm.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System; -using System.Windows; -using System.Windows.Forms; - -using Bloxstrap.Extensions; - -namespace Bloxstrap.Dialogs -{ - public class BootstrapperDialogForm : Form, IBootstrapperDialog - { - public Bootstrapper? Bootstrapper { get; set; } - - #region UI Elements - protected virtual string _message { get; set; } = "Please wait..."; - protected virtual ProgressBarStyle _progressStyle { get; set; } - protected virtual int _progressValue { get; set; } - protected virtual bool _cancelEnabled { get; set; } - - public string Message - { - get => _message; - set - { - if (this.InvokeRequired) - this.Invoke(() => _message = value); - else - _message = value; - } - } - - public ProgressBarStyle ProgressStyle - { - get => _progressStyle; - set - { - if (this.InvokeRequired) - this.Invoke(() => _progressStyle = value); - else - _progressStyle = value; - } - } - - public int ProgressValue - { - get => _progressValue; - set - { - if (this.InvokeRequired) - this.Invoke(() => _progressValue = value); - else - _progressValue = value; - } - } - - public bool CancelEnabled - { - get => _cancelEnabled; - set - { - if (this.InvokeRequired) - this.Invoke(() => _cancelEnabled = value); - else - _cancelEnabled = value; - } - } - #endregion - - public void ScaleWindow() - { - this.Size = this.MinimumSize = this.MaximumSize = WindowScaling.GetScaledSize(this.Size); - - foreach (Control control in this.Controls) - { - control.Size = WindowScaling.GetScaledSize(control.Size); - control.Location = WindowScaling.GetScaledPoint(control.Location); - control.Padding = WindowScaling.GetScaledPadding(control.Padding); - } - } - - public void SetupDialog() - { - this.Text = App.Settings.Prop.BootstrapperTitle; - this.Icon = App.Settings.Prop.BootstrapperIcon.GetIcon(); - } - - public void ButtonCancel_Click(object? sender, EventArgs e) - { - Bootstrapper?.CancelInstall(); - this.Close(); - } - - #region IBootstrapperDialog Methods - public void ShowBootstrapper() => this.ShowDialog(); - - public virtual void CloseBootstrapper() - { - if (this.InvokeRequired) - this.Invoke(CloseBootstrapper); - else - this.Close(); - } - - public virtual void ShowSuccess(string message) - { - App.ShowMessageBox(message, MessageBoxImage.Information); - App.Terminate(); - } - - public virtual void ShowError(string message) - { - App.ShowMessageBox($"An error occurred while starting Roblox\n\nDetails: {message}", MessageBoxImage.Error); - App.Terminate(Bootstrapper.ERROR_INSTALL_FAILURE); - } - - public void PromptShutdown() - { - MessageBoxResult result = App.ShowMessageBox( - "Roblox is currently running, but needs to close. Would you like close Roblox now?", - MessageBoxImage.Information, - MessageBoxButton.OKCancel - ); - - if (result != MessageBoxResult.OK) - Environment.Exit(Bootstrapper.ERROR_INSTALL_USEREXIT); - } - #endregion - } -} diff --git a/Bloxstrap/Directories.cs b/Bloxstrap/Directories.cs index 877c7862..21f9d13c 100644 --- a/Bloxstrap/Directories.cs +++ b/Bloxstrap/Directories.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; - -namespace Bloxstrap +namespace Bloxstrap { static class Directories { @@ -15,23 +12,27 @@ static class Directories public static string Base { get; private set; } = ""; public static string Downloads { get; private set; } = ""; + public static string Logs { get; private set; } = ""; public static string Integrations { get; private set; } = ""; public static string Versions { get; private set; } = ""; public static string Modifications { get; private set; } = ""; public static string Application { get; private set; } = ""; - public static bool Initialized => string.IsNullOrEmpty(Base); + public static bool Initialized => !String.IsNullOrEmpty(Base); public static void Initialize(string baseDirectory) { Base = baseDirectory; Downloads = Path.Combine(Base, "Downloads"); + Logs = Path.Combine(Base, "Logs"); Integrations = Path.Combine(Base, "Integrations"); Versions = Path.Combine(Base, "Versions"); Modifications = Path.Combine(Base, "Modifications"); Application = Path.Combine(Base, $"{App.ProjectName}.exe"); + + } } } diff --git a/Bloxstrap/Enums/BootstrapperIcon.cs b/Bloxstrap/Enums/BootstrapperIcon.cs index 4a44b878..a67185d9 100644 --- a/Bloxstrap/Enums/BootstrapperIcon.cs +++ b/Bloxstrap/Enums/BootstrapperIcon.cs @@ -3,7 +3,7 @@ public enum BootstrapperIcon { IconBloxstrap, - Icon2009, + Icon2008, Icon2011, IconEarly2015, IconLate2015, diff --git a/Bloxstrap/Enums/BootstrapperStyle.cs b/Bloxstrap/Enums/BootstrapperStyle.cs index 69aa77a8..2d1b0633 100644 --- a/Bloxstrap/Enums/BootstrapperStyle.cs +++ b/Bloxstrap/Enums/BootstrapperStyle.cs @@ -3,9 +3,10 @@ public enum BootstrapperStyle { VistaDialog, - LegacyDialog2009, + LegacyDialog2008, LegacyDialog2011, ProgressDialog, - FluentDialog + FluentDialog, + ByfronDialog } } diff --git a/Bloxstrap/Enums/CursorType.cs b/Bloxstrap/Enums/CursorType.cs new file mode 100644 index 00000000..98e8ec44 --- /dev/null +++ b/Bloxstrap/Enums/CursorType.cs @@ -0,0 +1,9 @@ +namespace Bloxstrap.Enums +{ + public enum CursorType + { + Default, + From2006, + From2013 + } +} diff --git a/Bloxstrap/Enums/EmojiType.cs b/Bloxstrap/Enums/EmojiType.cs new file mode 100644 index 00000000..cdefefd5 --- /dev/null +++ b/Bloxstrap/Enums/EmojiType.cs @@ -0,0 +1,11 @@ +namespace Bloxstrap.Enums +{ + public enum EmojiType + { + Default, + Catmoji, + Windows11, + Windows10, + Windows8 + } +} diff --git a/Bloxstrap/Enums/ErrorCode.cs b/Bloxstrap/Enums/ErrorCode.cs new file mode 100644 index 00000000..d9fbd421 --- /dev/null +++ b/Bloxstrap/Enums/ErrorCode.cs @@ -0,0 +1,14 @@ +namespace Bloxstrap.Enums +{ + // https://learn.microsoft.com/en-us/windows/win32/msi/error-codes + // https://i-logic.com/serial/errorcodes.htm + // just the ones that we're interested in + + public enum ErrorCode + { + ERROR_SUCCESS = 0, + ERROR_INSTALL_USEREXIT = 1602, + ERROR_INSTALL_FAILURE = 1603, + ERROR_CANCELLED = 1223 + } +} diff --git a/Bloxstrap/Enums/ServerType.cs b/Bloxstrap/Enums/ServerType.cs new file mode 100644 index 00000000..70386d67 --- /dev/null +++ b/Bloxstrap/Enums/ServerType.cs @@ -0,0 +1,9 @@ +namespace Bloxstrap.Enums +{ + public enum ServerType + { + Public, + Private, + Reserved + } +} diff --git a/Bloxstrap/Exceptions/HttpResponseUnsuccessfulException.cs b/Bloxstrap/Exceptions/HttpResponseUnsuccessfulException.cs new file mode 100644 index 00000000..1409f788 --- /dev/null +++ b/Bloxstrap/Exceptions/HttpResponseUnsuccessfulException.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Exceptions +{ + internal class HttpResponseUnsuccessfulException : Exception + { + public HttpResponseMessage ResponseMessage { get; } + + public HttpResponseUnsuccessfulException(HttpResponseMessage responseMessage) : base() + { + ResponseMessage = responseMessage; + } + } +} diff --git a/Bloxstrap/Extensions/BootstrapperIconEx.cs b/Bloxstrap/Extensions/BootstrapperIconEx.cs index 5b8721b4..90656a76 100644 --- a/Bloxstrap/Extensions/BootstrapperIconEx.cs +++ b/Bloxstrap/Extensions/BootstrapperIconEx.cs @@ -1,7 +1,4 @@ -using System; -using System.Drawing; - -using Bloxstrap.Enums; +using System.Drawing; namespace Bloxstrap.Extensions { @@ -33,7 +30,7 @@ public static Icon GetIcon(this BootstrapperIcon icon) return icon switch { BootstrapperIcon.IconBloxstrap => Properties.Resources.IconBloxstrap, - BootstrapperIcon.Icon2009 => Properties.Resources.Icon2009, + BootstrapperIcon.Icon2008 => Properties.Resources.Icon2008, BootstrapperIcon.Icon2011 => Properties.Resources.Icon2011, BootstrapperIcon.IconEarly2015 => Properties.Resources.IconEarly2015, BootstrapperIcon.IconLate2015 => Properties.Resources.IconLate2015, diff --git a/Bloxstrap/Extensions/BootstrapperStyleEx.cs b/Bloxstrap/Extensions/BootstrapperStyleEx.cs index 0a6ef0bc..607c6b28 100644 --- a/Bloxstrap/Extensions/BootstrapperStyleEx.cs +++ b/Bloxstrap/Extensions/BootstrapperStyleEx.cs @@ -1,21 +1,7 @@ -using Bloxstrap.Dialogs; -using Bloxstrap.Enums; - -namespace Bloxstrap.Extensions +namespace Bloxstrap.Extensions { static class BootstrapperStyleEx { - public static IBootstrapperDialog GetNew(this BootstrapperStyle bootstrapperStyle) - { - return bootstrapperStyle switch - { - BootstrapperStyle.VistaDialog => new VistaDialog(), - BootstrapperStyle.LegacyDialog2009 => new LegacyDialog2009(), - BootstrapperStyle.LegacyDialog2011 => new LegacyDialog2011(), - BootstrapperStyle.ProgressDialog => new ProgressDialog(), - BootstrapperStyle.FluentDialog => new FluentDialog(), - _ => new FluentDialog() - }; - } + public static IBootstrapperDialog GetNew(this BootstrapperStyle bootstrapperStyle) => Controls.GetBootstrapperDialog(bootstrapperStyle); } } diff --git a/Bloxstrap/Extensions/CursorTypeEx.cs b/Bloxstrap/Extensions/CursorTypeEx.cs new file mode 100644 index 00000000..34b8de94 --- /dev/null +++ b/Bloxstrap/Extensions/CursorTypeEx.cs @@ -0,0 +1,12 @@ +namespace Bloxstrap.Extensions +{ + static class CursorTypeEx + { + public static IReadOnlyDictionary Selections => new Dictionary + { + { "Default", CursorType.Default }, + { "2013 (Angular)", CursorType.From2013 }, + { "2006 (Cartoony)", CursorType.From2006 }, + }; + } +} diff --git a/Bloxstrap/Extensions/DateTimeEx.cs b/Bloxstrap/Extensions/DateTimeEx.cs new file mode 100644 index 00000000..0dc8a248 --- /dev/null +++ b/Bloxstrap/Extensions/DateTimeEx.cs @@ -0,0 +1,10 @@ +namespace Bloxstrap.Extensions +{ + static class DateTimeEx + { + public static string ToFriendlyString(this DateTime dateTime) + { + return dateTime.ToString("dddd, d MMMM yyyy 'at' h:mm:ss tt", CultureInfo.InvariantCulture); + } + } +} diff --git a/Bloxstrap/Extensions/EmojiTypeEx.cs b/Bloxstrap/Extensions/EmojiTypeEx.cs new file mode 100644 index 00000000..0eb9d6ea --- /dev/null +++ b/Bloxstrap/Extensions/EmojiTypeEx.cs @@ -0,0 +1,40 @@ +namespace Bloxstrap.Extensions +{ + static class EmojiTypeEx + { + public static IReadOnlyDictionary Selections => new Dictionary + { + { "Default (Twemoji)", EmojiType.Default }, + { "Catmoji", EmojiType.Catmoji }, + { "Windows 11", EmojiType.Windows11 }, + { "Windows 10", EmojiType.Windows10 }, + { "Windows 8", EmojiType.Windows8 }, + }; + + public static IReadOnlyDictionary Filenames => new Dictionary + { + { EmojiType.Catmoji, "Catmoji.ttf" }, + { EmojiType.Windows11, "Win1122H2SegoeUIEmoji.ttf" }, + { EmojiType.Windows10, "Win10April2018SegoeUIEmoji.ttf" }, + { EmojiType.Windows8, "Win8.1SegoeUIEmoji.ttf" }, + }; + + public static IReadOnlyDictionary Hashes => new Dictionary + { + { EmojiType.Catmoji, "98138f398a8cde897074dd2b8d53eca0" }, + { EmojiType.Windows11, "d50758427673578ddf6c9edcdbf367f5" }, + { EmojiType.Windows10, "d8a7eecbebf9dfdf622db8ccda63aff5" }, + { EmojiType.Windows8, "2b01c6caabbe95afc92aa63b9bf100f3" }, + }; + + public static string GetHash(this EmojiType emojiType) => Hashes[emojiType]; + + public static string GetUrl(this EmojiType emojiType) + { + if (emojiType == EmojiType.Default) + return ""; + + return $"https://github.com/NikSavchenk0/rbxcustom-fontemojis/raw/8a552f4aaaecfa58d6bd9b0540e1ac16e81faadb/{Filenames[emojiType]}"; + } + } +} diff --git a/Bloxstrap/Extensions/IconEx.cs b/Bloxstrap/Extensions/IconEx.cs index bdb1ddd4..2cd48af7 100644 --- a/Bloxstrap/Extensions/IconEx.cs +++ b/Bloxstrap/Extensions/IconEx.cs @@ -1,5 +1,4 @@ using System.Drawing; -using System.IO; using System.Windows.Media.Imaging; using System.Windows.Media; diff --git a/Bloxstrap/Extensions/ThemeEx.cs b/Bloxstrap/Extensions/ThemeEx.cs index 57ab9556..30da5392 100644 --- a/Bloxstrap/Extensions/ThemeEx.cs +++ b/Bloxstrap/Extensions/ThemeEx.cs @@ -1,5 +1,4 @@ using Microsoft.Win32; -using Bloxstrap.Enums; namespace Bloxstrap.Extensions { diff --git a/Bloxstrap/FastFlagManager.cs b/Bloxstrap/FastFlagManager.cs new file mode 100644 index 00000000..404528d4 --- /dev/null +++ b/Bloxstrap/FastFlagManager.cs @@ -0,0 +1,199 @@ +using System.Windows.Input; +using System.Windows.Media.Animation; + +namespace Bloxstrap +{ + public class FastFlagManager : JsonManager> + { + public override string FileLocation => Path.Combine(Directories.Modifications, "ClientSettings\\ClientAppSettings.json"); + + // this is the value of the 'FStringPartTexturePackTablePre2022' flag + public const string OldTexturesFlagValue = "{\"foil\":{\"ids\":[\"rbxassetid://7546645012\",\"rbxassetid://7546645118\"],\"color\":[255,255,255,255]},\"brick\":{\"ids\":[\"rbxassetid://7546650097\",\"rbxassetid://7546645118\"],\"color\":[204,201,200,232]},\"cobblestone\":{\"ids\":[\"rbxassetid://7546652947\",\"rbxassetid://7546645118\"],\"color\":[212,200,187,250]},\"concrete\":{\"ids\":[\"rbxassetid://7546653951\",\"rbxassetid://7546654144\"],\"color\":[208,208,208,255]},\"diamondplate\":{\"ids\":[\"rbxassetid://7547162198\",\"rbxassetid://7546645118\"],\"color\":[170,170,170,255]},\"fabric\":{\"ids\":[\"rbxassetid://7547101130\",\"rbxassetid://7546645118\"],\"color\":[105,104,102,244]},\"glass\":{\"ids\":[\"rbxassetid://7547304948\",\"rbxassetid://7546645118\"],\"color\":[254,254,254,7]},\"granite\":{\"ids\":[\"rbxassetid://7547164710\",\"rbxassetid://7546645118\"],\"color\":[113,113,113,255]},\"grass\":{\"ids\":[\"rbxassetid://7547169285\",\"rbxassetid://7546645118\"],\"color\":[165,165,159,255]},\"ice\":{\"ids\":[\"rbxassetid://7547171356\",\"rbxassetid://7546645118\"],\"color\":[255,255,255,255]},\"marble\":{\"ids\":[\"rbxassetid://7547177270\",\"rbxassetid://7546645118\"],\"color\":[199,199,199,255]},\"metal\":{\"ids\":[\"rbxassetid://7547288171\",\"rbxassetid://7546645118\"],\"color\":[199,199,199,255]},\"pebble\":{\"ids\":[\"rbxassetid://7547291361\",\"rbxassetid://7546645118\"],\"color\":[208,208,208,255]},\"corrodedmetal\":{\"ids\":[\"rbxassetid://7547184629\",\"rbxassetid://7546645118\"],\"color\":[159,119,95,200]},\"sand\":{\"ids\":[\"rbxassetid://7547295153\",\"rbxassetid://7546645118\"],\"color\":[220,220,220,255]},\"slate\":{\"ids\":[\"rbxassetid://7547298114\",\"rbxassetid://7547298323\"],\"color\":[193,193,193,255]},\"wood\":{\"ids\":[\"rbxassetid://7547303225\",\"rbxassetid://7547298786\"],\"color\":[227,227,227,255]},\"woodplanks\":{\"ids\":[\"rbxassetid://7547332968\",\"rbxassetid://7546645118\"],\"color\":[212,209,203,255]},\"asphalt\":{\"ids\":[\"rbxassetid://9873267379\",\"rbxassetid://9438410548\"],\"color\":[123,123,123,234]},\"basalt\":{\"ids\":[\"rbxassetid://9873270487\",\"rbxassetid://9438413638\"],\"color\":[154,154,153,238]},\"crackedlava\":{\"ids\":[\"rbxassetid://9438582231\",\"rbxassetid://9438453972\"],\"color\":[74,78,80,156]},\"glacier\":{\"ids\":[\"rbxassetid://9438851661\",\"rbxassetid://9438453972\"],\"color\":[226,229,229,243]},\"ground\":{\"ids\":[\"rbxassetid://9439044431\",\"rbxassetid://9438453972\"],\"color\":[114,114,112,240]},\"leafygrass\":{\"ids\":[\"rbxassetid://9873288083\",\"rbxassetid://9438453972\"],\"color\":[121,117,113,234]},\"limestone\":{\"ids\":[\"rbxassetid://9873289812\",\"rbxassetid://9438453972\"],\"color\":[235,234,230,250]},\"mud\":{\"ids\":[\"rbxassetid://9873319819\",\"rbxassetid://9438453972\"],\"color\":[130,130,130,252]},\"pavement\":{\"ids\":[\"rbxassetid://9873322398\",\"rbxassetid://9438453972\"],\"color\":[142,142,144,236]},\"rock\":{\"ids\":[\"rbxassetid://9873515198\",\"rbxassetid://9438453972\"],\"color\":[154,154,154,248]},\"salt\":{\"ids\":[\"rbxassetid://9439566986\",\"rbxassetid://9438453972\"],\"color\":[220,220,221,255]},\"sandstone\":{\"ids\":[\"rbxassetid://9873521380\",\"rbxassetid://9438453972\"],\"color\":[174,171,169,246]},\"snow\":{\"ids\":[\"rbxassetid://9439632387\",\"rbxassetid://9438453972\"],\"color\":[218,218,218,255]}}"; + + public static IReadOnlyDictionary PresetFlags = new Dictionary + { + { "HTTP.Log", "DFLogHttpTraceLight" }, + + { "HTTP.Proxy.Enable", "DFFlagDebugEnableHttpProxy" }, + { "HTTP.Proxy.Address.1", "DFStringDebugPlayerHttpProxyUrl" }, + { "HTTP.Proxy.Address.2", "DFStringHttpCurlProxyHostAndPort" }, + { "HTTP.Proxy.Address.3", "DFStringHttpCurlProxyHostAndPortForExternalUrl" }, + + { "Rendering.Framerate", "DFIntTaskSchedulerTargetFps" }, + { "Rendering.Fullscreen", "FFlagHandleAltEnterFullscreenManually" }, + { "Rendering.TexturePack", "FStringPartTexturePackTable2022" }, + + { "Rendering.DPI.Disable", "DFFlagDisableDPIScale" }, + { "Rendering.DPI.Variable", "DFFlagVariableDPIScale2" }, + + { "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" }, + { "Rendering.Lighting.Future", "FFlagDebugForceFutureIsBrightPhase3" }, + + { "UI.Hide", "DFIntCanHideGuiGroupId" }, + { "UI.FlagState", "FStringDebugShowFlagState" }, + + { "UI.Menu.GraphicsSlider", "FFlagFixGraphicsQuality" }, + + { "UI.Menu.Style.DisableV2", "FFlagDisableNewIGMinDUA" }, + { "UI.Menu.Style.EnableV4", "FFlagEnableInGameMenuControls" } + }; + + // only one missing here is Metal because lol + public static IReadOnlyDictionary RenderingModes => new Dictionary + { + { "Automatic", "None" }, + { "Vulkan", "Vulkan" }, + { "Direct3D 11", "D3D11" }, + { "Direct3D 10", "D3D10" }, + { "OpenGL", "OpenGL" } + }; + + public static IReadOnlyDictionary LightingModes => new Dictionary + { + { "Chosen by game", "None" }, + { "Voxel (Phase 1)", "Voxel" }, + { "ShadowMap (Phase 2)", "ShadowMap" }, + { "Future (Phase 3)", "Future" } + }; + + // this is one hell of a dictionary definition lmao + // since these all set the same flags, wouldn't making this use bitwise operators be better? + public static IReadOnlyDictionary> IGMenuVersions => new Dictionary> + { + { + "Default", + new Dictionary + { + { "DisableV2", null }, + { "EnableV4", null } + } + }, + + { + "Version 1 (2015)", + new Dictionary + { + { "DisableV2", "True" }, + { "EnableV4", "False" } + } + }, + + { + "Version 2 (2020)", + new Dictionary + { + { "DisableV2", "False" }, + { "EnableV4", "False" } + } + }, + + { + "Version 4 (2023)", + new Dictionary + { + { "DisableV2", "True" }, + { "EnableV4", "True" } + } + } + }; + + // all fflags are stored as strings + // to delete a flag, set the value as null + public void SetValue(string key, object? value) + { + if (value is null) + { + if (Prop.ContainsKey(key)) + App.Logger.WriteLine($"[FastFlagManager::SetValue] Deletion of '{key}' is pending"); + + Prop.Remove(key); + } + else + { + if (Prop.ContainsKey(key)) + App.Logger.WriteLine($"[FastFlagManager::SetValue] Setting of '{key}' from '{Prop[key]}' to '{value}' is pending"); + else + App.Logger.WriteLine($"[FastFlagManager::SetValue] Setting of '{key}' to '{value}' is pending"); + + Prop[key] = value.ToString()!; + } + } + + // this returns null if the fflag doesn't exist + public string? GetValue(string key) + { + // check if we have an updated change for it pushed first + if (Prop.TryGetValue(key, out object? value) && value is not null) + return value.ToString(); + + return null; + } + + public void SetPreset(string prefix, object? value) + { + foreach (var pair in PresetFlags.Where(x => x.Key.StartsWith(prefix))) + SetValue(pair.Value, value); + } + + public void SetPresetOnce(string key, object? value) + { + if (GetPreset(key) is null) + SetPreset(key, value); + } + + public void SetPresetEnum(string prefix, string target, object? value) + { + foreach (var pair in PresetFlags.Where(x => x.Key.StartsWith(prefix))) + { + if (pair.Key.StartsWith($"{prefix}.{target}")) + SetValue(pair.Value, value); + else + SetValue(pair.Value, null); + } + } + + public string? GetPreset(string name) => GetValue(PresetFlags[name]); + + public string GetPresetEnum(IReadOnlyDictionary mapping, string prefix, string value) + { + foreach (var pair in mapping) + { + if (pair.Value == "None") + continue; + + if (GetPreset($"{prefix}.{pair.Value}") == value) + return pair.Key; + } + + return mapping.First().Key; + } + + public override void Save() + { + // convert all flag values to strings before saving + + foreach (var pair in Prop) + Prop[pair.Key] = pair.Value.ToString()!; + + base.Save(); + } + + public override void Load() + { + base.Load(); + + SetPresetOnce("Rendering.Framerate", 9999); + SetPresetOnce("Rendering.Fullscreen", "False"); + + SetPresetOnce("Rendering.DPI.Disable", "True"); + SetPresetOnce("Rendering.DPI.Variable", "False"); + } + } +} diff --git a/Bloxstrap/GlobalUsings.cs b/Bloxstrap/GlobalUsings.cs new file mode 100644 index 00000000..bcb19310 --- /dev/null +++ b/Bloxstrap/GlobalUsings.cs @@ -0,0 +1,23 @@ +global using System; +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; +global using System.Text.RegularExpressions; +global using System.Linq; +global using System.Net; +global using System.Net.Http; +global using System.Threading; +global using System.Threading.Tasks; + +global using Bloxstrap.Enums; +global using Bloxstrap.Extensions; +global using Bloxstrap.Models; +global using Bloxstrap.Models.Attributes; +global using Bloxstrap.Models.RobloxApi; +global using Bloxstrap.UI; +global using Bloxstrap.Utility; \ No newline at end of file diff --git a/Bloxstrap/HttpClientLoggingHandler.cs b/Bloxstrap/HttpClientLoggingHandler.cs new file mode 100644 index 00000000..a39b94ad --- /dev/null +++ b/Bloxstrap/HttpClientLoggingHandler.cs @@ -0,0 +1,22 @@ +namespace Bloxstrap +{ + internal class HttpClientLoggingHandler : MessageProcessingHandler + { + public HttpClientLoggingHandler(HttpMessageHandler innerHandler) + : base(innerHandler) + { + } + + protected override HttpRequestMessage ProcessRequest(HttpRequestMessage request, CancellationToken cancellationToken) + { + App.Logger.WriteLine($"[HttpClientLoggingHandler::HttpRequestMessage] {request.Method} {request.RequestUri}"); + return request; + } + + protected override HttpResponseMessage ProcessResponse(HttpResponseMessage response, CancellationToken cancellationToken) + { + App.Logger.WriteLine($"[HttpClientLoggingHandler::HttpResponseMessage] {(int)response.StatusCode} {response.ReasonPhrase} {response.RequestMessage!.RequestUri}"); + return response; + } + } +} diff --git a/Bloxstrap/Integrations/DiscordRichPresence.cs b/Bloxstrap/Integrations/DiscordRichPresence.cs index 69c10e45..5e2a18ca 100644 --- a/Bloxstrap/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Integrations/DiscordRichPresence.cs @@ -1,11 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -using DiscordRPC; - -using Bloxstrap.Models.RobloxApi; +using DiscordRPC; namespace Bloxstrap.Integrations { @@ -14,6 +7,9 @@ public class DiscordRichPresence : IDisposable private readonly DiscordRpcClient _rpcClient = new("1005469189907173486"); private readonly RobloxActivity _activityWatcher; + private RichPresence? _currentPresence; + private bool _visible = true; + private string? _initialStatus; private long _currentUniverseId; private DateTime? _timeStartedUniverse; @@ -21,14 +17,15 @@ public DiscordRichPresence(RobloxActivity activityWatcher) { _activityWatcher = activityWatcher; - _activityWatcher.OnGameJoin += (_, _) => Task.Run(() => SetPresence()); - _activityWatcher.OnGameLeave += (_, _) => Task.Run(() => SetPresence()); + _activityWatcher.OnGameJoin += (_, _) => Task.Run(() => SetCurrentGame()); + _activityWatcher.OnGameLeave += (_, _) => Task.Run(() => SetCurrentGame()); + _activityWatcher.OnGameMessage += (_, message) => OnGameMessage(message); _rpcClient.OnReady += (_, e) => App.Logger.WriteLine($"[DiscordRichPresence::DiscordRichPresence] Received ready from user {e.User.Username} ({e.User.ID})"); _rpcClient.OnPresenceUpdate += (_, e) => - App.Logger.WriteLine("[DiscordRichPresence::DiscordRichPresence] Updated presence"); + App.Logger.WriteLine("[DiscordRichPresence::DiscordRichPresence] Presence updated"); _rpcClient.OnError += (_, e) => App.Logger.WriteLine($"[DiscordRichPresence::DiscordRichPresence] An RPC error occurred - {e.Message}"); @@ -46,83 +43,152 @@ public DiscordRichPresence(RobloxActivity activityWatcher) _rpcClient.Initialize(); } - public async Task SetPresence() + public void OnGameMessage(GameMessage message) { - if (!_activityWatcher.ActivityInGame) + if (message.Command == "SetPresenceStatus") + SetStatus(message.Data); + } + + public void SetStatus(string status) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetStatus] Setting status to '{status}'"); + + if (_currentPresence is null) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetStatus] Presence is not set, aborting"); + return; + } + + if (status.Length > 128) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetStatus] Status cannot be longer than 128 characters, aborting"); + return; + } + + if (_initialStatus is null) + _initialStatus = _currentPresence.State; + + string finalStatus; + + if (string.IsNullOrEmpty(status)) { - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Clearing presence"); + App.Logger.WriteLine($"[DiscordRichPresence::SetStatus] Status is empty, reverting to initial status"); + finalStatus = _initialStatus; + } + else + { + finalStatus = status; + } + + if (_currentPresence.State == finalStatus) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetStatus] Status is unchanged, aborting"); + return; + } + + _currentPresence.State = finalStatus; + UpdatePresence(); + } + + public void SetVisibility(bool visible) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetVisibility] Setting presence visibility ({visible})"); + + _visible = visible; + + if (_visible) + UpdatePresence(); + else _rpcClient.ClearPresence(); + } + + public async Task SetCurrentGame() + { + if (!_activityWatcher.ActivityInGame) + { + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Not in game, clearing presence"); + _currentPresence = null; + _initialStatus = null; + UpdatePresence(); return true; } string icon = "roblox"; + long placeId = _activityWatcher.ActivityPlaceId; - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Setting presence for Place ID {_activityWatcher.ActivityPlaceId}"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Setting presence for Place ID {placeId}"); - var universeIdResponse = await Utilities.GetJson($"https://apis.roblox.com/universes/v1/places/{_activityWatcher.ActivityPlaceId}/universe"); + var universeIdResponse = await Http.GetJson($"https://apis.roblox.com/universes/v1/places/{placeId}/universe"); if (universeIdResponse is null) { - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Could not get Universe ID!"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Could not get Universe ID!"); return false; } long universeId = universeIdResponse.UniverseId; - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Got Universe ID as {universeId}"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] 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; - _activityWatcher.ActivityIsTeleport = false; _currentUniverseId = universeId; - var gameDetailResponse = await Utilities.GetJson>($"https://games.roblox.com/v1/games?universeIds={universeId}"); + var gameDetailResponse = await Http.GetJson>($"https://games.roblox.com/v1/games?universeIds={universeId}"); if (gameDetailResponse is null || !gameDetailResponse.Data.Any()) { - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Could not get Universe info!"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Could not get Universe info!"); return false; } GameDetailResponse universeDetails = gameDetailResponse.Data.ToArray()[0]; - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Got Universe details"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Got Universe details"); - var universeThumbnailResponse = await Utilities.GetJson>($"https://thumbnails.roblox.com/v1/games/icons?universeIds={universeId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false"); + 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($"[DiscordRichPresence::SetPresence] Could not get Universe thumbnail info!"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Could not get Universe thumbnail info!"); } else { icon = universeThumbnailResponse.Data.ToArray()[0].ImageUrl; - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Got Universe thumbnail as {icon}"); + App.Logger.WriteLine($"[DiscordRichPresence::SetCurrentGame] Got Universe thumbnail as {icon}"); } - List + + + + + + + + + + + + + + + + + + + + + diff --git a/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs b/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs new file mode 100644 index 00000000..88020adb --- /dev/null +++ b/Bloxstrap/UI/Elements/Bootstrapper/ByfronDialog.xaml.cs @@ -0,0 +1,97 @@ +using System.Windows; +using System.Windows.Forms; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +using Bloxstrap.UI.Elements.Bootstrapper.Base; +using Bloxstrap.UI.ViewModels.Bootstrapper; + +namespace Bloxstrap.UI.Elements.Bootstrapper +{ + /// + /// Interaction logic for ByfronDialog.xaml + /// + public partial class ByfronDialog : IBootstrapperDialog + { + private readonly ByfronDialogViewModel _viewModel; + + public Bloxstrap.Bootstrapper? Bootstrapper { get; set; } + + #region UI Elements + public string Message + { + get => _viewModel.Message; + set + { + _viewModel.Message = value; + _viewModel.OnPropertyChanged(nameof(_viewModel.Message)); + } + } + + public ProgressBarStyle ProgressStyle + { + get => _viewModel.ProgressIndeterminate ? ProgressBarStyle.Marquee : ProgressBarStyle.Continuous; + set + { + _viewModel.ProgressIndeterminate = (value == ProgressBarStyle.Marquee); + _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressIndeterminate)); + } + } + + public int ProgressValue + { + get => _viewModel.ProgressValue; + set + { + _viewModel.ProgressValue = value; + _viewModel.OnPropertyChanged(nameof(_viewModel.ProgressValue)); + } + } + + public bool CancelEnabled + { + get => _viewModel.CancelEnabled; + set + { + _viewModel.CancelEnabled = value; + + _viewModel.OnPropertyChanged(nameof(_viewModel.CancelEnabled)); + _viewModel.OnPropertyChanged(nameof(_viewModel.CancelButtonVisibility)); + + _viewModel.OnPropertyChanged(nameof(_viewModel.VersionTextVisibility)); + _viewModel.OnPropertyChanged(nameof(_viewModel.VersionText)); + } + } + #endregion + + public ByfronDialog() + { + _viewModel = new ByfronDialogViewModel(this); + DataContext = _viewModel; + Title = App.Settings.Prop.BootstrapperTitle; + Icon = App.Settings.Prop.BootstrapperIcon.GetIcon().GetImageSource(); + + if (App.Settings.Prop.Theme.GetFinal() == Theme.Light) + { + // Matching the roblox website light theme as close as possible. + _viewModel.DialogBorder = new Thickness(1); + _viewModel.Background = new SolidColorBrush(Color.FromRgb(242, 244, 245)); + _viewModel.Foreground = new SolidColorBrush(Color.FromRgb(57, 59, 61)); + _viewModel.IconColor = new SolidColorBrush(Color.FromRgb(57, 59, 61)); + _viewModel.ProgressBarBackground = new SolidColorBrush(Color.FromRgb(189, 190, 190)); + _viewModel.ByfronLogoLocation = new BitmapImage(new Uri("pack://application:,,,/Resources/BootstrapperStyles/ByfronDialog/ByfronLogoLight.jpg")); + } + + InitializeComponent(); + } + + #region IBootstrapperDialog Methods + // Referencing FluentDialog + public void ShowBootstrapper() => this.ShowDialog(); + + public void CloseBootstrapper() => Dispatcher.BeginInvoke(this.Close); + + public void ShowSuccess(string message, Action? callback) => BaseFunctions.ShowSuccess(message, callback); + #endregion + } +} diff --git a/Bloxstrap/Dialogs/FluentDialog.xaml b/Bloxstrap/UI/Elements/Bootstrapper/FluentDialog.xaml similarity index 59% rename from Bloxstrap/Dialogs/FluentDialog.xaml rename to Bloxstrap/UI/Elements/Bootstrapper/FluentDialog.xaml index fad49b8d..bbcc4a01 100644 --- a/Bloxstrap/Dialogs/FluentDialog.xaml +++ b/Bloxstrap/UI/Elements/Bootstrapper/FluentDialog.xaml @@ -1,31 +1,34 @@ - - - - + + + + + + + + + + - + @@ -33,8 +36,9 @@ - - +