bloxstrap/Bloxstrap/Bootstrapper.cs
Matt dbdd0e37d6
Some checks failed
CI (Debug) / build (push) Has been cancelled
CI (Release) / build (push) Has been cancelled
CI (Release) / release (push) Has been cancelled
CI (Release) / release-test (push) Has been cancelled
Cancel parenthesis in ExtractZip regex (#4968)
2025-03-29 09:00:02 +00:00

1544 lines
60 KiB
C#

// 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 =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n" +
"<Settings>\r\n" +
" <ContentFolder>content</ContentFolder>\r\n" +
" <BaseUrl>http://www.roblox.com</BaseUrl>\r\n" +
"</Settings>\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();
}
/// <summary>
/// Will throw whatever HttpClient can throw
/// </summary>
/// <returns></returns>
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<int>();
// 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<bool> 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<Process> processes = new List<Process>();
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<Task>();
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<string>();
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<bool> 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<string> 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<FontFamily>(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<string, List<string>>();
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<string>? 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<string>();
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
}
}