diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index f0da708766..d78459ff28 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -5,14 +5,21 @@ using System; using System.Text; using DiscordRPC; using DiscordRPC.Message; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; +using osu.Framework.Threading; +using osu.Game; using osu.Game.Configuration; using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Users; using LogLevel = osu.Framework.Logging.LogLevel; @@ -21,7 +28,7 @@ namespace osu.Desktop { internal partial class DiscordRichPresence : Component { - private const string client_id = "367827983903490050"; + private const string client_id = "1216669957799018608"; private DiscordRpcClient client = null!; @@ -33,6 +40,15 @@ namespace osu.Desktop [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private OsuGame game { get; set; } = null!; + + [Resolved] + private LoginOverlay? login { get; set; } + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } = null!; + private readonly IBindable status = new Bindable(); private readonly IBindable activity = new Bindable(); @@ -40,7 +56,12 @@ namespace osu.Desktop private readonly RichPresence presence = new RichPresence { - Assets = new Assets { LargeImageKey = "osu_logo_lazer", } + Assets = new Assets { LargeImageKey = "osu_logo_lazer" }, + Secrets = new Secrets + { + JoinSecret = null, + SpectateSecret = null, + }, }; [BackgroundDependencyLoader] @@ -48,12 +69,18 @@ namespace osu.Desktop { client = new DiscordRpcClient(client_id) { - SkipIdenticalPresence = false // handles better on discord IPC loss, see updateStatus call in onReady. + // SkipIdenticalPresence allows us to fire SetPresence at any point and leave it to the underlying implementation + // to check whether a difference has actually occurred before sending a command to Discord (with a minor caveat that's handled in onReady). + SkipIdenticalPresence = true }; client.OnReady += onReady; + client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network, LogLevel.Error); - client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network); + // A URI scheme is required to support game invitations, as well as informing Discord of the game executable path to support launching the game when a user clicks on join/spectate. + client.RegisterUriScheme(); + client.Subscribe(EventType.Join); + client.OnJoin += onJoin; config.BindWith(OsuSetting.DiscordRichPresence, privacyMode); @@ -67,10 +94,11 @@ namespace osu.Desktop activity.BindTo(u.NewValue.Activity); }, true); - ruleset.BindValueChanged(_ => updateStatus()); - status.BindValueChanged(_ => updateStatus()); - activity.BindValueChanged(_ => updateStatus()); - privacyMode.BindValueChanged(_ => updateStatus()); + ruleset.BindValueChanged(_ => updatePresence()); + status.BindValueChanged(_ => updatePresence()); + activity.BindValueChanged(_ => updatePresence()); + privacyMode.BindValueChanged(_ => updatePresence()); + multiplayerClient.RoomUpdated += onRoomUpdated; client.Initialize(); } @@ -78,24 +106,46 @@ namespace osu.Desktop private void onReady(object _, ReadyMessage __) { Logger.Log("Discord RPC Client ready.", LoggingTarget.Network, LogLevel.Debug); - updateStatus(); + + // when RPC is lost and reconnected, we have to clear presence state for updatePresence to work (see DiscordRpcClient.SkipIdenticalPresence). + if (client.CurrentPresence != null) + client.SetPresence(null); + + updatePresence(); } - private void updateStatus() + private void onRoomUpdated() => updatePresence(); + + private ScheduledDelegate? presenceUpdateDelegate; + + private void updatePresence() { - if (!client.IsInitialized) - return; - - if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off) + presenceUpdateDelegate?.Cancel(); + presenceUpdateDelegate = Scheduler.AddDelayed(() => { - client.ClearPresence(); - return; - } + if (!client.IsInitialized) + return; + + if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off) + { + client.ClearPresence(); + return; + } - if (activity.Value != null) - { bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb; + updatePresenceStatus(hideIdentifiableInformation); + updatePresenceParty(hideIdentifiableInformation); + updatePresenceAssets(); + + client.SetPresence(presence); + }, 200); + } + + private void updatePresenceStatus(bool hideIdentifiableInformation) + { + if (activity.Value != null) + { presence.State = truncate(activity.Value.GetStatus(hideIdentifiableInformation)); presence.Details = truncate(activity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty); @@ -120,7 +170,41 @@ namespace osu.Desktop presence.State = "Idle"; presence.Details = string.Empty; } + } + private void updatePresenceParty(bool hideIdentifiableInformation) + { + if (!hideIdentifiableInformation && multiplayerClient.Room != null) + { + MultiplayerRoom room = multiplayerClient.Room; + + presence.Party = new Party + { + Privacy = string.IsNullOrEmpty(room.Settings.Password) ? Party.PrivacySetting.Public : Party.PrivacySetting.Private, + ID = room.RoomID.ToString(), + // technically lobbies can have infinite users, but Discord needs this to be set to something. + // to make party display sensible, assign a powers of two above participants count (8 at minimum). + Max = (int)Math.Max(8, Math.Pow(2, Math.Ceiling(Math.Log2(room.Users.Count)))), + Size = room.Users.Count, + }; + + RoomSecret roomSecret = new RoomSecret + { + RoomID = room.RoomID, + Password = room.Settings.Password, + }; + + presence.Secrets.JoinSecret = JsonConvert.SerializeObject(roomSecret); + } + else + { + presence.Party = null; + presence.Secrets.JoinSecret = null; + } + } + + private void updatePresenceAssets() + { // update user information if (privacyMode.Value == DiscordRichPresenceMode.Limited) presence.Assets.LargeImageText = string.Empty; @@ -135,13 +219,40 @@ namespace osu.Desktop // update ruleset presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom"; presence.Assets.SmallImageText = ruleset.Value.Name; - - client.SetPresence(presence); } + private void onJoin(object sender, JoinMessage args) => Scheduler.AddOnce(() => + { + game.Window?.Raise(); + + if (!api.IsLoggedIn) + { + login?.Show(); + return; + } + + Logger.Log($"Received room secret from Discord RPC Client: \"{args.Secret}\"", LoggingTarget.Network, LogLevel.Debug); + + // Stable and lazer share the same Discord client ID, meaning they can accept join requests from each other. + // Since they aren't compatible in multi, see if stable's format is being used and log to avoid confusion. + if (args.Secret[0] != '{' || !tryParseRoomSecret(args.Secret, out long roomId, out string? password)) + { + Logger.Log("Could not join multiplayer room, invitation is invalid or incompatible.", LoggingTarget.Network, LogLevel.Important); + return; + } + + var request = new GetRoomRequest(roomId); + request.Success += room => Schedule(() => + { + game.PresentMultiplayerMatch(room, password); + }); + request.Failure += _ => Logger.Log($"Could not join multiplayer room, room could not be found (room ID: {roomId}).", LoggingTarget.Network, LogLevel.Important); + api.Queue(request); + }); + private static readonly int ellipsis_length = Encoding.UTF8.GetByteCount(new[] { '…' }); - private string truncate(string str) + private static string truncate(string str) { if (Encoding.UTF8.GetByteCount(str) <= 128) return str; @@ -160,7 +271,31 @@ namespace osu.Desktop }); } - private int? getBeatmapID(UserActivity activity) + private static bool tryParseRoomSecret(string secretJson, out long roomId, out string? password) + { + roomId = 0; + password = null; + + RoomSecret? roomSecret; + + try + { + roomSecret = JsonConvert.DeserializeObject(secretJson); + } + catch + { + return false; + } + + if (roomSecret == null) return false; + + roomId = roomSecret.RoomID; + password = roomSecret.Password; + + return true; + } + + private static int? getBeatmapID(UserActivity activity) { switch (activity) { @@ -176,8 +311,20 @@ namespace osu.Desktop protected override void Dispose(bool isDisposing) { + if (multiplayerClient.IsNotNull()) + multiplayerClient.RoomUpdated -= onRoomUpdated; + client.Dispose(); base.Dispose(isDisposing); } + + private class RoomSecret + { + [JsonProperty(@"roomId", Required = Required.Always)] + public long RoomID { get; set; } + + [JsonProperty(@"password", Required = Required.AllowNull)] + public string? Password { get; set; } + } } }