From e72202f6f82d4b7555b69af127ff88e6ab6cb612 Mon Sep 17 00:00:00 2001 From: pizzaboxer <41478239+pizzaboxer@users.noreply.github.com> Date: Mon, 13 Mar 2023 00:54:41 +0000 Subject: [PATCH] Add integration for notifying server details might just be the coolest integration yet --- Bloxstrap/App.xaml.cs | 2 + Bloxstrap/Bootstrapper.cs | 15 +- Bloxstrap/Helpers/GameActivityWatcher.cs | 182 ++++++++++++++++++ Bloxstrap/Integrations/DiscordRichPresence.cs | 178 ++--------------- Bloxstrap/Integrations/ServerNotifier.cs | 68 +++++++ Bloxstrap/Models/Settings.cs | 2 +- Bloxstrap/ViewModels/IntegrationsViewModel.cs | 8 +- Bloxstrap/Views/Pages/IntegrationsPage.xaml | 13 +- 8 files changed, 298 insertions(+), 170 deletions(-) create mode 100644 Bloxstrap/Helpers/GameActivityWatcher.cs create mode 100644 Bloxstrap/Integrations/ServerNotifier.cs diff --git a/Bloxstrap/App.xaml.cs b/Bloxstrap/App.xaml.cs index eaa922c..4215c37 100644 --- a/Bloxstrap/App.xaml.cs +++ b/Bloxstrap/App.xaml.cs @@ -337,6 +337,8 @@ namespace Bloxstrap } #endif + Logger.WriteLine($"[App::OnStartup] Successfully reached end of main thread. Terminating..."); + Terminate(); } } diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index 12ad26d..924537c 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -333,7 +333,9 @@ namespace Bloxstrap Process gameClient = Process.Start(Path.Combine(_versionFolder, "RobloxPlayerBeta.exe"), _launchCommandLine); List autocloseProcesses = new(); + GameActivityWatcher? activityWatcher = null; DiscordRichPresence? richPresence = null; + ServerNotifier? serverNotifier = null; App.Logger.WriteLine($"[Bootstrapper::StartRoblox] Started Roblox (PID {gameClient.Id})"); @@ -366,10 +368,19 @@ namespace Bloxstrap } } + if (App.Settings.Prop.UseDiscordRichPresence || App.Settings.Prop.ShowServerDetails) + activityWatcher = new(); + if (App.Settings.Prop.UseDiscordRichPresence) { App.Logger.WriteLine("[Bootstrapper::StartRoblox] Using Discord Rich Presence"); - richPresence = new DiscordRichPresence(); + richPresence = new(activityWatcher!); + } + + if (App.Settings.Prop.ShowServerDetails) + { + App.Logger.WriteLine("[Bootstrapper::StartRoblox] Using server details notifier"); + serverNotifier = new(activityWatcher!); shouldWait = true; } @@ -402,7 +413,7 @@ namespace Bloxstrap if (!shouldWait) return; - richPresence?.MonitorGameActivity(); + activityWatcher?.StartWatcher(); App.Logger.WriteLine("[Bootstrapper::StartRoblox] Waiting for Roblox to close"); await gameClient.WaitForExitAsync(); diff --git a/Bloxstrap/Helpers/GameActivityWatcher.cs b/Bloxstrap/Helpers/GameActivityWatcher.cs new file mode 100644 index 0000000..ddd146b --- /dev/null +++ b/Bloxstrap/Helpers/GameActivityWatcher.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Bloxstrap.Helpers +{ + public class GameActivityWatcher : IDisposable + { + // i'm thinking the functionality for parsing roblox logs could be broadened for more features than just rich presence, + // like checking the ping and region of the current connected server. maybe that's something to add? + private const string GameJoiningEntry = "[FLog::Output] ! Joining game"; + private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = "; + private const string GameJoinedEntry = "[FLog::Network] serverId:"; + private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:"; + + private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)"; + private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+"; + private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+"; + + private int _logEntriesRead = 0; + + public event EventHandler? OnGameJoin; + public event EventHandler? OnGameLeave; + + // these are values to use assuming the player isn't currently in a game + public bool ActivityInGame = false; + public long ActivityPlaceId = 0; + public string ActivityJobId = ""; + public string ActivityMachineAddress = ""; + + public bool IsDisposed = false; + + public async void StartWatcher() + { + // okay, here's the process: + // + // - tail the latest log file from %localappdata%\roblox\logs + // - check for specific lines to determine player's game activity as shown below: + // + // - get the place id, job id and machine address from '! Joining game '{{JOBID}}' place {{PLACEID}} at {{MACHINEADDRESS}}' entry + // - confirm place join with 'serverId: {{MACHINEADDRESS}}|{{MACHINEPORT}}' entry + // - check for leaves/disconnects with 'Time to disconnect replication data: {{TIME}}' entry + // + // we'll tail the log file continuously, monitoring for any log entries that we need to determine the current game activity + + string logDirectory = Path.Combine(Directories.LocalAppData, "Roblox\\logs"); + + if (!Directory.Exists(logDirectory)) + return; + + FileInfo logFileInfo; + + // we need to make sure we're fetching the absolute latest log file + // if roblox doesn't start quickly enough, we can wind up fetching the previous log file + // good rule of thumb is to find a log file that was created in the last 15 seconds or so + + App.Logger.WriteLine("[GameActivityWatcher::StartWatcher] Opening Roblox log file..."); + + while (true) + { + logFileInfo = new DirectoryInfo(logDirectory).GetFiles().OrderByDescending(x => x.CreationTime).First(); + + if (logFileInfo.CreationTime.AddSeconds(15) > DateTime.Now) + break; + + App.Logger.WriteLine($"[GameActivityWatcher::StartWatcher] Could not find recent enough log file, waiting... (newest is {logFileInfo.Name})"); + await Task.Delay(1000); + } + + FileStream logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + App.Logger.WriteLine($"[GameActivityWatcher::StartWatcher] Opened {logFileInfo.Name}"); + + AutoResetEvent logUpdatedEvent = new(false); + FileSystemWatcher logWatcher = new() + { + Path = logDirectory, + Filter = Path.GetFileName(logFileInfo.FullName), + EnableRaisingEvents = true + }; + logWatcher.Changed += (s, e) => logUpdatedEvent.Set(); + + using StreamReader sr = new(logFileStream); + + while (!IsDisposed) + { + string? log = await sr.ReadLineAsync(); + + if (string.IsNullOrEmpty(log)) + logUpdatedEvent.WaitOne(1000); + else + ExamineLogEntry(log); + } + } + + private void ExamineLogEntry(string entry) + { + // App.Logger.WriteLine(entry); + _logEntriesRead += 1; + + // debug stats to ensure that the log reader is working correctly + // if more than 1000 log entries have been read, only log per 100 to save on spam + if (_logEntriesRead <= 1000 && _logEntriesRead % 50 == 0) + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Read {_logEntriesRead} log entries"); + else if (_logEntriesRead % 100 == 0) + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Read {_logEntriesRead} log entries"); + + if (!ActivityInGame && ActivityPlaceId == 0 && entry.Contains(GameJoiningEntry)) + { + Match match = Regex.Match(entry, GameJoiningEntryPattern); + + if (match.Groups.Count != 4) + { + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Failed to assert format for game join entry"); + App.Logger.WriteLine(entry); + return; + } + + ActivityInGame = false; + ActivityPlaceId = long.Parse(match.Groups[2].Value); + ActivityJobId = match.Groups[1].Value; + ActivityMachineAddress = match.Groups[3].Value; + + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Joining Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + } + else if (!ActivityInGame && ActivityPlaceId != 0) + { + if (entry.Contains(GameJoiningUDMUXEntry)) + { + Match match = Regex.Match(entry, GameJoiningUDMUXPattern); + + if (match.Groups.Count != 3 || match.Groups[2].Value != ActivityMachineAddress) + { + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Failed to assert format for game join UDMUX entry"); + App.Logger.WriteLine(entry); + return; + } + + ActivityMachineAddress = match.Groups[1].Value; + + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Server is UDMUX protected ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + } + else if (entry.Contains(GameJoinedEntry)) + { + Match match = Regex.Match(entry, GameJoinedEntryPattern); + + if (match.Groups.Count != 2 || match.Groups[1].Value != ActivityMachineAddress) + { + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Failed to assert format for game joined entry"); + App.Logger.WriteLine(entry); + return; + } + + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Joined Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + + ActivityInGame = true; + OnGameJoin?.Invoke(this, new EventArgs()); + } + } + else if (ActivityInGame && ActivityPlaceId != 0 && entry.Contains(GameDisconnectedEntry)) + { + App.Logger.WriteLine($"[GameActivityWatcher::ExamineLogEntry] Disconnected from Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + + ActivityInGame = false; + ActivityPlaceId = 0; + ActivityJobId = ""; + ActivityMachineAddress = ""; + + OnGameLeave?.Invoke(this, new EventArgs()); + } + } + + public void Dispose() + { + IsDisposed = true; + } + } +} diff --git a/Bloxstrap/Integrations/DiscordRichPresence.cs b/Bloxstrap/Integrations/DiscordRichPresence.cs index 64a3052..02ec344 100644 --- a/Bloxstrap/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Integrations/DiscordRichPresence.cs @@ -16,28 +16,15 @@ namespace Bloxstrap.Integrations class DiscordRichPresence : IDisposable { private readonly DiscordRpcClient _rpcClient = new("1005469189907173486"); + private readonly GameActivityWatcher _activityWatcher; - // i'm thinking the functionality for parsing roblox logs could be broadened for more features than just rich presence, - // like checking the ping and region of the current connected server. maybe that's something to add? - private const string GameJoiningEntry = "[FLog::Output] ! Joining game"; - private const string GameJoiningUDMUXEntry = "[FLog::Network] UDMUX Address = "; - private const string GameJoinedEntry = "[FLog::Network] serverId:"; - private const string GameDisconnectedEntry = "[FLog::Network] Time to disconnect replication data:"; - - private const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)"; - private const string GameJoiningUDMUXPattern = @"UDMUX Address = ([0-9\.]+), Port = [0-9]+ \| RCC Server Address = ([0-9\.]+), Port = [0-9]+"; - private const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|[0-9]+"; - - private int _logEntriesRead = 0; - - // these are values to use assuming the player isn't currently in a game - private bool _activityInGame = false; - private long _activityPlaceId = 0; - private string _activityJobId = ""; - private string _activityMachineAddress = ""; - - public DiscordRichPresence() + public DiscordRichPresence(GameActivityWatcher activityWatcher) { + _activityWatcher = activityWatcher; + + _activityWatcher.OnGameJoin += (_, _) => Task.Run(() => SetPresence()); + _activityWatcher.OnGameLeave += (_, _) => Task.Run(() => SetPresence()); + _rpcClient.OnReady += (_, e) => App.Logger.WriteLine($"[DiscordRichPresence::DiscordRichPresence] Received ready from user {e.User.Username} ({e.User.ID})"); @@ -57,159 +44,20 @@ namespace Bloxstrap.Integrations _rpcClient.Initialize(); } - private async Task ExamineLogEntry(string entry) - { - // App.Logger.WriteLine(entry); - _logEntriesRead += 1; - - // debug stats to ensure that the log reader is working correctly - // if more than 5000 log entries have been read, only log per 100 to save on spam - if (_logEntriesRead <= 5000 && _logEntriesRead % 50 == 0) - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Read {_logEntriesRead} log entries"); - else if (_logEntriesRead % 100 == 0) - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Read {_logEntriesRead} log entries"); - - if (!_activityInGame && _activityPlaceId == 0 && entry.Contains(GameJoiningEntry)) - { - Match match = Regex.Match(entry, GameJoiningEntryPattern); - - if (match.Groups.Count != 4) - { - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Failed to assert format for game join entry"); - App.Logger.WriteLine(entry); - return; - } - - _activityInGame = false; - _activityPlaceId = long.Parse(match.Groups[2].Value); - _activityJobId = match.Groups[1].Value; - _activityMachineAddress = match.Groups[3].Value; - - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Joining Game ({_activityPlaceId}/{_activityJobId}/{_activityMachineAddress})"); - } - else if (!_activityInGame && _activityPlaceId != 0) - { - if (entry.Contains(GameJoiningUDMUXEntry)) - { - Match match = Regex.Match(entry, GameJoiningUDMUXPattern); - - if (match.Groups.Count != 3 || match.Groups[2].Value != _activityMachineAddress) - { - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Failed to assert format for game join UDMUX entry"); - App.Logger.WriteLine(entry); - return; - } - - _activityMachineAddress = match.Groups[1].Value; - - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Server is UDMUX protected ({_activityPlaceId}/{_activityJobId}/{_activityMachineAddress})"); - } - else if (entry.Contains(GameJoinedEntry)) - { - Match match = Regex.Match(entry, GameJoinedEntryPattern); - - if (match.Groups.Count != 2 || match.Groups[1].Value != _activityMachineAddress) - { - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Failed to assert format for game joined entry"); - App.Logger.WriteLine(entry); - return; - } - - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Joined Game ({_activityPlaceId}/{_activityJobId}/{_activityMachineAddress})"); - - _activityInGame = true; - await SetPresence(); - } - } - else if (_activityInGame && _activityPlaceId != 0 && entry.Contains(GameDisconnectedEntry)) - { - App.Logger.WriteLine($"[DiscordRichPresence::ExamineLogEntry] Disconnected from Game ({_activityPlaceId}/{_activityJobId}/{_activityMachineAddress})"); - - _activityInGame = false; - _activityPlaceId = 0; - _activityJobId = ""; - _activityMachineAddress = ""; - await SetPresence(); - } - } - - public async void MonitorGameActivity() - { - // okay, here's the process: - // - // - tail the latest log file from %localappdata%\roblox\logs - // - check for specific lines to determine player's game activity as shown below: - // - // - get the place id, job id and machine address from '! Joining game '{{JOBID}}' place {{PLACEID}} at {{MACHINEADDRESS}}' entry - // - confirm place join with 'serverId: {{MACHINEADDRESS}}|{{MACHINEPORT}}' entry - // - check for leaves/disconnects with 'Time to disconnect replication data: {{TIME}}' entry - // - // we'll tail the log file continuously, monitoring for any log entries that we need to determine the current game activity - - string logDirectory = Path.Combine(Directories.LocalAppData, "Roblox\\logs"); - - if (!Directory.Exists(logDirectory)) - return; - - FileInfo logFileInfo; - - // we need to make sure we're fetching the absolute latest log file - // if roblox doesn't start quickly enough, we can wind up fetching the previous log file - // good rule of thumb is to find a log file that was created in the last 15 seconds or so - - App.Logger.WriteLine("[DiscordRichPresence::MonitorGameActivity] Opening Roblox log file..."); - - while (true) - { - logFileInfo = new DirectoryInfo(logDirectory).GetFiles().OrderByDescending(x => x.CreationTime).First(); - - if (logFileInfo.CreationTime.AddSeconds(15) > DateTime.Now) - break; - - App.Logger.WriteLine($"[DiscordRichPresence::MonitorGameActivity] Could not find recent enough log file, waiting... (newest is {logFileInfo.Name})"); - await Task.Delay(1000); - } - - FileStream logFileStream = logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - App.Logger.WriteLine($"[DiscordRichPresence::MonitorGameActivity] Opened {logFileInfo.Name}"); - - AutoResetEvent logUpdatedEvent = new(false); - FileSystemWatcher logWatcher = new() - { - Path = logDirectory, - Filter = Path.GetFileName(logFileInfo.FullName), - EnableRaisingEvents = true - }; - logWatcher.Changed += (s, e) => logUpdatedEvent.Set(); - - using StreamReader sr = new(logFileStream); - - while (true) - { - string? log = await sr.ReadLineAsync(); - - if (string.IsNullOrEmpty(log)) - logUpdatedEvent.WaitOne(1000); - else - await ExamineLogEntry(log); - } - - // no need to close the event, its going to be finished with when the program closes anyway - } - public async Task SetPresence() { - if (!_activityInGame) + if (!_activityWatcher.ActivityInGame) { + App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Clearing presence"); _rpcClient.ClearPresence(); return true; } string icon = "roblox"; - App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Fetching data for Place ID {_activityPlaceId}"); + App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Setting presence for Place ID {_activityWatcher.ActivityPlaceId}"); - var universeIdResponse = await Utilities.GetJson($"https://apis.roblox.com/universes/v1/places/{_activityPlaceId}/universe"); + var universeIdResponse = await Utilities.GetJson($"https://apis.roblox.com/universes/v1/places/{_activityWatcher.ActivityPlaceId}/universe"); if (universeIdResponse is null) { App.Logger.WriteLine($"[DiscordRichPresence::SetPresence] Could not get Universe ID!"); @@ -245,7 +93,7 @@ namespace Bloxstrap.Integrations new Button { Label = "See Details", - Url = $"https://www.roblox.com/games/{_activityPlaceId}" + Url = $"https://www.roblox.com/games/{_activityWatcher.ActivityPlaceId}" } }; @@ -254,7 +102,7 @@ namespace Bloxstrap.Integrations buttons.Insert(0, new Button { Label = "Join", - Url = $"roblox://experiences/start?placeId={_activityPlaceId}&gameInstanceId={_activityJobId}" + Url = $"roblox://experiences/start?placeId={_activityWatcher.ActivityPlaceId}&gameInstanceId={_activityWatcher.ActivityJobId}" }); } diff --git a/Bloxstrap/Integrations/ServerNotifier.cs b/Bloxstrap/Integrations/ServerNotifier.cs new file mode 100644 index 0000000..163e315 --- /dev/null +++ b/Bloxstrap/Integrations/ServerNotifier.cs @@ -0,0 +1,68 @@ +using System.Net.NetworkInformation; +using System.Threading.Tasks; +using System.Windows.Forms; + +using Bloxstrap.Helpers; +using Bloxstrap.Properties; + +namespace Bloxstrap.Integrations +{ + public class ServerNotifier + { + private readonly GameActivityWatcher _activityWatcher; + + public ServerNotifier(GameActivityWatcher activityWatcher) + { + _activityWatcher = activityWatcher; + _activityWatcher.OnGameJoin += (_, _) => Task.Run(() => Notify()); + } + + public async void Notify() + { + string machineAddress = _activityWatcher.ActivityMachineAddress; + string message = ""; + + App.Logger.WriteLine($"[ServerNotifier::Notify] Getting server information for {machineAddress}"); + + // basically nobody has a free public access geolocation api that's accurate, + // the ones that do require an api key which isn't suitable for a client-side application like this + // so, hopefully this is reliable enough? + string locationCity = await App.HttpClient.GetStringAsync($"https://ipinfo.io/{machineAddress}/city"); + string locationRegion = await App.HttpClient.GetStringAsync($"https://ipinfo.io/{machineAddress}/region"); + string locationCountry = await App.HttpClient.GetStringAsync($"https://ipinfo.io/{machineAddress}/country"); + + locationCity = locationCity.ReplaceLineEndings(""); + locationRegion = locationRegion.ReplaceLineEndings(""); + locationCountry = locationCountry.ReplaceLineEndings(""); + + if (locationCity == locationRegion) + message = $"Location: {locationRegion}, {locationCountry}\n"; + else + message = $"Location: {locationCity}, {locationRegion}, {locationCountry}\n"; + + PingReply ping = await new Ping().SendPingAsync(machineAddress); + + // UDMUX protected servers reject ICMP packets and so the ping fails + // we could get around this by doing a UDP ping but ehhhhhhhhhhhhh + if (ping.Status == IPStatus.Success) + message += $"Latency: ~{ping.RoundtripTime}ms"; + else + message += "Latency: N/A (server may be UDMUX protected)"; + + App.Logger.WriteLine($"[ServerNotifier::Notify] {message}"); + + NotifyIcon notification = new() + { + Icon = Resources.IconBloxstrap, + Text = "Bloxstrap", + Visible = true, + BalloonTipTitle = "Connected to server", + BalloonTipText = message + }; + + notification.ShowBalloonTip(10); + await Task.Delay(10000); + notification.Dispose(); + } + } +} diff --git a/Bloxstrap/Models/Settings.cs b/Bloxstrap/Models/Settings.cs index a9d34f3..a8e96b6 100644 --- a/Bloxstrap/Models/Settings.cs +++ b/Bloxstrap/Models/Settings.cs @@ -29,7 +29,7 @@ namespace Bloxstrap.Models public bool RFUAutoclose { get; set; } = false; public bool UseReShade { get; set; } = true; public bool UseReShadeExtraviPresets { get; set; } = true; - // ideally should be List but wpf moment so blehhhhh :P + public bool ShowServerDetails { get; set; } = false; public ObservableCollection CustomIntegrations { get; set; } = new(); // mod preset configuration diff --git a/Bloxstrap/ViewModels/IntegrationsViewModel.cs b/Bloxstrap/ViewModels/IntegrationsViewModel.cs index 599c797..e242d81 100644 --- a/Bloxstrap/ViewModels/IntegrationsViewModel.cs +++ b/Bloxstrap/ViewModels/IntegrationsViewModel.cs @@ -114,7 +114,13 @@ namespace Bloxstrap.ViewModels set => App.Settings.Prop.RFUAutoclose = value; } - public ObservableCollection CustomIntegrations + public bool ShowServerDetailsEnabled + { + get => App.Settings.Prop.ShowServerDetails; + set => App.Settings.Prop.ShowServerDetails = value; + } + + public ObservableCollection CustomIntegrations { get => App.Settings.Prop.CustomIntegrations; set => App.Settings.Prop.CustomIntegrations = value; diff --git a/Bloxstrap/Views/Pages/IntegrationsPage.xaml b/Bloxstrap/Views/Pages/IntegrationsPage.xaml index 815d412..8744c47 100644 --- a/Bloxstrap/Views/Pages/IntegrationsPage.xaml +++ b/Bloxstrap/Views/Pages/IntegrationsPage.xaml @@ -114,7 +114,18 @@ - + + + + + + + + + + + +