mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-19 01:16:12 +08:00
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 <game>" 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
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Requests the given presence; the worker applies it asynchronously and
|
||||||
|
/// retries (with reconnection) until it is delivered or replaced.
|
||||||
|
/// </summary>
|
||||||
|
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<string, object?>
|
||||||
|
{
|
||||||
|
["cmd"] = "SET_ACTIVITY",
|
||||||
|
["nonce"] = Guid.NewGuid().ToString(),
|
||||||
|
["args"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["pid"] = Environment.ProcessId,
|
||||||
|
["activity"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["details"] = presence.Details,
|
||||||
|
["state"] = presence.State,
|
||||||
|
["timestamps"] = presence.StartUnixSeconds is { } start
|
||||||
|
? new Dictionary<string, object?> { ["start"] = start }
|
||||||
|
: null,
|
||||||
|
["assets"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,14 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
return;
|
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)
|
if (_processHandle != 0)
|
||||||
{
|
{
|
||||||
_ = TerminateProcess(_processHandle, 1);
|
_ = TerminateProcess(_processHandle, 1);
|
||||||
@@ -639,6 +647,10 @@ internal sealed class EmulatorProcess : IDisposable
|
|||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool TerminateProcess(nint process, uint exitCode);
|
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)]
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool CloseHandle(nint handle);
|
private static extern bool CloseHandle(nint handle);
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ public sealed class GuiSettings
|
|||||||
|
|
||||||
public string? EmulatorPath { get; set; }
|
public string? EmulatorPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
||||||
|
public bool DiscordRichPresence { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public string DiscordClientId { get; set; } = "1525606762248540221";
|
||||||
|
|
||||||
// The emulator is portable and keeps its data next to the executable;
|
// The emulator is portable and keeps its data next to the executable;
|
||||||
// the GUI follows the same convention.
|
// the GUI follows the same convention.
|
||||||
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json");
|
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json");
|
||||||
|
|||||||
@@ -252,10 +252,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock Classes="fieldLabel" Text="Log to file" />
|
<TextBlock Classes="fieldLabel" Text="Log to file" />
|
||||||
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
|
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel>
|
<StackPanel Margin="0,0,24,0">
|
||||||
<TextBlock Classes="fieldLabel" Text="Title music" />
|
<TextBlock Classes="fieldLabel" Text="Title music" />
|
||||||
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
|
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Classes="fieldLabel" Text="Discord presence" />
|
||||||
|
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off" />
|
||||||
|
</StackPanel>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ public partial class MainWindow : Window
|
|||||||
private string? _emulatorExePath;
|
private string? _emulatorExePath;
|
||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
private int _autoScrollTicks;
|
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 _detailLoadGeneration;
|
||||||
private int _backdropGeneration;
|
private int _backdropGeneration;
|
||||||
|
|
||||||
@@ -82,12 +89,17 @@ public partial class MainWindow : Window
|
|||||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
StopButton.Click += (_, _) => StopEmulator();
|
||||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||||
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
||||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
|
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
|
||||||
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
|
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
|
||||||
|
DiscordToggle.IsCheckedChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
|
||||||
|
UpdateDiscordPresence();
|
||||||
|
};
|
||||||
|
|
||||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||||
@@ -164,7 +176,7 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
if ((pressed & 0x2000) != 0) // Circle
|
if ((pressed & 0x2000) != 0) // Circle
|
||||||
{
|
{
|
||||||
_emulator?.Stop();
|
StopEmulator();
|
||||||
}
|
}
|
||||||
|
|
||||||
_previousPadButtons = pad.Buttons;
|
_previousPadButtons = pad.Buttons;
|
||||||
@@ -235,9 +247,45 @@ public partial class MainWindow : Window
|
|||||||
_settings = GuiSettings.Load();
|
_settings = GuiSettings.Load();
|
||||||
ApplySettingsToControls();
|
ApplySettingsToControls();
|
||||||
LocateEmulator();
|
LocateEmulator();
|
||||||
|
UpdateDiscordPresence();
|
||||||
await RescanLibraryAsync();
|
await RescanLibraryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Discord Rich Presence ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private void OnWindowClosing()
|
||||||
{
|
{
|
||||||
ReadControlsIntoSettings();
|
ReadControlsIntoSettings();
|
||||||
@@ -245,6 +293,7 @@ public partial class MainWindow : Window
|
|||||||
_consoleFlushTimer.Stop();
|
_consoleFlushTimer.Stop();
|
||||||
_gamepadTimer.Stop();
|
_gamepadTimer.Stop();
|
||||||
_sndPreview.Stop();
|
_sndPreview.Stop();
|
||||||
|
_discord?.Dispose();
|
||||||
_emulator?.Dispose();
|
_emulator?.Dispose();
|
||||||
DropFileLog();
|
DropFileLog();
|
||||||
}
|
}
|
||||||
@@ -275,6 +324,7 @@ public partial class MainWindow : Window
|
|||||||
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
||||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||||
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
|
||||||
|
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ReadControlsIntoSettings()
|
private void ReadControlsIntoSettings()
|
||||||
@@ -284,6 +334,7 @@ public partial class MainWindow : Window
|
|||||||
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||||
_settings.LogToFile = LogToFileToggle.IsChecked == true;
|
_settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||||
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
|
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
|
||||||
|
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string SelectedLogLevel()
|
private string SelectedLogLevel()
|
||||||
@@ -382,6 +433,7 @@ public partial class MainWindow : Window
|
|||||||
_allGames.AddRange(games);
|
_allGames.AddRange(games);
|
||||||
RefreshVisibleGames();
|
RefreshVisibleGames();
|
||||||
LoadGameDetailsInBackground(games);
|
LoadGameDetailsInBackground(games);
|
||||||
|
UpdateDiscordPresence();
|
||||||
StatusBarRight.Text = folders.Length == 0
|
StatusBarRight.Text = folders.Length == 0
|
||||||
? "Add a game folder to populate the library."
|
? "Add a game folder to populate the library."
|
||||||
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
||||||
@@ -1009,10 +1061,38 @@ public partial class MainWindow : Window
|
|||||||
|
|
||||||
_emulator = emulator;
|
_emulator = emulator;
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
|
_runningGameName = displayName;
|
||||||
|
_runningGameTitleId = _allGames
|
||||||
|
.FirstOrDefault(game => game.Path.Equals(ebootPath, StringComparison.OrdinalIgnoreCase))?
|
||||||
|
.TitleId;
|
||||||
|
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
StatusDot.Fill = SuccessLineBrush;
|
StatusDot.Fill = SuccessLineBrush;
|
||||||
StatusText.Text = $"Running — {displayName}";
|
StatusText.Text = $"Running — {displayName}";
|
||||||
StatusBarRight.Text = $"Running {displayName}";
|
StatusBarRight.Text = $"Running {displayName}";
|
||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
|
UpdateDiscordPresence();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private void StopEmulator()
|
||||||
|
{
|
||||||
|
if (!_isRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_emulator?.Stop();
|
||||||
|
_runningGameName = null;
|
||||||
|
_runningGameTitleId = null;
|
||||||
|
StatusText.Text = "Stopping…";
|
||||||
|
StatusBarRight.Text = "Stopping…";
|
||||||
|
UpdateDiscordPresence();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1069,7 +1149,10 @@ public partial class MainWindow : Window
|
|||||||
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||||
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
|
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
|
||||||
StatusBarRight.Text = "Idle";
|
StatusBarRight.Text = "Idle";
|
||||||
|
_runningGameName = null;
|
||||||
|
_runningGameTitleId = null;
|
||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
|
UpdateDiscordPresence();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRunButtons()
|
private void UpdateRunButtons()
|
||||||
|
|||||||
Reference in New Issue
Block a user