2019-12-18 13:07:53 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
2019-12-23 18:34:12 +08:00
using System ;
2019-12-21 22:48:15 +08:00
using System.Text ;
2019-12-18 13:07:53 +08:00
using DiscordRPC ;
using DiscordRPC.Message ;
2024-03-06 07:15:53 +08:00
using Newtonsoft.Json ;
2019-12-18 13:07:53 +08:00
using osu.Framework.Allocation ;
using osu.Framework.Bindables ;
2024-03-20 11:40:18 +08:00
using osu.Framework.Extensions.ObjectExtensions ;
2019-12-18 13:07:53 +08:00
using osu.Framework.Graphics ;
using osu.Framework.Logging ;
2024-03-20 11:40:18 +08:00
using osu.Framework.Threading ;
2024-03-01 08:57:32 +08:00
using osu.Game ;
2020-12-30 13:29:51 +08:00
using osu.Game.Configuration ;
2022-03-03 13:15:25 +08:00
using osu.Game.Extensions ;
2019-12-18 13:07:53 +08:00
using osu.Game.Online.API ;
2021-11-04 17:02:44 +08:00
using osu.Game.Online.API.Requests.Responses ;
2024-03-01 08:57:32 +08:00
using osu.Game.Online.Multiplayer ;
using osu.Game.Online.Rooms ;
2024-03-20 04:15:22 +08:00
using osu.Game.Overlays ;
2019-12-18 13:07:53 +08:00
using osu.Game.Rulesets ;
using osu.Game.Users ;
using LogLevel = osu . Framework . Logging . LogLevel ;
namespace osu.Desktop
{
2022-11-24 13:32:20 +08:00
internal partial class DiscordRichPresence : Component
2019-12-18 13:07:53 +08:00
{
2024-03-11 16:55:49 +08:00
private const string client_id = "1216669957799018608" ;
2019-12-18 13:07:53 +08:00
2022-08-02 22:23:54 +08:00
private DiscordRpcClient client = null ! ;
2019-12-18 13:07:53 +08:00
[Resolved]
2022-08-02 22:23:54 +08:00
private IBindable < RulesetInfo > ruleset { get ; set ; } = null ! ;
2019-12-18 13:07:53 +08:00
2022-05-31 04:38:47 +08:00
[Resolved]
2022-08-02 22:23:54 +08:00
private IAPIProvider api { get ; set ; } = null ! ;
2022-05-31 04:38:47 +08:00
2024-03-01 08:57:32 +08:00
[Resolved]
private OsuGame game { get ; set ; } = null ! ;
2024-03-20 12:36:15 +08:00
[Resolved]
2024-03-20 04:15:22 +08:00
private LoginOverlay ? login { get ; set ; }
2024-03-01 08:57:32 +08:00
[Resolved]
private MultiplayerClient multiplayerClient { get ; set ; } = null ! ;
2024-04-08 22:25:45 +08:00
[Resolved]
private OsuConfigManager config { get ; set ; } = null ! ;
2023-12-07 01:21:44 +08:00
private readonly IBindable < UserStatus ? > status = new Bindable < UserStatus ? > ( ) ;
2019-12-18 13:07:53 +08:00
private readonly IBindable < UserActivity > activity = new Bindable < UserActivity > ( ) ;
2021-01-06 23:05:12 +08:00
private readonly Bindable < DiscordRichPresenceMode > privacyMode = new Bindable < DiscordRichPresenceMode > ( ) ;
2019-12-18 13:07:53 +08:00
private readonly RichPresence presence = new RichPresence
{
2024-03-01 08:57:32 +08:00
Assets = new Assets { LargeImageKey = "osu_logo_lazer" } ,
Secrets = new Secrets
{
JoinSecret = null ,
SpectateSecret = null ,
} ,
2019-12-18 13:07:53 +08:00
} ;
2024-04-08 22:25:45 +08:00
private IBindable < APIUser > ? user ;
2019-12-18 13:07:53 +08:00
[BackgroundDependencyLoader]
2024-04-08 22:25:45 +08:00
private void load ( )
2019-12-18 13:07:53 +08:00
{
client = new DiscordRpcClient ( client_id )
{
2024-03-20 11:40:18 +08:00
// 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
2019-12-18 13:07:53 +08:00
} ;
client . OnReady + = onReady ;
2024-03-26 00:54:20 +08:00
client . OnError + = ( _ , e ) = > Logger . Log ( $"An error occurred with Discord RPC Client: {e.Message} ({e.Code})" , LoggingTarget . Network , LogLevel . Error ) ;
2019-12-23 18:50:35 +08:00
2024-04-17 16:10:19 +08:00
try
2024-04-04 15:43:26 +08:00
{
client . RegisterUriScheme ( ) ;
client . Subscribe ( EventType . Join ) ;
client . OnJoin + = onJoin ;
}
2024-04-17 16:10:19 +08:00
catch ( Exception ex )
{
// This is known to fail in at least the following sandboxed environments:
// - macOS (when packaged as an app bundle)
// - flatpak (see: https://github.com/flathub/sh.ppy.osu/issues/170)
// There is currently no better way to do this offered by Discord, so the best we can do is simply ignore it for now.
Logger . Log ( $"Failed to register Discord URI scheme: {ex}" ) ;
}
2019-12-18 13:07:53 +08:00
2024-04-08 22:25:45 +08:00
client . Initialize ( ) ;
}
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
2021-01-06 23:05:12 +08:00
config . BindWith ( OsuSetting . DiscordRichPresence , privacyMode ) ;
2022-06-09 11:32:30 +08:00
user = api . LocalUser . GetBoundCopy ( ) ;
user . BindValueChanged ( u = >
2019-12-18 13:07:53 +08:00
{
status . UnbindBindings ( ) ;
status . BindTo ( u . NewValue . Status ) ;
activity . UnbindBindings ( ) ;
activity . BindTo ( u . NewValue . Activity ) ;
} , true ) ;
2024-03-26 00:57:13 +08:00
ruleset . BindValueChanged ( _ = > schedulePresenceUpdate ( ) ) ;
status . BindValueChanged ( _ = > schedulePresenceUpdate ( ) ) ;
activity . BindValueChanged ( _ = > schedulePresenceUpdate ( ) ) ;
privacyMode . BindValueChanged ( _ = > schedulePresenceUpdate ( ) ) ;
2024-03-20 11:40:18 +08:00
multiplayerClient . RoomUpdated + = onRoomUpdated ;
2019-12-18 13:07:53 +08:00
}
private void onReady ( object _ , ReadyMessage __ )
{
Logger . Log ( "Discord RPC Client ready." , LoggingTarget . Network , LogLevel . Debug ) ;
2024-03-20 11:40:18 +08:00
// 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 ) ;
2024-03-26 00:57:13 +08:00
schedulePresenceUpdate ( ) ;
2019-12-18 13:07:53 +08:00
}
2024-03-26 00:57:13 +08:00
private void onRoomUpdated ( ) = > schedulePresenceUpdate ( ) ;
2024-03-20 04:02:06 +08:00
2024-03-20 11:40:18 +08:00
private ScheduledDelegate ? presenceUpdateDelegate ;
2020-01-11 23:03:00 +08:00
2024-03-26 00:57:13 +08:00
private void schedulePresenceUpdate ( )
2024-03-20 11:40:18 +08:00
{
presenceUpdateDelegate ? . Cancel ( ) ;
presenceUpdateDelegate = Scheduler . AddDelayed ( ( ) = >
2019-12-18 13:07:53 +08:00
{
2024-03-20 11:40:18 +08:00
if ( ! client . IsInitialized )
return ;
if ( status . Value = = UserStatus . Offline | | privacyMode . Value = = DiscordRichPresenceMode . Off )
{
client . ClearPresence ( ) ;
return ;
}
bool hideIdentifiableInformation = privacyMode . Value = = DiscordRichPresenceMode . Limited | | status . Value = = UserStatus . DoNotDisturb ;
2019-12-18 13:07:53 +08:00
2024-03-26 01:00:42 +08:00
updatePresence ( hideIdentifiableInformation ) ;
2024-03-20 11:40:18 +08:00
client . SetPresence ( presence ) ;
} , 200 ) ;
}
2024-03-26 01:00:42 +08:00
private void updatePresence ( bool hideIdentifiableInformation )
2024-03-20 11:40:18 +08:00
{
2024-04-08 22:25:45 +08:00
if ( user = = null )
return ;
2024-03-26 01:00:42 +08:00
// user activity
2024-02-24 11:07:47 +08:00
if ( activity . Value ! = null )
2019-12-18 13:07:53 +08:00
{
2024-05-17 00:23:19 +08:00
presence . State = clampLength ( activity . Value . GetStatus ( hideIdentifiableInformation ) ) ;
presence . Details = clampLength ( activity . Value . GetDetails ( hideIdentifiableInformation ) ? ? string . Empty ) ;
2021-11-20 20:41:01 +08:00
2023-12-07 01:16:45 +08:00
if ( getBeatmapID ( activity . Value ) is int beatmapId & & beatmapId > 0 )
2021-11-20 20:41:01 +08:00
{
2022-06-15 01:25:06 +08:00
presence . Buttons = new [ ]
2021-11-20 20:41:01 +08:00
{
2022-06-15 01:25:06 +08:00
new Button
{
Label = "View beatmap" ,
2023-12-07 01:16:45 +08:00
Url = $@"{api.WebsiteRootUrl}/beatmaps/{beatmapId}?mode={ruleset.Value.ShortName}"
2022-06-15 01:25:06 +08:00
}
2021-11-20 20:41:01 +08:00
} ;
}
else
{
presence . Buttons = null ;
}
2024-03-20 04:03:32 +08:00
}
else
{
presence . State = "Idle" ;
presence . Details = string . Empty ;
}
2024-03-01 08:57:32 +08:00
2024-03-26 01:00:42 +08:00
// user party
2024-03-20 04:03:32 +08:00
if ( ! hideIdentifiableInformation & & multiplayerClient . Room ! = null )
{
MultiplayerRoom room = multiplayerClient . Room ;
2024-03-01 08:57:32 +08:00
2024-03-20 04:03:32 +08:00
presence . Party = new Party
2024-03-01 08:57:32 +08:00
{
2024-03-20 04:03:32 +08:00
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 ,
} ;
2024-04-13 12:17:06 +08:00
if ( client . HasRegisteredUriScheme )
presence . Secrets . JoinSecret = JsonConvert . SerializeObject ( roomSecret ) ;
2024-03-26 01:02:54 +08:00
// discord cannot handle both secrets and buttons at the same time, so we need to choose something.
// the multiplayer room seems more important.
presence . Buttons = null ;
2019-12-18 13:07:53 +08:00
}
else
{
2024-03-20 04:03:32 +08:00
presence . Party = null ;
presence . Secrets . JoinSecret = null ;
2019-12-18 13:07:53 +08:00
}
2024-03-26 01:00:42 +08:00
// game images:
// large image tooltip
2021-01-06 23:05:12 +08:00
if ( privacyMode . Value = = DiscordRichPresenceMode . Limited )
2020-12-30 13:29:51 +08:00
presence . Assets . LargeImageText = string . Empty ;
else
2022-05-31 04:38:47 +08:00
{
2022-08-02 22:23:54 +08:00
if ( user . Value . RulesetsStatistics ! = null & & user . Value . RulesetsStatistics . TryGetValue ( ruleset . Value . ShortName , out UserStatistics ? statistics ) )
2022-05-31 04:38:47 +08:00
presence . Assets . LargeImageText = $"{user.Value.Username}" + ( statistics . GlobalRank > 0 ? $" (rank #{statistics.GlobalRank:N0})" : string . Empty ) ;
else
presence . Assets . LargeImageText = $"{user.Value.Username}" + ( user . Value . Statistics ? . GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string . Empty ) ;
}
2019-12-18 13:07:53 +08:00
2024-03-26 01:00:42 +08:00
// small image
2022-03-03 13:15:25 +08:00
presence . Assets . SmallImageKey = ruleset . Value . IsLegacyRuleset ( ) ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom" ;
2019-12-18 13:07:53 +08:00
presence . Assets . SmallImageText = ruleset . Value . Name ;
}
2024-03-20 11:40:18 +08:00
private void onJoin ( object sender , JoinMessage args ) = > Scheduler . AddOnce ( ( ) = >
2024-03-01 08:57:32 +08:00
{
2024-03-02 03:32:44 +08:00
game . Window ? . Raise ( ) ;
2024-03-01 08:57:32 +08:00
2024-03-20 04:15:22 +08:00
if ( ! api . IsLoggedIn )
{
2024-03-20 11:40:18 +08:00
login ? . Show ( ) ;
2024-03-20 04:15:22 +08:00
return ;
}
2024-03-11 06:01:26 +08:00
Logger . Log ( $"Received room secret from Discord RPC Client: \" { args . Secret } \ "" , LoggingTarget . Network , LogLevel . Debug ) ;
2024-03-06 14:17:11 +08:00
2024-03-11 06:01:26 +08:00
// 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 ) )
2024-03-06 07:15:53 +08:00
{
2024-03-11 06:01:26 +08:00
Logger . Log ( "Could not join multiplayer room, invitation is invalid or incompatible." , LoggingTarget . Network , LogLevel . Important ) ;
2024-03-06 07:15:53 +08:00
return ;
}
2024-03-01 08:57:32 +08:00
var request = new GetRoomRequest ( roomId ) ;
request . Success + = room = > Schedule ( ( ) = >
{
game . PresentMultiplayerMatch ( room , password ) ;
} ) ;
2024-03-11 06:01:26 +08:00
request . Failure + = _ = > Logger . Log ( $"Could not join multiplayer room, room could not be found (room ID: {roomId})." , LoggingTarget . Network , LogLevel . Important ) ;
2024-03-01 08:57:32 +08:00
api . Queue ( request ) ;
2024-03-20 11:40:18 +08:00
} ) ;
2024-03-01 08:57:32 +08:00
2019-12-25 10:14:40 +08:00
private static readonly int ellipsis_length = Encoding . UTF8 . GetByteCount ( new [ ] { '…' } ) ;
2024-05-17 00:23:19 +08:00
private static string clampLength ( string str )
2019-12-23 17:55:44 +08:00
{
2024-05-20 17:36:39 +08:00
// Empty strings are fine to discord even though single-character strings are not. Make it make sense.
if ( string . IsNullOrEmpty ( str ) )
return str ;
// As above, discord decides that *non-empty* strings shorter than 2 characters cannot possibly be valid input, because... reasons?
2024-05-17 00:23:19 +08:00
// And yes, that is two *characters*, or *codepoints*, not *bytes* as further down below (as determined by empirical testing).
// That seems very questionable, and isn't even documented anywhere. So to *make it* accept such valid input,
// just tack on enough of U+200B ZERO WIDTH SPACEs at the end.
if ( str . Length < 2 )
return str . PadRight ( 2 , ' \ u200B ' ) ;
2019-12-23 18:56:05 +08:00
if ( Encoding . UTF8 . GetByteCount ( str ) < = 128 )
2019-12-25 11:04:28 +08:00
return str ;
ReadOnlyMemory < char > strMem = str . AsMemory ( ) ;
2019-12-23 17:55:44 +08:00
2019-12-23 18:34:12 +08:00
do
{
2019-12-25 11:04:28 +08:00
strMem = strMem [ . . ^ 1 ] ;
} while ( Encoding . UTF8 . GetByteCount ( strMem . Span ) + ellipsis_length > 128 ) ;
2019-12-23 18:34:12 +08:00
2019-12-25 11:04:28 +08:00
return string . Create ( strMem . Length + 1 , strMem , ( span , mem ) = >
{
mem . Span . CopyTo ( span ) ;
span [ ^ 1 ] = '…' ;
} ) ;
2019-12-23 17:55:44 +08:00
}
2019-12-21 22:48:15 +08:00
2024-03-06 07:15:53 +08:00
private static bool tryParseRoomSecret ( string secretJson , out long roomId , out string? password )
2024-03-01 08:57:32 +08:00
{
roomId = 0 ;
password = null ;
2024-03-06 07:15:53 +08:00
RoomSecret ? roomSecret ;
2024-03-01 08:57:32 +08:00
2024-03-06 07:15:53 +08:00
try
{
roomSecret = JsonConvert . DeserializeObject < RoomSecret > ( secretJson ) ;
}
catch
{
2024-03-01 08:57:32 +08:00
return false ;
2024-03-06 07:15:53 +08:00
}
2024-03-01 08:57:32 +08:00
2024-03-06 07:15:53 +08:00
if ( roomSecret = = null ) return false ;
2024-03-01 08:57:32 +08:00
2024-03-06 07:15:53 +08:00
roomId = roomSecret . RoomID ;
password = roomSecret . Password ;
2024-03-01 08:57:32 +08:00
return true ;
}
2024-03-01 13:02:20 +08:00
private static int? getBeatmapID ( UserActivity activity )
2022-06-15 01:25:06 +08:00
{
switch ( activity )
{
case UserActivity . InGame game :
2023-12-07 01:16:45 +08:00
return game . BeatmapID ;
2022-06-15 01:25:06 +08:00
2023-02-13 04:32:17 +08:00
case UserActivity . EditingBeatmap edit :
2023-12-07 01:16:45 +08:00
return edit . BeatmapID ;
2022-06-15 01:25:06 +08:00
}
return null ;
}
2019-12-18 13:07:53 +08:00
protected override void Dispose ( bool isDisposing )
{
2024-03-20 11:40:18 +08:00
if ( multiplayerClient . IsNotNull ( ) )
multiplayerClient . RoomUpdated - = onRoomUpdated ;
2019-12-18 13:07:53 +08:00
client . Dispose ( ) ;
base . Dispose ( isDisposing ) ;
}
2024-03-06 07:15:53 +08:00
private class RoomSecret
{
[JsonProperty(@"roomId", Required = Required.Always)]
public long RoomID { get ; set ; }
[JsonProperty(@"password", Required = Required.AllowNull)]
public string? Password { get ; set ; }
}
2019-12-18 13:07:53 +08:00
}
}