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)
This commit is contained in:
pizzaboxer 2022-11-13 23:52:02 +00:00
parent 01a9cff6e1
commit 1c14a0a3fa
6 changed files with 470 additions and 25 deletions

View File

@ -212,21 +212,25 @@ namespace Bloxstrap
// i might be able to experiment with reading from the latest log file in realtime to circumvent this,
// but i have no idea how reliable it will be. todo?
if (Program.Settings.UseDiscordRichPresence)
{
// probably not the most ideal way to do this
string? placeId = Utilities.GetKeyValue(LaunchCommandLine, "placeId=", '&');
if (placeId is not null)
{
richPresence = new DiscordRichPresence();
bool presenceSet = await richPresence.SetPresence(placeId);
richPresence.MonitorGameActivity();
if (presenceSet)
shouldWait = true;
else
richPresence.Dispose();
}
// probably not the most ideal way to do this
//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 (!shouldWait)

View File

@ -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",

View File

@ -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<bool> 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<string> GetLog()
{
Debug.WriteLine("[DiscordRichPresence] Reading log file...");
string logDirectory = Path.Combine(Program.LocalAppData, "Roblox\\logs");
if (!Directory.Exists(logDirectory))
return Enumerable.Empty<string>();
FileInfo logFileInfo = new DirectoryInfo(logDirectory).GetFiles().OrderByDescending(f => f.LastWriteTime).First();
List<string> 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<string> 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<string> 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<bool> SetPresence()
{
if (!ActivityInGame)
{
RichPresence.ClearPresence();
return true;
}
string placeThumbnail = "roblox";
var placeInfo = await Utilities.GetJson<RobloxAsset>($"https://economy.roblox.com/v2/assets/{placeId}/details");
var placeInfo = await Utilities.GetJson<RobloxAsset>($"https://economy.roblox.com/v2/assets/{ActivityPlaceId}/details");
if (placeInfo is null || placeInfo.Creator is null)
return false;
var thumbnailInfo = await Utilities.GetJson<RobloxThumbnails>($"https://thumbnails.roblox.com/v1/places/gameicons?placeIds={placeId}&returnPolicy=PlaceHolder&size=512x512&format=Png&isCircular=false");
var thumbnailInfo = await Utilities.GetJson<RobloxThumbnails>($"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,

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class ReverseLineReader : IEnumerable<string>
{
/// <summary>
/// Buffer size to use by default. Classes with internal access can specify
/// a different buffer size - this is useful for testing.
/// </summary>
private const int DefaultBufferSize = 4096;
/// <summary>
/// Means of creating a Stream to read from.
/// </summary>
private readonly Func<Stream> streamSource;
/// <summary>
/// Encoding to use when converting bytes to text
/// </summary>
private readonly Encoding encoding;
/// <summary>
/// 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.
/// </summary>
private readonly int bufferSize;
/// <summary>
/// Function which, when given a position within a file and a byte, states whether
/// or not the byte represents the start of a character.
/// </summary>
private Func<long, byte, bool> characterStartDetector;
/// <summary>
/// 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.
/// </summary>
/// <param name="streamSource">Data source</param>
public ReverseLineReader(Func<Stream> streamSource)
: this(streamSource, Encoding.UTF8)
{
}
/// <summary>
/// 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.
/// </summary>
/// <param name="filename">File to read from</param>
public ReverseLineReader(string filename)
: this(filename, Encoding.UTF8)
{
}
/// <summary>
/// Creates a LineReader from a filename. The file is only opened
/// (or even checked for existence) when the enumerator is fetched.
/// </summary>
/// <param name="filename">File to read from</param>
/// <param name="encoding">Encoding to use to decode the file into text</param>
public ReverseLineReader(string filename, Encoding encoding)
: this(() => File.OpenRead(filename), encoding)
{
}
/// <summary>
/// Creates a LineReader from a stream source. The delegate is only
/// called when the enumerator is fetched.
/// </summary>
/// <param name="streamSource">Data source</param>
/// <param name="encoding">Encoding to use to decode the stream into text</param>
public ReverseLineReader(Func<Stream> streamSource, Encoding encoding)
: this(streamSource, encoding, DefaultBufferSize)
{
}
internal ReverseLineReader(Func<Stream> 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");
}
}
/// <summary>
/// Returns the enumerator reading strings backwards. If this method discovers that
/// the returned stream is either unreadable or unseekable, a NotSupportedException is thrown.
/// </summary>
public IEnumerator<string> 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<string> 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;
}
}
}
}

View File

@ -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);
}
}

View File

@ -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);