// To debug the automatic updater: // - Uncomment the definition below // - Publish the executable // - Launch the executable (click no when it asks you to upgrade) // - Launch Roblox (for testing web launches, run it from the command prompt) // - To re-test the same executable, delete it from the installation folder // #define DEBUG_UPDATER #if DEBUG_UPDATER #warning "Automatic updater debugging is enabled" #endif using System.ComponentModel; using System.Data; using System.Windows; using System.Windows.Forms; using System.Windows.Shell; using Microsoft.Win32; using Bloxstrap.AppData; using Bloxstrap.RobloxInterfaces; using Bloxstrap.UI.Elements.Bootstrapper.Base; using ICSharpCode.SharpZipLib.Zip; namespace Bloxstrap { public class Bootstrapper { #region Properties private const int ProgressBarMaximum = 10000; private const double TaskbarProgressMaximumWpf = 1; // this can not be changed. keep it at 1. private const int TaskbarProgressMaximumWinForms = WinFormsDialogBase.TaskbarProgressMaximum; private const string AppSettings = "\r\n" + "\r\n" + " content\r\n" + " http://www.roblox.com\r\n" + "\r\n"; private readonly FastZipEvents _fastZipEvents = new(); private readonly CancellationTokenSource _cancelTokenSource = new(); private IAppData AppData = default!; private LaunchMode _launchMode; private string _launchCommandLine = App.LaunchSettings.RobloxLaunchArgs; private Version? _latestVersion = null; private string _latestVersionGuid = null!; private string _latestVersionDirectory = null!; private PackageManifest _versionPackageManifest = null!; private bool _isInstalling = false; private double _progressIncrement; private double _taskbarProgressIncrement; private double _taskbarProgressMaximum; private long _totalDownloadedBytes = 0; private bool _packageExtractionSuccess = true; private bool _mustUpgrade => App.LaunchSettings.ForceFlag.Active || App.State.Prop.ForceReinstall || String.IsNullOrEmpty(AppData.State.VersionGuid) || !File.Exists(AppData.ExecutablePath); private bool _noConnection = false; private AsyncMutex? _mutex; private int _appPid = 0; public IBootstrapperDialog? Dialog = null; public bool IsStudioLaunch => _launchMode != LaunchMode.Player; public string MutexName { get; set; } = "Bloxstrap-Bootstrapper"; public bool QuitIfMutexExists { get; set; } = false; #endregion #region Core public Bootstrapper(LaunchMode launchMode) { _launchMode = launchMode; // https://github.com/icsharpcode/SharpZipLib/blob/master/src/ICSharpCode.SharpZipLib/Zip/FastZip.cs/#L669-L680 // exceptions don't get thrown if we define events without actually binding to the failure events. probably a bug. ¯\_(ツ)_/¯ _fastZipEvents.FileFailure += (_, e) => { // only give a pass to font files (no idea whats wrong with them) if (!e.Name.EndsWith(".ttf")) throw e.Exception; App.Logger.WriteLine("FastZipEvents::OnFileFailure", $"Failed to extract {e.Name}"); _packageExtractionSuccess = false; }; _fastZipEvents.DirectoryFailure += (_, e) => throw e.Exception; _fastZipEvents.ProcessFile += (_, e) => e.ContinueRunning = !_cancelTokenSource.IsCancellationRequested; SetupAppData(); } private void SetupAppData() { AppData = IsStudioLaunch ? new RobloxStudioData() : new RobloxPlayerData(); Deployment.BinaryType = AppData.BinaryType; } private void SetStatus(string message) { App.Logger.WriteLine("Bootstrapper::SetStatus", message); message = message.Replace("{product}", AppData.ProductName); if (Dialog is not null) Dialog.Message = message; } private void UpdateProgressBar() { if (Dialog is null) return; // UI progress int progressValue = (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 progressValue = Math.Clamp(progressValue, 0, ProgressBarMaximum); Dialog.ProgressValue = progressValue; // taskbar progress double taskbarProgressValue = _taskbarProgressIncrement * _totalDownloadedBytes; taskbarProgressValue = Math.Clamp(taskbarProgressValue, 0, _taskbarProgressMaximum); Dialog.TaskbarProgressValue = taskbarProgressValue; } private void HandleConnectionError(Exception exception) { const string LOG_IDENT = "Bootstrapper::HandleConnectionError"; _noConnection = true; App.Logger.WriteLine(LOG_IDENT, "Connectivity check failed"); App.Logger.WriteException(LOG_IDENT, exception); string message = Strings.Dialog_Connectivity_BadConnection; if (exception is AggregateException) exception = exception.InnerException!; // https://gist.github.com/pizzaboxer/4b58303589ee5b14cc64397460a8f386 if (exception is HttpRequestException && exception.InnerException is null) message = String.Format(Strings.Dialog_Connectivity_RobloxDown, "[status.roblox.com](https://status.roblox.com)"); if (_mustUpgrade) message += $"\n\n{Strings.Dialog_Connectivity_RobloxUpgradeNeeded}\n\n{Strings.Dialog_Connectivity_TryAgainLater}"; else message += $"\n\n{Strings.Dialog_Connectivity_RobloxUpgradeSkip}"; Frontend.ShowConnectivityDialog( String.Format(Strings.Dialog_Connectivity_UnableToConnect, "Roblox"), message, _mustUpgrade ? MessageBoxImage.Error : MessageBoxImage.Warning, exception); if (_mustUpgrade) App.Terminate(ErrorCode.ERROR_CANCELLED); } public async Task Run() { const string LOG_IDENT = "Bootstrapper::Run"; App.Logger.WriteLine(LOG_IDENT, "Running bootstrapper"); // this is now always enabled as of v2.8.0 if (Dialog is not null) Dialog.CancelEnabled = true; SetStatus(Strings.Bootstrapper_Status_Connecting); var connectionResult = await Deployment.InitializeConnectivity(); App.Logger.WriteLine(LOG_IDENT, "Connectivity check finished"); if (connectionResult is not null) HandleConnectionError(connectionResult); #if (!DEBUG || DEBUG_UPDATER) && !QA_BUILD if (App.Settings.Prop.CheckForUpdates && !App.LaunchSettings.UpgradeFlag.Active) { bool updatePresent = await CheckForUpdates(); if (updatePresent) return; } #endif App.AssertWindowsOSVersion(); // 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 = Utilities.DoesMutexExist(MutexName); if (mutexExists) { if (!QuitIfMutexExists) { App.Logger.WriteLine(LOG_IDENT, $"{MutexName} mutex exists, waiting..."); SetStatus(Strings.Bootstrapper_Status_WaitingOtherInstances); } else { App.Logger.WriteLine(LOG_IDENT, $"{MutexName} mutex exists, exiting!"); return; } } // wait for mutex to be released if it's not yet await using var mutex = new AsyncMutex(false, MutexName); await mutex.AcquireAsync(_cancelTokenSource.Token); _mutex = mutex; // reload our configs since they've likely changed by now if (mutexExists) { App.Settings.Load(); App.State.Load(); App.RobloxState.Load(); } if (!_noConnection) { try { await GetLatestVersionInfo(); } catch (Exception ex) { HandleConnectionError(ex); } } CleanupVersionsFolder(); // cleanup after background updater bool allModificationsApplied = true; if (!_noConnection) { if (AppData.State.VersionGuid != _latestVersionGuid || _mustUpgrade) { bool backgroundUpdaterMutexOpen = Utilities.DoesMutexExist("Bloxstrap-BackgroundUpdater"); if (App.LaunchSettings.BackgroundUpdaterFlag.Active) backgroundUpdaterMutexOpen = false; // we want to actually update lol App.Logger.WriteLine(LOG_IDENT, $"Background updater running: {backgroundUpdaterMutexOpen}"); if (backgroundUpdaterMutexOpen && _mustUpgrade) { // I am Forced Upgrade, killer of Background Updates Utilities.KillBackgroundUpdater(); backgroundUpdaterMutexOpen = false; } if (!backgroundUpdaterMutexOpen) { if (IsEligibleForBackgroundUpdate()) StartBackgroundUpdater(); else await UpgradeRoblox(); } } if (_cancelTokenSource.IsCancellationRequested) return; // we require deployment details for applying modifications for a worst case scenario, // where we'd need to restore files from a package that isn't present on disk and needs to be redownloaded allModificationsApplied = await ApplyModifications(); } // check registry entries for every launch, just in case the stock bootstrapper changes it back if (IsStudioLaunch) WindowsRegistry.RegisterStudio(); else WindowsRegistry.RegisterPlayer(); if (_launchMode != LaunchMode.Player) await mutex.ReleaseAsync(); if (!App.LaunchSettings.NoLaunchFlag.Active && !_cancelTokenSource.IsCancellationRequested) { if (!App.LaunchSettings.QuietFlag.Active) { // show some balloon tips if (!_packageExtractionSuccess) Frontend.ShowBalloonTip(Strings.Bootstrapper_ExtractionFailed_Title, Strings.Bootstrapper_ExtractionFailed_Message, ToolTipIcon.Warning); else if (!allModificationsApplied) Frontend.ShowBalloonTip(Strings.Bootstrapper_ModificationsFailed_Title, Strings.Bootstrapper_ModificationsFailed_Message, ToolTipIcon.Warning); } StartRoblox(); } await mutex.ReleaseAsync(); Dialog?.CloseBootstrapper(); } /// /// Will throw whatever HttpClient can throw /// /// private async Task GetLatestVersionInfo() { const string LOG_IDENT = "Bootstrapper::GetLatestVersionInfo"; // before we do anything, we need to query our channel // if it's set in the launch uri, we need to use it and set the registry key for it // else, check if the registry key for it exists, and use it using var key = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\ROBLOX Corporation\\Environments\\{AppData.RegistryName}\\Channel"); var match = Regex.Match( App.LaunchSettings.RobloxLaunchArgs, "channel:([a-zA-Z0-9-_]+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant ); if (App.LaunchSettings.ChannelFlag.Active && !string.IsNullOrEmpty(App.LaunchSettings.ChannelFlag.Data)) { App.Logger.WriteLine(LOG_IDENT, $"Channel set to {App.LaunchSettings.ChannelFlag.Data} from arguments"); Deployment.Channel = App.LaunchSettings.ChannelFlag.Data.ToLowerInvariant(); } else if (match.Groups.Count == 2) { Deployment.Channel = match.Groups[1].Value.ToLowerInvariant(); } else if (key.GetValue("www.roblox.com") is string value && !String.IsNullOrEmpty(value)) { Deployment.Channel = value.ToLowerInvariant(); } if (String.IsNullOrEmpty(Deployment.Channel)) Deployment.Channel = Deployment.DefaultChannel; App.Logger.WriteLine(LOG_IDENT, $"Got channel as {Deployment.DefaultChannel}"); if (!Deployment.IsDefaultChannel) App.SendStat("robloxChannel", Deployment.Channel); if (!App.LaunchSettings.VersionFlag.Active || string.IsNullOrEmpty(App.LaunchSettings.VersionFlag.Data)) { ClientVersion clientVersion; try { clientVersion = await Deployment.GetInfo(); } catch (InvalidChannelException ex) { App.Logger.WriteLine(LOG_IDENT, $"Resetting channel from {Deployment.Channel} because {ex.StatusCode}"); Deployment.Channel = Deployment.DefaultChannel; clientVersion = await Deployment.GetInfo(); } key.SetValueSafe("www.roblox.com", Deployment.IsDefaultChannel ? "" : Deployment.Channel); _latestVersionGuid = clientVersion.VersionGuid; _latestVersion = Utilities.ParseVersionSafe(clientVersion.Version); } else { App.Logger.WriteLine(LOG_IDENT, $"Version set to {App.LaunchSettings.VersionFlag.Data} from arguments"); _latestVersionGuid = App.LaunchSettings.VersionFlag.Data; // we can't determine the version } _latestVersionDirectory = Path.Combine(Paths.Versions, _latestVersionGuid); string pkgManifestUrl = Deployment.GetLocation($"/{_latestVersionGuid}-rbxPkgManifest.txt"); var pkgManifestData = await App.HttpClient.GetStringAsync(pkgManifestUrl); _versionPackageManifest = new(pkgManifestData); // this can happen if version is set through arguments if (_launchMode == LaunchMode.Unknown) { App.Logger.WriteLine(LOG_IDENT, "Identifying launch mode from package manifest"); bool isPlayer = _versionPackageManifest.Exists(x => x.Name == "RobloxApp.zip"); App.Logger.WriteLine(LOG_IDENT, $"isPlayer: {isPlayer}"); _launchMode = isPlayer ? LaunchMode.Player : LaunchMode.Studio; SetupAppData(); // we need to set it up again } } private bool IsEligibleForBackgroundUpdate() { const string LOG_IDENT = "Bootstrapper::IsEligibleForBackgroundUpdate"; if (App.LaunchSettings.BackgroundUpdaterFlag.Active) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Is the background updater process"); return false; } if (!App.Settings.Prop.BackgroundUpdatesEnabled) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Background updates disabled"); return false; } if (IsStudioLaunch) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Studio launch"); return false; } if (_mustUpgrade) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Must upgrade is true"); return false; } // at least 3GB of free space const long minimumFreeSpace = 3_000_000_000; long space = Filesystem.GetFreeDiskSpace(Paths.Base); if (space < minimumFreeSpace) { App.Logger.WriteLine(LOG_IDENT, $"Not eligible: User has {space} free space, at least {minimumFreeSpace} is required"); return false; } if (_latestVersion == default) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Latest version is undefined"); return false; } Version? currentVersion = Utilities.GetRobloxVersion(AppData); if (currentVersion == default) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Current version is undefined"); return false; } // always normally upgrade for downgrades if (currentVersion.Minor > _latestVersion.Minor) { App.Logger.WriteLine(LOG_IDENT, "Not eligible: Downgrade"); return false; } // only background update if we're: // - one major update behind // - the same major update int diff = _latestVersion.Minor - currentVersion.Minor; if (diff == 0 || diff == 1) { App.Logger.WriteLine(LOG_IDENT, "Eligible"); return true; } else { App.Logger.WriteLine(LOG_IDENT, $"Not eligible: Major version diff is {diff}"); return false; } } private static void LaunchMultiInstanceWatcher() { const string LOG_IDENT = "Bootstrapper::LaunchMultiInstanceWatcher"; if (Utilities.DoesMutexExist("ROBLOX_singletonMutex")) { App.Logger.WriteLine(LOG_IDENT, "Roblox singleton mutex already exists"); return; } using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, "Bloxstrap-MultiInstanceWatcherInitialisationFinished"); Process.Start(Paths.Process, "-multiinstancewatcher"); bool initSuccess = initEventHandle.WaitOne(TimeSpan.FromSeconds(2)); if (initSuccess) App.Logger.WriteLine(LOG_IDENT, "Initialisation finished signalled, continuing."); else App.Logger.WriteLine(LOG_IDENT, "Did not receive the initialisation finished signal, continuing."); } private void StartRoblox() { const string LOG_IDENT = "Bootstrapper::StartRoblox"; SetStatus(Strings.Bootstrapper_Status_Starting); if (_launchMode == LaunchMode.Player) { // this needs to be done before roblox launches if (App.Settings.Prop.MultiInstanceLaunching) LaunchMultiInstanceWatcher(); if (App.Settings.Prop.ForceRobloxLanguage) { var match = Regex.Match(_launchCommandLine, "gameLocale:([a-z_]+)", RegexOptions.CultureInvariant); if (match.Groups.Count == 2) _launchCommandLine = _launchCommandLine.Replace( "robloxLocale:en_us", $"robloxLocale:{match.Groups[1].Value}", StringComparison.OrdinalIgnoreCase); } } var startInfo = new ProcessStartInfo() { FileName = AppData.ExecutablePath, Arguments = _launchCommandLine, WorkingDirectory = AppData.Directory }; if (_launchMode == LaunchMode.Player && ShouldRunAsAdmin()) { startInfo.Verb = "runas"; startInfo.UseShellExecute = true; } else if (_launchMode == LaunchMode.StudioAuth) { Process.Start(startInfo); return; } string? logFileName = null; string rbxDir = Path.Combine(Paths.LocalAppData, "Roblox"); if (!Directory.Exists(rbxDir)) Directory.CreateDirectory(rbxDir); string rbxLogDir = Path.Combine(rbxDir, "logs"); if (!Directory.Exists(rbxLogDir)) Directory.CreateDirectory(rbxLogDir); var logWatcher = new FileSystemWatcher() { Path = rbxLogDir, Filter = "*.log", EnableRaisingEvents = true }; var logCreatedEvent = new AutoResetEvent(false); logWatcher.Created += (_, e) => { logWatcher.EnableRaisingEvents = false; logFileName = e.FullPath; logCreatedEvent.Set(); }; // v2.2.0 - byfron will trip if we keep a process handle open for over a minute, so we're doing this now try { using var process = Process.Start(startInfo)!; _appPid = process.Id; } catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) { // 1223 = ERROR_CANCELLED, gets thrown if a UAC prompt is cancelled return; } catch (Exception) { // attempt a reinstall on next launch File.Delete(AppData.ExecutablePath); throw; } App.Logger.WriteLine(LOG_IDENT, $"Started Roblox (PID {_appPid}), waiting for log file"); logCreatedEvent.WaitOne(TimeSpan.FromSeconds(15)); if (String.IsNullOrEmpty(logFileName)) { App.Logger.WriteLine(LOG_IDENT, "Unable to identify log file"); Frontend.ShowPlayerErrorDialog(); return; } else { App.Logger.WriteLine(LOG_IDENT, $"Got log file as {logFileName}"); } _mutex?.ReleaseAsync(); if (IsStudioLaunch) return; var autoclosePids = new List(); // launch custom integrations now foreach (var integration in App.Settings.Prop.CustomIntegrations) { App.Logger.WriteLine(LOG_IDENT, $"Launching custom integration '{integration.Name}' ({integration.Location} {integration.LaunchArgs} - autoclose is {integration.AutoClose})"); int pid = 0; try { var process = Process.Start(new ProcessStartInfo { FileName = integration.Location, Arguments = integration.LaunchArgs.Replace("\r\n", " "), WorkingDirectory = Path.GetDirectoryName(integration.Location), UseShellExecute = true })!; pid = process.Id; } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to launch integration '{integration.Name}'!"); App.Logger.WriteLine(LOG_IDENT, ex.Message); } if (integration.AutoClose && pid != 0) autoclosePids.Add(pid); } if (App.Settings.Prop.EnableActivityTracking || App.LaunchSettings.TestModeFlag.Active || autoclosePids.Any()) { using var ipl = new InterProcessLock("Watcher", TimeSpan.FromSeconds(5)); var watcherData = new WatcherData { ProcessId = _appPid, LogFile = logFileName, AutoclosePids = autoclosePids }; string watcherDataArg = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(watcherData))); string args = $"-watcher \"{watcherDataArg}\""; if (App.LaunchSettings.TestModeFlag.Active) args += " -testmode"; if (ipl.IsAcquired) Process.Start(Paths.Process, args); } // allow for window to show, since the log is created pretty far beforehand Thread.Sleep(1000); } private bool ShouldRunAsAdmin() { foreach (var root in WindowsRegistry.Roots) { using var key = root.OpenSubKey("SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers"); if (key is null) continue; string? flags = (string?)key.GetValue(AppData.ExecutablePath); if (flags is not null && flags.Contains("RUNASADMIN", StringComparison.OrdinalIgnoreCase)) return true; } return false; } public void Cancel() { const string LOG_IDENT = "Bootstrapper::Cancel"; if (_cancelTokenSource.IsCancellationRequested) return; App.Logger.WriteLine(LOG_IDENT, "Cancelling launch..."); _cancelTokenSource.Cancel(); if (Dialog is not null) Dialog.CancelEnabled = false; if (_isInstalling) { try { // clean up install if (Directory.Exists(_latestVersionDirectory)) Directory.Delete(_latestVersionDirectory, true); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, "Could not fully clean up installation!"); App.Logger.WriteException(LOG_IDENT, ex); } } else if (_appPid != 0) { try { using var process = Process.GetProcessById(_appPid); process.Kill(); } catch (Exception) { } } Dialog?.CloseBootstrapper(); App.SoftTerminate(ErrorCode.ERROR_CANCELLED); } #endregion #region App Install private async Task CheckForUpdates() { const string LOG_IDENT = "Bootstrapper::CheckForUpdates"; // don't update if there's another instance running (likely running in the background) // i don't like this, but there isn't much better way of doing it /shrug if (Process.GetProcessesByName(App.ProjectName).Length > 1) { App.Logger.WriteLine(LOG_IDENT, $"More than one Bloxstrap instance running, aborting update check"); return false; } App.Logger.WriteLine(LOG_IDENT, "Checking for updates..."); #if !DEBUG_UPDATER var releaseInfo = await App.GetLatestRelease(); if (releaseInfo is null) return false; var versionComparison = Utilities.CompareVersions(App.Version, releaseInfo.TagName); // check if we aren't using a deployed build, so we can update to one if a new version comes out if (App.IsProductionBuild && versionComparison == VersionComparison.Equal || versionComparison == VersionComparison.GreaterThan) { App.Logger.WriteLine(LOG_IDENT, "No updates found"); return false; } if (Dialog is not null) Dialog.CancelEnabled = false; string version = releaseInfo.TagName; #else string version = App.Version; #endif SetStatus(Strings.Bootstrapper_Status_UpgradingBloxstrap); try { #if DEBUG_UPDATER string downloadLocation = Path.Combine(Paths.TempUpdates, "Bloxstrap.exe"); Directory.CreateDirectory(Paths.TempUpdates); File.Copy(Paths.Process, downloadLocation, true); #else var asset = releaseInfo.Assets![0]; string downloadLocation = Path.Combine(Paths.TempUpdates, asset.Name); Directory.CreateDirectory(Paths.TempUpdates); App.Logger.WriteLine(LOG_IDENT, $"Downloading {releaseInfo.TagName}..."); if (!File.Exists(downloadLocation)) { var response = await App.HttpClient.GetAsync(asset.BrowserDownloadUrl); await using var fileStream = new FileStream(downloadLocation, FileMode.OpenOrCreate, FileAccess.Write); await response.Content.CopyToAsync(fileStream); } #endif App.Logger.WriteLine(LOG_IDENT, $"Starting {version}..."); ProcessStartInfo startInfo = new() { FileName = downloadLocation, }; startInfo.ArgumentList.Add("-upgrade"); foreach (string arg in App.LaunchSettings.Args) startInfo.ArgumentList.Add(arg); if (_launchMode == LaunchMode.Player && !startInfo.ArgumentList.Contains("-player")) startInfo.ArgumentList.Add("-player"); else if (_launchMode == LaunchMode.Studio && !startInfo.ArgumentList.Contains("-studio")) startInfo.ArgumentList.Add("-studio"); App.Settings.Save(); new InterProcessLock("AutoUpdater"); Process.Start(startInfo); return true; } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the auto-updater"); App.Logger.WriteException(LOG_IDENT, ex); Frontend.ShowMessageBox( string.Format(Strings.Bootstrapper_AutoUpdateFailed, version), MessageBoxImage.Information ); Utilities.ShellExecute(App.ProjectDownloadLink); } return false; } #endregion #region Roblox Install private static bool TryDeleteRobloxInDirectory(string dir) { string clientPath = Path.Combine(dir, "RobloxPlayerBeta.exe"); if (!File.Exists(dir)) { clientPath = Path.Combine(dir, "RobloxStudioBeta.exe"); if (!File.Exists(dir)) return true; // ok??? } try { File.Delete(clientPath); return true; } catch (Exception) { return false; } } public static void CleanupVersionsFolder() { const string LOG_IDENT = "Bootstrapper::CleanupVersionsFolder"; if (App.LaunchSettings.BackgroundUpdaterFlag.Active) { App.Logger.WriteLine(LOG_IDENT, "Background updater tried to cleanup, stopping!"); return; } if (!Directory.Exists(Paths.Versions)) { App.Logger.WriteLine(LOG_IDENT, "Versions directory does not exist, skipping cleanup."); return; } foreach (string dir in Directory.GetDirectories(Paths.Versions)) { string dirName = Path.GetFileName(dir); if (dirName != App.RobloxState.Prop.Player.VersionGuid && dirName != App.RobloxState.Prop.Studio.VersionGuid) { // TODO: this is too expensive //Filesystem.AssertReadOnlyDirectory(dir); // check if it's still being used first // we dont want to accidentally delete the files of a running roblox instance if (!TryDeleteRobloxInDirectory(dir)) continue; try { Directory.Delete(dir, true); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {dir}"); App.Logger.WriteException(LOG_IDENT, ex); } } } } private void MigrateCompatibilityFlags() { const string LOG_IDENT = "Bootstrapper::MigrateCompatibilityFlags"; string oldClientLocation = Path.Combine(Paths.Versions, AppData.State.VersionGuid, AppData.ExecutableName); string newClientLocation = Path.Combine(_latestVersionDirectory, AppData.ExecutableName); // move old compatibility flags for the old location using RegistryKey appFlagsKey = Registry.CurrentUser.CreateSubKey($"SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\AppCompatFlags\\Layers"); string? appFlags = appFlagsKey.GetValue(oldClientLocation) as string; if (appFlags is not null) { App.Logger.WriteLine(LOG_IDENT, $"Migrating app compatibility flags from {oldClientLocation} to {newClientLocation}..."); appFlagsKey.SetValueSafe(newClientLocation, appFlags); appFlagsKey.DeleteValueSafe(oldClientLocation); } } private static void KillRobloxPlayers() { const string LOG_IDENT = "Bootstrapper::KillRobloxPlayers"; List processes = new List(); processes.AddRange(Process.GetProcessesByName("RobloxPlayerBeta")); processes.AddRange(Process.GetProcessesByName("RobloxCrashHandler")); // roblox studio doesnt depend on crash handler being open, so this should be fine foreach (Process process in processes) { try { process.Kill(); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to close process {process.Id}"); App.Logger.WriteException(LOG_IDENT, ex); } } } private async Task UpgradeRoblox() { const string LOG_IDENT = "Bootstrapper::UpgradeRoblox"; if (String.IsNullOrEmpty(AppData.State.VersionGuid)) SetStatus(Strings.Bootstrapper_Status_Installing); else SetStatus(Strings.Bootstrapper_Status_Upgrading); Directory.CreateDirectory(Paths.Base); Directory.CreateDirectory(Paths.Downloads); Directory.CreateDirectory(Paths.Versions); _isInstalling = true; // make sure nothing is running before continuing upgrade if (!App.LaunchSettings.BackgroundUpdaterFlag.Active && !IsStudioLaunch) // TODO: wait for studio processes to close before updating to prevent data loss KillRobloxPlayers(); // get a fully clean install if (!App.LaunchSettings.BackgroundUpdaterFlag.Active && Directory.Exists(_latestVersionDirectory)) { try { Directory.Delete(_latestVersionDirectory, true); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, "Failed to delete the latest version directory"); App.Logger.WriteException(LOG_IDENT, ex); } } Directory.CreateDirectory(_latestVersionDirectory); var cachedPackageHashes = Directory.GetFiles(Paths.Downloads).Select(x => Path.GetFileName(x)); // package manifest states packed size and uncompressed size in exact bytes int totalSizeRequired = 0; // packed size only matters if we don't already have the package cached on disk totalSizeRequired += _versionPackageManifest.Where(x => !cachedPackageHashes.Contains(x.Signature)).Sum(x => x.PackedSize); totalSizeRequired += _versionPackageManifest.Sum(x => x.Size); if (Filesystem.GetFreeDiskSpace(Paths.Base) < totalSizeRequired) { Frontend.ShowMessageBox(Strings.Bootstrapper_NotEnoughSpace, MessageBoxImage.Error); App.Terminate(ErrorCode.ERROR_INSTALL_FAILURE); return; } if (Dialog is not null) { Dialog.ProgressStyle = ProgressBarStyle.Continuous; Dialog.TaskbarProgressState = TaskbarItemProgressState.Normal; Dialog.ProgressMaximum = ProgressBarMaximum; // compute total bytes to download int totalPackedSize = _versionPackageManifest.Sum(package => package.PackedSize); _progressIncrement = (double)ProgressBarMaximum / totalPackedSize; if (Dialog is WinFormsDialogBase) _taskbarProgressMaximum = (double)TaskbarProgressMaximumWinForms; else _taskbarProgressMaximum = (double)TaskbarProgressMaximumWpf; _taskbarProgressIncrement = _taskbarProgressMaximum / (double)totalPackedSize; } var extractionTasks = new List(); foreach (var package in _versionPackageManifest) { if (_cancelTokenSource.IsCancellationRequested) 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 async immediately after download extractionTasks.Add(Task.Run(() => ExtractPackage(package), _cancelTokenSource.Token)); } if (_cancelTokenSource.IsCancellationRequested) return; if (Dialog is not null) { Dialog.ProgressStyle = ProgressBarStyle.Marquee; Dialog.TaskbarProgressState = TaskbarItemProgressState.Indeterminate; SetStatus(Strings.Bootstrapper_Status_Configuring); } await Task.WhenAll(extractionTasks); App.Logger.WriteLine(LOG_IDENT, "Writing AppSettings.xml..."); await File.WriteAllTextAsync(Path.Combine(_latestVersionDirectory, "AppSettings.xml"), AppSettings); if (_cancelTokenSource.IsCancellationRequested) return; if (App.State.Prop.PromptWebView2Install) { using var hklmKey = Registry.LocalMachine.OpenSubKey("SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); using var hkcuKey = Registry.CurrentUser.OpenSubKey("Software\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"); if (hklmKey is not null || hkcuKey is not null) { // reset prompt state if the user has it installed App.State.Prop.PromptWebView2Install = true; } else { var result = Frontend.ShowMessageBox(Strings.Bootstrapper_WebView2NotFound, MessageBoxImage.Warning, MessageBoxButton.YesNo, MessageBoxResult.Yes); if (result != MessageBoxResult.Yes) { App.State.Prop.PromptWebView2Install = false; } else { App.Logger.WriteLine(LOG_IDENT, "Installing WebView2 runtime..."); var package = _versionPackageManifest.Find(x => x.Name == "WebView2RuntimeInstaller.zip"); if (package is null) { App.Logger.WriteLine(LOG_IDENT, "Aborted runtime install because package does not exist, has WebView2 been added in this Roblox version yet?"); return; } string baseDirectory = Path.Combine(_latestVersionDirectory, AppData.PackageDirectoryMap[package.Name]); ExtractPackage(package); SetStatus(Strings.Bootstrapper_Status_InstallingWebView2); var startInfo = new ProcessStartInfo() { WorkingDirectory = baseDirectory, FileName = Path.Combine(baseDirectory, "MicrosoftEdgeWebview2Setup.exe"), Arguments = "/silent /install" }; await Process.Start(startInfo)!.WaitForExitAsync(); App.Logger.WriteLine(LOG_IDENT, "Finished installing runtime"); Directory.Delete(baseDirectory, true); } } } // finishing and cleanup MigrateCompatibilityFlags(); AppData.State.VersionGuid = _latestVersionGuid; AppData.State.PackageHashes.Clear(); foreach (var package in _versionPackageManifest) AppData.State.PackageHashes.Add(package.Name, package.Signature); CleanupVersionsFolder(); var allPackageHashes = new List(); allPackageHashes.AddRange(App.RobloxState.Prop.Player.PackageHashes.Values); allPackageHashes.AddRange(App.RobloxState.Prop.Studio.PackageHashes.Values); if (!App.Settings.Prop.DebugDisableVersionPackageCleanup) { foreach (string hash in cachedPackageHashes) { if (!allPackageHashes.Contains(hash)) { App.Logger.WriteLine(LOG_IDENT, $"Deleting unused package {hash}"); try { File.Delete(Path.Combine(Paths.Downloads, hash)); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to delete {hash}!"); App.Logger.WriteException(LOG_IDENT, ex); } } } } App.Logger.WriteLine(LOG_IDENT, "Registering approximate program size..."); int distributionSize = _versionPackageManifest.Sum(x => x.Size + x.PackedSize) / 1024; AppData.State.Size = distributionSize; int totalSize = App.RobloxState.Prop.Player.Size + App.RobloxState.Prop.Studio.Size; using (var uninstallKey = Registry.CurrentUser.CreateSubKey(App.UninstallKey)) { uninstallKey.SetValueSafe("EstimatedSize", totalSize); } App.Logger.WriteLine(LOG_IDENT, $"Registered as {totalSize} KB"); App.State.Prop.ForceReinstall = false; App.State.Save(); App.RobloxState.Save(); _isInstalling = false; } private static void StartBackgroundUpdater() { const string LOG_IDENT = "Bootstrapper::StartBackgroundUpdater"; if (Utilities.DoesMutexExist("Bloxstrap-BackgroundUpdater")) { App.Logger.WriteLine(LOG_IDENT, "Background updater already running"); return; } App.Logger.WriteLine(LOG_IDENT, "Starting background updater"); Process.Start(Paths.Process, "-backgroundupdater"); } private async Task ApplyModifications() { const string LOG_IDENT = "Bootstrapper::ApplyModifications"; bool success = true; SetStatus(Strings.Bootstrapper_Status_ApplyingModifications); // handle file mods App.Logger.WriteLine(LOG_IDENT, "Checking file mods..."); // manifest has been moved to State.json File.Delete(Path.Combine(Paths.Base, "ModManifest.txt")); List modFolderFiles = new(); Directory.CreateDirectory(Paths.Modifications); // check custom font mod // instead of replacing the fonts themselves, we'll just alter the font family manifests string modFontFamiliesFolder = Path.Combine(Paths.Modifications, "content\\fonts\\families"); if (File.Exists(Paths.CustomFont)) { App.Logger.WriteLine(LOG_IDENT, "Begin font check"); Directory.CreateDirectory(modFontFamiliesFolder); const string path = "rbxasset://fonts/CustomFont.ttf"; // lets make sure the content/fonts/families path exists in the version directory string contentFolder = Path.Combine(_latestVersionDirectory, "content"); Directory.CreateDirectory(contentFolder); string fontsFolder = Path.Combine(contentFolder, "fonts"); Directory.CreateDirectory(fontsFolder); string familiesFolder = Path.Combine(fontsFolder, "families"); Directory.CreateDirectory(familiesFolder); foreach (string jsonFilePath in Directory.GetFiles(familiesFolder)) { string jsonFilename = Path.GetFileName(jsonFilePath); string modFilepath = Path.Combine(modFontFamiliesFolder, jsonFilename); if (File.Exists(modFilepath)) continue; App.Logger.WriteLine(LOG_IDENT, $"Setting font for {jsonFilename}"); var fontFamilyData = JsonSerializer.Deserialize(File.ReadAllText(jsonFilePath)); if (fontFamilyData is null) continue; bool shouldWrite = false; foreach (var fontFace in fontFamilyData.Faces) { if (fontFace.AssetId != path) { fontFace.AssetId = path; shouldWrite = true; } } if (shouldWrite) File.WriteAllText(modFilepath, JsonSerializer.Serialize(fontFamilyData, new JsonSerializerOptions { WriteIndented = true })); } App.Logger.WriteLine(LOG_IDENT, "End font check"); } else if (Directory.Exists(modFontFamiliesFolder)) { Directory.Delete(modFontFamiliesFolder, true); } foreach (string file in Directory.GetFiles(Paths.Modifications, "*.*", SearchOption.AllDirectories)) { if (_cancelTokenSource.IsCancellationRequested) return true; // get relative directory path string relativeFile = file.Substring(Paths.Modifications.Length + 1); // v1.7.0 - README has been moved to the preferences menu now if (relativeFile == "README.txt") { File.Delete(file); continue; } if (!App.Settings.Prop.UseFastFlagManager && String.Equals(relativeFile, "ClientSettings\\ClientAppSettings.json", StringComparison.OrdinalIgnoreCase)) continue; if (relativeFile.EndsWith(".lock")) continue; modFolderFiles.Add(relativeFile); string fileModFolder = Path.Combine(Paths.Modifications, relativeFile); string fileVersionFolder = Path.Combine(_latestVersionDirectory, relativeFile); if (File.Exists(fileVersionFolder) && MD5Hash.FromFile(fileModFolder) == MD5Hash.FromFile(fileVersionFolder)) { App.Logger.WriteLine(LOG_IDENT, $"{relativeFile} already exists in the version folder, and is a match"); continue; } Directory.CreateDirectory(Path.GetDirectoryName(fileVersionFolder)!); Filesystem.AssertReadOnly(fileVersionFolder); try { File.Copy(fileModFolder, fileVersionFolder, true); Filesystem.AssertReadOnly(fileVersionFolder); App.Logger.WriteLine(LOG_IDENT, $"{relativeFile} has been copied to the version folder"); } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"Failed to apply modification ({relativeFile})"); App.Logger.WriteException(LOG_IDENT, ex); success = false; } } // the manifest is primarily here to keep track of what files have been // deleted from the modifications folder, so that we know when to restore the original files from the downloaded packages // now check for files that have been deleted from the mod folder according to the manifest var fileRestoreMap = new Dictionary>(); foreach (string fileLocation in App.RobloxState.Prop.ModManifest) { if (modFolderFiles.Contains(fileLocation)) continue; var packageMapEntry = AppData.PackageDirectoryMap.SingleOrDefault(x => !String.IsNullOrEmpty(x.Value) && fileLocation.StartsWith(x.Value)); string packageName = packageMapEntry.Key; // package doesn't exist, likely mistakenly placed file if (String.IsNullOrEmpty(packageName)) { App.Logger.WriteLine(LOG_IDENT, $"{fileLocation} was removed as a mod but does not belong to a package"); string versionFileLocation = Path.Combine(_latestVersionDirectory, fileLocation); if (File.Exists(versionFileLocation)) File.Delete(versionFileLocation); continue; } string fileName = fileLocation.Substring(packageMapEntry.Value.Length); if (!fileRestoreMap.ContainsKey(packageName)) fileRestoreMap[packageName] = new(); fileRestoreMap[packageName].Add(fileName); App.Logger.WriteLine(LOG_IDENT, $"{fileLocation} was removed as a mod, restoring from {packageName}"); } foreach (var entry in fileRestoreMap) { var package = _versionPackageManifest.Find(x => x.Name == entry.Key); if (package is not null) { if (_cancelTokenSource.IsCancellationRequested) return true; await DownloadPackage(package); ExtractPackage(package, entry.Value); } } // make sure we're not overwriting a new update // if we're the background update process, always overwrite if (App.LaunchSettings.BackgroundUpdaterFlag.Active || !App.RobloxState.HasFileOnDiskChanged()) { App.RobloxState.Prop.ModManifest = modFolderFiles; App.RobloxState.Save(); } else { App.Logger.WriteLine(LOG_IDENT, "RobloxState disk mismatch, not saving ModManifest"); } App.Logger.WriteLine(LOG_IDENT, $"Finished checking file mods"); if (!success) App.Logger.WriteLine(LOG_IDENT, "Failed to apply all modifications"); return success; } private async Task DownloadPackage(Package package) { string LOG_IDENT = $"Bootstrapper::DownloadPackage.{package.Name}"; if (_cancelTokenSource.IsCancellationRequested) return; Directory.CreateDirectory(Paths.Downloads); string packageUrl = Deployment.GetLocation($"/{_latestVersionGuid}-{package.Name}"); string robloxPackageLocation = Path.Combine(Paths.LocalAppData, "Roblox", "Downloads", package.Signature); if (File.Exists(package.DownloadPath)) { var file = new FileInfo(package.DownloadPath); string calculatedMD5 = MD5Hash.FromFile(package.DownloadPath); if (calculatedMD5 != package.Signature) { App.Logger.WriteLine(LOG_IDENT, $"Package is corrupted ({calculatedMD5} != {package.Signature})! Deleting and re-downloading..."); file.Delete(); } else { App.Logger.WriteLine(LOG_IDENT, $"Package 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(LOG_IDENT, $"Found existing copy at '{robloxPackageLocation}'! Copying to Downloads folder..."); File.Copy(robloxPackageLocation, package.DownloadPath); _totalDownloadedBytes += package.PackedSize; UpdateProgressBar(); return; } if (File.Exists(package.DownloadPath)) return; const int maxTries = 5; App.Logger.WriteLine(LOG_IDENT, "Downloading..."); var buffer = new byte[4096]; for (int i = 1; i <= maxTries; i++) { if (_cancelTokenSource.IsCancellationRequested) return; int totalBytesRead = 0; try { var response = await App.HttpClient.GetAsync(packageUrl, HttpCompletionOption.ResponseHeadersRead, _cancelTokenSource.Token); await using var stream = await response.Content.ReadAsStreamAsync(_cancelTokenSource.Token); await using var fileStream = new FileStream(package.DownloadPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Delete); while (true) { if (_cancelTokenSource.IsCancellationRequested) { stream.Close(); fileStream.Close(); return; } int bytesRead = await stream.ReadAsync(buffer, _cancelTokenSource.Token); if (bytesRead == 0) break; totalBytesRead += bytesRead; await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), _cancelTokenSource.Token); _totalDownloadedBytes += bytesRead; UpdateProgressBar(); } string hash = MD5Hash.FromStream(fileStream); if (hash != package.Signature) throw new ChecksumFailedException($"Failed to verify download of {packageUrl}\n\nExpected hash: {package.Signature}\nGot hash: {hash}"); App.Logger.WriteLine(LOG_IDENT, $"Finished downloading! ({totalBytesRead} bytes total)"); break; } catch (Exception ex) { App.Logger.WriteLine(LOG_IDENT, $"An exception occurred after downloading {totalBytesRead} bytes. ({i}/{maxTries})"); App.Logger.WriteException(LOG_IDENT, ex); if (ex.GetType() == typeof(ChecksumFailedException)) { App.SendStat("packageDownloadState", "httpFail"); Frontend.ShowConnectivityDialog( Strings.Dialog_Connectivity_UnableToDownload, String.Format(Strings.Dialog_Connectivity_UnableToDownloadReason, "[https://github.com/bloxstraplabs/bloxstrap/wiki/Bloxstrap-is-unable-to-download-Roblox](https://github.com/bloxstraplabs/bloxstrap/wiki/Bloxstrap-is-unable-to-download-Roblox)"), MessageBoxImage.Error, ex ); App.Terminate(ErrorCode.ERROR_CANCELLED); } else if (i >= maxTries) throw; if (File.Exists(package.DownloadPath)) File.Delete(package.DownloadPath); _totalDownloadedBytes -= totalBytesRead; UpdateProgressBar(); // attempt download over HTTP // this isn't actually that unsafe - signatures were fetched earlier over HTTPS // so we've already established that our signatures are legit, and that there's very likely no MITM anyway if (ex.GetType() == typeof(IOException) && !packageUrl.StartsWith("http://")) { App.Logger.WriteLine(LOG_IDENT, "Retrying download over HTTP..."); packageUrl = packageUrl.Replace("https://", "http://"); } } } } private void ExtractPackage(Package package, List? files = null) { const string LOG_IDENT = "Bootstrapper::ExtractPackage"; string? packageDir = AppData.PackageDirectoryMap.GetValueOrDefault(package.Name); if (packageDir is null) { App.Logger.WriteLine(LOG_IDENT, $"WARNING: {package.Name} was not found in the package map!"); return; } string packageFolder = Path.Combine(_latestVersionDirectory, packageDir); string? fileFilter = null; // for sharpziplib, each file in the filter needs to be a regex if (files is not null) { var regexList = new List(); foreach (string file in files) regexList.Add("^" + file.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)") + "$"); fileFilter = String.Join(';', regexList); } App.Logger.WriteLine(LOG_IDENT, $"Extracting {package.Name}..."); var fastZip = new FastZip(_fastZipEvents); fastZip.ExtractZip(package.DownloadPath, packageFolder, fileFilter); App.Logger.WriteLine(LOG_IDENT, $"Finished extracting {package.Name}"); } #endregion } }