From 1c14a0a3faaa143d4c3f23562dadc45a7eed23d8 Mon Sep 17 00:00:00 2001 From: pizzaboxer <41478239+pizzaboxer@users.noreply.github.com> Date: Sun, 13 Nov 2022 23:52:02 +0000 Subject: [PATCH] Add full Discord RP support for desktop app Unlike how it worked before, this should now follow the player's game activity even with in-app game switching. (#24) --- Bloxstrap/Bootstrapper.cs | 26 +- Bloxstrap/Helpers/DeployManager.cs | 4 +- .../Integrations/DiscordRichPresence.cs | 170 ++++++++++- Bloxstrap/Helpers/ReverseLineReader.cs | 288 ++++++++++++++++++ Bloxstrap/Helpers/Updater.cs | 3 + Bloxstrap/Program.cs | 4 +- 6 files changed, 470 insertions(+), 25 deletions(-) create mode 100644 Bloxstrap/Helpers/ReverseLineReader.cs diff --git a/Bloxstrap/Bootstrapper.cs b/Bloxstrap/Bootstrapper.cs index 15ee22e..99a4467 100644 --- a/Bloxstrap/Bootstrapper.cs +++ b/Bloxstrap/Bootstrapper.cs @@ -213,20 +213,24 @@ namespace Bloxstrap // but i have no idea how reliable it will be. todo? if (Program.Settings.UseDiscordRichPresence) { + richPresence = new DiscordRichPresence(); + richPresence.MonitorGameActivity(); + + shouldWait = true; + // probably not the most ideal way to do this - string? placeId = Utilities.GetKeyValue(LaunchCommandLine, "placeId=", '&'); + //string? placeId = Utilities.GetKeyValue(LaunchCommandLine, "placeId=", '&'); - if (placeId is not null) - { - richPresence = new DiscordRichPresence(); - bool presenceSet = await richPresence.SetPresence(placeId); - - if (presenceSet) - shouldWait = true; - else - richPresence.Dispose(); - } + //if (placeId is not null) + //{ + // richPresence = new DiscordRichPresence(); + // bool presenceSet = await richPresence.SetPresence(placeId); + // if (presenceSet) + // shouldWait = true; + // else + // richPresence.Dispose(); + //} } if (!shouldWait) diff --git a/Bloxstrap/Helpers/DeployManager.cs b/Bloxstrap/Helpers/DeployManager.cs index b537bec..d794dbd 100644 --- a/Bloxstrap/Helpers/DeployManager.cs +++ b/Bloxstrap/Helpers/DeployManager.cs @@ -20,7 +20,8 @@ namespace Bloxstrap.Helpers "LIVE", "ZAvatarTeam", "ZCanary", - "ZFeatureHarmony", + //"ZFeatureHarmony", last updated 9/20, shouldn't be here anymore + "ZFlag", "ZIntegration", "ZLive", "ZNext", @@ -61,6 +62,7 @@ namespace Bloxstrap.Helpers "ZFeatureSubsystemHttpClient", "ZFeatureTelemLife", "ZFeatureUse-New-RapidJson-In-Flag-Loading", + "ZFlag", "ZIntegration", "ZIntegration1", "ZLang", diff --git a/Bloxstrap/Helpers/Integrations/DiscordRichPresence.cs b/Bloxstrap/Helpers/Integrations/DiscordRichPresence.cs index 01a1790..31cb89f 100644 --- a/Bloxstrap/Helpers/Integrations/DiscordRichPresence.cs +++ b/Bloxstrap/Helpers/Integrations/DiscordRichPresence.cs @@ -1,22 +1,172 @@ -using Bloxstrap.Models; +using System; +using System.Diagnostics; +using System.IO; +using System.Text.RegularExpressions; + +using Bloxstrap.Models; + using DiscordRPC; namespace Bloxstrap.Helpers.Integrations { - internal class DiscordRichPresence : IDisposable + class DiscordRichPresence : IDisposable { readonly DiscordRpcClient RichPresence = new("1005469189907173486"); - public async Task SetPresence(string placeId) + const string GameJoiningEntry = "[FLog::Output] ! Joining game"; + const string GameJoinedEntry = "[FLog::Network] serverId:"; + const string GameDisconnectedEntry = "[FLog::Network] Client:Disconnect"; + + const string GameJoiningEntryPattern = @"! Joining game '([0-9a-f\-]{36})' place ([0-9]+) at ([0-9\.]+)"; + const string GameJoinedEntryPattern = @"serverId: ([0-9\.]+)\|([0-9]+)"; + + // these are values to use assuming the player isn't currently in a game + bool ActivityInGame = false; + long ActivityPlaceId = 0; + string ActivityJobId = ""; + string ActivityMachineAddress = ""; // we're only really using this to confirm a place join + + public DiscordRichPresence() { + RichPresence.Initialize(); + } + + private static IEnumerable GetLog() + { + Debug.WriteLine("[DiscordRichPresence] Reading log file..."); + + string logDirectory = Path.Combine(Program.LocalAppData, "Roblox\\logs"); + + if (!Directory.Exists(logDirectory)) + return Enumerable.Empty(); + + FileInfo logFileInfo = new DirectoryInfo(logDirectory).GetFiles().OrderByDescending(f => f.LastWriteTime).First(); + List log = new(); + + // we just want to read the last 3500 lines of the log file + // this should typically more than cover the last 30 seconds of logs + // it has to be last 3500 lines (~360KB) because voice chat outputs a loooot of logs :') + + ReverseLineReader rlr = new(() => logFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite)); + log = rlr.Take(3500).ToList(); + + Debug.WriteLine("[DiscordRichPresence] Finished reading log file"); + + return log; + } + + private async Task ExamineLog(List log) + { + Debug.WriteLine("[DiscordRichPresence] Examining log file..."); + + foreach (string entry in log) + { + if (entry.Contains(GameJoiningEntry) && !ActivityInGame && ActivityPlaceId == 0) + { + Match match = Regex.Match(entry, GameJoiningEntryPattern); + + if (match.Groups.Count != 4) + continue; + + ActivityInGame = false; + ActivityPlaceId = Int64.Parse(match.Groups[2].Value); + ActivityJobId = match.Groups[1].Value; + ActivityMachineAddress = match.Groups[3].Value; + + Debug.WriteLine($"[DiscordRichPresence] Joining Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + + // examine log again to check for immediate changes + await Task.Delay(1000); + MonitorGameActivity(false); + break; + } + else if (entry.Contains(GameJoinedEntry) && !ActivityInGame && ActivityPlaceId != 0) + { + Match match = Regex.Match(entry, GameJoinedEntryPattern); + + if (match.Groups.Count != 3 || match.Groups[1].Value != ActivityMachineAddress) + continue; + + Debug.WriteLine($"[DiscordRichPresence] Joined Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + + ActivityInGame = true; + await SetPresence(); + + // examine log again to check for immediate changes + await Task.Delay(1000); + MonitorGameActivity(false); + break; + } + //else if (entry.Contains(GameDisconnectedEntry) && ActivityInGame && ActivityPlaceId != 0) + else if (entry.Contains(GameDisconnectedEntry)) + { + // for this one, we want to break as soon as we see this entry + // or else it'll match a game join entry and think we're joining again + if (ActivityInGame && ActivityPlaceId != 0) + { + Debug.WriteLine($"[DiscordRichPresence] Disconnected from Game ({ActivityPlaceId}/{ActivityJobId}/{ActivityMachineAddress})"); + + ActivityInGame = false; + ActivityPlaceId = 0; + ActivityJobId = ""; + ActivityMachineAddress = ""; + await SetPresence(); + + // examine log again to check for immediate changes + await Task.Delay(1000); + MonitorGameActivity(false); + } + + break; + } + } + + Debug.WriteLine("[DiscordRichPresence] Finished examining log file"); + } + + public async void MonitorGameActivity(bool loop = true) + { + // okay, here's the process: + // + // - read the latest log file from %localappdata%\roblox\logs approx every 30 sec or so + // - 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 'Client:Disconnect' entry + // + // we'll read the log file from bottom-to-top and find which line meets the criteria + // the processes for reading and examining the log files are separated since the log may have to be examined multiple times + + // read log file + List log = GetLog().ToList(); + + // and now let's get the current status from the log + await ExamineLog(log); + + if (!loop) + return; + + await Task.Delay(ActivityInGame ? 30000 : 10000); + MonitorGameActivity(); + } + + public async Task SetPresence() + { + if (!ActivityInGame) + { + RichPresence.ClearPresence(); + return true; + } + string placeThumbnail = "roblox"; - var placeInfo = await Utilities.GetJson($"https://economy.roblox.com/v2/assets/{placeId}/details"); + var placeInfo = await Utilities.GetJson($"https://economy.roblox.com/v2/assets/{ActivityPlaceId}/details"); if (placeInfo is null || placeInfo.Creator is null) return false; - var thumbnailInfo = await Utilities.GetJson($"https://thumbnails.roblox.com/v1/places/gameicons?placeIds={placeId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false"); + var thumbnailInfo = await Utilities.GetJson($"https://thumbnails.roblox.com/v1/places/gameicons?placeIds={ActivityPlaceId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false"); if (thumbnailInfo is not null) placeThumbnail = thumbnailInfo.Data![0].ImageUrl!; @@ -29,20 +179,18 @@ namespace Bloxstrap.Helpers.Integrations { new DiscordRPC.Button() { - Label = "Play", - Url = $"https://www.roblox.com/games/start?placeId={placeId}&launchData=%7B%7D" + Label = "Join", + Url = $"https://www.roblox.com/games/start?placeId={ActivityPlaceId}&gameInstanceId={ActivityJobId}&launchData=%7B%7D" }, new DiscordRPC.Button() { - Label = "View Details", - Url = $"https://www.roblox.com/games/{placeId}" + Label = "See Details", + Url = $"https://www.roblox.com/games/{ActivityPlaceId}" } }; } - RichPresence.Initialize(); - RichPresence.SetPresence(new RichPresence() { Details = placeInfo.Name, diff --git a/Bloxstrap/Helpers/ReverseLineReader.cs b/Bloxstrap/Helpers/ReverseLineReader.cs new file mode 100644 index 0000000..864e2c0 --- /dev/null +++ b/Bloxstrap/Helpers/ReverseLineReader.cs @@ -0,0 +1,288 @@ +using System.Collections; +using System.IO; +using System.Text; + +// taken from MiscUtil: https://github.com/loory/MiscUtil +// the proper usage of MiscUtil nowadays is to *not* use the library and rather copy the code you need so lol + +namespace Bloxstrap.Helpers +{ + /// + /// Takes an encoding (defaulting to UTF-8) and a function which produces a seekable stream + /// (or a filename for convenience) and yields lines from the end of the stream backwards. + /// Only single byte encodings, and UTF-8 and Unicode, are supported. The stream + /// returned by the function must be seekable. + /// + public sealed class ReverseLineReader : IEnumerable + { + /// + /// Buffer size to use by default. Classes with internal access can specify + /// a different buffer size - this is useful for testing. + /// + private const int DefaultBufferSize = 4096; + + /// + /// Means of creating a Stream to read from. + /// + private readonly Func streamSource; + + /// + /// Encoding to use when converting bytes to text + /// + private readonly Encoding encoding; + + /// + /// Size of buffer (in bytes) to read each time we read from the + /// stream. This must be at least as big as the maximum number of + /// bytes for a single character. + /// + private readonly int bufferSize; + + /// + /// Function which, when given a position within a file and a byte, states whether + /// or not the byte represents the start of a character. + /// + private Func characterStartDetector; + + /// + /// Creates a LineReader from a stream source. The delegate is only + /// called when the enumerator is fetched. UTF-8 is used to decode + /// the stream into text. + /// + /// Data source + public ReverseLineReader(Func streamSource) + : this(streamSource, Encoding.UTF8) + { + } + + /// + /// Creates a LineReader from a filename. The file is only opened + /// (or even checked for existence) when the enumerator is fetched. + /// UTF8 is used to decode the file into text. + /// + /// File to read from + public ReverseLineReader(string filename) + : this(filename, Encoding.UTF8) + { + } + + /// + /// Creates a LineReader from a filename. The file is only opened + /// (or even checked for existence) when the enumerator is fetched. + /// + /// File to read from + /// Encoding to use to decode the file into text + public ReverseLineReader(string filename, Encoding encoding) + : this(() => File.OpenRead(filename), encoding) + { + } + + /// + /// Creates a LineReader from a stream source. The delegate is only + /// called when the enumerator is fetched. + /// + /// Data source + /// Encoding to use to decode the stream into text + public ReverseLineReader(Func streamSource, Encoding encoding) + : this(streamSource, encoding, DefaultBufferSize) + { + } + + internal ReverseLineReader(Func streamSource, Encoding encoding, int bufferSize) + { + this.streamSource = streamSource; + this.encoding = encoding; + this.bufferSize = bufferSize; + if (encoding.IsSingleByte) + { + // For a single byte encoding, every byte is the start (and end) of a character + characterStartDetector = (pos, data) => true; + } + else if (encoding is UnicodeEncoding) + { + // For UTF-16, even-numbered positions are the start of a character. + // TODO: This assumes no surrogate pairs. More work required + // to handle that. + characterStartDetector = (pos, data) => (pos & 1) == 0; + } + else if (encoding is UTF8Encoding) + { + // For UTF-8, bytes with the top bit clear or the second bit set are the start of a character + // See http://www.cl.cam.ac.uk/~mgk25/unicode.html + characterStartDetector = (pos, data) => (data & 0x80) == 0 || (data & 0x40) != 0; + } + else + { + throw new ArgumentException("Only single byte, UTF-8 and Unicode encodings are permitted"); + } + } + + /// + /// Returns the enumerator reading strings backwards. If this method discovers that + /// the returned stream is either unreadable or unseekable, a NotSupportedException is thrown. + /// + public IEnumerator GetEnumerator() + { + Stream stream = streamSource(); + if (!stream.CanSeek) + { + stream.Dispose(); + throw new NotSupportedException("Unable to seek within stream"); + } + if (!stream.CanRead) + { + stream.Dispose(); + throw new NotSupportedException("Unable to read within stream"); + } + return GetEnumeratorImpl(stream); + } + + private IEnumerator GetEnumeratorImpl(Stream stream) + { + try + { + long position = stream.Length; + + if (encoding is UnicodeEncoding && (position & 1) != 0) + { + throw new InvalidDataException("UTF-16 encoding provided, but stream has odd length."); + } + + // Allow up to two bytes for data from the start of the previous + // read which didn't quite make it as full characters + byte[] buffer = new byte[bufferSize + 2]; + char[] charBuffer = new char[encoding.GetMaxCharCount(buffer.Length)]; + int leftOverData = 0; + String? previousEnd = null; + // TextReader doesn't return an empty string if there's line break at the end + // of the data. Therefore we don't return an empty string if it's our *first* + // return. + bool firstYield = true; + + // A line-feed at the start of the previous buffer means we need to swallow + // the carriage-return at the end of this buffer - hence this needs declaring + // way up here! + bool swallowCarriageReturn = false; + + while (position > 0) + { + int bytesToRead = Math.Min(position > int.MaxValue ? bufferSize : (int)position, bufferSize); + + position -= bytesToRead; + stream.Position = position; + StreamUtil.ReadExactly(stream, buffer, bytesToRead); + // If we haven't read a full buffer, but we had bytes left + // over from before, copy them to the end of the buffer + if (leftOverData > 0 && bytesToRead != bufferSize) + { + // Buffer.BlockCopy doesn't document its behaviour with respect + // to overlapping data: we *might* just have read 7 bytes instead of + // 8, and have two bytes to copy... + Array.Copy(buffer, bufferSize, buffer, bytesToRead, leftOverData); + } + // We've now *effectively* read this much data. + bytesToRead += leftOverData; + + int firstCharPosition = 0; + while (!characterStartDetector(position + firstCharPosition, buffer[firstCharPosition])) + { + firstCharPosition++; + // Bad UTF-8 sequences could trigger this. For UTF-8 we should always + // see a valid character start in every 3 bytes, and if this is the start of the file + // so we've done a short read, we should have the character start + // somewhere in the usable buffer. + if (firstCharPosition == 3 || firstCharPosition == bytesToRead) + { + throw new InvalidDataException("Invalid UTF-8 data"); + } + } + leftOverData = firstCharPosition; + + int charsRead = encoding.GetChars(buffer, firstCharPosition, bytesToRead - firstCharPosition, charBuffer, 0); + int endExclusive = charsRead; + + for (int i = charsRead - 1; i >= 0; i--) + { + char lookingAt = charBuffer[i]; + if (swallowCarriageReturn) + { + swallowCarriageReturn = false; + if (lookingAt == '\r') + { + endExclusive--; + continue; + } + } + // Anything non-line-breaking, just keep looking backwards + if (lookingAt != '\n' && lookingAt != '\r') + { + continue; + } + // End of CRLF? Swallow the preceding CR + if (lookingAt == '\n') + { + swallowCarriageReturn = true; + } + int start = i + 1; + string bufferContents = new string(charBuffer, start, endExclusive - start); + endExclusive = i; + string stringToYield = previousEnd == null ? bufferContents : bufferContents + previousEnd; + if (!firstYield || stringToYield.Length != 0) + { + yield return stringToYield; + } + firstYield = false; + previousEnd = null; + } + + previousEnd = endExclusive == 0 ? null : (new string(charBuffer, 0, endExclusive) + previousEnd); + + // If we didn't decode the start of the array, put it at the end for next time + if (leftOverData != 0) + { + Buffer.BlockCopy(buffer, 0, buffer, bufferSize, leftOverData); + } + } + if (leftOverData != 0) + { + // At the start of the final buffer, we had the end of another character. + throw new InvalidDataException("Invalid UTF-8 data at start of stream"); + } + if (firstYield && string.IsNullOrEmpty(previousEnd)) + { + yield break; + } + yield return previousEnd ?? ""; + } + finally + { + stream.Dispose(); + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + public static class StreamUtil + { + public static void ReadExactly(Stream input, byte[] buffer, int bytesToRead) + { + int index = 0; + while (index < bytesToRead) + { + int read = input.Read(buffer, index, bytesToRead - index); + if (read == 0) + { + throw new EndOfStreamException + (String.Format("End of stream reached with {0} byte{1} left to read.", + bytesToRead - index, + bytesToRead - index == 1 ? "s" : "")); + } + index += read; + } + } + } +} \ No newline at end of file diff --git a/Bloxstrap/Helpers/Updater.cs b/Bloxstrap/Helpers/Updater.cs index 500cf04..316a898 100644 --- a/Bloxstrap/Helpers/Updater.cs +++ b/Bloxstrap/Helpers/Updater.cs @@ -6,6 +6,7 @@ using System.Text.Json; using Newtonsoft.Json.Linq; using Bloxstrap.Models; +using Bloxstrap.Dialogs; namespace Bloxstrap.Helpers { @@ -39,6 +40,8 @@ namespace Bloxstrap.Helpers MessageBoxButtons.OK ); + new Preferences().ShowDialog(); + Environment.Exit(0); } } diff --git a/Bloxstrap/Program.cs b/Bloxstrap/Program.cs index 6e26b59..f8c9426 100644 --- a/Bloxstrap/Program.cs +++ b/Bloxstrap/Program.cs @@ -71,13 +71,13 @@ namespace Bloxstrap } else { - BaseDirectory = (string?)registryKey.GetValue("InstallLocation"); + BaseDirectory = (string)registryKey.GetValue("InstallLocation")!; registryKey.Close(); } // preferences dialog was closed, and so base directory was never set // (this doesnt account for the registry value not existing but thats basically never gonna happen) - if (BaseDirectory is null) + if (String.IsNullOrEmpty(BaseDirectory)) return; Directories.Initialize(BaseDirectory);