diff --git a/MinecraftClient/ChatBots/DiscordRpc.cs b/MinecraftClient/ChatBots/DiscordRpc.cs
index 02a72049..f4be0532 100644
--- a/MinecraftClient/ChatBots/DiscordRpc.cs
+++ b/MinecraftClient/ChatBots/DiscordRpc.cs
@@ -1,5 +1,10 @@
using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Pipes;
+using System.Threading;
using DiscordRPC;
+using DiscordRPC.IO;
using DiscordRPC.Logging;
using MinecraftClient.Mapping;
using MinecraftClient.Scripting;
@@ -104,12 +109,13 @@ namespace MinecraftClient.ChatBots
try
{
- _rpcClient = new DiscordRpcClient(Config.ApplicationId.Trim())
- {
- Logger = Settings.Config.Logging.DebugMessages
- ? new ConsoleLogger(LogLevel.Trace)
- : new ConsoleLogger(LogLevel.None)
- };
+ _rpcClient = OperatingSystem.IsLinux()
+ ? new DiscordRpcClient(Config.ApplicationId.Trim(), client: new DiscordRpcPipeClient())
+ : new DiscordRpcClient(Config.ApplicationId.Trim());
+
+ _rpcClient.Logger = Settings.Config.Logging.DebugMessages
+ ? new ConsoleLogger(LogLevel.Trace)
+ : new ConsoleLogger(LogLevel.None);
_rpcClient.OnReady += (_, e) =>
{
@@ -328,5 +334,394 @@ namespace MinecraftClient.ChatBots
return value.Length <= maxLength ? value : value[..(maxLength - 3)] + "...";
}
+
+ ///
+ /// Flatpak Discord exposes the RPC socket under app/com.discordapp.Discord,
+ /// but the currently published DiscordRichPresence package does not probe that path.
+ ///
+ private sealed class DiscordRpcPipeClient : INamedPipeClient
+ {
+ private const string DiscordPipePrefix = "discord-ipc-";
+ private const int MaximumPipeVariations = 10;
+
+ private static readonly string[] s_unixPackageDirectories =
+ [
+ // Official Discord clients
+ "app/com.discordapp.Discord",
+ "snap.discord",
+
+ // Community desktop clients / wrappers
+ "app/dev.vencord.Vesktop",
+ ".flatpak/dev.vencord.Vesktop/xdg-run",
+ "app/org.equicord.equibop",
+ "app/io.github.equicord.equibop",
+ "app/xyz.armcord.ArmCord",
+ "app/io.github.spacingbat3.webcord"
+ ];
+
+ private readonly byte[] _buffer = new byte[PipeFrame.MAX_SIZE];
+ private readonly Queue _frameQueue = new();
+ private readonly object _frameQueueLock = new();
+ private readonly object _streamLock = new();
+
+ private int _connectedPipe;
+ private NamedPipeClientStream? _stream;
+ private volatile bool _isClosed = true;
+ private volatile bool _isDisposed;
+
+ public ILogger Logger { get; set; } = new NullLogger();
+
+ public bool IsConnected
+ {
+ get
+ {
+ if (_isClosed)
+ return false;
+
+ lock (_streamLock)
+ return _stream is { IsConnected: true };
+ }
+ }
+
+ [Obsolete("The connected pipe is not neccessary information.")]
+ public int ConnectedPipe => _connectedPipe;
+
+ public bool Connect(int pipe)
+ {
+ Logger.Trace("DiscordRpcPipeClient.Connect({0})", pipe);
+
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(DiscordRpcPipeClient));
+
+ if (pipe > 9)
+ throw new ArgumentOutOfRangeException(nameof(pipe), "Argument cannot be greater than 9");
+
+ int startPipe = pipe >= 0 ? pipe : 0;
+
+ foreach (string pipeName in GetPipeCandidates(startPipe))
+ {
+ if (AttemptConnection(pipeName))
+ {
+ BeginReadStream();
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public bool ReadFrame(out PipeFrame frame)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(DiscordRpcPipeClient));
+
+ lock (_frameQueueLock)
+ {
+ if (_frameQueue.Count == 0)
+ {
+ frame = default;
+ return false;
+ }
+
+ frame = _frameQueue.Dequeue();
+ return true;
+ }
+ }
+
+ public bool WriteFrame(PipeFrame frame)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(DiscordRpcPipeClient));
+
+ if (_isClosed || !IsConnected)
+ {
+ Logger.Error("Failed to write frame because the stream is closed");
+ return false;
+ }
+
+ try
+ {
+ frame.WriteStream(_stream);
+ return true;
+ }
+ catch (IOException io)
+ {
+ Logger.Error("Failed to write frame because of a IO Exception: {0}", io.Message);
+ }
+ catch (ObjectDisposedException)
+ {
+ Logger.Warning("Failed to write frame as the stream was already disposed");
+ }
+ catch (InvalidOperationException)
+ {
+ Logger.Warning("Failed to write frame because of a invalid operation");
+ }
+
+ return false;
+ }
+
+ public void Close()
+ {
+ if (_isClosed)
+ {
+ Logger.Warning("Tried to close a already closed pipe.");
+ return;
+ }
+
+ try
+ {
+ lock (_streamLock)
+ {
+ if (_stream is not null)
+ {
+ try
+ {
+ _stream.Flush();
+ _stream.Dispose();
+ }
+ catch
+ {
+ }
+
+ _stream = null;
+ _isClosed = true;
+ }
+ else
+ {
+ Logger.Warning("Stream was closed, but no stream was available to begin with!");
+ }
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ Logger.Warning("Tried to dispose already disposed stream");
+ }
+ finally
+ {
+ _isClosed = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_isDisposed)
+ return;
+
+ if (!_isClosed)
+ Close();
+
+ lock (_streamLock)
+ {
+ _stream?.Dispose();
+ _stream = null;
+ }
+
+ _isDisposed = true;
+ }
+
+ private bool AttemptConnection(string pipeName)
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(DiscordRpcPipeClient));
+
+ try
+ {
+ lock (_streamLock)
+ {
+ Logger.Info("Attempting to connect to {0}", pipeName);
+ _stream = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
+ _stream.Connect(0);
+
+ Logger.Trace("Waiting for connection...");
+ while (!_stream.IsConnected)
+ Thread.Sleep(10);
+ }
+
+ Logger.Info("Connected to {0}", pipeName);
+ _connectedPipe = int.Parse(pipeName[(pipeName.LastIndexOf('-') + 1)..], System.Globalization.CultureInfo.InvariantCulture);
+ _isClosed = false;
+ }
+ catch (Exception e)
+ {
+ Logger.Error("Failed connection to {0}. {1}", pipeName, e.Message);
+ Close();
+ }
+
+ Logger.Trace("Done. Result: {0}", _isClosed);
+ return !_isClosed;
+ }
+
+ private void BeginReadStream()
+ {
+ if (_isClosed)
+ return;
+
+ try
+ {
+ lock (_streamLock)
+ {
+ if (_stream is not { IsConnected: true })
+ return;
+
+ Logger.Trace("Beginning Read of {0} bytes", _buffer.Length);
+ _stream.BeginRead(_buffer, 0, _buffer.Length, EndReadStream, _stream.IsConnected);
+ }
+ }
+ catch (ObjectDisposedException)
+ {
+ Logger.Warning("Attempted to start reading from a disposed pipe");
+ }
+ catch (InvalidOperationException)
+ {
+ Logger.Warning("Attempted to start reading from a closed pipe");
+ }
+ catch (Exception e)
+ {
+ Logger.Error("An exception occurred while starting to read a stream: {0}", e.Message);
+ Logger.Error(e.StackTrace);
+ }
+ }
+
+ private void EndReadStream(IAsyncResult callback)
+ {
+ Logger.Trace("Ending Read");
+ int bytes;
+
+ try
+ {
+ lock (_streamLock)
+ {
+ if (_stream is not { IsConnected: true })
+ return;
+
+ bytes = _stream.EndRead(callback);
+ }
+ }
+ catch (IOException)
+ {
+ Logger.Warning("Attempted to end reading from a closed pipe");
+ return;
+ }
+ catch (NullReferenceException)
+ {
+ Logger.Warning("Attempted to read from a null pipe");
+ return;
+ }
+ catch (ObjectDisposedException)
+ {
+ Logger.Warning("Attempted to end reading from a disposed pipe");
+ return;
+ }
+ catch (Exception e)
+ {
+ Logger.Error("An exception occurred while ending a read of a stream: {0}", e.Message);
+ Logger.Error(e.StackTrace);
+ return;
+ }
+
+ Logger.Trace("Read {0} bytes", bytes);
+
+ if (bytes > 0)
+ {
+ using MemoryStream memory = new(_buffer, 0, bytes);
+ try
+ {
+ PipeFrame frame = new();
+ if (frame.ReadStream(memory))
+ {
+ Logger.Trace("Read a frame: {0}", frame.Opcode);
+ lock (_frameQueueLock)
+ _frameQueue.Enqueue(frame);
+ }
+ else
+ {
+ Logger.Error("Pipe failed to read from the data received by the stream.");
+ Close();
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.Error("An exception has occurred while trying to parse the pipe data: {0}", e.Message);
+ Close();
+ }
+ }
+ else
+ {
+ Logger.Error("Empty frame was read on {0}, aborting.", Environment.OSVersion);
+ Close();
+ }
+
+ if (!_isClosed && IsConnected)
+ {
+ Logger.Trace("Starting another read");
+ BeginReadStream();
+ }
+ }
+
+ private static IEnumerable GetPipeCandidates(int startPipe)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ for (int i = startPipe; i < MaximumPipeVariations; i++)
+ yield return $"{DiscordPipePrefix}{i}";
+
+ yield break;
+ }
+
+ foreach (string runtimeDir in GetUnixRuntimeDirectories())
+ {
+ for (int index = startPipe; index < MaximumPipeVariations; index++)
+ {
+ string pipeFileName = $"{DiscordPipePrefix}{index}";
+
+ foreach (string packageDirectory in s_unixPackageDirectories)
+ {
+ string packagePipe = Path.Combine(runtimeDir, packageDirectory, pipeFileName);
+ if (File.Exists(packagePipe))
+ yield return packagePipe;
+ }
+
+ string defaultPipe = Path.Combine(runtimeDir, pipeFileName);
+ if (File.Exists(defaultPipe))
+ yield return defaultPipe;
+
+ foreach (string packageDirectory in s_unixPackageDirectories)
+ {
+ string packagePipe = Path.Combine(runtimeDir, packageDirectory, pipeFileName);
+ if (!File.Exists(packagePipe))
+ yield return packagePipe;
+ }
+
+ if (!File.Exists(defaultPipe))
+ yield return defaultPipe;
+ }
+ }
+ }
+
+ private static IEnumerable GetUnixRuntimeDirectories()
+ {
+ HashSet yielded = new(StringComparer.Ordinal);
+
+ string[] candidates =
+ [
+ Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR") ?? string.Empty,
+ Environment.GetEnvironmentVariable("TMPDIR") ?? string.Empty,
+ Environment.GetEnvironmentVariable("TMP") ?? string.Empty,
+ Environment.GetEnvironmentVariable("TEMP") ?? string.Empty,
+ Path.GetTempPath(),
+ "/tmp"
+ ];
+
+ foreach (string candidate in candidates)
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ continue;
+
+ string normalized = candidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ if (yielded.Add(normalized))
+ yield return normalized;
+ }
+ }
+ }
}
}