diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs index dfc11ca..81cecce 100644 --- a/Bloxstrap/App.xaml.cs +++ b/Bloxstrap/App.xaml.cs @@ -65,6 +65,20 @@ namespace Bloxstrap ); private static bool _showingExceptionDialog = false; + + private static string? _webUrl = null; + public static string WebUrl + { + get { + if (_webUrl != null) + return _webUrl; + + string url = ConstructBloxstrapWebUrl(); + if (Settings.Loaded) // only cache if settings are done loading + _webUrl = url; + return url; + } + } public static void Terminate(ErrorCode exitCode = ErrorCode.ERROR_SUCCESS) { @@ -126,6 +140,25 @@ namespace Bloxstrap Terminate(ErrorCode.ERROR_INSTALL_FAILURE); } + public static string ConstructBloxstrapWebUrl() + { + // dont let user switch web environment if debug mode is not on + if (Settings.Prop.WebEnvironment == WebEnvironment.Production || !Settings.Prop.DeveloperMode) + return "bloxstraplabs.com"; + + string? sub = Settings.Prop.WebEnvironment.GetDescription(); + return $"web-{sub}.bloxstraplabs.com"; + } + + public static bool CanSendLogs() + { + // non developer mode always uses production + if (!Settings.Prop.DeveloperMode || Settings.Prop.WebEnvironment == WebEnvironment.Production) + return IsProductionBuild; + + return true; + } + public static async Task GetLatestRelease() { const string LOG_IDENT = "App::GetLatestRelease"; @@ -157,7 +190,7 @@ namespace Bloxstrap try { - await HttpClient.GetAsync($"https://bloxstraplabs.com/metrics/post?key={key}&value={value}"); + await HttpClient.GetAsync($"https://{WebUrl}/metrics/post?key={key}&value={value}"); } catch (Exception ex) { @@ -167,13 +200,13 @@ namespace Bloxstrap public static async void SendLog() { - if (!Settings.Prop.EnableAnalytics || !IsProductionBuild) + if (!Settings.Prop.EnableAnalytics || !CanSendLogs()) return; try { await HttpClient.PostAsync( - $"https://bloxstraplabs.com/metrics/post-exception", + $"https://{WebUrl}/metrics/post-exception", new StringContent(Logger.AsDocument) ); } @@ -347,6 +380,9 @@ namespace Bloxstrap Settings.Save(); } + Logger.WriteLine(LOG_IDENT, $"Developer mode: {Settings.Prop.DeveloperMode}"); + Logger.WriteLine(LOG_IDENT, $"Web environment: {Settings.Prop.WebEnvironment}"); + Locale.Set(Settings.Prop.Locale); if (!LaunchSettings.BypassUpdateCheck) diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index f680e00..bfde105 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -294,11 +294,14 @@ namespace Bloxstrap if (!App.LaunchSettings.NoLaunchFlag.Active && !_cancelTokenSource.IsCancellationRequested) { - // 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); + 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(); } @@ -471,21 +474,48 @@ namespace Bloxstrap } } + 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 && App.Settings.Prop.ForceRobloxLanguage) + if (_launchMode == LaunchMode.Player) { - var match = Regex.Match(_launchCommandLine, "gameLocale:([a-z_]+)", RegexOptions.CultureInvariant); + // this needs to be done before roblox launches + if (App.Settings.Prop.MultiInstanceLaunching) + LaunchMultiInstanceWatcher(); - if (match.Groups.Count == 2) - _launchCommandLine = _launchCommandLine.Replace( - "robloxLocale:en_us", - $"robloxLocale:{match.Groups[1].Value}", - StringComparison.OrdinalIgnoreCase); + 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() @@ -829,6 +859,12 @@ namespace Bloxstrap 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); @@ -1109,6 +1145,8 @@ namespace Bloxstrap App.Logger.WriteLine(LOG_IDENT, $"Registered as {totalSize} KB"); + App.State.Prop.ForceReinstall = false; + App.State.Save(); App.RobloxState.Save(); diff --git a/Bloxstrap/Enums/WebEnvironment.cs b/Bloxstrap/Enums/WebEnvironment.cs new file mode 100644 index 0000000..5bf857a --- /dev/null +++ b/Bloxstrap/Enums/WebEnvironment.cs @@ -0,0 +1,26 @@ +using System.ComponentModel; + +namespace Bloxstrap.Enums +{ + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum WebEnvironment + { + [Description("prod")] + Production, + + [Description("stage")] + Staging, + + [Description("dev")] + Dev, + + [Description("pizza")] + DevPizza, + + [Description("matt")] + DevMatt, + + [Description("local")] + Local + } +} diff --git a/Bloxstrap/Extensions/CustomThemeTemplateEx.cs b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs index 4088f02..4b72197 100644 --- a/Bloxstrap/Extensions/CustomThemeTemplateEx.cs +++ b/Bloxstrap/Extensions/CustomThemeTemplateEx.cs @@ -1,10 +1,36 @@ -namespace Bloxstrap.Extensions +using System.Text; + +namespace Bloxstrap.Extensions { static class CustomThemeTemplateEx { + const string EXAMPLES_URL = "https://github.com/bloxstraplabs/custom-bootstrapper-examples"; + public static string GetFileName(this CustomThemeTemplate template) { return $"CustomBootstrapperTemplate_{template}.xml"; } + + public static string GetFileContents(this CustomThemeTemplate template) + { + string contents = Encoding.UTF8.GetString(Resource.Get(template.GetFileName()).Result); + + switch (template) + { + case CustomThemeTemplate.Blank: + { + string moreText = string.Format(Strings.CustomTheme_Templates_Blank_MoreExamples, EXAMPLES_URL); + return contents.Replace("{0}", Strings.CustomTheme_Templates_Blank_UIElements).Replace("{1}", moreText); + } + case CustomThemeTemplate.Simple: + { + string moreText = string.Format(Strings.CustomTheme_Templates_Simple_MoreExamples, EXAMPLES_URL); + return contents.Replace("{0}", moreText); + } + default: + Debug.Assert(false); + return contents; + } + } } } diff --git a/Bloxstrap/Extensions/HttpClientEx.cs b/Bloxstrap/Extensions/HttpClientEx.cs new file mode 100644 index 0000000..00680fa --- /dev/null +++ b/Bloxstrap/Extensions/HttpClientEx.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bloxstrap.Extensions +{ + internal static class HttpClientEx + { + public static async Task GetWithRetriesAsync(this HttpClient client, string url, int retries, CancellationToken token) + { + HttpResponseMessage response = null!; + + for (int i = 1; i <= retries; i++) + { + try + { + response = await client.GetAsync(url, token); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + App.Logger.WriteException("HttpClientEx::GetWithRetriesAsync", ex); + + if (i == retries) + throw; + } + } + + return response; + } + + public static async Task PostWithRetriesAsync(this HttpClient client, string url, HttpContent? content, int retries, CancellationToken token) + { + HttpResponseMessage response = null!; + + for (int i = 1; i <= retries; i++) + { + try + { + response = await client.PostAsync(url, content, token); + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception ex) + { + App.Logger.WriteException("HttpClientEx::PostWithRetriesAsync", ex); + + if (i == retries) + throw; + } + } + + return response; + } + + public static async Task GetFromJsonWithRetriesAsync(this HttpClient client, string url, int retries, CancellationToken token) where T : class + { + HttpResponseMessage response = await GetWithRetriesAsync(client, url, retries, token); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(token); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: token); + } + + public static async Task PostFromJsonWithRetriesAsync(this HttpClient client, string url, HttpContent? content, int retries, CancellationToken token) where T : class + { + HttpResponseMessage response = await PostWithRetriesAsync(client, url, content, retries, token); + response.EnsureSuccessStatusCode(); + + using var stream = await response.Content.ReadAsStreamAsync(token); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: token); + } + } +} diff --git a/Bloxstrap/Extensions/TEnumEx.cs b/Bloxstrap/Extensions/TEnumEx.cs new file mode 100644 index 0000000..971334d --- /dev/null +++ b/Bloxstrap/Extensions/TEnumEx.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.Reflection; + +namespace Bloxstrap.Extensions +{ + internal static class TEnumEx + { + public static string? GetDescription(this TEnum e) + { + string? enumName = e?.ToString(); + if (enumName == null) + return null; + + FieldInfo? field = e?.GetType().GetField(enumName); + if (field == null) + return null; + + DescriptionAttribute? attribute = field.GetCustomAttribute(); + return attribute?.Description; + } + } +} diff --git a/Bloxstrap/Installer.cs b/Bloxstrap/Installer.cs index d57e985..8d1a6e6 100644 --- a/Bloxstrap/Installer.cs +++ b/Bloxstrap/Installer.cs @@ -9,7 +9,7 @@ namespace Bloxstrap /// Should this version automatically open the release notes page? /// Recommended for major updates only. /// - private const bool OpenReleaseNotes = false; + private const bool OpenReleaseNotes = true; private static string DesktopShortcut => Path.Combine(Paths.Desktop, $"{App.ProjectName}.lnk"); diff --git a/Bloxstrap/Integrations/DiscordRichPresence.cs b/Bloxstrap/Integrations/DiscordRichPresence.cs index 8d140f9..744c085 100644 --- a/Bloxstrap/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Integrations/DiscordRichPresence.cs @@ -13,6 +13,12 @@ namespace Bloxstrap.Integrations private DiscordRPC.RichPresence? _currentPresence; private DiscordRPC.RichPresence? _originalPresence; + private FixedSizeList _thumbnailCache = new FixedSizeList(20); + + private ulong? _smallImgBeingFetched = null; + private ulong? _largeImgBeingFetched = null; + private CancellationTokenSource? _fetchThumbnailsToken; + private bool _visible = true; public DiscordRichPresence(ActivityWatcher activityWatcher) @@ -69,101 +75,239 @@ namespace Bloxstrap.Integrations } else if (message.Command == "SetRichPresence") { - Models.BloxstrapRPC.RichPresence? presenceData; - - try - { - presenceData = message.Data.Deserialize(); - } - catch (Exception) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); - return; - } - - if (presenceData is null) - { - App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); - return; - } - - if (presenceData.Details is not null) - { - if (presenceData.Details.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); - else if (presenceData.Details == "") - _currentPresence.Details = _originalPresence.Details; - else - _currentPresence.Details = presenceData.Details; - } - - if (presenceData.State is not null) - { - if (presenceData.State.Length > 128) - App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); - else if (presenceData.State == "") - _currentPresence.State = _originalPresence.State; - else - _currentPresence.State = presenceData.State; - } - - if (presenceData.TimestampStart == 0) - _currentPresence.Timestamps.Start = null; - else if (presenceData.TimestampStart is not null) - _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; - - if (presenceData.TimestampEnd == 0) - _currentPresence.Timestamps.End = null; - else if (presenceData.TimestampEnd is not null) - _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; - - if (presenceData.SmallImage is not null) - { - if (presenceData.SmallImage.Clear) - { - _currentPresence.Assets.SmallImageKey = ""; - } - else if (presenceData.SmallImage.Reset) - { - _currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText; - _currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey; - } - else - { - if (presenceData.SmallImage.AssetId is not null) - _currentPresence.Assets.SmallImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.SmallImage.AssetId}"; - - if (presenceData.SmallImage.HoverText is not null) - _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; - } - } - - if (presenceData.LargeImage is not null) - { - if (presenceData.LargeImage.Clear) - { - _currentPresence.Assets.LargeImageKey = ""; - } - else if (presenceData.LargeImage.Reset) - { - _currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText; - _currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey; - } - else - { - if (presenceData.LargeImage.AssetId is not null) - _currentPresence.Assets.LargeImageKey = $"https://assetdelivery.roblox.com/v1/asset/?id={presenceData.LargeImage.AssetId}"; - - if (presenceData.LargeImage.HoverText is not null) - _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; - } - } + ProcessSetRichPresence(message, implicitUpdate); } if (implicitUpdate) UpdatePresence(); } + private void AddToThumbnailCache(ulong id, string? url) + { + if (url != null) + _thumbnailCache.Add(new ThumbnailCacheEntry { Id = id, Url = url }); + } + + private async Task UpdatePresenceIconsAsync(ulong? smallImg, ulong? largeImg, bool implicitUpdate, CancellationToken token) + { + Debug.Assert(smallImg != null || largeImg != null); + + if (smallImg != null && largeImg != null) + { + string?[] urls = await Thumbnails.GetThumbnailUrlsAsync(new List + { + new ThumbnailRequest + { + TargetId = (ulong)smallImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, + new ThumbnailRequest + { + TargetId = (ulong)largeImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + } + }, token); + + string? smallUrl = urls[0]; + string? largeUrl = urls[1]; + + AddToThumbnailCache((ulong)smallImg, smallUrl); + AddToThumbnailCache((ulong)largeImg, largeUrl); + + if (_currentPresence != null) + { + _currentPresence.Assets.SmallImageKey = smallUrl; + _currentPresence.Assets.LargeImageKey = largeUrl; + } + } + else if (smallImg != null) + { + string? url = await Thumbnails.GetThumbnailUrlAsync(new ThumbnailRequest + { + TargetId = (ulong)smallImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, token); + + AddToThumbnailCache((ulong)smallImg, url); + + if (_currentPresence != null) + _currentPresence.Assets.SmallImageKey = url; + } + else if (largeImg != null) + { + string? url = await Thumbnails.GetThumbnailUrlAsync(new ThumbnailRequest + { + TargetId = (ulong)largeImg, + Type = "Asset", + Size = "512x512", + IsCircular = false + }, token); + + AddToThumbnailCache((ulong)largeImg, url); + + if (_currentPresence != null) + _currentPresence.Assets.LargeImageKey = url; + } + + _smallImgBeingFetched = null; + _largeImgBeingFetched = null; + + if (implicitUpdate) + UpdatePresence(); + } + + private void ProcessSetRichPresence(Message message, bool implicitUpdate) + { + const string LOG_IDENT = "DiscordRichPresence::ProcessSetRichPresence"; + Models.BloxstrapRPC.RichPresence? presenceData; + + Debug.Assert(_currentPresence is not null); + Debug.Assert(_originalPresence is not null); + + if (_fetchThumbnailsToken != null) + { + _fetchThumbnailsToken.Cancel(); + _fetchThumbnailsToken = null; + } + + try + { + presenceData = message.Data.Deserialize(); + } + catch (Exception) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization threw an exception)"); + return; + } + + if (presenceData is null) + { + App.Logger.WriteLine(LOG_IDENT, "Failed to parse message! (JSON deserialization returned null)"); + return; + } + + if (presenceData.Details is not null) + { + if (presenceData.Details.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"Details cannot be longer than 128 characters"); + else if (presenceData.Details == "") + _currentPresence.Details = _originalPresence.Details; + else + _currentPresence.Details = presenceData.Details; + } + + if (presenceData.State is not null) + { + if (presenceData.State.Length > 128) + App.Logger.WriteLine(LOG_IDENT, $"State cannot be longer than 128 characters"); + else if (presenceData.State == "") + _currentPresence.State = _originalPresence.State; + else + _currentPresence.State = presenceData.State; + } + + if (presenceData.TimestampStart == 0) + _currentPresence.Timestamps.Start = null; + else if (presenceData.TimestampStart is not null) + _currentPresence.Timestamps.StartUnixMilliseconds = presenceData.TimestampStart * 1000; + + if (presenceData.TimestampEnd == 0) + _currentPresence.Timestamps.End = null; + else if (presenceData.TimestampEnd is not null) + _currentPresence.Timestamps.EndUnixMilliseconds = presenceData.TimestampEnd * 1000; + + // set these to start fetching + ulong? smallImgFetch = null; + ulong? largeImgFetch = null; + + if (presenceData.SmallImage is not null) + { + if (presenceData.SmallImage.Clear) + { + _currentPresence.Assets.SmallImageKey = ""; + _smallImgBeingFetched = null; + } + else if (presenceData.SmallImage.Reset) + { + _currentPresence.Assets.SmallImageText = _originalPresence.Assets.SmallImageText; + _currentPresence.Assets.SmallImageKey = _originalPresence.Assets.SmallImageKey; + _smallImgBeingFetched = null; + } + else + { + if (presenceData.SmallImage.AssetId is not null) + { + ThumbnailCacheEntry? entry = _thumbnailCache.FirstOrDefault(x => x.Id == presenceData.SmallImage.AssetId); + + if (entry == null) + { + smallImgFetch = presenceData.SmallImage.AssetId; + } + else + { + _currentPresence.Assets.SmallImageKey = entry.Url; + _smallImgBeingFetched = null; + } + } + + if (presenceData.SmallImage.HoverText is not null) + _currentPresence.Assets.SmallImageText = presenceData.SmallImage.HoverText; + } + } + + if (presenceData.LargeImage is not null) + { + if (presenceData.LargeImage.Clear) + { + _currentPresence.Assets.LargeImageKey = ""; + _largeImgBeingFetched = null; + } + else if (presenceData.LargeImage.Reset) + { + _currentPresence.Assets.LargeImageText = _originalPresence.Assets.LargeImageText; + _currentPresence.Assets.LargeImageKey = _originalPresence.Assets.LargeImageKey; + _largeImgBeingFetched = null; + } + else + { + if (presenceData.LargeImage.AssetId is not null) + { + ThumbnailCacheEntry? entry = _thumbnailCache.FirstOrDefault(x => x.Id == presenceData.LargeImage.AssetId); + + if (entry == null) + { + largeImgFetch = presenceData.LargeImage.AssetId; + } + else + { + _currentPresence.Assets.LargeImageKey = entry.Url; + _largeImgBeingFetched = null; + } + } + + if (presenceData.LargeImage.HoverText is not null) + _currentPresence.Assets.LargeImageText = presenceData.LargeImage.HoverText; + } + } + + if (smallImgFetch != null) + _smallImgBeingFetched = smallImgFetch; + if (largeImgFetch != null) + _largeImgBeingFetched = largeImgFetch; + + if (_smallImgBeingFetched != null || _largeImgBeingFetched != null) + { + _fetchThumbnailsToken = new CancellationTokenSource(); + Task.Run(() => UpdatePresenceIconsAsync(_smallImgBeingFetched, _largeImgBeingFetched, implicitUpdate, _fetchThumbnailsToken.Token)); + } + } + public void SetVisibility(bool visible) { App.Logger.WriteLine("DiscordRichPresence::SetVisibility", $"Setting presence visibility ({visible})"); @@ -225,13 +369,13 @@ namespace Bloxstrap.Integrations var universeDetails = activity.UniverseDetails!; - icon = universeDetails.Thumbnail.ImageUrl; + icon = universeDetails.Thumbnail.ImageUrl!; if (App.Settings.Prop.ShowAccountOnRichPresence) { var userDetails = await UserDetails.Fetch(activity.UserId); - smallImage = userDetails.Thumbnail.ImageUrl; + smallImage = userDetails.Thumbnail.ImageUrl!; smallImageText = $"Playing on {userDetails.Data.DisplayName} (@{userDetails.Data.Name})"; // i.e. "axell (@Axelan_se)" } diff --git a/Bloxstrap/JsonManager.cs b/Bloxstrap/JsonManager.cs index 6c04a2a..2bf55cf 100644 --- a/Bloxstrap/JsonManager.cs +++ b/Bloxstrap/JsonManager.cs @@ -13,6 +13,8 @@ namespace Bloxstrap /// public string? LastFileHash { get; private set; } + public bool Loaded { get; set; } = false; + public virtual string ClassName => typeof(T).Name; public virtual string FileLocation => Path.Combine(Paths.Base, $"{ClassName}.json"); @@ -35,6 +37,7 @@ namespace Bloxstrap throw new ArgumentNullException("Deserialization returned null"); Prop = settings; + Loaded = true; LastFileHash = MD5Hash.FromString(contents); App.Logger.WriteLine(LOG_IDENT, "Loaded successfully!"); diff --git a/Bloxstrap/LaunchHandler.cs b/Bloxstrap/LaunchHandler.cs index 1eee8b2..4171b83 100644 --- a/Bloxstrap/LaunchHandler.cs +++ b/Bloxstrap/LaunchHandler.cs @@ -59,6 +59,11 @@ namespace Bloxstrap App.Logger.WriteLine(LOG_IDENT, "Opening watcher"); LaunchWatcher(); } + else if (App.LaunchSettings.MultiInstanceWatcherFlag.Active) + { + App.Logger.WriteLine(LOG_IDENT, "Opening multi-instance watcher"); + LaunchMultiInstanceWatcher(); + } else if (App.LaunchSettings.BackgroundUpdaterFlag.Active) { App.Logger.WriteLine(LOG_IDENT, "Opening background updater"); @@ -223,7 +228,7 @@ namespace Bloxstrap App.Terminate(ErrorCode.ERROR_FILE_NOT_FOUND); } - if (App.Settings.Prop.ConfirmLaunches && Mutex.TryOpenExisting("ROBLOX_singletonMutex", out var _)) + if (App.Settings.Prop.ConfirmLaunches && Mutex.TryOpenExisting("ROBLOX_singletonMutex", out var _) && !App.Settings.Prop.MultiInstanceLaunching) { // this currently doesn't work very well since it relies on checking the existence of the singleton mutex // which often hangs around for a few seconds after the window closes @@ -302,6 +307,28 @@ namespace Bloxstrap }); } + public static void LaunchMultiInstanceWatcher() + { + const string LOG_IDENT = "LaunchHandler::LaunchMultiInstanceWatcher"; + + App.Logger.WriteLine(LOG_IDENT, "Starting multi-instance watcher"); + + Task.Run(MultiInstanceWatcher.Run).ContinueWith(t => + { + App.Logger.WriteLine(LOG_IDENT, "Multi instance watcher task has finished"); + + if (t.IsFaulted) + { + App.Logger.WriteLine(LOG_IDENT, "An exception occurred when running the multi-instance watcher"); + + if (t.Exception is not null) + App.FinalizeExceptionHandling(t.Exception); + } + + App.Terminate(); + }); + } + public static void LaunchBackgroundUpdater() { const string LOG_IDENT = "LaunchHandler::LaunchBackgroundUpdater"; diff --git a/Bloxstrap/LaunchSettings.cs b/Bloxstrap/LaunchSettings.cs index e0a5051..337e482 100644 --- a/Bloxstrap/LaunchSettings.cs +++ b/Bloxstrap/LaunchSettings.cs @@ -12,33 +12,35 @@ namespace Bloxstrap { public class LaunchSettings { - public LaunchFlag MenuFlag { get; } = new("preferences,menu,settings"); + public LaunchFlag MenuFlag { get; } = new("preferences,menu,settings"); - public LaunchFlag WatcherFlag { get; } = new("watcher"); + public LaunchFlag WatcherFlag { get; } = new("watcher"); - public LaunchFlag BackgroundUpdaterFlag { get; } = new("backgroundupdater"); + public LaunchFlag MultiInstanceWatcherFlag { get; } = new("multiinstancewatcher"); - public LaunchFlag QuietFlag { get; } = new("quiet"); + public LaunchFlag BackgroundUpdaterFlag { get; } = new("backgroundupdater"); - public LaunchFlag UninstallFlag { get; } = new("uninstall"); + public LaunchFlag QuietFlag { get; } = new("quiet"); - public LaunchFlag NoLaunchFlag { get; } = new("nolaunch"); + public LaunchFlag UninstallFlag { get; } = new("uninstall"); + + public LaunchFlag NoLaunchFlag { get; } = new("nolaunch"); - public LaunchFlag TestModeFlag { get; } = new("testmode"); + public LaunchFlag TestModeFlag { get; } = new("testmode"); - public LaunchFlag NoGPUFlag { get; } = new("nogpu"); + public LaunchFlag NoGPUFlag { get; } = new("nogpu"); - public LaunchFlag UpgradeFlag { get; } = new("upgrade"); + public LaunchFlag UpgradeFlag { get; } = new("upgrade"); - public LaunchFlag PlayerFlag { get; } = new("player"); + public LaunchFlag PlayerFlag { get; } = new("player"); - public LaunchFlag StudioFlag { get; } = new("studio"); + public LaunchFlag StudioFlag { get; } = new("studio"); - public LaunchFlag VersionFlag { get; } = new("version"); + public LaunchFlag VersionFlag { get; } = new("version"); - public LaunchFlag ChannelFlag { get; } = new("channel"); + public LaunchFlag ChannelFlag { get; } = new("channel"); - public LaunchFlag ForceFlag { get; } = new("force"); + public LaunchFlag ForceFlag { get; } = new("force"); #if DEBUG public bool BypassUpdateCheck => true; @@ -55,11 +57,9 @@ namespace Bloxstrap /// public string[] Args { get; private set; } - private readonly Dictionary _flagMap = new(); - public LaunchSettings(string[] args) { - const string LOG_IDENT = "LaunchSettings"; + const string LOG_IDENT = "LaunchSettings::LaunchSettings"; #if DEBUG App.Logger.WriteLine(LOG_IDENT, $"Launched with arguments: {string.Join(' ', args)}"); @@ -67,6 +67,8 @@ namespace Bloxstrap Args = args; + Dictionary flagMap = new(); + // build flag map foreach (var prop in this.GetType().GetProperties()) { @@ -77,7 +79,7 @@ namespace Bloxstrap continue; foreach (string identifier in flag.Identifiers.Split(',')) - _flagMap.Add(identifier, flag); + flagMap.Add(identifier, flag); } int startIdx = 0; @@ -117,7 +119,7 @@ namespace Bloxstrap string identifier = arg[1..]; - if (!_flagMap.TryGetValue(identifier, out LaunchFlag? flag) || flag is null) + if (!flagMap.TryGetValue(identifier, out LaunchFlag? flag) || flag is null) { App.Logger.WriteLine(LOG_IDENT, $"Unknown argument: {identifier}"); continue; diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs new file mode 100644 index 0000000..9d7485f --- /dev/null +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailBatchResponse.cs @@ -0,0 +1,8 @@ +namespace Bloxstrap.Models.APIs.Roblox +{ + internal class ThumbnailBatchResponse + { + [JsonPropertyName("data")] + public ThumbnailResponse[] Data { get; set; } = Array.Empty(); + } +} diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs new file mode 100644 index 0000000..b3779e0 --- /dev/null +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailRequest.cs @@ -0,0 +1,34 @@ +namespace Bloxstrap.Models.APIs.Roblox +{ + internal class ThumbnailRequest + { + [JsonPropertyName("requestId")] + public string? RequestId { get; set; } + + [JsonPropertyName("targetId")] + public ulong TargetId { get; set; } + + /// + /// TODO: make this an enum + /// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("type")] + public string Type { get; set; } = "Avatar"; + + /// + /// List of valid sizes can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("size")] + public string Size { get; set; } = "30x30"; + + /// + /// TODO: make this an enum + /// List of valid types can be found at https://thumbnails.roblox.com//docs/index.html + /// + [JsonPropertyName("format")] + public string Format { get; set; } = "Png"; + + [JsonPropertyName("isCircular")] + public bool IsCircular { get; set; } = true; + } +} diff --git a/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs b/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs index 213083c..bc10ba5 100644 --- a/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs +++ b/Bloxstrap/Models/APIs/Roblox/ThumbnailResponse.cs @@ -5,13 +5,31 @@ /// public class ThumbnailResponse { + [JsonPropertyName("requestId")] + public string RequestId { get; set; } = null!; + + [JsonPropertyName("errorCode")] + public int ErrorCode { get; set; } = 0; + + [JsonPropertyName("errorMessage")] + public string? ErrorMessage { get; set; } = null; + [JsonPropertyName("targetId")] public long TargetId { get; set; } + /// + /// Valid states: + /// - Error + /// - Completed + /// - InReview + /// - Pending + /// - Blocked + /// - TemporarilyUnavailable + /// [JsonPropertyName("state")] public string State { get; set; } = null!; [JsonPropertyName("imageUrl")] - public string ImageUrl { get; set; } = null!; + public string? ImageUrl { get; set; } = null!; } } diff --git a/Bloxstrap/Models/Persistable/Settings.cs b/Bloxstrap/Models/Persistable/Settings.cs index 0f6bb86..eebf03d 100644 --- a/Bloxstrap/Models/Persistable/Settings.cs +++ b/Bloxstrap/Models/Persistable/Settings.cs @@ -10,16 +10,20 @@ namespace Bloxstrap.Models.Persistable public string BootstrapperTitle { get; set; } = App.ProjectName; public string BootstrapperIconCustomLocation { get; set; } = ""; public Theme Theme { get; set; } = Theme.Default; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool DeveloperMode { get; set; } = false; public bool CheckForUpdates { get; set; } = true; + public bool MultiInstanceLaunching { get; set; } = false; public bool ConfirmLaunches { get; set; } = false; public string Locale { get; set; } = "nil"; public bool ForceRobloxLanguage { get; set; } = false; public bool UseFastFlagManager { get; set; } = true; public bool WPFSoftwareRender { get; set; } = false; public bool EnableAnalytics { get; set; } = true; - public bool BackgroundUpdatesEnabled { get; set; } = true; + public bool BackgroundUpdatesEnabled { get; set; } = false; public bool DebugDisableVersionPackageCleanup { get; set; } = false; public string? SelectedCustomTheme { get; set; } = null; + public WebEnvironment WebEnvironment { get; set; } = WebEnvironment.Production; // integration configuration public bool EnableActivityTracking { get; set; } = true; diff --git a/Bloxstrap/Models/ThumbnailCacheEntry.cs b/Bloxstrap/Models/ThumbnailCacheEntry.cs new file mode 100644 index 0000000..3ce6204 --- /dev/null +++ b/Bloxstrap/Models/ThumbnailCacheEntry.cs @@ -0,0 +1,8 @@ +namespace Bloxstrap.Models +{ + internal class ThumbnailCacheEntry + { + public ulong Id { get; set; } + public string Url { get; set; } = string.Empty; + } +} diff --git a/Bloxstrap/MultiInstanceWatcher.cs b/Bloxstrap/MultiInstanceWatcher.cs new file mode 100644 index 0000000..8799490 --- /dev/null +++ b/Bloxstrap/MultiInstanceWatcher.cs @@ -0,0 +1,68 @@ +namespace Bloxstrap +{ + internal static class MultiInstanceWatcher + { + private static int GetOpenProcessesCount() + { + const string LOG_IDENT = "MultiInstanceWatcher::GetOpenProcessesCount"; + + try + { + // prevent any possible race conditions by checking for bloxstrap processes too + int count = Process.GetProcesses().Count(x => x.ProcessName is "RobloxPlayerBeta" or "Bloxstrap"); + count -= 1; // ignore the current process + return count; + } + catch (Exception ex) + { + // everything process related can error at any time + App.Logger.WriteException(LOG_IDENT, ex); + return -1; + } + } + + private static void FireInitialisedEvent() + { + using EventWaitHandle initEventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, "Bloxstrap-MultiInstanceWatcherInitialisationFinished"); + initEventHandle.Set(); + } + + public static void Run() + { + const string LOG_IDENT = "MultiInstanceWatcher::Run"; + + // try to get the mutex + bool acquiredMutex; + using Mutex mutex = new Mutex(false, "ROBLOX_singletonMutex"); + try + { + acquiredMutex = mutex.WaitOne(0); + } + catch (AbandonedMutexException) + { + acquiredMutex = true; + } + + if (!acquiredMutex) + { + App.Logger.WriteLine(LOG_IDENT, "Client singleton mutex is already acquired"); + FireInitialisedEvent(); + return; + } + + App.Logger.WriteLine(LOG_IDENT, "Acquired mutex!"); + FireInitialisedEvent(); + + // watch for alive processes + int count; + do + { + Thread.Sleep(5000); + count = GetOpenProcessesCount(); + } + while (count == -1 || count > 0); // redo if -1 (one of the Process apis failed) + + App.Logger.WriteLine(LOG_IDENT, "All Roblox related processes have closed, exiting!"); + } + } +} diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml index 99efce9..3945086 100644 --- a/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml +++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Blank.xml @@ -1,4 +1,4 @@  - - + + \ No newline at end of file diff --git a/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml index 7664806..bb3b1d1 100644 --- a/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml +++ b/Bloxstrap/Resources/CustomBootstrapperTemplate_Simple.xml @@ -1,9 +1,9 @@  - + -