From d4bb282c4977eccdc4ffb721d2ae413284232b35 Mon Sep 17 00:00:00 2001 From: Brando Date: Sat, 11 Jul 2026 17:54:42 -0400 Subject: [PATCH] GUI: Discord Rich Presence (#73) Publish launcher status to Discord over its local IPC named pipe with no external dependency: framed-JSON handshake and SET_ACTIVITY, a background worker with reconnection and latest-state dedup, and silent no-op behavior when Discord is not running. - "Browsing the library" (game count, session elapsed) while idle; "Playing " with title id and elapsed time during emulation. Activities always carry timestamps: Discord accepts but does not render activities without them - Presence flips back to browsing immediately on Stop instead of waiting for process exit: a game wedged in a GPU driver call can outlive termination for a while. Stop also terminates the job object so the whole child tree dies even in that state - Toggle in the Options panel (persisted); client id configurable in gui-settings.json; SHARPEMU_LOG_DISCORD=1 traces queue/send/failure --- src/SharpEmu.GUI/DiscordRichPresence.cs | 252 ++++++++++++++++++++++++ src/SharpEmu.GUI/EmulatorProcess.cs | 12 ++ src/SharpEmu.GUI/GuiSettings.cs | 10 + src/SharpEmu.GUI/MainWindow.axaml | 6 +- src/SharpEmu.GUI/MainWindow.axaml.cs | 87 +++++++- 5 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 src/SharpEmu.GUI/DiscordRichPresence.cs diff --git a/src/SharpEmu.GUI/DiscordRichPresence.cs b/src/SharpEmu.GUI/DiscordRichPresence.cs new file mode 100644 index 0000000..880a102 --- /dev/null +++ b/src/SharpEmu.GUI/DiscordRichPresence.cs @@ -0,0 +1,252 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.IO.Pipes; +using System.Text; +using System.Text.Json; + +namespace SharpEmu.GUI; + +/// +/// Minimal Discord Rich Presence client. Speaks Discord's local IPC +/// protocol (framed JSON over the discord-ipc-N named pipe) directly, so no +/// external dependency is needed. All failures are silent: no Discord, no +/// presence, no impact on the launcher. +/// +public sealed class DiscordRichPresence : IDisposable +{ + private const int OpHandshake = 0; + private const int OpFrame = 1; + private const int MaxFrameBytes = 64 * 1024; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly object _gate = new(); + private readonly string _clientId; + private readonly AutoResetEvent _signal = new(false); + private readonly Thread _worker; + + private NamedPipeClientStream? _pipe; + private PresenceState? _desired; + private PresenceState? _sent; + private volatile bool _disposed; + + private sealed record PresenceState(string Details, string? State, long? StartUnixSeconds); + + public DiscordRichPresence(string clientId) + { + _clientId = clientId; + _worker = new Thread(WorkerLoop) + { + IsBackground = true, + Name = "DiscordRichPresence", + }; + _worker.Start(); + } + + /// + /// Requests the given presence; the worker applies it asynchronously and + /// retries (with reconnection) until it is delivered or replaced. + /// + public void SetPresence(string details, string? state = null, long? startUnixSeconds = null) + { + lock (_gate) + { + _desired = new PresenceState(details, state, startUnixSeconds); + } + + Trace($"queued details=\"{details}\" state=\"{state}\""); + _signal.Set(); + } + + public void Dispose() + { + _disposed = true; + _signal.Set(); + ClosePipe(); + } + + private void WorkerLoop() + { + while (!_disposed) + { + // Woken immediately on presence changes; the periodic tick + // retries after connection failures (e.g. Discord starts later). + _signal.WaitOne(TimeSpan.FromSeconds(15)); + if (_disposed || _clientId.Length == 0) + { + continue; + } + + PresenceState? desired; + lock (_gate) + { + desired = _desired; + } + + if (desired is null || desired == _sent) + { + continue; + } + + try + { + EnsureConnected(); + SendActivity(desired); + _sent = desired; + Trace($"sent details=\"{desired.Details}\" state=\"{desired.State}\""); + } + catch (Exception exception) + { + // Connection died (or never existed); reconnect on a later tick. + Trace($"send failed: {exception.Message}"); + ClosePipe(); + _sent = null; + } + } + } + + private void EnsureConnected() + { + if (_pipe is { IsConnected: true }) + { + return; + } + + ClosePipe(); + Exception? lastError = null; + for (var index = 0; index < 10; index++) + { + var pipe = new NamedPipeClientStream( + ".", + $"discord-ipc-{index}", + PipeDirection.InOut, + PipeOptions.Asynchronous); + try + { + pipe.Connect(500); + _pipe = pipe; + WriteFrame(OpHandshake, JsonSerializer.Serialize(new + { + v = 1, + client_id = _clientId, + })); + + // Discord replies with a READY (or error) frame; consume it + // so the pipe buffer stays drained. + _ = ReadFrame(); + return; + } + catch (Exception exception) + { + lastError = exception; + pipe.Dispose(); + _pipe = null; + } + } + + throw lastError ?? new IOException("Discord IPC pipe not found."); + } + + private void SendActivity(PresenceState presence) + { + var payload = new Dictionary + { + ["cmd"] = "SET_ACTIVITY", + ["nonce"] = Guid.NewGuid().ToString(), + ["args"] = new Dictionary + { + ["pid"] = Environment.ProcessId, + ["activity"] = new Dictionary + { + ["details"] = presence.Details, + ["state"] = presence.State, + ["timestamps"] = presence.StartUnixSeconds is { } start + ? new Dictionary { ["start"] = start } + : null, + ["assets"] = new Dictionary + { + ["large_image"] = "logo", + ["large_text"] = "SharpEmu", + }, + }, + }, + }; + + WriteFrame(OpFrame, JsonSerializer.Serialize(payload, JsonOptions)); + + // Consume Discord's acknowledgement to keep the pipe drained. + _ = ReadFrame(); + } + + private void WriteFrame(int opcode, string json) + { + var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected."); + var payload = Encoding.UTF8.GetBytes(json); + var frame = new byte[8 + payload.Length]; + BitConverter.TryWriteBytes(frame.AsSpan(0, 4), opcode); + BitConverter.TryWriteBytes(frame.AsSpan(4, 4), payload.Length); + payload.CopyTo(frame, 8); + pipe.Write(frame, 0, frame.Length); + pipe.Flush(); + } + + private string ReadFrame() + { + var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected."); + var header = ReadExactly(pipe, 8); + var length = BitConverter.ToInt32(header, 4); + if (length is < 0 or > MaxFrameBytes) + { + throw new IOException($"Discord IPC frame length {length} is out of range."); + } + + return Encoding.UTF8.GetString(ReadExactly(pipe, length)); + } + + private static byte[] ReadExactly(NamedPipeClientStream pipe, int count) + { + var buffer = new byte[count]; + var read = 0; + while (read < count) + { + var chunk = pipe.Read(buffer, read, count - read); + if (chunk <= 0) + { + throw new IOException("Discord IPC pipe closed."); + } + + read += chunk; + } + + return buffer; + } + + private static void Trace(string message) + { + if (string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISCORD"), + "1", + StringComparison.Ordinal)) + { + Console.Error.WriteLine($"[DISCORD] {message}"); + } + } + + private void ClosePipe() + { + var pipe = _pipe; + _pipe = null; + try + { + pipe?.Dispose(); + } + catch (Exception) + { + // Best effort; the pipe may already be broken. + } + } +} diff --git a/src/SharpEmu.GUI/EmulatorProcess.cs b/src/SharpEmu.GUI/EmulatorProcess.cs index f7b120d..c713456 100644 --- a/src/SharpEmu.GUI/EmulatorProcess.cs +++ b/src/SharpEmu.GUI/EmulatorProcess.cs @@ -85,6 +85,14 @@ internal sealed class EmulatorProcess : IDisposable return; } + // Prefer terminating the job: it kills the whole tree, including + // any children the emulator spawned, even when the main process + // is wedged in a GPU driver call. + if (_jobHandle != 0) + { + _ = TerminateJobObject(_jobHandle, 1); + } + if (_processHandle != 0) { _ = TerminateProcess(_processHandle, 1); @@ -639,6 +647,10 @@ internal sealed class EmulatorProcess : IDisposable [return: MarshalAs(UnmanagedType.Bool)] private static extern bool TerminateProcess(nint process, uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool TerminateJobObject(nint job, uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool CloseHandle(nint handle); diff --git a/src/SharpEmu.GUI/GuiSettings.cs b/src/SharpEmu.GUI/GuiSettings.cs index 85c6c9b..4af24c3 100644 --- a/src/SharpEmu.GUI/GuiSettings.cs +++ b/src/SharpEmu.GUI/GuiSettings.cs @@ -31,6 +31,16 @@ public sealed class GuiSettings public string? EmulatorPath { get; set; } + /// Publish launcher/game status to Discord Rich Presence. + public bool DiscordRichPresence { get; set; } = true; + + /// + /// Discord application ID used for Rich Presence; the default is the + /// SharpEmu application. Override to rebrand what Discord shows as + /// "Playing …" (register at discord.com/developers/applications). + /// + public string DiscordClientId { get; set; } = "1525606762248540221"; + // The emulator is portable and keeps its data next to the executable; // the GUI follows the same convention. public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json"); diff --git a/src/SharpEmu.GUI/MainWindow.axaml b/src/SharpEmu.GUI/MainWindow.axaml index 80bc6b7..3693610 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml +++ b/src/SharpEmu.GUI/MainWindow.axaml @@ -252,10 +252,14 @@ SPDX-License-Identifier: GPL-2.0-or-later - + + + + + diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index fd3e612..844bb18 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -44,6 +44,13 @@ public partial class MainWindow : Window private string? _emulatorExePath; private bool _isRunning; private int _autoScrollTicks; + + // Discord Rich Presence state. + private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + private DiscordRichPresence? _discord; + private string? _runningGameName; + private string? _runningGameTitleId; + private long _runningSinceUnixSeconds; private int _detailLoadGeneration; private int _backdropGeneration; @@ -82,12 +89,17 @@ public partial class MainWindow : Window RescanButton.Click += async (_, _) => await RescanLibraryAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync(); LaunchButton.Click += (_, _) => LaunchSelected(); - StopButton.Click += (_, _) => _emulator?.Stop(); + StopButton.Click += (_, _) => StopEmulator(); ClearLogButton.Click += (_, _) => _consoleLines.Clear(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true; ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true; TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled(); + DiscordToggle.IsCheckedChanged += (_, _) => + { + _settings.DiscordRichPresence = DiscordToggle.IsChecked == true; + UpdateDiscordPresence(); + }; GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); CtxLaunch.Click += (_, _) => LaunchSelected(); @@ -164,7 +176,7 @@ public partial class MainWindow : Window if ((pressed & 0x2000) != 0) // Circle { - _emulator?.Stop(); + StopEmulator(); } _previousPadButtons = pad.Buttons; @@ -235,9 +247,45 @@ public partial class MainWindow : Window _settings = GuiSettings.Load(); ApplySettingsToControls(); LocateEmulator(); + UpdateDiscordPresence(); await RescanLibraryAsync(); } + // ---- Discord Rich Presence ---- + + /// + /// Publishes the launcher state to Discord: browsing while idle, the + /// running game (with elapsed time) during emulation. No-ops when + /// disabled or when no Discord application ID is configured. + /// + private void UpdateDiscordPresence() + { + if (!_settings.DiscordRichPresence || _settings.DiscordClientId.Length == 0) + { + _discord?.Dispose(); + _discord = null; + return; + } + + _discord ??= new DiscordRichPresence(_settings.DiscordClientId); + if (_isRunning && _runningGameName is { } gameName) + { + _discord.SetPresence( + $"Playing {gameName}", + _runningGameTitleId, + _runningSinceUnixSeconds); + } + else + { + // Discord does not render activities without timestamps, so the + // browsing state carries the launcher's start time. + _discord.SetPresence( + "Browsing the library", + $"{_allGames.Count} game(s)", + _launcherStartUnixSeconds); + } + } + private void OnWindowClosing() { ReadControlsIntoSettings(); @@ -245,6 +293,7 @@ public partial class MainWindow : Window _consoleFlushTimer.Stop(); _gamepadTimer.Stop(); _sndPreview.Stop(); + _discord?.Dispose(); _emulator?.Dispose(); DropFileLog(); } @@ -275,6 +324,7 @@ public partial class MainWindow : Window StrictToggle.IsChecked = _settings.StrictDynlibResolution; LogToFileToggle.IsChecked = _settings.LogToFile; TitleMusicToggle.IsChecked = _settings.PlayTitleMusic; + DiscordToggle.IsChecked = _settings.DiscordRichPresence; } private void ReadControlsIntoSettings() @@ -284,6 +334,7 @@ public partial class MainWindow : Window _settings.StrictDynlibResolution = StrictToggle.IsChecked == true; _settings.LogToFile = LogToFileToggle.IsChecked == true; _settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true; + _settings.DiscordRichPresence = DiscordToggle.IsChecked == true; } private string SelectedLogLevel() @@ -382,6 +433,7 @@ public partial class MainWindow : Window _allGames.AddRange(games); RefreshVisibleGames(); LoadGameDetailsInBackground(games); + UpdateDiscordPresence(); StatusBarRight.Text = folders.Length == 0 ? "Add a game folder to populate the library." : $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s)."; @@ -1009,10 +1061,38 @@ public partial class MainWindow : Window _emulator = emulator; _isRunning = true; + _runningGameName = displayName; + _runningGameTitleId = _allGames + .FirstOrDefault(game => game.Path.Equals(ebootPath, StringComparison.OrdinalIgnoreCase))? + .TitleId; + _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); StatusDot.Fill = SuccessLineBrush; StatusText.Text = $"Running — {displayName}"; StatusBarRight.Text = $"Running {displayName}"; UpdateRunButtons(); + UpdateDiscordPresence(); + } + + /// + /// Stops the running game and updates status/presence immediately. The + /// process-exit path still runs when the corpse is collected, but a game + /// wedged in a GPU driver call can keep its process alive for a long + /// time after termination — the launcher should not look (or tell + /// Discord it is) "playing" during that window. + /// + private void StopEmulator() + { + if (!_isRunning) + { + return; + } + + _emulator?.Stop(); + _runningGameName = null; + _runningGameTitleId = null; + StatusText.Text = "Stopping…"; + StatusBarRight.Text = "Stopping…"; + UpdateDiscordPresence(); } /// @@ -1069,7 +1149,10 @@ public partial class MainWindow : Window StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush; StatusText.Text = $"Exited with code {exitCode} ({meaning})"; StatusBarRight.Text = "Idle"; + _runningGameName = null; + _runningGameTitleId = null; UpdateRunButtons(); + UpdateDiscordPresence(); } private void UpdateRunButtons()