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; + } + } + } } }