From b91db81473fbf2853f783382ec5ef3dc55ccd7fd Mon Sep 17 00:00:00 2001 From: EnderIce2 Date: Sun, 25 Oct 2020 16:19:43 +0200 Subject: [PATCH] First commit --- DiscordAPI/Configuration.cs | 32 + .../Converters/EnumSnakeCaseConverter.cs | 98 ++ DiscordAPI/Converters/EnumValueAttribute.cs | 16 + DiscordAPI/DiscordRpcClient.cs | 901 ++++++++++++++++++ DiscordAPI/EventType.cs | 34 + DiscordAPI/Events.cs | 94 ++ DiscordAPI/Exceptions/BadPresenceException.cs | 15 + .../InvalidConfigurationException.cs | 15 + DiscordAPI/Exceptions/InvalidPipeException.cs | 16 + .../Exceptions/StringOutOfRangeException.cs | 50 + .../Exceptions/UninitializedException.cs | 24 + DiscordAPI/Helper/BackoffDelay.cs | 71 ++ DiscordAPI/Helper/StringTools.cs | 73 ++ DiscordAPI/IO/Handshake.cs | 23 + DiscordAPI/IO/INamedPipeClient.cs | 56 ++ DiscordAPI/IO/ManagedNamedPipeClient.cs | 509 ++++++++++ DiscordAPI/IO/Opcode.cs | 33 + DiscordAPI/IO/PipeFrame.cs | 204 ++++ DiscordAPI/LICENSE.txt | 21 + DiscordAPI/Logging/ConsoleLogger.cs | 101 ++ DiscordAPI/Logging/FileLogger.cs | 92 ++ DiscordAPI/Logging/ILogger.cs | 46 + DiscordAPI/Logging/LogLevel.cs | 38 + DiscordAPI/Logging/NullLogger.cs | 58 ++ DiscordAPI/Message/CloseMessage.cs | 26 + .../Message/ConnectionEstablishedMessage.cs | 24 + DiscordAPI/Message/ConnectionFailedMessage.cs | 24 + DiscordAPI/Message/ErrorMessage.cs | 76 ++ DiscordAPI/Message/IMessage.cs | 29 + DiscordAPI/Message/JoinMessage.cs | 25 + DiscordAPI/Message/JoinRequestMessage.cs | 25 + DiscordAPI/Message/MessageType.cs | 64 ++ DiscordAPI/Message/PresenceMessage.cs | 47 + DiscordAPI/Message/ReadyMessage.cs | 35 + DiscordAPI/Message/SpectateMessage.cs | 19 + DiscordAPI/Message/SubscribeMessage.cs | 40 + DiscordAPI/Message/UnsubscribeMsesage.cs | 39 + DiscordAPI/RPC/Commands/CloseCommand.cs | 34 + DiscordAPI/RPC/Commands/ICommand.cs | 13 + DiscordAPI/RPC/Commands/PresenceCommand.cs | 32 + DiscordAPI/RPC/Commands/RespondCommand.cs | 32 + DiscordAPI/RPC/Commands/SubscribeCommand.cs | 23 + DiscordAPI/RPC/Payload/ClosePayload.cs | 23 + DiscordAPI/RPC/Payload/Command.cs | 129 +++ DiscordAPI/RPC/Payload/IPayload.cs | 35 + DiscordAPI/RPC/Payload/PayloadArgument.cs | 53 ++ DiscordAPI/RPC/Payload/PayloadEvent.cs | 57 ++ DiscordAPI/RPC/Payload/ServerEvent.cs | 95 ++ DiscordAPI/RPC/RpcConnection.cs | 869 +++++++++++++++++ DiscordAPI/Registry/IUriSchemeCreator.cs | 14 + DiscordAPI/Registry/MacUriSchemeCreator.cs | 54 ++ DiscordAPI/Registry/UnixUriSchemeCreator.cs | 99 ++ DiscordAPI/Registry/UriScheme.cs | 89 ++ .../Registry/WindowsUriSchemeCreator.cs | 87 ++ DiscordAPI/RichPresence.cs | 836 ++++++++++++++++ DiscordAPI/User.cs | 229 +++++ DiscordAPI/Web/WebRPC.cs | 191 ++++ LogWriter.cs | 38 + MainPlugin.cs | 298 ++++++ Properties/AssemblyInfo.cs | 36 + Resgister.txt | 1 + SDRSharpPlugin.DiscordRPC.csproj | 173 ++++ SDRSharpPlugin.DiscordRPC.sln | 25 + SettingsPanel.Designer.cs | 135 +++ SettingsPanel.cs | 86 ++ SettingsPanel.resx | 120 +++ TopWindowMessages.Designer.cs | 108 +++ TopWindowMessages.cs | 72 ++ TopWindowMessages.resx | 120 +++ WelcomeForm.Designer.cs | 135 +++ WelcomeForm.cs | 36 + WelcomeForm.resx | 160 ++++ packages.config | 4 + 73 files changed, 7634 insertions(+) create mode 100644 DiscordAPI/Configuration.cs create mode 100644 DiscordAPI/Converters/EnumSnakeCaseConverter.cs create mode 100644 DiscordAPI/Converters/EnumValueAttribute.cs create mode 100644 DiscordAPI/DiscordRpcClient.cs create mode 100644 DiscordAPI/EventType.cs create mode 100644 DiscordAPI/Events.cs create mode 100644 DiscordAPI/Exceptions/BadPresenceException.cs create mode 100644 DiscordAPI/Exceptions/InvalidConfigurationException.cs create mode 100644 DiscordAPI/Exceptions/InvalidPipeException.cs create mode 100644 DiscordAPI/Exceptions/StringOutOfRangeException.cs create mode 100644 DiscordAPI/Exceptions/UninitializedException.cs create mode 100644 DiscordAPI/Helper/BackoffDelay.cs create mode 100644 DiscordAPI/Helper/StringTools.cs create mode 100644 DiscordAPI/IO/Handshake.cs create mode 100644 DiscordAPI/IO/INamedPipeClient.cs create mode 100644 DiscordAPI/IO/ManagedNamedPipeClient.cs create mode 100644 DiscordAPI/IO/Opcode.cs create mode 100644 DiscordAPI/IO/PipeFrame.cs create mode 100644 DiscordAPI/LICENSE.txt create mode 100644 DiscordAPI/Logging/ConsoleLogger.cs create mode 100644 DiscordAPI/Logging/FileLogger.cs create mode 100644 DiscordAPI/Logging/ILogger.cs create mode 100644 DiscordAPI/Logging/LogLevel.cs create mode 100644 DiscordAPI/Logging/NullLogger.cs create mode 100644 DiscordAPI/Message/CloseMessage.cs create mode 100644 DiscordAPI/Message/ConnectionEstablishedMessage.cs create mode 100644 DiscordAPI/Message/ConnectionFailedMessage.cs create mode 100644 DiscordAPI/Message/ErrorMessage.cs create mode 100644 DiscordAPI/Message/IMessage.cs create mode 100644 DiscordAPI/Message/JoinMessage.cs create mode 100644 DiscordAPI/Message/JoinRequestMessage.cs create mode 100644 DiscordAPI/Message/MessageType.cs create mode 100644 DiscordAPI/Message/PresenceMessage.cs create mode 100644 DiscordAPI/Message/ReadyMessage.cs create mode 100644 DiscordAPI/Message/SpectateMessage.cs create mode 100644 DiscordAPI/Message/SubscribeMessage.cs create mode 100644 DiscordAPI/Message/UnsubscribeMsesage.cs create mode 100644 DiscordAPI/RPC/Commands/CloseCommand.cs create mode 100644 DiscordAPI/RPC/Commands/ICommand.cs create mode 100644 DiscordAPI/RPC/Commands/PresenceCommand.cs create mode 100644 DiscordAPI/RPC/Commands/RespondCommand.cs create mode 100644 DiscordAPI/RPC/Commands/SubscribeCommand.cs create mode 100644 DiscordAPI/RPC/Payload/ClosePayload.cs create mode 100644 DiscordAPI/RPC/Payload/Command.cs create mode 100644 DiscordAPI/RPC/Payload/IPayload.cs create mode 100644 DiscordAPI/RPC/Payload/PayloadArgument.cs create mode 100644 DiscordAPI/RPC/Payload/PayloadEvent.cs create mode 100644 DiscordAPI/RPC/Payload/ServerEvent.cs create mode 100644 DiscordAPI/RPC/RpcConnection.cs create mode 100644 DiscordAPI/Registry/IUriSchemeCreator.cs create mode 100644 DiscordAPI/Registry/MacUriSchemeCreator.cs create mode 100644 DiscordAPI/Registry/UnixUriSchemeCreator.cs create mode 100644 DiscordAPI/Registry/UriScheme.cs create mode 100644 DiscordAPI/Registry/WindowsUriSchemeCreator.cs create mode 100644 DiscordAPI/RichPresence.cs create mode 100644 DiscordAPI/User.cs create mode 100644 DiscordAPI/Web/WebRPC.cs create mode 100644 LogWriter.cs create mode 100644 MainPlugin.cs create mode 100644 Properties/AssemblyInfo.cs create mode 100644 Resgister.txt create mode 100644 SDRSharpPlugin.DiscordRPC.csproj create mode 100644 SDRSharpPlugin.DiscordRPC.sln create mode 100644 SettingsPanel.Designer.cs create mode 100644 SettingsPanel.cs create mode 100644 SettingsPanel.resx create mode 100644 TopWindowMessages.Designer.cs create mode 100644 TopWindowMessages.cs create mode 100644 TopWindowMessages.resx create mode 100644 WelcomeForm.Designer.cs create mode 100644 WelcomeForm.cs create mode 100644 WelcomeForm.resx create mode 100644 packages.config diff --git a/DiscordAPI/Configuration.cs b/DiscordAPI/Configuration.cs new file mode 100644 index 0000000..33378c3 --- /dev/null +++ b/DiscordAPI/Configuration.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC +{ + /// + /// Configuration of the current RPC connection + /// + public class Configuration + { + /// + /// The Discord API endpoint that should be used. + /// + [JsonProperty("api_endpoint")] + public string ApiEndpoint { get; set; } + + /// + /// The CDN endpoint + /// + [JsonProperty("cdn_host")] + public string CdnHost { get; set; } + + /// + /// The type of enviroment the connection on. Usually Production. + /// + [JsonProperty("enviroment")] + public string Enviroment { get; set; } + } +} diff --git a/DiscordAPI/Converters/EnumSnakeCaseConverter.cs b/DiscordAPI/Converters/EnumSnakeCaseConverter.cs new file mode 100644 index 0000000..d6cecd5 --- /dev/null +++ b/DiscordAPI/Converters/EnumSnakeCaseConverter.cs @@ -0,0 +1,98 @@ +using DiscordRPC.Helper; +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Reflection; + +namespace DiscordRPC.Converters +{ + /// + /// Converts enums with the into Json friendly terms. + /// + internal class EnumSnakeCaseConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType.IsEnum; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.Value == null) return null; + + object val = null; + if (TryParseEnum(objectType, (string)reader.Value, out val)) + return val; + + return existingValue; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var enumtype = value.GetType(); + var name = Enum.GetName(enumtype, value); + + //Get each member and look for hte correct one + var members = enumtype.GetMembers(BindingFlags.Public | BindingFlags.Static); + foreach (var m in members) + { + if (m.Name.Equals(name)) + { + var attributes = m.GetCustomAttributes(typeof(EnumValueAttribute), true); + if (attributes.Length > 0) + { + name = ((EnumValueAttribute)attributes[0]).Value; + } + } + } + + writer.WriteValue(name); + } + + + public bool TryParseEnum(Type enumType, string str, out object obj) + { + //Make sure the string isn;t null + if (str == null) + { + obj = null; + return false; + } + + //Get the real type + Type type = enumType; + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) + type = type.GetGenericArguments().First(); + + //Make sure its actually a enum + if (!type.IsEnum) + { + obj = null; + return false; + } + + + //Get each member and look for hte correct one + var members = type.GetMembers(BindingFlags.Public | BindingFlags.Static); + foreach (var m in members) + { + var attributes = m.GetCustomAttributes(typeof(EnumValueAttribute), true); + foreach(var a in attributes) + { + var enumval = (EnumValueAttribute)a; + if (str.Equals(enumval.Value)) + { + obj = Enum.Parse(type, m.Name, ignoreCase: true); + + return true; + } + } + } + + //We failed + obj = null; + return false; + } + + } +} diff --git a/DiscordAPI/Converters/EnumValueAttribute.cs b/DiscordAPI/Converters/EnumValueAttribute.cs new file mode 100644 index 0000000..35a8c08 --- /dev/null +++ b/DiscordAPI/Converters/EnumValueAttribute.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Converters +{ + internal class EnumValueAttribute : Attribute + { + public string Value { get; set; } + public EnumValueAttribute(string value) + { + this.Value = value; + } + } +} diff --git a/DiscordAPI/DiscordRpcClient.cs b/DiscordAPI/DiscordRpcClient.cs new file mode 100644 index 0000000..9bc4798 --- /dev/null +++ b/DiscordAPI/DiscordRpcClient.cs @@ -0,0 +1,901 @@ +using DiscordRPC.Events; +using DiscordRPC.Exceptions; +using DiscordRPC.IO; +using DiscordRPC.Logging; +using DiscordRPC.Message; +using DiscordRPC.Registry; +using DiscordRPC.RPC; +using DiscordRPC.RPC.Commands; +using System; + +namespace DiscordRPC +{ + + /// + /// A Discord RPC Client which is used to send Rich Presence updates and receive Join / Spectate events. + /// + public sealed class DiscordRpcClient : IDisposable + { + #region Properties + + + /// + /// Gets a value indicating if the client has registered a URI Scheme. If this is false, Join / Spectate events will fail. + /// To register a URI Scheme, call . + /// + public bool HasRegisteredUriScheme { get; private set; } + + /// + /// Gets the Application ID of the RPC Client. + /// + public string ApplicationID { get; private set; } + + /// + /// Gets the Steam ID of the RPC Client. This value can be null if none was supplied. + /// + public string SteamID { get; private set; } + + /// + /// Gets the ID of the process used to run the RPC Client. Discord tracks this process ID and waits for its termination. Defaults to the current application process ID. + /// + public int ProcessID { get; private set; } + + /// + /// The maximum size of the message queue received from Discord. + /// + public int MaxQueueSize { get; private set; } + + /// + /// The dispose state of the client object. + /// + public bool IsDisposed { get; private set; } + + /// + /// The logger used this client and its associated components. are not called safely and can come from any thread. It is upto the to account for this and apply appropriate thread safe methods. + /// + public ILogger Logger + { + get { return _logger; } + set + { + this._logger = value; + if (connection != null) connection.Logger = value; + } + } + private ILogger _logger; + + /// + /// Indicates if the client will automatically invoke the events without having to be called. + /// + public bool AutoEvents { get; private set; } + + /// + /// Skips sending presences that are identical to the current one. + /// + public bool SkipIdenticalPresence { get; set; } + #endregion + + /// + /// The pipe the discord client is on, ranging from 0 to 9. Use -1 to scan through all pipes. + /// This property can be used for testing multiple clients. For example, if a Discord Client was on pipe 0, the Discord Canary is most likely on pipe 1. + /// + public int TargetPipe { get; private set; } + + private RpcConnection connection; + + /// + /// The current presence that the client has. Gets set with and updated on . + /// + public RichPresence CurrentPresence { get; private set; } + + /// + /// Current subscription to events. Gets set with , and updated on , . + /// + public EventType Subscription { get; private set; } + + /// + /// The current discord user. This is updated with the ready event and will be null until the event is fired from the connection. + /// + public User CurrentUser { get; private set; } + + /// + /// The current configuration the connection is using. Only becomes available after a ready event. + /// + public Configuration Configuration { get; private set; } + + /// + /// Represents if the client has been + /// + public bool IsInitialized { get; private set; } + + /// + /// Forces the connection to shutdown gracefully instead of just aborting the connection. + /// This option helps prevents ghosting in applications where the Process ID is a host and the game is executed within the host (ie: the Unity3D editor). This will tell Discord that we have no presence and we are closing the connection manually, instead of waiting for the process to terminate. + /// + public bool ShutdownOnly + { + get { return _shutdownOnly; } + set + { + _shutdownOnly = value; + if (connection != null) connection.ShutdownOnly = value; + } + } + private bool _shutdownOnly = true; + private object _sync = new object(); + + #region Events + + /// + /// Called when the discord client is ready to send and receive messages. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnReadyEvent OnReady; + + /// + /// Called when connection to the Discord Client is lost. The connection will remain close and unready to accept messages until the Ready event is called again. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnCloseEvent OnClose; + + /// + /// Called when a error has occured during the transmission of a message. For example, if a bad Rich Presence payload is sent, this event will be called explaining what went wrong. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnErrorEvent OnError; + + /// + /// Called when the Discord Client has updated the presence. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnPresenceUpdateEvent OnPresenceUpdate; + + /// + /// Called when the Discord Client has subscribed to an event. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnSubscribeEvent OnSubscribe; + + /// + /// Called when the Discord Client has unsubscribed from an event. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnUnsubscribeEvent OnUnsubscribe; + + /// + /// Called when the Discord Client wishes for this process to join a game. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnJoinEvent OnJoin; + + /// + /// Called when the Discord Client wishes for this process to spectate a game. + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnSpectateEvent OnSpectate; + + /// + /// Called when another discord user requests permission to join this game. + /// This event is not invoked untill is executed. + /// + public event OnJoinRequestedEvent OnJoinRequested; + + /// + /// The connection to the discord client was succesfull. This is called before . + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnConnectionEstablishedEvent OnConnectionEstablished; + + /// + /// Failed to establish any connection with discord. Discord is potentially not running? + /// If is true then this event will execute on a different thread. If it is not true however, then this event is not invoked untill and will be on the calling thread. + /// + public event OnConnectionFailedEvent OnConnectionFailed; + + /// + /// The RPC Connection has sent a message. Called before any other event and executed from the RPC Thread. + /// + public event OnRpcMessageEvent OnRpcMessage; + #endregion + + #region Initialization + + /// + /// Creates a new Discord RPC Client which can be used to send Rich Presence and receive Join / Spectate events. + /// + /// The ID of the application created at discord's developers portal. + public DiscordRpcClient(string applicationID) : this(applicationID, -1) { } + + /// + /// Creates a new Discord RPC Client which can be used to send Rich Presence and receive Join / Spectate events. This constructor exposes more advance features such as custom NamedPipeClients and Loggers. + /// + /// The ID of the application created at discord's developers portal. + /// The pipe to connect too. If -1, then the client will scan for the first available instance of Discord. + /// The logger used to report messages. If null, then a will be created and logs will be ignored. + /// Should events be automatically invoked from the RPC Thread as they arrive from discord? + /// The pipe client to use and communicate to discord through. If null, the default will be used. + public DiscordRpcClient(string applicationID, int pipe = -1, ILogger logger = null, bool autoEvents = true, INamedPipeClient client = null) + { + //Make sure appID is NOT null. + if (string.IsNullOrEmpty(applicationID)) + throw new ArgumentNullException("applicationID"); + + //Ensure we actually have json ahead of time. If statement is pointless, but its there just to ensure there is no unused warnings. + var jsonConverterType = typeof(Newtonsoft.Json.JsonConverter); + if (jsonConverterType == null) throw new Exception("JsonConverter Type Not Found"); + + //Store the properties + ApplicationID = applicationID.Trim(); + TargetPipe = pipe; + ProcessID = System.Diagnostics.Process.GetCurrentProcess().Id; + HasRegisteredUriScheme = false; + AutoEvents = autoEvents; + SkipIdenticalPresence = true; + + //Prepare the logger + _logger = logger ?? new NullLogger(); + + //Create the RPC client, giving it the important details + connection = new RpcConnection(ApplicationID, ProcessID, TargetPipe, client ?? new ManagedNamedPipeClient(), autoEvents ? 0 : 128U) + { + ShutdownOnly = _shutdownOnly, + Logger = _logger + }; + + //Subscribe to its event + connection.OnRpcMessage += (sender, msg) => + { + if (OnRpcMessage != null) + OnRpcMessage.Invoke(this, msg); + + if (AutoEvents) + ProcessMessage(msg); + }; + } + + #endregion + + #region Message Handling + /// + /// Dequeues all the messages from Discord, processes them and then invoke appropriate event handlers. This will process the message and update the internal state before invoking the events. Returns the messages that were invoked in the order they were invoked. + /// This method cannot be used if is enabled. + /// + /// Returns the messages that were invoked and in the order they were invoked. + public IMessage[] Invoke() + { + if (AutoEvents) + { + Logger.Error("Cannot Invoke client when AutomaticallyInvokeEvents has been set."); + return new IMessage[0]; + //throw new InvalidOperationException("Cannot Invoke client when AutomaticallyInvokeEvents has been set."); + } + + //Dequeue all the messages and process them + IMessage[] messages = connection.DequeueMessages(); + for (int i = 0; i < messages.Length; i++) + { + //Do a bit of pre-processing + var message = messages[i]; + ProcessMessage(message); + } + + //Finally, return the messages + return messages; + } + + /// + /// Processes the message, updating our internal state and then invokes the events. + /// + /// + private void ProcessMessage(IMessage message) + { + if (message == null) return; + switch (message.Type) + { + //We got a update, so we will update our current presence + case MessageType.PresenceUpdate: + lock (_sync) + { + var pm = message as PresenceMessage; + if (pm != null) + { + //We need to merge these presences together + if (CurrentPresence == null) + { + CurrentPresence = pm.Presence; + } + else if (pm.Presence == null) + { + CurrentPresence = null; + } + else + { + CurrentPresence.Merge(pm.Presence); + } + + //Update the message + pm.Presence = CurrentPresence; + } + } + + break; + + //Update our configuration + case MessageType.Ready: + var rm = message as ReadyMessage; + if (rm != null) + { + lock (_sync) + { + Configuration = rm.Configuration; + CurrentUser = rm.User; + } + + //Resend our presence and subscription + SynchronizeState(); + } + break; + + //Update the request's CDN for the avatar helpers + case MessageType.JoinRequest: + if (Configuration != null) + { + //Update the User object within the join request if the current Cdn + var jrm = message as JoinRequestMessage; + if (jrm != null) jrm.User.SetConfiguration(Configuration); + } + break; + + case MessageType.Subscribe: + lock (_sync) + { + var sub = message as SubscribeMessage; + Subscription |= sub.Event; + } + break; + + case MessageType.Unsubscribe: + lock (_sync) + { + var unsub = message as UnsubscribeMessage; + Subscription &= ~unsub.Event; + } + break; + + //We got a message we dont know what to do with. + default: + break; + } + + //Invoke the appropriate methods + switch (message.Type) + { + case MessageType.Ready: + if (OnReady != null) OnReady.Invoke(this, message as ReadyMessage); + break; + + case MessageType.Close: + if (OnClose != null) OnClose.Invoke(this, message as CloseMessage); + break; + + case MessageType.Error: + if (OnError != null) OnError.Invoke(this, message as ErrorMessage); + break; + + case MessageType.PresenceUpdate: + if (OnPresenceUpdate != null) OnPresenceUpdate.Invoke(this, message as PresenceMessage); + break; + + case MessageType.Subscribe: + if (OnSubscribe != null) OnSubscribe.Invoke(this, message as SubscribeMessage); + break; + + case MessageType.Unsubscribe: + if (OnUnsubscribe != null) OnUnsubscribe.Invoke(this, message as UnsubscribeMessage); + break; + + case MessageType.Join: + if (OnJoin != null) OnJoin.Invoke(this, message as JoinMessage); + break; + + case MessageType.Spectate: + if (OnSpectate != null) OnSpectate.Invoke(this, message as SpectateMessage); + break; + + case MessageType.JoinRequest: + if (OnJoinRequested != null) OnJoinRequested.Invoke(this, message as JoinRequestMessage); + break; + + case MessageType.ConnectionEstablished: + if (OnConnectionEstablished != null) OnConnectionEstablished.Invoke(this, message as ConnectionEstablishedMessage); + break; + + case MessageType.ConnectionFailed: + if (OnConnectionFailed != null) OnConnectionFailed.Invoke(this, message as ConnectionFailedMessage); + break; + + default: + //This in theory can never happen, but its a good idea as a reminder to update this part of the library if any new messages are implemented. + Logger.Error("Message was queued with no appropriate handle! {0}", message.Type); + break; + } + } + #endregion + + /// + /// Respond to a Join Request. All requests will timeout after 30 seconds. + /// Because of the 30 second timeout, it is recommended to call faster than every 15 seconds to give your users adequate time to respond to the request. + /// + /// The request that is being responded too. + /// Accept the join request. + public void Respond(JoinRequestMessage request, bool acceptRequest) + { + if (IsDisposed) + throw new ObjectDisposedException("Discord IPC Client"); + + if (connection == null) + throw new ObjectDisposedException("Connection", "Cannot initialize as the connection has been deinitialized"); + + if (!IsInitialized) + throw new UninitializedException(); + + connection.EnqueueCommand(new RespondCommand() { Accept = acceptRequest, UserID = request.User.ID.ToString() }); + } + + /// + /// Sets the Rich Presence. + /// + /// The Rich Presence to set on the current Discord user. + public void SetPresence(RichPresence presence) + { + if (IsDisposed) + throw new ObjectDisposedException("Discord IPC Client"); + + if (connection == null) + throw new ObjectDisposedException("Connection", "Cannot initialize as the connection has been deinitialized"); + + if (!IsInitialized) + Logger.Warning("The client is not yet initialized, storing the presence as a state instead."); + + //Send the event + if (!presence) + { + //Clear the presence + if (!SkipIdenticalPresence || CurrentPresence != null) + connection.EnqueueCommand(new PresenceCommand() { PID = this.ProcessID, Presence = null }); + } + else + { + //Send valid presence + //Validate the presence with our settings + if (presence.HasSecrets() && !HasRegisteredUriScheme) + throw new BadPresenceException("Cannot send a presence with secrets as this object has not registered a URI scheme. Please enable the uri scheme registration in the DiscordRpcClient constructor."); + + if (presence.HasParty() && presence.Party.Max < presence.Party.Size) + throw new BadPresenceException("Presence maximum party size cannot be smaller than the current size."); + + if (presence.HasSecrets() && !presence.HasParty()) + Logger.Warning("The presence has set the secrets but no buttons will show as there is no party available."); + + //Send the presence, but only if we are not skipping + if (!SkipIdenticalPresence || !presence.Matches(CurrentPresence)) + connection.EnqueueCommand(new PresenceCommand() { PID = this.ProcessID, Presence = presence.Clone() }); + } + + //Update our local store + lock (_sync) { CurrentPresence = presence != null ? presence.Clone() : null; } + } + + #region Updates + /// + /// Updates only the of the and sends the updated presence to Discord. Returns the newly edited Rich Presence. + /// + /// The details of the Rich Presence + /// Updated Rich Presence + public RichPresence UpdateDetails(string details) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + CurrentPresence.Details = details; + SetPresence(CurrentPresence); + } + return CurrentPresence; + } + /// + /// Updates only the of the and sends the updated presence to Discord. Returns the newly edited Rich Presence. + /// + /// The state of the Rich Presence + /// Updated Rich Presence + public RichPresence UpdateState(string state) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + CurrentPresence.State = state; + SetPresence(CurrentPresence); + } + return CurrentPresence; + } + /// + /// Updates only the of the and sends the updated presence to Discord. Returns the newly edited Rich Presence. + /// + /// The party of the Rich Presence + /// Updated Rich Presence + public RichPresence UpdateParty(Party party) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + CurrentPresence.Party = party; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + /// + /// Updates the of the and sends the update presence to Discord. Returns the newly edited Rich Presence. + /// Will return null if no presence exists and will throw a new if the Party does not exist. + /// + /// The new size of the party. It cannot be greater than + /// Updated Rich Presence + public RichPresence UpdatePartySize(int size) + { + if (!IsInitialized) + throw new UninitializedException(); + + if (CurrentPresence == null) return null; + if (CurrentPresence.Party == null) + throw new BadPresenceException("Cannot set the size of the party if the party does not exist"); + + try { UpdatePartySize(size, CurrentPresence.Party.Max); } catch (Exception) { throw; } + return CurrentPresence; + } + /// + /// Updates the of the and sends the update presence to Discord. Returns the newly edited Rich Presence. + /// Will return null if no presence exists and will throw a new if the Party does not exist. + /// + /// The new size of the party. It cannot be greater than + /// The new size of the party. It cannot be smaller than + /// Updated Rich Presence + public RichPresence UpdatePartySize(int size, int max) + { + if (!IsInitialized) + throw new UninitializedException(); + + if (CurrentPresence == null) return null; + if (CurrentPresence.Party == null) + throw new BadPresenceException("Cannot set the size of the party if the party does not exist"); + + lock (_sync) + { + CurrentPresence.Party.Size = size; + CurrentPresence.Party.Max = max; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Updates the large of the and sends the updated presence to Discord. Both and are optional and will be ignored it null. + /// + /// Optional: The new key to set the asset too + /// Optional: The new tooltip to display on the asset + /// Updated Rich Presence + public RichPresence UpdateLargeAsset(string key = null, string tooltip = null) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + if (CurrentPresence.Assets == null) CurrentPresence.Assets = new Assets(); + CurrentPresence.Assets.LargeImageKey = key ?? CurrentPresence.Assets.LargeImageKey; + CurrentPresence.Assets.LargeImageText = tooltip ?? CurrentPresence.Assets.LargeImageText; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Updates the small of the and sends the updated presence to Discord. Both and are optional and will be ignored it null. + /// + /// Optional: The new key to set the asset too + /// Optional: The new tooltip to display on the asset + /// Updated Rich Presence + public RichPresence UpdateSmallAsset(string key = null, string tooltip = null) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + if (CurrentPresence.Assets == null) CurrentPresence.Assets = new Assets(); + CurrentPresence.Assets.SmallImageKey = key ?? CurrentPresence.Assets.SmallImageKey; + CurrentPresence.Assets.SmallImageText = tooltip ?? CurrentPresence.Assets.SmallImageText; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Updates the of the and sends the updated presence to Discord. Will override previous secret entirely. + /// + /// The new secret to send to discord. + /// Updated Rich Presence + public RichPresence UpdateSecrets(Secrets secrets) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + CurrentPresence.Secrets = secrets; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Sets the start time of the to now and sends the updated presence to Discord. + /// + /// Updated Rich Presence + public RichPresence UpdateStartTime() { try { return UpdateStartTime(DateTime.UtcNow); } catch (Exception) { throw; } } + + /// + /// Sets the start time of the and sends the updated presence to Discord. + /// + /// The new time for the start + /// Updated Rich Presence + public RichPresence UpdateStartTime(DateTime time) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + if (CurrentPresence.Timestamps == null) CurrentPresence.Timestamps = new Timestamps(); + CurrentPresence.Timestamps.Start = time; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Sets the end time of the to now and sends the updated presence to Discord. + /// + /// Updated Rich Presence + public RichPresence UpdateEndTime() { try { return UpdateEndTime(DateTime.UtcNow); } catch (Exception) { throw; } } + + /// + /// Sets the end time of the and sends the updated presence to Discord. + /// + /// The new time for the end + /// Updated Rich Presence + public RichPresence UpdateEndTime(DateTime time) + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) CurrentPresence = new RichPresence(); + if (CurrentPresence.Timestamps == null) CurrentPresence.Timestamps = new Timestamps(); + CurrentPresence.Timestamps.End = time; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + + /// + /// Sets the start and end time of to null and sends it to Discord. + /// + /// Updated Rich Presence + public RichPresence UpdateClearTime() + { + if (!IsInitialized) + throw new UninitializedException(); + + lock (_sync) + { + if (CurrentPresence == null) return null; + CurrentPresence.Timestamps = null; + } + + SetPresence(CurrentPresence); + return CurrentPresence; + } + #endregion + + /// + /// Clears the Rich Presence. Use this just before disposal to prevent ghosting. + /// + public void ClearPresence() + { + if (IsDisposed) + throw new ObjectDisposedException("Discord IPC Client"); + + if (!IsInitialized) + throw new UninitializedException(); + + if (connection == null) + throw new ObjectDisposedException("Connection", "Cannot initialize as the connection has been deinitialized"); + + //Just a wrapper function for sending null + SetPresence(null); + } + + #region Subscriptions + + /// + /// Registers the application executable to a custom URI Scheme. + /// This is required for the Join and Spectate features. Discord will run this custom URI Scheme to launch your application when a user presses either of the buttons. + /// + /// Optional Steam ID. If supplied, Discord will launch the game through steam instead of directly calling it. + /// The path to the executable. If null, the path to the current executable will be used instead. + /// + public bool RegisterUriScheme(string steamAppID = null, string executable = null) + { + var urischeme = new UriSchemeRegister(_logger, ApplicationID, steamAppID, executable); + return HasRegisteredUriScheme = urischeme.RegisterUriScheme(); + } + + /// + /// Subscribes to an event sent from discord. Used for Join / Spectate feature. + /// Requires the UriScheme to be registered. + /// + /// The event type to subscribe to + public void Subscribe(EventType type) { SetSubscription(Subscription | type); } + + /// + /// + /// + /// + [System.Obsolete("Replaced with Unsubscribe", true)] + public void Unubscribe(EventType type) { SetSubscription(Subscription & ~type); } + + /// + /// Unsubscribe from the event sent by discord. Used for Join / Spectate feature. + /// Requires the UriScheme to be registered. + /// + /// The event type to unsubscribe from + public void Unsubscribe(EventType type) { SetSubscription(Subscription & ~type); } + + /// + /// Sets the subscription to the events sent from Discord. + /// Requires the UriScheme to be registered. + /// + /// The new subscription as a flag. Events selected in the flag will be subscribed too and the other events will be unsubscribed. + public void SetSubscription(EventType type) + { + if (IsInitialized) + { + //Calculate what needs to be unsubscrinbed + SubscribeToTypes(Subscription & ~type, true); + SubscribeToTypes(~Subscription & type, false); + } + else + { + Logger.Warning("Client has not yet initialized, but events are being subscribed too. Storing them as state instead."); + } + + lock (_sync) + { + Subscription = type; + } + } + + /// + /// Simple helper function that will subscribe to the specified types in the flag. + /// + /// The flag to subscribe to + /// Represents if the unsubscribe payload should be sent instead. + private void SubscribeToTypes(EventType type, bool isUnsubscribe) + { + //Because of SetSubscription, this can actually be none as there is no differences. + //If that is the case, we should just stop here + if (type == EventType.None) return; + + //We cannot do anything if we are disposed or missing our connection. + if (IsDisposed) + throw new ObjectDisposedException("Discord IPC Client"); + + if (!IsInitialized) + throw new UninitializedException(); + + if (connection == null) + throw new ObjectDisposedException("Connection", "Cannot initialize as the connection has been deinitialized"); + + //We dont have the Uri Scheme registered, we should throw a exception to tell the user. + if (!HasRegisteredUriScheme) + throw new InvalidConfigurationException("Cannot subscribe/unsubscribe to an event as this application has not registered a URI Scheme. Call RegisterUriScheme()."); + + //Add the subscribe command to be sent when the connection is able too + if ((type & EventType.Spectate) == EventType.Spectate) + connection.EnqueueCommand(new SubscribeCommand() { Event = RPC.Payload.ServerEvent.ActivitySpectate, IsUnsubscribe = isUnsubscribe }); + + if ((type & EventType.Join) == EventType.Join) + connection.EnqueueCommand(new SubscribeCommand() { Event = RPC.Payload.ServerEvent.ActivityJoin, IsUnsubscribe = isUnsubscribe }); + + if ((type & EventType.JoinRequest) == EventType.JoinRequest) + connection.EnqueueCommand(new SubscribeCommand() { Event = RPC.Payload.ServerEvent.ActivityJoinRequest, IsUnsubscribe = isUnsubscribe }); + } + + #endregion + + /// + /// Resends the current presence and subscription. This is used when Ready is called to keep the current state within discord. + /// + public void SynchronizeState() + { + //Cannot sync over uninitialized connection + if (!IsInitialized) + throw new UninitializedException(); + + //Set the presence and if we have registered the uri scheme, resubscribe. + SetPresence(CurrentPresence); + if (HasRegisteredUriScheme) + SubscribeToTypes(Subscription, false); + } + + /// + /// Attempts to initalize a connection to the Discord IPC. + /// + /// + public bool Initialize() + { + if (IsDisposed) + throw new ObjectDisposedException("Discord IPC Client"); + + if (IsInitialized) + throw new UninitializedException("Cannot initialize a client that is already initialized"); + + if (connection == null) + throw new ObjectDisposedException("Connection", "Cannot initialize as the connection has been deinitialized"); + + return IsInitialized = connection.AttemptConnection(); + } + + /// + /// Attempts to disconnect and deinitialize the IPC connection while retaining the settings. + /// + public void Deinitialize() + { + if (!IsInitialized) + throw new UninitializedException("Cannot deinitialize a client that has not been initalized."); + + connection.Close(); + IsInitialized = false; + } + + /// + /// Terminates the connection to Discord and disposes of the object. + /// + public void Dispose() + { + if (IsDisposed) return; + if (IsInitialized) Deinitialize(); + IsDisposed = true; + } + + } +} diff --git a/DiscordAPI/EventType.cs b/DiscordAPI/EventType.cs new file mode 100644 index 0000000..61a8aea --- /dev/null +++ b/DiscordAPI/EventType.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC +{ + /// + /// The type of event receieved by the RPC. A flag type that can be combined. + /// + [System.Flags] + public enum EventType + { + /// + /// No event + /// + None = 0, + + /// + /// Called when the Discord Client wishes to enter a game to spectate + /// + Spectate = 0x1, + + /// + /// Called when the Discord Client wishes to enter a game to play. + /// + Join = 0x2, + + /// + /// Called when another Discord Client has requested permission to join this game. + /// + JoinRequest = 0x4 + } +} diff --git a/DiscordAPI/Events.cs b/DiscordAPI/Events.cs new file mode 100644 index 0000000..b28c7fe --- /dev/null +++ b/DiscordAPI/Events.cs @@ -0,0 +1,94 @@ +using DiscordRPC.Message; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Events +{ + /// + /// Called when the Discord Client is ready to send and receive messages. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnReadyEvent(object sender, ReadyMessage args); + + /// + /// Called when connection to the Discord Client is lost. The connection will remain close and unready to accept messages until the Ready event is called again. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnCloseEvent(object sender, CloseMessage args); + + /// + /// Called when a error has occured during the transmission of a message. For example, if a bad Rich Presence payload is sent, this event will be called explaining what went wrong. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnErrorEvent(object sender, ErrorMessage args); + + /// + /// Called when the Discord Client has updated the presence. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnPresenceUpdateEvent(object sender, PresenceMessage args); + + /// + /// Called when the Discord Client has subscribed to an event. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnSubscribeEvent(object sender, SubscribeMessage args); + + /// + /// Called when the Discord Client has unsubscribed from an event. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnUnsubscribeEvent(object sender, UnsubscribeMessage args); + + /// + /// Called when the Discord Client wishes for this process to join a game. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnJoinEvent(object sender, JoinMessage args); + + /// + /// Called when the Discord Client wishes for this process to spectate a game. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnSpectateEvent(object sender, SpectateMessage args); + + /// + /// Called when another discord user requests permission to join this game. + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnJoinRequestedEvent(object sender, JoinRequestMessage args); + + + /// + /// The connection to the discord client was succesfull. This is called before . + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnConnectionEstablishedEvent(object sender, ConnectionEstablishedMessage args); + + /// + /// Failed to establish any connection with discord. Discord is potentially not running? + /// + /// The Discord client handler that sent this event + /// The arguments supplied with the event + public delegate void OnConnectionFailedEvent(object sender, ConnectionFailedMessage args); + + + /// + /// A RPC Message is received. + /// + /// The handler that sent this event + /// The raw message from the RPC + public delegate void OnRpcMessageEvent(object sender, IMessage msg); +} diff --git a/DiscordAPI/Exceptions/BadPresenceException.cs b/DiscordAPI/Exceptions/BadPresenceException.cs new file mode 100644 index 0000000..f5acf6c --- /dev/null +++ b/DiscordAPI/Exceptions/BadPresenceException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Exceptions +{ + /// + /// A BadPresenceException is thrown when invalid, incompatible or conflicting properties and is unable to be sent. + /// + public class BadPresenceException : Exception + { + internal BadPresenceException(string message) : base(message) { } + } +} diff --git a/DiscordAPI/Exceptions/InvalidConfigurationException.cs b/DiscordAPI/Exceptions/InvalidConfigurationException.cs new file mode 100644 index 0000000..37f00e0 --- /dev/null +++ b/DiscordAPI/Exceptions/InvalidConfigurationException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Exceptions +{ + /// + /// A InvalidConfigurationException is thrown when trying to perform a action that conflicts with the current configuration. + /// + public class InvalidConfigurationException : Exception + { + internal InvalidConfigurationException(string message) : base(message) { } + } +} diff --git a/DiscordAPI/Exceptions/InvalidPipeException.cs b/DiscordAPI/Exceptions/InvalidPipeException.cs new file mode 100644 index 0000000..2637e2a --- /dev/null +++ b/DiscordAPI/Exceptions/InvalidPipeException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Exceptions +{ + /// + /// The exception that is thrown when a error occurs while communicating with a pipe or when a connection attempt fails. + /// + [System.Obsolete("Not actually used anywhere")] + public class InvalidPipeException : Exception + { + internal InvalidPipeException(string message) : base(message) { } + } +} diff --git a/DiscordAPI/Exceptions/StringOutOfRangeException.cs b/DiscordAPI/Exceptions/StringOutOfRangeException.cs new file mode 100644 index 0000000..e9a7b09 --- /dev/null +++ b/DiscordAPI/Exceptions/StringOutOfRangeException.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Exceptions +{ + /// + /// A StringOutOfRangeException is thrown when the length of a string exceeds the allowed limit. + /// + public class StringOutOfRangeException : Exception + { + /// + /// Maximum length the string is allowed to be. + /// + public int MaximumLength { get; private set; } + + /// + /// Minimum length the string is allowed to be. + /// + public int MinimumLength { get; private set; } + + /// + /// Creates a new string out of range exception with a range of min to max and a custom message + /// + /// The custom message + /// Minimum length the string can be + /// Maximum length the string can be + internal StringOutOfRangeException(string message, int min, int max) : base(message) + { + MinimumLength = min; + MaximumLength = max; + } + + /// + /// Creates a new sting out of range exception with a range of min to max + /// + /// + /// + internal StringOutOfRangeException(int minumum, int max) + : this("Length of string is out of range. Expected a value between " + minumum + " and " + max, minumum, max) { } + + /// + /// Creates a new sting out of range exception with a range of 0 to max + /// + /// + internal StringOutOfRangeException(int max) + : this("Length of string is out of range. Expected a value with a maximum length of " + max, 0, max) { } + } +} diff --git a/DiscordAPI/Exceptions/UninitializedException.cs b/DiscordAPI/Exceptions/UninitializedException.cs new file mode 100644 index 0000000..a894f3f --- /dev/null +++ b/DiscordAPI/Exceptions/UninitializedException.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Exceptions +{ + /// + /// Thrown when an action is performed on a client that has not yet been initialized + /// + public class UninitializedException : Exception + { + /// + /// Creates a new unintialized exception + /// + /// + internal UninitializedException(string message) : base(message) { } + + /// + /// Creates a new uninitialized exception with default message. + /// + internal UninitializedException() : this("Cannot perform action because the client has not been initialized yet or has been deinitialized.") { } + } +} diff --git a/DiscordAPI/Helper/BackoffDelay.cs b/DiscordAPI/Helper/BackoffDelay.cs new file mode 100644 index 0000000..bb10254 --- /dev/null +++ b/DiscordAPI/Helper/BackoffDelay.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Helper +{ + + internal class BackoffDelay + { + /// + /// The maximum time the backoff can reach + /// + public int Maximum { get; private set; } + + /// + /// The minimum time the backoff can start at + /// + public int Minimum { get; private set; } + + /// + /// The current time of the backoff + /// + public int Current { get { return _current; } } + private int _current; + + /// + /// The current number of failures + /// + public int Fails { get { return _fails; } } + private int _fails; + + /// + /// The random generator + /// + public Random Random { get; set; } + + private BackoffDelay() { } + public BackoffDelay(int min, int max) : this(min, max, new Random()) { } + public BackoffDelay(int min, int max, Random random) + { + this.Minimum = min; + this.Maximum = max; + + this._current = min; + this._fails = 0; + this.Random = random; + } + + /// + /// Resets the backoff + /// + public void Reset() + { + _fails = 0; + _current = Minimum; + } + + public int NextDelay() + { + //Increment the failures + _fails++; + + double diff = (Maximum - Minimum) / 100f; + _current = (int)Math.Floor(diff * _fails) + Minimum; + + + return Math.Min(Math.Max(_current, Minimum), Maximum); + } + } +} diff --git a/DiscordAPI/Helper/StringTools.cs b/DiscordAPI/Helper/StringTools.cs new file mode 100644 index 0000000..01e57de --- /dev/null +++ b/DiscordAPI/Helper/StringTools.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Helper +{ + /// + /// Collectin of helpful string extensions + /// + public static class StringTools + { + /// + /// Will return null if the string is whitespace, otherwise it will return the string. + /// + /// The string to check + /// Null if the string is empty, otherwise the string + public static string GetNullOrString(this string str) + { + return str.Length == 0 || string.IsNullOrEmpty(str.Trim()) ? null : str; + } + + /// + /// Does the string fit within the given amount of bytes? Uses UTF8 encoding. + /// + /// The string to check + /// The maximum number of bytes the string can take up + /// True if the string fits within the number of bytes + public static bool WithinLength(this string str, int bytes) + { + return str.WithinLength(bytes, Encoding.UTF8); + } + + /// + /// Does the string fit within the given amount of bytes? + /// + /// The string to check + /// The maximum number of bytes the string can take up + /// The encoding to count the bytes with + /// True if the string fits within the number of bytes + public static bool WithinLength(this string str, int bytes, Encoding encoding) + { + return encoding.GetByteCount(str) <= bytes; + } + + + /// + /// Converts the string into UpperCamelCase (Pascal Case). + /// + /// The string to convert + /// + public static string ToCamelCase(this string str) + { + if (str == null) return null; + + return str.ToLower() + .Split(new[] { "_", " " }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => char.ToUpper(s[0]) + s.Substring(1, s.Length - 1)) + .Aggregate(string.Empty, (s1, s2) => s1 + s2); + } + + /// + /// Converts the string into UPPER_SNAKE_CASE + /// + /// The string to convert + /// + public static string ToSnakeCase(this string str) + { + if (str == null) return null; + var concat = string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString()).ToArray()); + return concat.ToUpper(); + } + } +} diff --git a/DiscordAPI/IO/Handshake.cs b/DiscordAPI/IO/Handshake.cs new file mode 100644 index 0000000..1aa1204 --- /dev/null +++ b/DiscordAPI/IO/Handshake.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.IO +{ + internal class Handshake + { + /// + /// Version of the IPC API we are using + /// + [JsonProperty("v")] + public int Version { get; set; } + + /// + /// The ID of the app. + /// + [JsonProperty("client_id")] + public string ClientID { get; set; } + } +} diff --git a/DiscordAPI/IO/INamedPipeClient.cs b/DiscordAPI/IO/INamedPipeClient.cs new file mode 100644 index 0000000..5f171cb --- /dev/null +++ b/DiscordAPI/IO/INamedPipeClient.cs @@ -0,0 +1,56 @@ +using DiscordRPC.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.IO +{ + /// + /// Pipe Client used to communicate with Discord. + /// + public interface INamedPipeClient : IDisposable + { + + /// + /// The logger for the Pipe client to use + /// + ILogger Logger { get; set; } + + /// + /// Is the pipe client currently connected? + /// + bool IsConnected { get; } + + /// + /// The pipe the client is currently connected too + /// + int ConnectedPipe { get; } + + /// + /// Attempts to connect to the pipe. If 0-9 is passed to pipe, it should try to only connect to the specified pipe. If -1 is passed, the pipe will find the first available pipe. + /// + /// If -1 is passed, the pipe will find the first available pipe, otherwise it connects to the pipe that was supplied + /// + bool Connect(int pipe); + + /// + /// Reads a frame if there is one available. Returns false if there is none. This should be non blocking (aka use a Peek first). + /// + /// The frame that has been read. Will be default(PipeFrame) if it fails to read + /// Returns true if a frame has been read, otherwise false. + bool ReadFrame(out PipeFrame frame); + + /// + /// Writes the frame to the pipe. Returns false if any errors occur. + /// + /// The frame to be written + bool WriteFrame(PipeFrame frame); + + /// + /// Closes the connection + /// + void Close(); + + } +} diff --git a/DiscordAPI/IO/ManagedNamedPipeClient.cs b/DiscordAPI/IO/ManagedNamedPipeClient.cs new file mode 100644 index 0000000..1e0312d --- /dev/null +++ b/DiscordAPI/IO/ManagedNamedPipeClient.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DiscordRPC.Logging; +using System.IO.Pipes; +using System.Threading; +using System.IO; + +namespace DiscordRPC.IO +{ + /// + /// A named pipe client using the .NET framework + /// + public sealed class ManagedNamedPipeClient : INamedPipeClient + { + /// + /// Name format of the pipe + /// + const string PIPE_NAME = @"discord-ipc-{0}"; + + /// + /// The logger for the Pipe client to use + /// + public ILogger Logger { get; set; } + + /// + /// Checks if the client is connected + /// + public bool IsConnected + { + get + { + //This will trigger if the stream is disabled. This should prevent the lock check + if (_isClosed) return false; + lock (l_stream) + { + //We cannot be sure its still connected, so lets double check + return _stream != null && _stream.IsConnected; + } + } + } + + /// + /// The pipe we are currently connected too. + /// + public int ConnectedPipe { get { return _connectedPipe; } } + + private int _connectedPipe; + private NamedPipeClientStream _stream; + + private byte[] _buffer = new byte[PipeFrame.MAX_SIZE]; + + private Queue _framequeue = new Queue(); + private object _framequeuelock = new object(); + + private volatile bool _isDisposed = false; + private volatile bool _isClosed = true; + + private object l_stream = new object(); + + /// + /// Creates a new instance of a Managed NamedPipe client. Doesn't connect to anything yet, just setups the values. + /// + public ManagedNamedPipeClient() + { + _buffer = new byte[PipeFrame.MAX_SIZE]; + Logger = new NullLogger(); + _stream = null; + } + + /// + /// Connects to the pipe + /// + /// + /// + public bool Connect(int pipe) + { + Logger.Trace("ManagedNamedPipeClient.Connection(" + pipe + ")"); + + if (_isDisposed) + throw new ObjectDisposedException("NamedPipe"); + + if (pipe > 9) + throw new ArgumentOutOfRangeException("pipe", "Argument cannot be greater than 9"); + + if (pipe < 0) + { + //Iterate until we connect to a pipe + for (int i = 0; i < 10; i++) + { + if (AttemptConnection(i) || AttemptConnection(i, true)) + { + BeginReadStream(); + return true; + } + } + } + else + { + //Attempt to connect to a specific pipe + if (AttemptConnection(pipe) || AttemptConnection(pipe, true)) + { + BeginReadStream(); + return true; + } + } + + //We failed to connect + return false; + } + + /// + /// Attempts a new connection + /// + /// The pipe number to connect too. + /// Should the connection to a sandbox be attempted? + /// + private bool AttemptConnection(int pipe, bool isSandbox = false) + { + if (_isDisposed) + throw new ObjectDisposedException("_stream"); + + //If we are sandbox but we dont support sandbox, then skip + string sandbox = isSandbox ? GetPipeSandbox() : ""; + if (isSandbox && sandbox == null) + { + Logger.Trace("Skipping sandbox connection."); + return false; + } + + //Prepare the pipename + Logger.Trace("Connection Attempt " + pipe + " (" + sandbox + ")"); + string pipename = GetPipeName(pipe, sandbox); + + try + { + //Create the client + lock (l_stream) + { + Logger.Info("Attempting to connect to " + pipename); + _stream = new NamedPipeClientStream(".", pipename, PipeDirection.InOut, PipeOptions.Asynchronous); + _stream.Connect(1000); + + //Spin for a bit while we wait for it to finish connecting + Logger.Trace("Waiting for connection..."); + do { Thread.Sleep(10); } while (!_stream.IsConnected); + } + + //Store the value + Logger.Info("Connected to " + pipename); + _connectedPipe = pipe; + _isClosed = false; + } + catch (Exception e) + { + //Something happened, try again + //TODO: Log the failure condition + Logger.Error("Failed connection to {0}. {1}", pipename, e.Message); + Close(); + } + + Logger.Trace("Done. Result: {0}", _isClosed); + return !_isClosed; + } + + /// + /// Starts a read. Can be executed in another thread. + /// + private void BeginReadStream() + { + if (_isClosed) return; + try + { + lock (l_stream) + { + //Make sure the stream is valid + if (_stream == null || !_stream.IsConnected) return; + + Logger.Trace("Begining Read of {0} bytes", _buffer.Length); + _stream.BeginRead(_buffer, 0, _buffer.Length, new AsyncCallback(EndReadStream), _stream.IsConnected); + } + } + catch (ObjectDisposedException) + { + Logger.Warning("Attempted to start reading from a disposed pipe"); + return; + } + catch (InvalidOperationException) + { + //The pipe has been closed + Logger.Warning("Attempted to start reading from a closed pipe"); + return; + } + catch (Exception e) + { + Logger.Error("An exception occured while starting to read a stream: {0}", e.Message); + Logger.Error(e.StackTrace); + } + } + + /// + /// Ends a read. Can be executed in another thread. + /// + /// + private void EndReadStream(IAsyncResult callback) + { + Logger.Trace("Ending Read"); + int bytes = 0; + + try + { + //Attempt to read the bytes, catching for IO exceptions or dispose exceptions + lock (l_stream) + { + //Make sure the stream is still valid + if (_stream == null || !_stream.IsConnected) return; + + //Read our btyes + 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("Attemped to end reading from a disposed pipe"); + return; + } + catch (Exception e) + { + Logger.Error("An exception occured while ending a read of a stream: {0}", e.Message); + Logger.Error(e.StackTrace); + return; + } + + //How much did we read? + Logger.Trace("Read {0} bytes", bytes); + + //Did we read anything? If we did we should enqueue it. + if (bytes > 0) + { + //Load it into a memory stream and read the frame + using (MemoryStream memory = new MemoryStream(_buffer, 0, bytes)) + { + try + { + PipeFrame frame = new PipeFrame(); + if (frame.ReadStream(memory)) + { + Logger.Trace("Read a frame: {0}", frame.Opcode); + + //Enqueue the stream + lock (_framequeuelock) + _framequeue.Enqueue(frame); + } + else + { + //TODO: Enqueue a pipe close event here as we failed to read something. + Logger.Error("Pipe failed to read from the data received by the stream."); + Close(); + } + } + catch (Exception e) + { + Logger.Error("A exception has occured while trying to parse the pipe data: " + e.Message); + Close(); + } + } + } + else + { + //If we read 0 bytes, its probably a broken pipe. However, I have only confirmed this is the case for MacOSX. + // I have added this check here just so the Windows builds are not effected and continue to work as expected. + if (IsUnix()) + { + Logger.Error("Empty frame was read on " + Environment.OSVersion.ToString() + ", aborting."); + Close(); + } + else + { + Logger.Warning("Empty frame was read. Please send report to Lachee."); + } + } + + //We are still connected, so continue to read + if (!_isClosed && IsConnected) + { + Logger.Trace("Starting another read"); + BeginReadStream(); + } + } + + /// + /// Reads a frame, returning false if none are available + /// + /// + /// + public bool ReadFrame(out PipeFrame frame) + { + if (_isDisposed) + throw new ObjectDisposedException("_stream"); + + //Check the queue, returning the pipe if we have anything available. Otherwise null. + lock (_framequeuelock) + { + if (_framequeue.Count == 0) + { + //We found nothing, so just default and return null + frame = default(PipeFrame); + return false; + } + + //Return the dequed frame + frame = _framequeue.Dequeue(); + return true; + } + } + + /// + /// Writes a frame to the pipe + /// + /// + /// + public bool WriteFrame(PipeFrame frame) + { + if (_isDisposed) + throw new ObjectDisposedException("_stream"); + + //Write the frame. We are assuming proper duplex connection here + if (_isClosed || !IsConnected) + { + Logger.Error("Failed to write frame because the stream is closed"); + return false; + } + + try + { + //Write the pipe + //This can only happen on the main thread so it should be fine. + 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"); + } + + //We must have failed the try catch + return false; + } + + /// + /// Closes the pipe + /// + public void Close() + { + //If we are already closed, jsut exit + if (_isClosed) + { + Logger.Warning("Tried to close a already closed pipe."); + return; + } + + //flush and dispose + try + { + //Wait for the stream object to become available. + lock (l_stream) + { + if (_stream != null) + { + try + { + //Stream isn't null, so flush it and then dispose of it.\ + // We are doing a catch here because it may throw an error during this process and we dont care if it fails. + _stream.Flush(); + _stream.Dispose(); + } + catch (Exception) + { + //We caught an error, but we dont care anyways because we are disposing of the stream. + } + + //Make the stream null and set our flag. + _stream = null; + _isClosed = true; + } + else + { + //The stream is already null? + Logger.Warning("Stream was closed, but no stream was available to begin with!"); + } + } + } + catch (ObjectDisposedException) + { + //ITs already been disposed + Logger.Warning("Tried to dispose already disposed stream"); + } + finally + { + //For good measures, we will mark the pipe as closed anyways + _isClosed = true; + _connectedPipe = -1; + } + } + + /// + /// Disposes of the stream + /// + public void Dispose() + { + //Prevent double disposing + if (_isDisposed) return; + + //Close the stream (disposing of it too) + if (!_isClosed) Close(); + + //Dispose of the stream if it hasnt been destroyed already. + lock (l_stream) + { + if (_stream != null) + { + _stream.Dispose(); + _stream = null; + } + } + + //Set our dispose flag + _isDisposed = true; + } + + /// + /// Returns a platform specific path that Discord is hosting the IPC on. + /// + /// The pipe number. + /// The sandbox the pipe is in. Leave blank for no sandbox. + /// + public static string GetPipeName(int pipe, string sandbox = "") + { + if (!IsUnix()) return sandbox + string.Format(PIPE_NAME, pipe); + return Path.Combine(GetTemporaryDirectory(), sandbox + string.Format(PIPE_NAME, pipe)); + } + + /// + /// Gets the name of the possible sandbox enviroment the pipe might be located within. If the platform doesn't support sandboxed Discord, then it will return null. + /// + /// + public static string GetPipeSandbox() + { + switch (Environment.OSVersion.Platform) + { + default: + return null; + case PlatformID.Unix: + return "snap.discord/"; + } + } + + /// + /// Gets the temporary path for the current enviroment. Only applicable for UNIX based systems. + /// + /// + private static string GetTemporaryDirectory() + { + string temp = null; + temp = temp ?? Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR"); + temp = temp ?? Environment.GetEnvironmentVariable("TMPDIR"); + temp = temp ?? Environment.GetEnvironmentVariable("TMP"); + temp = temp ?? Environment.GetEnvironmentVariable("TEMP"); + temp = temp ?? "/tmp"; + return temp; + } + + /// + /// Returns true if the current OS platform is Unix based (Unix or MacOSX). + /// + /// + public static bool IsUnix() + { + switch (Environment.OSVersion.Platform) + { + default: + return false; + + case PlatformID.Unix: + case PlatformID.MacOSX: + return true; + } + } + } +} diff --git a/DiscordAPI/IO/Opcode.cs b/DiscordAPI/IO/Opcode.cs new file mode 100644 index 0000000..f38a213 --- /dev/null +++ b/DiscordAPI/IO/Opcode.cs @@ -0,0 +1,33 @@ +namespace DiscordRPC.IO +{ + /// + /// The operation code that the was sent under. This defines the type of frame and the data to expect. + /// + public enum Opcode : uint + { + /// + /// Initial handshake frame + /// + Handshake = 0, + + /// + /// Generic message frame + /// + Frame = 1, + + /// + /// Discord has closed the connection + /// + Close = 2, + + /// + /// Ping frame (not used?) + /// + Ping = 3, + + /// + /// Pong frame (not used?) + /// + Pong = 4 + } +} diff --git a/DiscordAPI/IO/PipeFrame.cs b/DiscordAPI/IO/PipeFrame.cs new file mode 100644 index 0000000..2ff6db2 --- /dev/null +++ b/DiscordAPI/IO/PipeFrame.cs @@ -0,0 +1,204 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace DiscordRPC.IO +{ + /// + /// A frame received and sent to the Discord client for RPC communications. + /// + public struct PipeFrame + { + /// + /// The maxium size of a pipe frame (16kb). + /// + public static readonly int MAX_SIZE = 16 * 1024; + + /// + /// The opcode of the frame + /// + public Opcode Opcode { get; set; } + + /// + /// The length of the frame data + /// + public uint Length { get { return (uint) Data.Length; } } + + /// + /// The data in the frame + /// + public byte[] Data { get; set; } + + /// + /// The data represented as a string. + /// + public string Message + { + get { return GetMessage(); } + set { SetMessage(value); } + } + + /// + /// Creates a new pipe frame instance + /// + /// The opcode of the frame + /// The data of the frame that will be serialized as JSON + public PipeFrame(Opcode opcode, object data) + { + //Set the opcode and a temp field for data + Opcode = opcode; + Data = null; + + //Set the data + SetObject(data); + } + + /// + /// Gets the encoding used for the pipe frames + /// + public Encoding MessageEncoding { get { return Encoding.UTF8; } } + + /// + /// Sets the data based of a string + /// + /// + private void SetMessage(string str) { Data = MessageEncoding.GetBytes(str); } + + /// + /// Gets a string based of the data + /// + /// + private string GetMessage() { return MessageEncoding.GetString(Data); } + + /// + /// Serializes the object into json string then encodes it into . + /// + /// + public void SetObject(object obj) + { + string json = JsonConvert.SerializeObject(obj); + SetMessage(json); + } + + /// + /// Sets the opcodes and serializes the object into a json string. + /// + /// + /// + public void SetObject(Opcode opcode, object obj) + { + Opcode = opcode; + SetObject(obj); + } + + /// + /// Deserializes the data into the supplied type using JSON. + /// + /// The type to deserialize into + /// + public T GetObject() + { + string json = GetMessage(); + return JsonConvert.DeserializeObject(json); + } + + /// + /// Attempts to read the contents of the frame from the stream + /// + /// + /// + public bool ReadStream(Stream stream) + { + //Try to read the opcode + uint op; + if (!TryReadUInt32(stream, out op)) + return false; + + //Try to read the length + uint len; + if (!TryReadUInt32(stream, out len)) + return false; + + uint readsRemaining = len; + + //Read the contents + using (var mem = new MemoryStream()) + { + byte[] buffer = new byte[Min(2048, len)]; // read in chunks of 2KB + int bytesRead; + while ((bytesRead = stream.Read(buffer, 0, Min(buffer.Length, readsRemaining))) > 0) + { + readsRemaining -= len; + mem.Write(buffer, 0, bytesRead); + } + + byte[] result = mem.ToArray(); + if (result.LongLength != len) + return false; + + Opcode = (Opcode)op; + Data = result; + return true; + } + + //fun + //if (a != null) { do { yield return true; switch (a) { case 1: await new Task(); default: lock (obj) { foreach (b in c) { for (int d = 0; d < 1; d++) { a++; } } } while (a is typeof(int) || (new Class()) != null) } goto MY_LABEL; + + } + + /// + /// Returns minimum value between a int and a unsigned int + /// + private int Min(int a, uint b) + { + if (b >= a) return a; + return (int) b; + } + + /// + /// Attempts to read a UInt32 + /// + /// + /// + /// + private bool TryReadUInt32(Stream stream, out uint value) + { + //Read the bytes available to us + byte[] bytes = new byte[4]; + int cnt = stream.Read(bytes, 0, bytes.Length); + + //Make sure we actually have a valid value + if (cnt != 4) + { + value = default(uint); + return false; + } + + value = BitConverter.ToUInt32(bytes, 0); + return true; + } + + /// + /// Writes the frame into the target frame as one big byte block. + /// + /// + public void WriteStream(Stream stream) + { + //Get all the bytes + byte[] op = BitConverter.GetBytes((uint) Opcode); + byte[] len = BitConverter.GetBytes(Length); + + //Copy it all into a buffer + byte[] buff = new byte[op.Length + len.Length + Data.Length]; + op.CopyTo(buff, 0); + len.CopyTo(buff, op.Length); + Data.CopyTo(buff, op.Length + len.Length); + + //Write it to the stream + stream.Write(buff, 0, buff.Length); + } + } +} diff --git a/DiscordAPI/LICENSE.txt b/DiscordAPI/LICENSE.txt new file mode 100644 index 0000000..36854d6 --- /dev/null +++ b/DiscordAPI/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Lachee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/DiscordAPI/Logging/ConsoleLogger.cs b/DiscordAPI/Logging/ConsoleLogger.cs new file mode 100644 index 0000000..d940b0c --- /dev/null +++ b/DiscordAPI/Logging/ConsoleLogger.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Logging +{ + /// + /// Logs the outputs to the console using + /// + public class ConsoleLogger : ILogger + { + /// + /// The level of logging to apply to this logger. + /// + public LogLevel Level { get; set; } + + /// + /// Should the output be coloured? + /// + public bool Coloured { get; set; } + + /// + /// A alias too + /// + public bool Colored { get { return Coloured; } set { Coloured = value; } } + + /// + /// Creates a new instance of a Console Logger. + /// + public ConsoleLogger() + { + this.Level = LogLevel.Info; + Coloured = false; + } + + /// + /// Creates a new instance of a Console Logger with a set log level + /// + /// + /// + public ConsoleLogger(LogLevel level, bool coloured = false) + { + Level = level; + Coloured = coloured; + } + + /// + /// Informative log messages + /// + /// + /// + public void Trace(string message, params object[] args) + { + if (Level > LogLevel.Trace) return; + + if (Coloured) Console.ForegroundColor = ConsoleColor.Gray; + Console.WriteLine("TRACE: " + message, args); + } + + /// + /// Informative log messages + /// + /// + /// + public void Info(string message, params object[] args) + { + if (Level > LogLevel.Info) return; + + if (Coloured) Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine("INFO: " + message, args); + } + + /// + /// Warning log messages + /// + /// + /// + public void Warning(string message, params object[] args) + { + if (Level > LogLevel.Warning) return; + + if (Coloured) Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("WARN: " + message, args); + } + + /// + /// Error log messsages + /// + /// + /// + public void Error(string message, params object[] args) + { + if (Level > LogLevel.Error) return; + + if (Coloured) Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("ERR : " + message, args); + } + + } +} diff --git a/DiscordAPI/Logging/FileLogger.cs b/DiscordAPI/Logging/FileLogger.cs new file mode 100644 index 0000000..9a16d5d --- /dev/null +++ b/DiscordAPI/Logging/FileLogger.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Logging +{ + /// + /// Logs the outputs to a file + /// + public class FileLogger : ILogger + { + /// + /// The level of logging to apply to this logger. + /// + public LogLevel Level { get; set; } + + /// + /// Should the output be coloured? + /// + public string File { get; set; } + + private object filelock; + + /// + /// Creates a new instance of the file logger + /// + /// The path of the log file. + public FileLogger(string path) + : this(path, LogLevel.Info) { } + + /// + /// Creates a new instance of the file logger + /// + /// The path of the log file. + /// The level to assign to the logger. + public FileLogger(string path, LogLevel level) + { + Level = level; + File = path; + filelock = new object(); + } + + + /// + /// Informative log messages + /// + /// + /// + public void Trace(string message, params object[] args) + { + if (Level > LogLevel.Trace) return; + lock (filelock) System.IO.File.AppendAllText(File, "\r\nTRCE: " + (args.Length > 0 ? string.Format(message, args) : message)); + } + + /// + /// Informative log messages + /// + /// + /// + public void Info(string message, params object[] args) + { + if (Level > LogLevel.Info) return; + lock(filelock) System.IO.File.AppendAllText(File, "\r\nINFO: " + (args.Length > 0 ? string.Format(message, args) : message)); + } + + /// + /// Warning log messages + /// + /// + /// + public void Warning(string message, params object[] args) + { + if (Level > LogLevel.Warning) return; + lock (filelock) + System.IO.File.AppendAllText(File, "\r\nWARN: " + (args.Length > 0 ? string.Format(message, args) : message)); + } + + /// + /// Error log messsages + /// + /// + /// + public void Error(string message, params object[] args) + { + if (Level > LogLevel.Error) return; + lock (filelock) + System.IO.File.AppendAllText(File, "\r\nERR : " + (args.Length > 0 ? string.Format(message, args) : message)); + } + + } +} diff --git a/DiscordAPI/Logging/ILogger.cs b/DiscordAPI/Logging/ILogger.cs new file mode 100644 index 0000000..682d00e --- /dev/null +++ b/DiscordAPI/Logging/ILogger.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Logging +{ + /// + /// Logging interface to log the internal states of the pipe. Logs are sent in a NON thread safe way. They can come from multiple threads and it is upto the ILogger to account for it. + /// + public interface ILogger + { + /// + /// The level of logging to apply to this logger. + /// + LogLevel Level { get; set; } + + /// + /// Debug trace messeages used for debugging internal elements. + /// + /// + /// + void Trace(string message, params object[] args); + + /// + /// Informative log messages + /// + /// + /// + void Info(string message, params object[] args); + + /// + /// Warning log messages + /// + /// + /// + void Warning(string message, params object[] args); + + /// + /// Error log messsages + /// + /// + /// + void Error(string message, params object[] args); + } +} diff --git a/DiscordAPI/Logging/LogLevel.cs b/DiscordAPI/Logging/LogLevel.cs new file mode 100644 index 0000000..af689e5 --- /dev/null +++ b/DiscordAPI/Logging/LogLevel.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Logging +{ + /// + /// Level of logging to use. + /// + public enum LogLevel + { + /// + /// Trace, Info, Warning and Errors are logged + /// + Trace = 1, + + /// + /// Info, Warning and Errors are logged + /// + Info = 2, + + /// + /// Warning and Errors are logged + /// + Warning = 3, + + /// + /// Only Errors are logged + /// + Error = 4, + + /// + /// Nothing is logged + /// + None = 256 + } +} diff --git a/DiscordAPI/Logging/NullLogger.cs b/DiscordAPI/Logging/NullLogger.cs new file mode 100644 index 0000000..d5c04d7 --- /dev/null +++ b/DiscordAPI/Logging/NullLogger.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Logging +{ + /// + /// Ignores all log events + /// + public class NullLogger : ILogger + { + /// + /// The level of logging to apply to this logger. + /// + public LogLevel Level { get; set; } + + /// + /// Informative log messages + /// + /// + /// + public void Trace(string message, params object[] args) + { + //Null Logger, so no messages are acutally sent + } + + /// + /// Informative log messages + /// + /// + /// + public void Info(string message, params object[] args) + { + //Null Logger, so no messages are acutally sent + } + + /// + /// Warning log messages + /// + /// + /// + public void Warning(string message, params object[] args) + { + //Null Logger, so no messages are acutally sent + } + + /// + /// Error log messsages + /// + /// + /// + public void Error(string message, params object[] args) + { + //Null Logger, so no messages are acutally sent + } + } +} diff --git a/DiscordAPI/Message/CloseMessage.cs b/DiscordAPI/Message/CloseMessage.cs new file mode 100644 index 0000000..9ee7122 --- /dev/null +++ b/DiscordAPI/Message/CloseMessage.cs @@ -0,0 +1,26 @@ +namespace DiscordRPC.Message +{ + /// + /// Called when the IPC has closed. + /// + public class CloseMessage : IMessage + { + /// + /// The type of message + /// + public override MessageType Type { get { return MessageType.Close; } } + + /// + /// The reason for the close + /// + public string Reason { get; internal set; } + + /// + /// The closure code + /// + public int Code { get; internal set; } + + internal CloseMessage() { } + internal CloseMessage(string reason) { this.Reason = reason; } + } +} diff --git a/DiscordAPI/Message/ConnectionEstablishedMessage.cs b/DiscordAPI/Message/ConnectionEstablishedMessage.cs new file mode 100644 index 0000000..8315d92 --- /dev/null +++ b/DiscordAPI/Message/ConnectionEstablishedMessage.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Message +{ + /// + /// The connection to the discord client was succesfull. This is called before . + /// + public class ConnectionEstablishedMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.ConnectionEstablished; } } + + /// + /// The pipe we ended up connecting too + /// + public int ConnectedPipe { get; internal set; } + } +} diff --git a/DiscordAPI/Message/ConnectionFailedMessage.cs b/DiscordAPI/Message/ConnectionFailedMessage.cs new file mode 100644 index 0000000..477e4d2 --- /dev/null +++ b/DiscordAPI/Message/ConnectionFailedMessage.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Message +{ + /// + /// Failed to establish any connection with discord. Discord is potentially not running? + /// + public class ConnectionFailedMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.ConnectionFailed; } } + + /// + /// The pipe we failed to connect too. + /// + public int FailedPipe { get; internal set; } + } +} diff --git a/DiscordAPI/Message/ErrorMessage.cs b/DiscordAPI/Message/ErrorMessage.cs new file mode 100644 index 0000000..b703b19 --- /dev/null +++ b/DiscordAPI/Message/ErrorMessage.cs @@ -0,0 +1,76 @@ +using Newtonsoft.Json; + +namespace DiscordRPC.Message +{ + /// + /// Created when a error occurs within the ipc and it is sent to the client. + /// + public class ErrorMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Error; } } + + /// + /// The Discord error code. + /// + [JsonProperty("code")] + public ErrorCode Code { get; internal set; } + + /// + /// The message associated with the error code. + /// + [JsonProperty("message")] + public string Message { get; internal set; } + + } + + /// + /// The error message received by discord. See https://discordapp.com/developers/docs/topics/rpc#rpc-server-payloads-rpc-errors for documentation + /// + public enum ErrorCode + { + //Pipe Error Codes + /// Pipe was Successful + Success = 0, + + ///The pipe had an exception + PipeException = 1, + + ///The pipe received corrupted data + ReadCorrupt = 2, + + //Custom Error Code + ///The functionality was not yet implemented + NotImplemented = 10, + + //Discord RPC error codes + ///Unkown Discord error + UnkownError = 1000, + + ///Invalid Payload received + InvalidPayload = 4000, + + ///Invalid command was sent + InvalidCommand = 4002, + + /// Invalid event was sent + InvalidEvent = 4004, + + /* + InvalidGuild = 4003, + InvalidChannel = 4005, + InvalidPermissions = 4006, + InvalidClientID = 4007, + InvalidOrigin = 4008, + InvalidToken = 4009, + InvalidUser = 4010, + OAuth2Error = 5000, + SelectChannelTimeout = 5001, + GetGuildTimeout = 5002, + SelectVoiceForceRequired = 5003, + CaptureShortcutAlreadyListening = 5004 + */ + } +} diff --git a/DiscordAPI/Message/IMessage.cs b/DiscordAPI/Message/IMessage.cs new file mode 100644 index 0000000..34522d8 --- /dev/null +++ b/DiscordAPI/Message/IMessage.cs @@ -0,0 +1,29 @@ +using System; + +namespace DiscordRPC.Message +{ + /// + /// Messages received from discord. + /// + public abstract class IMessage + { + /// + /// The type of message received from discord + /// + public abstract MessageType Type { get; } + + /// + /// The time the message was created + /// + public DateTime TimeCreated { get { return _timecreated; } } + private DateTime _timecreated; + + /// + /// Creates a new instance of the message + /// + public IMessage() + { + _timecreated = DateTime.Now; + } + } +} diff --git a/DiscordAPI/Message/JoinMessage.cs b/DiscordAPI/Message/JoinMessage.cs new file mode 100644 index 0000000..21b4672 --- /dev/null +++ b/DiscordAPI/Message/JoinMessage.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Message +{ + /// + /// Called when the Discord Client wishes for this process to join a game. D -> C. + /// + public class JoinMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Join; } } + + /// + /// The to connect with. + /// + [JsonProperty("secret")] + public string Secret { get; internal set; } + } +} diff --git a/DiscordAPI/Message/JoinRequestMessage.cs b/DiscordAPI/Message/JoinRequestMessage.cs new file mode 100644 index 0000000..05db72e --- /dev/null +++ b/DiscordAPI/Message/JoinRequestMessage.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Message +{ + /// + /// Called when some other person has requested access to this game. C -> D -> C. + /// + public class JoinRequestMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.JoinRequest; } } + + /// + /// The discord user that is requesting access. + /// + [JsonProperty("user")] + public User User { get; internal set; } + } +} diff --git a/DiscordAPI/Message/MessageType.cs b/DiscordAPI/Message/MessageType.cs new file mode 100644 index 0000000..223291e --- /dev/null +++ b/DiscordAPI/Message/MessageType.cs @@ -0,0 +1,64 @@ + +namespace DiscordRPC.Message +{ + /// + /// Type of message. + /// + public enum MessageType + { + /// + /// The Discord Client is ready to send and receive messages. + /// + Ready, + + /// + /// The connection to the Discord Client is lost. The connection will remain close and unready to accept messages until the Ready event is called again. + /// + Close, + + /// + /// A error has occured during the transmission of a message. For example, if a bad Rich Presence payload is sent, this event will be called explaining what went wrong. + /// + Error, + + /// + /// The Discord Client has updated the presence. + /// + PresenceUpdate, + + /// + /// The Discord Client has subscribed to an event. + /// + Subscribe, + + /// + /// The Discord Client has unsubscribed from an event. + /// + Unsubscribe, + + /// + /// The Discord Client wishes for this process to join a game. + /// + Join, + + /// + /// The Discord Client wishes for this process to spectate a game. + /// + Spectate, + + /// + /// Another discord user requests permission to join this game. + /// + JoinRequest, + + /// + /// The connection to the discord client was succesfull. This is called before . + /// + ConnectionEstablished, + + /// + /// Failed to establish any connection with discord. Discord is potentially not running? + /// + ConnectionFailed + } +} diff --git a/DiscordAPI/Message/PresenceMessage.cs b/DiscordAPI/Message/PresenceMessage.cs new file mode 100644 index 0000000..7f13701 --- /dev/null +++ b/DiscordAPI/Message/PresenceMessage.cs @@ -0,0 +1,47 @@ + + +namespace DiscordRPC.Message +{ + /// + /// Representation of the message received by discord when the presence has been updated. + /// + public class PresenceMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.PresenceUpdate; } } + + internal PresenceMessage() : this(null) { } + internal PresenceMessage(RichPresenceResponse rpr) + { + if (rpr == null) + { + Presence = null; + Name = "No Rich Presence"; + ApplicationID = ""; + } + else + { + Presence = (RichPresence)rpr; + Name = rpr.Name; + ApplicationID = rpr.ClientID; + } + } + + /// + /// The rich presence Discord has set + /// + public RichPresence Presence { get; internal set; } + + /// + /// The name of the application Discord has set it for + /// + public string Name { get; internal set; } + + /// + /// The ID of the application discord has set it for + /// + public string ApplicationID { get; internal set; } + } +} diff --git a/DiscordAPI/Message/ReadyMessage.cs b/DiscordAPI/Message/ReadyMessage.cs new file mode 100644 index 0000000..c93ba57 --- /dev/null +++ b/DiscordAPI/Message/ReadyMessage.cs @@ -0,0 +1,35 @@ + + +using Newtonsoft.Json; + +namespace DiscordRPC.Message +{ + /// + /// Called when the ipc is ready to send arguments. + /// + public class ReadyMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Ready; } } + + /// + /// The configuration of the connection + /// + [JsonProperty("config")] + public Configuration Configuration { get; set; } + + /// + /// User the connection belongs too + /// + [JsonProperty("user")] + public User User { get; set; } + + /// + /// The version of the RPC + /// + [JsonProperty("v")] + public int Version { get; set; } + } +} diff --git a/DiscordAPI/Message/SpectateMessage.cs b/DiscordAPI/Message/SpectateMessage.cs new file mode 100644 index 0000000..8a3d16b --- /dev/null +++ b/DiscordAPI/Message/SpectateMessage.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Message +{ + /// + /// Called when the Discord Client wishes for this process to spectate a game. D -> C. + /// + public class SpectateMessage : JoinMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Spectate; } } + } +} diff --git a/DiscordAPI/Message/SubscribeMessage.cs b/DiscordAPI/Message/SubscribeMessage.cs new file mode 100644 index 0000000..ac694b0 --- /dev/null +++ b/DiscordAPI/Message/SubscribeMessage.cs @@ -0,0 +1,40 @@ +using DiscordRPC.RPC.Payload; + +namespace DiscordRPC.Message +{ + /// + /// Called as validation of a subscribe + /// + public class SubscribeMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Subscribe; } } + + /// + /// The event that was subscribed too. + /// + public EventType Event { get; internal set; } + + internal SubscribeMessage(ServerEvent evt) + { + switch (evt) + { + default: + case ServerEvent.ActivityJoin: + Event = EventType.Join; + break; + + case ServerEvent.ActivityJoinRequest: + Event = EventType.JoinRequest; + break; + + case ServerEvent.ActivitySpectate: + Event = EventType.Spectate; + break; + + } + } + } +} diff --git a/DiscordAPI/Message/UnsubscribeMsesage.cs b/DiscordAPI/Message/UnsubscribeMsesage.cs new file mode 100644 index 0000000..30eb5e1 --- /dev/null +++ b/DiscordAPI/Message/UnsubscribeMsesage.cs @@ -0,0 +1,39 @@ +using DiscordRPC.RPC.Payload; + +namespace DiscordRPC.Message +{ + /// + /// Called as validation of a subscribe + /// + public class UnsubscribeMessage : IMessage + { + /// + /// The type of message received from discord + /// + public override MessageType Type { get { return MessageType.Unsubscribe; } } + + /// + /// The event that was subscribed too. + /// + public EventType Event { get; internal set; } + + internal UnsubscribeMessage(ServerEvent evt) + { + switch (evt) + { + default: + case ServerEvent.ActivityJoin: + Event = EventType.Join; + break; + + case ServerEvent.ActivityJoinRequest: + Event = EventType.JoinRequest; + break; + + case ServerEvent.ActivitySpectate: + Event = EventType.Spectate; + break; + } + } + } +} diff --git a/DiscordAPI/RPC/Commands/CloseCommand.cs b/DiscordAPI/RPC/Commands/CloseCommand.cs new file mode 100644 index 0000000..a24867f --- /dev/null +++ b/DiscordAPI/RPC/Commands/CloseCommand.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DiscordRPC.RPC.Payload; +using Newtonsoft.Json; + +namespace DiscordRPC.RPC.Commands +{ + internal class CloseCommand : ICommand + { + /// + /// The process ID + /// + [JsonProperty("pid")] + public int PID { get; set; } + + /// + /// The rich presence to be set. Can be null. + /// + [JsonProperty("close_reason")] + public string value = "Unity 5.5 doesn't handle thread aborts. Can you please close me discord?"; + + public IPayload PreparePayload(long nonce) + { + return new ArgumentPayload() + { + Command = Command.Dispatch, + Nonce = null, + Arguments = null + }; + } + } +} diff --git a/DiscordAPI/RPC/Commands/ICommand.cs b/DiscordAPI/RPC/Commands/ICommand.cs new file mode 100644 index 0000000..6eeedb2 --- /dev/null +++ b/DiscordAPI/RPC/Commands/ICommand.cs @@ -0,0 +1,13 @@ +using DiscordRPC.RPC.Payload; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.RPC.Commands +{ + internal interface ICommand + { + IPayload PreparePayload(long nonce); + } +} diff --git a/DiscordAPI/RPC/Commands/PresenceCommand.cs b/DiscordAPI/RPC/Commands/PresenceCommand.cs new file mode 100644 index 0000000..cdffa48 --- /dev/null +++ b/DiscordAPI/RPC/Commands/PresenceCommand.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DiscordRPC.RPC.Payload; +using Newtonsoft.Json; + +namespace DiscordRPC.RPC.Commands +{ + internal class PresenceCommand : ICommand + { + /// + /// The process ID + /// + [JsonProperty("pid")] + public int PID { get; set; } + + /// + /// The rich presence to be set. Can be null. + /// + [JsonProperty("activity")] + public RichPresence Presence { get; set; } + + public IPayload PreparePayload(long nonce) + { + return new ArgumentPayload(this, nonce) + { + Command = Command.SetActivity + }; + } + } +} diff --git a/DiscordAPI/RPC/Commands/RespondCommand.cs b/DiscordAPI/RPC/Commands/RespondCommand.cs new file mode 100644 index 0000000..94dda99 --- /dev/null +++ b/DiscordAPI/RPC/Commands/RespondCommand.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DiscordRPC.RPC.Payload; +using Newtonsoft.Json; + +namespace DiscordRPC.RPC.Commands +{ + internal class RespondCommand : ICommand + { + /// + /// The user ID that we are accepting / rejecting + /// + [JsonProperty("user_id")] + public string UserID { get; set; } + + /// + /// If true, the user will be allowed to connect. + /// + [JsonIgnore] + public bool Accept { get; set; } + + public IPayload PreparePayload(long nonce) + { + return new ArgumentPayload(this, nonce) + { + Command = Accept ? Command.SendActivityJoinInvite : Command.CloseActivityJoinRequest + }; + } + } +} diff --git a/DiscordAPI/RPC/Commands/SubscribeCommand.cs b/DiscordAPI/RPC/Commands/SubscribeCommand.cs new file mode 100644 index 0000000..69c9cdb --- /dev/null +++ b/DiscordAPI/RPC/Commands/SubscribeCommand.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using DiscordRPC.RPC.Payload; + +namespace DiscordRPC.RPC.Commands +{ + internal class SubscribeCommand : ICommand + { + public ServerEvent Event { get; set; } + public bool IsUnsubscribe { get; set; } + + public IPayload PreparePayload(long nonce) + { + return new EventPayload(nonce) + { + Command = IsUnsubscribe ? Command.Unsubscribe : Command.Subscribe, + Event = Event + }; + } + } +} diff --git a/DiscordAPI/RPC/Payload/ClosePayload.cs b/DiscordAPI/RPC/Payload/ClosePayload.cs new file mode 100644 index 0000000..122e7f5 --- /dev/null +++ b/DiscordAPI/RPC/Payload/ClosePayload.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.RPC.Payload +{ + internal class ClosePayload : IPayload + { + /// + /// The close code the discord gave us + /// + [JsonProperty("code")] + public int Code { get; set; } + + /// + /// The close reason discord gave us + /// + [JsonProperty("message")] + public string Reason { get; set; } + } +} diff --git a/DiscordAPI/RPC/Payload/Command.cs b/DiscordAPI/RPC/Payload/Command.cs new file mode 100644 index 0000000..1863fb1 --- /dev/null +++ b/DiscordAPI/RPC/Payload/Command.cs @@ -0,0 +1,129 @@ +using DiscordRPC.Converters; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC.RPC.Payload +{ + /// + /// The possible commands that can be sent and received by the server. + /// + internal enum Command + { + /// + /// event dispatch + /// + [EnumValue("DISPATCH")] + Dispatch, + + /// + /// Called to set the activity + /// + [EnumValue("SET_ACTIVITY")] + SetActivity, + + /// + /// used to subscribe to an RPC event + /// + [EnumValue("SUBSCRIBE")] + Subscribe, + + /// + /// used to unsubscribe from an RPC event + /// + [EnumValue("UNSUBSCRIBE")] + Unsubscribe, + + /// + /// Used to accept join requests. + /// + [EnumValue("SEND_ACTIVITY_JOIN_INVITE")] + SendActivityJoinInvite, + + /// + /// Used to reject join requests. + /// + [EnumValue("CLOSE_ACTIVITY_JOIN_REQUEST")] + CloseActivityJoinRequest, + + /// + /// used to authorize a new client with your app + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + Authorize, + + /// + /// used to authenticate an existing client with your app + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + Authenticate, + + /// + /// used to retrieve guild information from the client + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetGuild, + + /// + /// used to retrieve a list of guilds from the client + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetGuilds, + + /// + /// used to retrieve channel information from the client + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetChannel, + + /// + /// used to retrieve a list of channels for a guild from the client + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetChannels, + + + /// + /// used to change voice settings of users in voice channels + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + SetUserVoiceSettings, + + /// + /// used to join or leave a voice channel, group dm, or dm + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + SelectVoiceChannel, + + /// + /// used to get the current voice channel the client is in + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetSelectedVoiceChannel, + + /// + /// used to join or leave a text channel, group dm, or dm + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + SelectTextChannel, + + /// + /// used to retrieve the client's voice settings + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + GetVoiceSettings, + + /// + /// used to set the client's voice settings + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + SetVoiceSettings, + + /// + /// used to capture a keyboard shortcut entered by the user RPC Events + /// + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + CaptureShortcut + } +} diff --git a/DiscordAPI/RPC/Payload/IPayload.cs b/DiscordAPI/RPC/Payload/IPayload.cs new file mode 100644 index 0000000..48b1f14 --- /dev/null +++ b/DiscordAPI/RPC/Payload/IPayload.cs @@ -0,0 +1,35 @@ +using DiscordRPC.Converters; +using Newtonsoft.Json; + +namespace DiscordRPC.RPC.Payload +{ + /// + /// Base Payload that is received by both client and server + /// + internal abstract class IPayload + { + /// + /// The type of payload + /// + [JsonProperty("cmd"), JsonConverter(typeof(EnumSnakeCaseConverter))] + public Command Command { get; set; } + + /// + /// A incremental value to help identify payloads + /// + [JsonProperty("nonce")] + public string Nonce { get; set; } + + protected IPayload() { } + protected IPayload(long nonce) + { + Nonce = nonce.ToString(); + } + + public override string ToString() + { + return "Payload || Command: " + Command.ToString() + ", Nonce: " + (Nonce != null ? Nonce.ToString() : "NULL"); + } + } +} + diff --git a/DiscordAPI/RPC/Payload/PayloadArgument.cs b/DiscordAPI/RPC/Payload/PayloadArgument.cs new file mode 100644 index 0000000..2116259 --- /dev/null +++ b/DiscordAPI/RPC/Payload/PayloadArgument.cs @@ -0,0 +1,53 @@ +using DiscordRPC.Converters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DiscordRPC.RPC.Payload +{ + /// + /// The payload that is sent by the client to discord for events such as setting the rich presence. + /// + /// SetPrecense + /// + /// + internal class ArgumentPayload : IPayload + { + /// + /// The data the server sent too us + /// + [JsonProperty("args", NullValueHandling = NullValueHandling.Ignore)] + public JObject Arguments { get; set; } + + public ArgumentPayload() : base() { Arguments = null; } + public ArgumentPayload(long nonce) : base(nonce) { Arguments = null; } + public ArgumentPayload(object args, long nonce) : base(nonce) + { + SetObject(args); + } + + /// + /// Sets the obejct stored within the data. + /// + /// + public void SetObject(object obj) + { + Arguments = JObject.FromObject(obj); + } + + /// + /// Gets the object stored within the Data + /// + /// + /// + public T GetObject() + { + return Arguments.ToObject(); + } + + public override string ToString() + { + return "Argument " + base.ToString(); + } + } +} + diff --git a/DiscordAPI/RPC/Payload/PayloadEvent.cs b/DiscordAPI/RPC/Payload/PayloadEvent.cs new file mode 100644 index 0000000..5ec94f9 --- /dev/null +++ b/DiscordAPI/RPC/Payload/PayloadEvent.cs @@ -0,0 +1,57 @@ +using DiscordRPC.Converters; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DiscordRPC.RPC.Payload +{ + /// + /// Used for Discord IPC Events + /// + internal class EventPayload : IPayload + { + /// + /// The data the server sent too us + /// + [JsonProperty("data", NullValueHandling = NullValueHandling.Ignore)] + public JObject Data { get; set; } + + /// + /// The type of event the server sent + /// + [JsonProperty("evt"), JsonConverter(typeof(EnumSnakeCaseConverter))] + public ServerEvent? Event { get; set; } + + /// + /// Creates a payload with empty data + /// + public EventPayload() : base() { Data = null; } + + /// + /// Creates a payload with empty data and a set nonce + /// + /// + public EventPayload(long nonce) : base(nonce) { Data = null; } + + /// + /// Gets the object stored within the Data + /// + /// + /// + public T GetObject() + { + if (Data == null) return default(T); + return Data.ToObject(); + } + + /// + /// Converts the object into a human readable string + /// + /// + public override string ToString() + { + return "Event " + base.ToString() + ", Event: " + (Event.HasValue ? Event.ToString() : "N/A"); + } + } + + +} diff --git a/DiscordAPI/RPC/Payload/ServerEvent.cs b/DiscordAPI/RPC/Payload/ServerEvent.cs new file mode 100644 index 0000000..f91bd75 --- /dev/null +++ b/DiscordAPI/RPC/Payload/ServerEvent.cs @@ -0,0 +1,95 @@ +using DiscordRPC.Converters; +using System; +using System.Runtime.Serialization; + +namespace DiscordRPC.RPC.Payload +{ + /// + /// See https://discordapp.com/developers/docs/topics/rpc#rpc-server-payloads-rpc-events for documentation + /// + internal enum ServerEvent + { + + /// + /// Sent when the server is ready to accept messages + /// + [EnumValue("READY")] + Ready, + + /// + /// Sent when something bad has happened + /// + [EnumValue("ERROR")] + Error, + + /// + /// Join Event + /// + [EnumValue("ACTIVITY_JOIN")] + ActivityJoin, + + /// + /// Spectate Event + /// + [EnumValue("ACTIVITY_SPECTATE")] + ActivitySpectate, + + /// + /// Request Event + /// + [EnumValue("ACTIVITY_JOIN_REQUEST")] + ActivityJoinRequest, + +#if INCLUDE_FULL_RPC + //Old things that are obsolete + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("GUILD_STATUS")] + GuildStatus, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("GUILD_CREATE")] + GuildCreate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("CHANNEL_CREATE")] + ChannelCreate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_CHANNEL_SELECT")] + VoiceChannelSelect, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_STATE_CREATED")] + VoiceStateCreated, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_STATE_UPDATED")] + VoiceStateUpdated, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_STATE_DELETE")] + VoiceStateDelete, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_SETTINGS_UPDATE")] + VoiceSettingsUpdate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("VOICE_CONNECTION_STATUS")] + VoiceConnectionStatus, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("SPEAKING_START")] + SpeakingStart, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("SPEAKING_STOP")] + SpeakingStop, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("MESSAGE_CREATE")] + MessageCreate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("MESSAGE_UPDATE")] + MessageUpdate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("MESSAGE_DELETE")] + MessageDelete, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("NOTIFICATION_CREATE")] + NotificationCreate, + [Obsolete("This value is appart of the RPC API and is not supported by this library.", true)] + [EnumValue("CAPTURE_SHORTCUT_CHANGE")] + CaptureShortcutChange +#endif + } +} diff --git a/DiscordAPI/RPC/RpcConnection.cs b/DiscordAPI/RPC/RpcConnection.cs new file mode 100644 index 0000000..17cd57a --- /dev/null +++ b/DiscordAPI/RPC/RpcConnection.cs @@ -0,0 +1,869 @@ +using DiscordRPC.Helper; +using DiscordRPC.Message; +using DiscordRPC.IO; +using DiscordRPC.RPC.Commands; +using DiscordRPC.RPC.Payload; +using System; +using System.Collections.Generic; +using System.Threading; +using Newtonsoft.Json; +using DiscordRPC.Logging; +using DiscordRPC.Events; + +namespace DiscordRPC.RPC +{ + /// + /// Communicates between the client and discord through RPC + /// + internal class RpcConnection : IDisposable + { + /// + /// Version of the RPC Protocol + /// + public static readonly int VERSION = 1; + + /// + /// The rate of poll to the discord pipe. + /// + public static readonly int POLL_RATE = 1000; + + /// + /// Should we send a null presence on the fairwells? + /// + private static readonly bool CLEAR_ON_SHUTDOWN = true; + + /// + /// Should we work in a lock step manner? This option is semi-obsolete and may not work as expected. + /// + private static readonly bool LOCK_STEP = false; + + /// + /// The logger used by the RPC connection + /// + public ILogger Logger + { + get { return _logger; } + set + { + _logger = value; + if (namedPipe != null) + namedPipe.Logger = value; + } + } + private ILogger _logger; + + /// + /// Called when a message is received from the RPC and is about to be enqueued. This is cross-thread and will execute on the RPC thread. + /// + public event OnRpcMessageEvent OnRpcMessage; + + #region States + + /// + /// The current state of the RPC connection + /// + public RpcState State { get { var tmp = RpcState.Disconnected; lock (l_states) tmp = _state; return tmp; } } + private RpcState _state; + private readonly object l_states = new object(); + + /// + /// The configuration received by the Ready + /// + public Configuration Configuration { get { Configuration tmp = null; lock (l_config) tmp = _configuration; return tmp; } } + private Configuration _configuration = null; + private readonly object l_config = new object(); + + private volatile bool aborting = false; + private volatile bool shutdown = false; + + /// + /// Indicates if the RPC connection is still running in the background + /// + public bool IsRunning { get { return thread != null; } } + + /// + /// Forces the to call instead, safely saying goodbye to Discord. + /// This option helps prevents ghosting in applications where the Process ID is a host and the game is executed within the host (ie: the Unity3D editor). This will tell Discord that we have no presence and we are closing the connection manually, instead of waiting for the process to terminate. + /// + public bool ShutdownOnly { get; set; } + + #endregion + + #region Privates + + private string applicationID; //ID of the Discord APP + private int processID; //ID of the process to track + + private long nonce; //Current command index + + private Thread thread; //The current thread + private INamedPipeClient namedPipe; + + private int targetPipe; //The pipe to taget. Leave as -1 for any available pipe. + + private readonly object l_rtqueue = new object(); //Lock for the send queue + private readonly uint _maxRtQueueSize; + private Queue _rtqueue; //The send queue + + private readonly object l_rxqueue = new object(); //Lock for the receive queue + private readonly uint _maxRxQueueSize; //The max size of the RX queue + private Queue _rxqueue; //The receive queue + + private AutoResetEvent queueUpdatedEvent = new AutoResetEvent(false); + private BackoffDelay delay; //The backoff delay before reconnecting. + #endregion + + /// + /// Creates a new instance of the RPC. + /// + /// The ID of the Discord App + /// The ID of the currently running process + /// The target pipe to connect too + /// The pipe client we shall use. + /// The maximum size of the out queue + /// The maximum size of the in queue + public RpcConnection(string applicationID, int processID, int targetPipe, INamedPipeClient client, uint maxRxQueueSize = 128, uint maxRtQueueSize = 512) + { + this.applicationID = applicationID; + this.processID = processID; + this.targetPipe = targetPipe; + this.namedPipe = client; + this.ShutdownOnly = true; + + //Assign a default logger + Logger = new ConsoleLogger(); + + delay = new BackoffDelay(500, 60 * 1000); + _maxRtQueueSize = maxRtQueueSize; + _rtqueue = new Queue((int)_maxRtQueueSize + 1); + + _maxRxQueueSize = maxRxQueueSize; + _rxqueue = new Queue((int)_maxRxQueueSize + 1); + + nonce = 0; + } + + + private long GetNextNonce() + { + nonce += 1; + return nonce; + } + + #region Queues + /// + /// Enqueues a command + /// + /// The command to enqueue + internal void EnqueueCommand(ICommand command) + { + Logger.Trace("Enqueue Command: " + command.GetType().FullName); + + //We cannot add anything else if we are aborting or shutting down. + if (aborting || shutdown) return; + + //Enqueue the set presence argument + lock (l_rtqueue) + { + //If we are too big drop the last element + if (_rtqueue.Count == _maxRtQueueSize) + { + Logger.Error("Too many enqueued commands, dropping oldest one. Maybe you are pushing new presences to fast?"); + _rtqueue.Dequeue(); + } + + //Enqueue the message + _rtqueue.Enqueue(command); + } + } + + /// + /// Adds a message to the message queue. Does not copy the message, so besure to copy it yourself or dereference it. + /// + /// The message to add + private void EnqueueMessage(IMessage message) + { + //Invoke the message + try + { + if (OnRpcMessage != null) + OnRpcMessage.Invoke(this, message); + } + catch (Exception e) + { + Logger.Error("Unhandled Exception while processing event: {0}", e.GetType().FullName); + Logger.Error(e.Message); + Logger.Error(e.StackTrace); + } + + //Small queue sizes should just ignore messages + if (_maxRxQueueSize <= 0) + { + Logger.Trace("Enqueued Message, but queue size is 0."); + return; + } + + //Large queue sizes should keep the queue in check + Logger.Trace("Enqueue Message: " + message.Type); + lock (l_rxqueue) + { + //If we are too big drop the last element + if (_rxqueue.Count == _maxRxQueueSize) + { + Logger.Warning("Too many enqueued messages, dropping oldest one."); + _rxqueue.Dequeue(); + } + + //Enqueue the message + _rxqueue.Enqueue(message); + } + } + + /// + /// Dequeues a single message from the event stack. Returns null if none are available. + /// + /// + internal IMessage DequeueMessage() + { + //Logger.Trace("Deque Message"); + lock (l_rxqueue) + { + //We have nothing, so just return null. + if (_rxqueue.Count == 0) return null; + + //Get the value and remove it from the list at the same time + return _rxqueue.Dequeue(); + } + } + + /// + /// Dequeues all messages from the event stack. + /// + /// + internal IMessage[] DequeueMessages() + { + //Logger.Trace("Deque Multiple Messages"); + lock (l_rxqueue) + { + //Copy the messages into an array + IMessage[] messages = _rxqueue.ToArray(); + + //Clear the entire queue + _rxqueue.Clear(); + + //return the array + return messages; + } + } + #endregion + + /// + /// Main thread loop + /// + private void MainLoop() + { + //initialize the pipe + Logger.Info("RPC Connection Started"); + if (Logger.Level <= LogLevel.Trace) + { + Logger.Trace("============================"); + Logger.Trace("Assembly: " + System.Reflection.Assembly.GetAssembly(typeof(RichPresence)).FullName); + Logger.Trace("Pipe: " + namedPipe.GetType().FullName); + Logger.Trace("Platform: " + Environment.OSVersion.ToString()); + Logger.Trace("applicationID: " + applicationID); + Logger.Trace("targetPipe: " + targetPipe); + Logger.Trace("POLL_RATE: " + POLL_RATE); + Logger.Trace("_maxRtQueueSize: " + _maxRtQueueSize); + Logger.Trace("_maxRxQueueSize: " + _maxRxQueueSize); + Logger.Trace("============================"); + } + + //Forever trying to connect unless the abort signal is sent + //Keep Alive Loop + while (!aborting && !shutdown) + { + try + { + //Wrap everything up in a try get + //Dispose of the pipe if we have any (could be broken) + if (namedPipe == null) + { + Logger.Error("Something bad has happened with our pipe client!"); + aborting = true; + return; + } + + //Connect to a new pipe + Logger.Trace("Connecting to the pipe through the {0}", namedPipe.GetType().FullName); + if (namedPipe.Connect(targetPipe)) + { + #region Connected + //We connected to a pipe! Reset the delay + Logger.Trace("Connected to the pipe. Attempting to establish handshake..."); + EnqueueMessage(new ConnectionEstablishedMessage() { ConnectedPipe = namedPipe.ConnectedPipe }); + + //Attempt to establish a handshake + EstablishHandshake(); + Logger.Trace("Connection Established. Starting reading loop..."); + + //Continously iterate, waiting for the frame + //We want to only stop reading if the inside tells us (mainloop), if we are aborting (abort) or the pipe disconnects + // We dont want to exit on a shutdown, as we still have information + PipeFrame frame; + bool mainloop = true; + while (mainloop && !aborting && !shutdown && namedPipe.IsConnected) + { + #region Read Loop + + //Iterate over every frame we have queued up, processing its contents + if (namedPipe.ReadFrame(out frame)) + { + #region Read Payload + Logger.Trace("Read Payload: {0}", frame.Opcode); + + //Do some basic processing on the frame + switch (frame.Opcode) + { + //We have been told by discord to close, so we will consider it an abort + case Opcode.Close: + + ClosePayload close = frame.GetObject(); + Logger.Warning("We have been told to terminate by discord: ({0}) {1}", close.Code, close.Reason); + EnqueueMessage(new CloseMessage() { Code = close.Code, Reason = close.Reason }); + mainloop = false; + break; + + //We have pinged, so we will flip it and respond back with pong + case Opcode.Ping: + Logger.Trace("PING"); + frame.Opcode = Opcode.Pong; + namedPipe.WriteFrame(frame); + break; + + //We have ponged? I have no idea if Discord actually sends ping/pongs. + case Opcode.Pong: + Logger.Trace("PONG"); + break; + + //A frame has been sent, we should deal with that + case Opcode.Frame: + if (shutdown) + { + //We are shutting down, so skip it + Logger.Warning("Skipping frame because we are shutting down."); + break; + } + + if (frame.Data == null) + { + //We have invalid data, thats not good. + Logger.Error("We received no data from the frame so we cannot get the event payload!"); + break; + } + + //We have a frame, so we are going to process the payload and add it to the stack + EventPayload response = null; + try { response = frame.GetObject(); } catch (Exception e) + { + Logger.Error("Failed to parse event! " + e.Message); + Logger.Error("Data: " + frame.Message); + } + + if (response != null) ProcessFrame(response); + break; + + + default: + case Opcode.Handshake: + //We have a invalid opcode, better terminate to be safe + Logger.Error("Invalid opcode: {0}", frame.Opcode); + mainloop = false; + break; + } + + #endregion + } + + if (!aborting && namedPipe.IsConnected) + { + //Process the entire command queue we have left + ProcessCommandQueue(); + + //Wait for some time, or until a command has been queued up + queueUpdatedEvent.WaitOne(POLL_RATE); + } + + #endregion + } + #endregion + + Logger.Trace("Left main read loop for some reason. Aborting: {0}, Shutting Down: {1}", aborting, shutdown); + } + else + { + Logger.Error("Failed to connect for some reason."); + EnqueueMessage(new ConnectionFailedMessage() { FailedPipe = targetPipe }); + } + + //If we are not aborting, we have to wait a bit before trying to connect again + if (!aborting && !shutdown) + { + //We have disconnected for some reason, either a failed pipe or a bad reading, + // so we are going to wait a bit before doing it again + long sleep = delay.NextDelay(); + + Logger.Trace("Waiting {0}ms before attempting to connect again", sleep); + Thread.Sleep(delay.NextDelay()); + } + } + //catch(InvalidPipeException e) + //{ + // Logger.Error("Invalid Pipe Exception: {0}", e.Message); + //} + catch (Exception e) + { + Logger.Error("Unhandled Exception: {0}", e.GetType().FullName); + Logger.Error(e.Message); + Logger.Error(e.StackTrace); + } + finally + { + //Disconnect from the pipe because something bad has happened. An exception has been thrown or the main read loop has terminated. + if (namedPipe.IsConnected) + { + //Terminate the pipe + Logger.Trace("Closing the named pipe."); + namedPipe.Close(); + } + + //Update our state + SetConnectionState(RpcState.Disconnected); + } + } + + //We have disconnected, so dispose of the thread and the pipe. + Logger.Trace("Left Main Loop"); + if (namedPipe != null) + namedPipe.Dispose(); + + Logger.Info("Thread Terminated, no longer performing RPC connection."); + } + + #region Reading + + /// Handles the response from the pipe and calls appropriate events and changes states. + /// The response received by the server. + private void ProcessFrame(EventPayload response) + { + Logger.Info("Handling Response. Cmd: {0}, Event: {1}", response.Command, response.Event); + + //Check if it is an error + if (response.Event.HasValue && response.Event.Value == ServerEvent.Error) + { + //We have an error + Logger.Error("Error received from the RPC"); + + //Create the event objetc and push it to the queue + ErrorMessage err = response.GetObject(); + Logger.Error("Server responded with an error message: ({0}) {1}", err.Code.ToString(), err.Message); + + //Enqueue the messsage and then end + EnqueueMessage(err); + return; + } + + //Check if its a handshake + if (State == RpcState.Connecting) + { + if (response.Command == Command.Dispatch && response.Event.HasValue && response.Event.Value == ServerEvent.Ready) + { + Logger.Info("Connection established with the RPC"); + SetConnectionState(RpcState.Connected); + delay.Reset(); + + //Prepare the object + ReadyMessage ready = response.GetObject(); + lock (l_config) + { + _configuration = ready.Configuration; + ready.User.SetConfiguration(_configuration); + } + + //Enqueue the message + EnqueueMessage(ready); + return; + } + } + + if (State == RpcState.Connected) + { + switch(response.Command) + { + //We were sent a dispatch, better process it + case Command.Dispatch: + ProcessDispatch(response); + break; + + //We were sent a Activity Update, better enqueue it + case Command.SetActivity: + if (response.Data == null) + { + EnqueueMessage(new PresenceMessage()); + } + else + { + RichPresenceResponse rp = response.GetObject(); + EnqueueMessage(new PresenceMessage(rp)); + } + break; + + case Command.Unsubscribe: + case Command.Subscribe: + + //Prepare a serializer that can account for snake_case enums. + JsonSerializer serializer = new JsonSerializer(); + serializer.Converters.Add(new Converters.EnumSnakeCaseConverter()); + + //Go through the data, looking for the evt property, casting it to a server event + var evt = response.GetObject().Event.Value; + + //Enqueue the appropriate message. + if (response.Command == Command.Subscribe) + EnqueueMessage(new SubscribeMessage(evt)); + else + EnqueueMessage(new UnsubscribeMessage(evt)); + + break; + + + case Command.SendActivityJoinInvite: + Logger.Trace("Got invite response ack."); + break; + + case Command.CloseActivityJoinRequest: + Logger.Trace("Got invite response reject ack."); + break; + + //we have no idea what we were sent + default: + Logger.Error("Unkown frame was received! {0}", response.Command); + return; + } + return; + } + + Logger.Trace("Received a frame while we are disconnected. Ignoring. Cmd: {0}, Event: {1}", response.Command, response.Event); + } + + private void ProcessDispatch(EventPayload response) + { + if (response.Command != Command.Dispatch) return; + if (!response.Event.HasValue) return; + + switch(response.Event.Value) + { + //We are to join the server + case ServerEvent.ActivitySpectate: + var spectate = response.GetObject(); + EnqueueMessage(spectate); + break; + + case ServerEvent.ActivityJoin: + var join = response.GetObject(); + EnqueueMessage(join); + break; + + case ServerEvent.ActivityJoinRequest: + var request = response.GetObject(); + EnqueueMessage(request); + break; + + //Unkown dispatch event received. We should just ignore it. + default: + Logger.Warning("Ignoring {0}", response.Event.Value); + break; + } + } + + #endregion + + #region Writting + + private void ProcessCommandQueue() + { + //Logger.Info("Checking command queue"); + + //We are not ready yet, dont even try + if (State != RpcState.Connected) + return; + + //We are aborting, so we will just log a warning so we know this is probably only going to send the CLOSE + if (aborting) + Logger.Warning("We have been told to write a queue but we have also been aborted."); + + //Prepare some variabels we will clone into with locks + bool needsWriting = true; + ICommand item = null; + + //Continue looping until we dont need anymore messages + while (needsWriting && namedPipe.IsConnected) + { + lock (l_rtqueue) + { + //Pull the value and update our writing needs + // If we have nothing to write, exit the loop + needsWriting = _rtqueue.Count > 0; + if (!needsWriting) break; + + //Peek at the item + item = _rtqueue.Peek(); + } + + //BReak out of the loop as soon as we send this item + if (shutdown || (!aborting && LOCK_STEP)) + needsWriting = false; + + //Prepare the payload + IPayload payload = item.PreparePayload(GetNextNonce()); + Logger.Trace("Attempting to send payload: " + payload.Command); + + //Prepare the frame + PipeFrame frame = new PipeFrame(); + if (item is CloseCommand) + { + //We have been sent a close frame. We better just send a handwave + //Send it off to the server + SendHandwave(); + + //Queue the item + Logger.Trace("Handwave sent, ending queue processing."); + lock (l_rtqueue) _rtqueue.Dequeue(); + + //Stop sending any more messages + return; + } + else + { + if (aborting) + { + //We are aborting, so just dequeue the message and dont bother sending it + Logger.Warning("- skipping frame because of abort."); + lock (l_rtqueue) _rtqueue.Dequeue(); + } + else + { + //Prepare the frame + frame.SetObject(Opcode.Frame, payload); + + //Write it and if it wrote perfectly fine, we will dequeue it + Logger.Trace("Sending payload: " + payload.Command); + if (namedPipe.WriteFrame(frame)) + { + //We sent it, so now dequeue it + Logger.Trace("Sent Successfully."); + lock (l_rtqueue) _rtqueue.Dequeue(); + } + else + { + //Something went wrong, so just giveup and wait for the next time around. + Logger.Warning("Something went wrong during writing!"); + return; + } + } + } + } + } + + #endregion + + #region Connection + + /// + /// Establishes the handshake with the server. + /// + /// + private void EstablishHandshake() + { + Logger.Trace("Attempting to establish a handshake..."); + + //We are establishing a lock and not releasing it until we sent the handshake message. + // We need to set the key, and it would not be nice if someone did things between us setting the key. + + //Check its state + if (State != RpcState.Disconnected) + { + Logger.Error("State must be disconnected in order to start a handshake!"); + return; + } + + //Send it off to the server + Logger.Trace("Sending Handshake..."); + if (!namedPipe.WriteFrame(new PipeFrame(Opcode.Handshake, new Handshake() { Version = VERSION, ClientID = applicationID }))) + { + Logger.Error("Failed to write a handshake."); + return; + } + + //This has to be done outside the lock + SetConnectionState(RpcState.Connecting); + } + + /// + /// Establishes a fairwell with the server by sending a handwave. + /// + private void SendHandwave() + { + Logger.Info("Attempting to wave goodbye..."); + + //Check its state + if (State == RpcState.Disconnected) + { + Logger.Error("State must NOT be disconnected in order to send a handwave!"); + return; + } + + //Send the handwave + if (!namedPipe.WriteFrame(new PipeFrame(Opcode.Close, new Handshake() { Version = VERSION, ClientID = applicationID }))) + { + Logger.Error("failed to write a handwave."); + return; + } + } + + + /// + /// Attempts to connect to the pipe. Returns true on success + /// + /// + public bool AttemptConnection() + { + Logger.Info("Attempting a new connection"); + + //The thread mustn't exist already + if (thread != null) + { + Logger.Error("Cannot attempt a new connection as the previous connection thread is not null!"); + return false; + } + + //We have to be in the disconnected state + if (State != RpcState.Disconnected) + { + Logger.Warning("Cannot attempt a new connection as the previous connection hasn't changed state yet."); + return false; + } + + if (aborting) + { + Logger.Error("Cannot attempt a new connection while aborting!"); + return false; + } + + //Start the thread up + thread = new Thread(MainLoop); + thread.Name = "Discord IPC Thread"; + thread.IsBackground = true; + thread.Start(); + + return true; + } + + /// + /// Sets the current state of the pipe, locking the l_states object for thread saftey. + /// + /// The state to set it too. + private void SetConnectionState(RpcState state) + { + Logger.Trace("Setting the connection state to {0}", state.ToString().ToSnakeCase().ToUpperInvariant()); + lock (l_states) + { + _state = state; + } + } + + /// + /// Closes the connection and disposes of resources. This will not force termination, but instead allow Discord disconnect us after we say goodbye. + /// This option helps prevents ghosting in applications where the Process ID is a host and the game is executed within the host (ie: the Unity3D editor). This will tell Discord that we have no presence and we are closing the connection manually, instead of waiting for the process to terminate. + /// + public void Shutdown() + { + //Enable the flag + Logger.Trace("Initiated shutdown procedure"); + shutdown = true; + + //Clear the commands and enqueue the close + lock(l_rtqueue) + { + _rtqueue.Clear(); + if (CLEAR_ON_SHUTDOWN) _rtqueue.Enqueue(new PresenceCommand() { PID = processID, Presence = null }); + _rtqueue.Enqueue(new CloseCommand()); + } + + //Trigger the event + queueUpdatedEvent.Set(); + } + + /// + /// Closes the connection and disposes of resources. + /// + public void Close() + { + if (thread == null) + { + Logger.Error("Cannot close as it is not available!"); + return; + } + + if (aborting) + { + Logger.Error("Cannot abort as it has already been aborted"); + return; + } + + //Set the abort state + if (ShutdownOnly) + { + Shutdown(); + return; + } + + //Terminate + Logger.Trace("Updating Abort State..."); + aborting = true; + queueUpdatedEvent.Set(); + } + + + /// + /// Closes the connection and disposes resources. Identical to but ignores the "ShutdownOnly" value. + /// + public void Dispose() + { + ShutdownOnly = false; + Close(); + } + #endregion + + } + + /// + /// State of the RPC connection + /// + internal enum RpcState + { + /// + /// Disconnected from the discord client + /// + Disconnected, + + /// + /// Connecting to the discord client. The handshake has been sent and we are awaiting the ready event + /// + Connecting, + + /// + /// We are connect to the client and can send and receive messages. + /// + Connected + } +} \ No newline at end of file diff --git a/DiscordAPI/Registry/IUriSchemeCreator.cs b/DiscordAPI/Registry/IUriSchemeCreator.cs new file mode 100644 index 0000000..cf5a117 --- /dev/null +++ b/DiscordAPI/Registry/IUriSchemeCreator.cs @@ -0,0 +1,14 @@ +using DiscordRPC.Logging; + +namespace DiscordRPC.Registry +{ + internal interface IUriSchemeCreator + { + /// + /// Registers the URI scheme. If Steam ID is passed, the application will be launched through steam instead of directly. + /// Additional arguments can be supplied if required. + /// + /// The register context. + bool RegisterUriScheme(UriSchemeRegister register); + } +} diff --git a/DiscordAPI/Registry/MacUriSchemeCreator.cs b/DiscordAPI/Registry/MacUriSchemeCreator.cs new file mode 100644 index 0000000..b4d939b --- /dev/null +++ b/DiscordAPI/Registry/MacUriSchemeCreator.cs @@ -0,0 +1,54 @@ +using DiscordRPC.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Registry +{ + internal class MacUriSchemeCreator : IUriSchemeCreator + { + private ILogger logger; + public MacUriSchemeCreator(ILogger logger) + { + this.logger = logger; + } + + public bool RegisterUriScheme(UriSchemeRegister register) + { + //var home = Environment.GetEnvironmentVariable("HOME"); + //if (string.IsNullOrEmpty(home)) return; //TODO: Log Error + + string exe = register.ExecutablePath; + if (string.IsNullOrEmpty(exe)) + { + logger.Error("Failed to register because the application could not be located."); + return false; + } + + logger.Trace("Registering Steam Command"); + + //Prepare the command + string command = exe; + if (register.UsingSteamApp) command = "steam://rungameid/" + register.SteamAppID; + else logger.Warning("This library does not fully support MacOS URI Scheme Registration."); + + //get the folder ready + string filepath = "~/Library/Application Support/discord/games"; + var directory = Directory.CreateDirectory(filepath); + if (!directory.Exists) + { + logger.Error("Failed to register because {0} does not exist", filepath); + return false; + } + + //Write the contents to file + File.WriteAllText(filepath + "/" + register.ApplicationID + ".json", "{ \"command\": \"" + command + "\" }"); + logger.Trace("Registered {0}, {1}", filepath + "/" + register.ApplicationID + ".json", command); + return true; + } + + } +} diff --git a/DiscordAPI/Registry/UnixUriSchemeCreator.cs b/DiscordAPI/Registry/UnixUriSchemeCreator.cs new file mode 100644 index 0000000..25c1351 --- /dev/null +++ b/DiscordAPI/Registry/UnixUriSchemeCreator.cs @@ -0,0 +1,99 @@ +using DiscordRPC.Logging; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; + +namespace DiscordRPC.Registry +{ + internal class UnixUriSchemeCreator : IUriSchemeCreator + { + private ILogger logger; + public UnixUriSchemeCreator(ILogger logger) + { + this.logger = logger; + } + + public bool RegisterUriScheme(UriSchemeRegister register) + { + var home = Environment.GetEnvironmentVariable("HOME"); + if (string.IsNullOrEmpty(home)) + { + logger.Error("Failed to register because the HOME variable was not set."); + return false; + } + + string exe = register.ExecutablePath; + if (string.IsNullOrEmpty(exe)) + { + logger.Error("Failed to register because the application was not located."); + return false; + } + + //Prepare the command + string command = null; + if (register.UsingSteamApp) + { + //A steam command isntead + command = "xdg-open steam://rungameid/" + register.SteamAppID; + } + else + { + //Just a regular discord command + command = exe; + } + + + //Prepare the file + string desktopFileFormat = +@"[Desktop Entry] +Name=Game {0} +Exec={1} %u +Type=Application +NoDisplay=true +Categories=Discord;Games; +MimeType=x-scheme-handler/discord-{2}"; + + string file = string.Format(desktopFileFormat, register.ApplicationID, command, register.ApplicationID); + + //Prepare the path + string filename = "/discord-" + register.ApplicationID + ".desktop"; + string filepath = home + "/.local/share/applications"; + var directory = Directory.CreateDirectory(filepath); + if (!directory.Exists) + { + logger.Error("Failed to register because {0} does not exist", filepath); + return false; + } + + //Write the file + File.WriteAllText(filepath + filename, file); + + //Register the Mime type + if (!RegisterMime(register.ApplicationID)) + { + logger.Error("Failed to register because the Mime failed."); + return false; + } + + logger.Trace("Registered {0}, {1}, {2}", filepath + filename, file, command); + return true; + } + + private bool RegisterMime(string appid) + { + //Format the arguments + string format = "default discord-{0}.desktop x-scheme-handler/discord-{0}"; + string arguments = string.Format(format, appid); + + //Run the process and wait for response + Process process = Process.Start("xdg-mime", arguments); + process.WaitForExit(); + + //Return if succesful + return process.ExitCode >= 0; + } + } +} diff --git a/DiscordAPI/Registry/UriScheme.cs b/DiscordAPI/Registry/UriScheme.cs new file mode 100644 index 0000000..2113d96 --- /dev/null +++ b/DiscordAPI/Registry/UriScheme.cs @@ -0,0 +1,89 @@ +using DiscordRPC.Logging; +using System; +using System.Diagnostics; + +namespace DiscordRPC.Registry +{ + internal class UriSchemeRegister + { + /// + /// The ID of the Discord App to register + /// + public string ApplicationID { get; set; } + + /// + /// Optional Steam App ID to register. If given a value, then the game will launch through steam instead of Discord. + /// + public string SteamAppID { get; set; } + + /// + /// Is this register using steam? + /// + public bool UsingSteamApp { get { return !string.IsNullOrEmpty(SteamAppID) && SteamAppID != ""; } } + + /// + /// The full executable path of the application. + /// + public string ExecutablePath { get; set; } + + private ILogger _logger; + public UriSchemeRegister(ILogger logger, string applicationID, string steamAppID = null, string executable = null) + { + _logger = logger; + ApplicationID = applicationID.Trim(); + SteamAppID = steamAppID != null ? steamAppID.Trim() : null; + ExecutablePath = executable ?? GetApplicationLocation(); + } + + /// + /// Registers the URI scheme, using the correct creator for the correct platform + /// + public bool RegisterUriScheme() + { + //Get the creator + IUriSchemeCreator creator = null; + switch(Environment.OSVersion.Platform) + { + case PlatformID.Win32Windows: + case PlatformID.Win32S: + case PlatformID.Win32NT: + case PlatformID.WinCE: + _logger.Trace("Creating Windows Scheme Creator"); + creator = new WindowsUriSchemeCreator(_logger); + break; + + case PlatformID.Unix: + _logger.Trace("Creating Unix Scheme Creator"); + creator = new UnixUriSchemeCreator(_logger); + break; + + case PlatformID.MacOSX: + _logger.Trace("Creating MacOSX Scheme Creator"); + creator = new MacUriSchemeCreator(_logger); + break; + + default: + _logger.Error("Unkown Platform: " + Environment.OSVersion.Platform); + throw new PlatformNotSupportedException("Platform does not support registration."); + } + + //Regiser the app + if (creator.RegisterUriScheme(this)) + { + _logger.Info("URI scheme registered."); + return true; + } + + return false; + } + + /// + /// Gets the FileName for the currently executing application + /// + /// + public static string GetApplicationLocation() + { + return Process.GetCurrentProcess().MainModule.FileName; + } + } +} diff --git a/DiscordAPI/Registry/WindowsUriSchemeCreator.cs b/DiscordAPI/Registry/WindowsUriSchemeCreator.cs new file mode 100644 index 0000000..cef4312 --- /dev/null +++ b/DiscordAPI/Registry/WindowsUriSchemeCreator.cs @@ -0,0 +1,87 @@ +using DiscordRPC.Logging; +using System; + +namespace DiscordRPC.Registry +{ + internal class WindowsUriSchemeCreator : IUriSchemeCreator + { + private ILogger logger; + public WindowsUriSchemeCreator(ILogger logger) + { + this.logger = logger; + } + + public bool RegisterUriScheme(UriSchemeRegister register) + { + if (Environment.OSVersion.Platform == PlatformID.Unix || Environment.OSVersion.Platform == PlatformID.MacOSX) + { + throw new PlatformNotSupportedException("URI schemes can only be registered on Windows"); + } + + //Prepare our location + string location = register.ExecutablePath; + if (location == null) + { + logger.Error("Failed to register application because the location was null."); + return false; + } + + //Prepare the Scheme, Friendly name, default icon and default command + string scheme = "discord-" + register.ApplicationID; + string friendlyName = "Run game " + register.ApplicationID + " protocol"; + string defaultIcon = location; + string command = location; + + //We have a steam ID, so attempt to replce the command with a steam command + if (register.UsingSteamApp) + { + //Try to get the steam location. If found, set the command to a run steam instead. + string steam = GetSteamLocation(); + if (steam != null) + command = string.Format("\"{0}\" steam://rungameid/{1}", steam, register.SteamAppID); + + } + + //Okay, now actually register it + CreateUriScheme(scheme, friendlyName, defaultIcon, command); + return true; + } + + /// + /// Creates the actual scheme + /// + /// + /// + /// + /// + private void CreateUriScheme(string scheme, string friendlyName, string defaultIcon, string command) + { + using (var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey("SOFTWARE\\Classes\\" + scheme)) + { + key.SetValue("", "URL:" + friendlyName); + key.SetValue("URL Protocol", ""); + + using (var iconKey = key.CreateSubKey("DefaultIcon")) + iconKey.SetValue("", defaultIcon); + + using (var commandKey = key.CreateSubKey("shell\\open\\command")) + commandKey.SetValue("", command); + } + + logger.Trace("Registered {0}, {1}, {2}", scheme, friendlyName, command); + } + + /// + /// Gets the current location of the steam client + /// + /// + public string GetSteamLocation() + { + using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Valve\\Steam")) + { + if (key == null) return null; + return key.GetValue("SteamExe") as string; + } + } + } +} diff --git a/DiscordAPI/RichPresence.cs b/DiscordAPI/RichPresence.cs new file mode 100644 index 0000000..f1d3c99 --- /dev/null +++ b/DiscordAPI/RichPresence.cs @@ -0,0 +1,836 @@ +using Newtonsoft.Json; +using System; +using DiscordRPC.Helper; +using System.Text; +using DiscordRPC.Exceptions; + +namespace DiscordRPC +{ + /// + /// The Rich Presence structure that will be sent and received by Discord. Use this class to build your presence and update it appropriately. + /// + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + [Serializable] + public class RichPresence + { + /// + /// The user's current status. For example, "Playing Solo" or "With Friends". + /// Max 128 bytes + /// + [JsonProperty("state", NullValueHandling = NullValueHandling.Ignore)] + public string State + { + get { return _state; } + set + { + if (!ValidateString(value, out _state, 128, Encoding.UTF8)) + throw new StringOutOfRangeException("State", 0, 128); + } + } + private string _state; + + /// + /// What the user is currently doing. For example, "Competitive - Total Mayhem". + /// Max 128 bytes + /// + [JsonProperty("details", NullValueHandling = NullValueHandling.Ignore)] + public string Details + { + get { return _details; } + set + { + if (!ValidateString(value, out _details, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _details; + + /// + /// The time elapsed / remaining time data. + /// + [JsonProperty("timestamps", NullValueHandling = NullValueHandling.Ignore)] + public Timestamps Timestamps { get; set; } + + /// + /// The names of the images to use and the tooltips to give those images. + /// + [JsonProperty("assets", NullValueHandling = NullValueHandling.Ignore)] + public Assets Assets { get; set; } + + /// + /// The party the player is currently in. The must be set for this to be included in the RichPresence update. + /// + [JsonProperty("party", NullValueHandling = NullValueHandling.Ignore)] + public Party Party { get; set; } + + /// + /// The secrets used for Join / Spectate. Secrets are obfuscated data of your choosing. They could be match ids, player ids, lobby ids, etc. Make this object null if you do not wish too / unable too implement the Join / Request feature. + /// To keep security on the up and up, Discord requires that you properly hash/encode/encrypt/put-a-padlock-on-and-swallow-the-key-but-wait-then-how-would-you-open-it your secrets. + /// Visit the Rich Presence How-To for more information. + /// + [JsonProperty("secrets", NullValueHandling = NullValueHandling.Ignore)] + public Secrets Secrets { get; set; } + + /// + /// Marks the as a game session with a specific beginning and end. It was going to be used as a form of notification, but was replaced with the join feature. It may potentially have use in the future, but it currently has no use. + /// + /// "TLDR it marks the matchSecret field as an instance, that is to say a context in game that’s not like a lobby state/not in game state. It was gonna he used for notify me, but we scrapped that for ask to join. We may put it to another use in the future. For now, don’t worry about it" - Mason (Discord API Server 14 / 03 / 2018) + /// + /// + [JsonProperty("instance", NullValueHandling = NullValueHandling.Ignore)] + [Obsolete("This was going to be used, but was replaced by JoinSecret instead")] + private bool Instance { get; set; } + + /// + /// Clones the presence into a new instance. Used for thread safe writing and reading. This function will ignore properties if they are in a invalid state. + /// + /// + public RichPresence Clone() + { + return new RichPresence + { + State = this._state != null ? _state.Clone() as string : null, + Details = this._details != null ? _details.Clone() as string : null, + + Secrets = !HasSecrets() ? null : new Secrets + { + //MatchSecret = this.Secrets.MatchSecret?.Clone() as string, + JoinSecret = this.Secrets.JoinSecret != null ? this.Secrets.JoinSecret.Clone() as string : null, + SpectateSecret = this.Secrets.SpectateSecret != null ? this.Secrets.SpectateSecret.Clone() as string : null + }, + + Timestamps = !HasTimestamps() ? null : new Timestamps + { + Start = this.Timestamps.Start, + End = this.Timestamps.End + }, + + Assets = !HasAssets() ? null : new Assets + { + LargeImageKey = this.Assets.LargeImageKey != null ? this.Assets.LargeImageKey.Clone() as string : null, + LargeImageText = this.Assets.LargeImageText != null ? this.Assets.LargeImageText.Clone() as string : null, + SmallImageKey = this.Assets.SmallImageKey != null ? this.Assets.SmallImageKey.Clone() as string : null, + SmallImageText = this.Assets.SmallImageText != null ? this.Assets.SmallImageText.Clone() as string : null + }, + + Party = !HasParty() ? null : new Party + { + ID = this.Party.ID, + Size = this.Party.Size, + Max = this.Party.Max + } + }; + } + + /// + /// Merges the passed presence with this one, taking into account the image key to image id annoyance. + /// + /// + internal void Merge(RichPresence presence) + { + this._state = presence._state; + this._details = presence._details; + this.Party = presence.Party; + this.Timestamps = presence.Timestamps; + this.Secrets = presence.Secrets; + + //If they have assets, we should merge them + if (presence.HasAssets()) + { + //Make sure we actually have assets too + if (!this.HasAssets()) + { + //We dont, so we will just use theirs + this.Assets = presence.Assets; + } + else + { + //We do, so we better merge them! + this.Assets.Merge(presence.Assets); + } + } + else + { + //They dont have assets, so we will just set ours to null + this.Assets = null; + } + } + + /// + /// Updates this presence with any values from the previous one + /// + /// + [System.Obsolete("No longer used and probably can be removed.")] + internal void Update(RichPresence presence) + { + if (presence == null) return; + + this._state = presence._state ?? this._state; + this._details = presence._details ?? this._details; + + if (presence.Party != null) + { + if (this.Party != null) + { + this.Party.ID = presence.Party.ID ?? this.Party.ID; + this.Party.Size = presence.Party.Size; + this.Party.Max = presence.Party.Max; + } + else + { + this.Party = presence.Party; + } + } + } + + #region Has Checks + /// + /// Does the Rich Presence have valid timestamps? + /// + /// + public bool HasTimestamps() + { + return this.Timestamps != null && (Timestamps.Start != null || Timestamps.End != null); + } + + /// + /// Does the Rich Presence have valid assets? + /// + /// + public bool HasAssets() + { + return this.Assets != null; + } + + /// + /// Does the Rich Presence have a valid party? + /// + /// + public bool HasParty() + { + return this.Party != null && this.Party.ID != null; + } + + /// + /// Does the Rich Presence have valid secrets? + /// + /// + public bool HasSecrets() + { + return Secrets != null && (Secrets.JoinSecret != null || Secrets.SpectateSecret != null); + } + #endregion + + #region Builder + /// + /// Sets the state of the Rich Presence. See also . + /// + /// The user's current status. + /// The modified Rich Presence. + public RichPresence WithState(string state) + { + State = state; + return this; + } + + /// + /// Sets the details of the Rich Presence. See also . + /// + /// What the user is currently doing. + /// The modified Rich Presence. + public RichPresence WithDetails(string details) + { + Details = details; + return this; + } + + /// + /// Sets the timestamp of the Rich Presence. See also . + /// + /// The time elapsed / remaining time data. + /// The modified Rich Presence. + public RichPresence WithTimestamps(Timestamps timestamps) + { + Timestamps = timestamps; + return this; + } + + /// + /// Sets the assets of the Rich Presence. See also . + /// + /// The names of the images to use and the tooltips to give those images. + /// The modified Rich Presence. + public RichPresence WithAssets(Assets assets) + { + Assets = assets; + return this; + } + + /// + /// Sets the Rich Presence's party. See also . + /// + /// The party the player is currently in. + /// The modified Rich Presence. + public RichPresence WithParty(Party party) + { + Party = party; + return this; + } + + /// + /// Sets the Rich Presence's secrets. See also . + /// + /// The secrets used for Join / Spectate. + /// The modified Rich Presence. + public RichPresence WithSecrets(Secrets secrets) + { + Secrets = secrets; + return this; + } + #endregion + + /// + /// Attempts to call on the string and return the result, if its within a valid length. + /// + /// The string to check + /// The formatted string result + /// The maximum number of bytes the string can take up + /// The encoding to count the bytes with + /// True if the string fits within the number of bytes + internal static bool ValidateString(string str, out string result, int bytes, Encoding encoding) + { + result = str; + if (str == null) + return true; + + //Trim the string, for the best chance of fitting + var s = str.Trim(); + + //Make sure it fits + if (!s.WithinLength(bytes, encoding)) + return false; + + //Make sure its not empty + result = s.GetNullOrString(); + return true; + } + + /// + /// Operator that converts a presence into a boolean for null checks. + /// + /// + public static implicit operator bool(RichPresence presesnce) + { + return presesnce != null; + } + + /// + /// Checks if the other rich presence differs from the current one + /// + /// + /// + internal bool Matches(RichPresence other) + { +#pragma warning disable CS0618 // Type or member is obsolete + if (other == null) + return false; + + if (State != other.State || Details != other.Details) + return false; + + //Checks if the timestamps are different + if (Timestamps != null) + { + if (other.Timestamps == null || + other.Timestamps.StartUnixMilliseconds != Timestamps.StartUnixMilliseconds || + other.Timestamps.EndUnixMilliseconds != Timestamps.EndUnixMilliseconds) + return false; + } + else if (other.Timestamps != null) + { + return false; + } + + //Checks if the secrets are different + if (Secrets != null) + { + if (other.Secrets == null || + other.Secrets.JoinSecret != Secrets.JoinSecret || + other.Secrets.MatchSecret != Secrets.MatchSecret || + other.Secrets.SpectateSecret != Secrets.SpectateSecret) + return false; + } + else if (other.Secrets != null) + { + return false; + } + + //Checks if the timestamps are different + if (Party != null) + { + if (other.Party == null || + other.Party.ID != Party.ID || + other.Party.Max != Party.Max || + other.Party.Size != Party.Size) + return false; + } + else if (other.Party != null) + { + return false; + } + + //Checks if the assets are different + if (Assets != null) + { + if (other.Assets == null || + other.Assets.LargeImageKey != Assets.LargeImageKey || + other.Assets.LargeImageText != Assets.LargeImageText || + other.Assets.SmallImageKey != Assets.SmallImageKey || + other.Assets.SmallImageText != Assets.SmallImageText) + return false; + } + else if (other.Assets != null) + { + return false; + } + + return Instance == other.Instance; +#pragma warning restore CS0618 // Type or member is obsolete + } + } + + /// + /// The secrets used for Join / Spectate. Secrets are obfuscated data of your choosing. They could be match ids, player ids, lobby ids, etc. + /// To keep security on the up and up, Discord requires that you properly hash/encode/encrypt/put-a-padlock-on-and-swallow-the-key-but-wait-then-how-would-you-open-it your secrets. + /// You should send discord data that someone else's game client would need to join or spectate their friend. If you can't or don't want to support those actions, you don't need to send secrets. + /// Visit the Rich Presence How-To for more information. + /// + [Serializable] + public class Secrets + { + /// + /// The unique match code to distinguish different games/lobbies. Use to get an appropriately sized secret. + /// This cannot be null and must be supplied for the Join / Spectate feature to work. + /// Max Length of 128 Bytes + /// + [Obsolete("This feature has been deprecated my Mason in issue #152 on the offical library. Was originally used as a Notify Me feature, it has been replaced with Join / Spectate.")] + [JsonProperty("match", NullValueHandling = NullValueHandling.Ignore)] + public string MatchSecret + { + get { return _matchSecret; } + set + { + if (!RichPresence.ValidateString(value, out _matchSecret, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _matchSecret; + + /// + /// The secret data that will tell the client how to connect to the game to play. This could be a unique identifier for a fancy match maker or player id, lobby id, etc. + /// It is recommended to encrypt this information so its hard for people to replicate it. + /// Do NOT just use the IP address in this. That is a bad practice and can leave your players vulnerable! + /// + /// Max Length of 128 Bytes + /// + [JsonProperty("join", NullValueHandling = NullValueHandling.Ignore)] + public string JoinSecret + { + get { return _joinSecret; } + set + { + if (!RichPresence.ValidateString(value, out _joinSecret, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _joinSecret; + + /// + /// The secret data that will tell the client how to connect to the game to spectate. This could be a unique identifier for a fancy match maker or player id, lobby id, etc. + /// It is recommended to encrypt this information so its hard for people to replicate it. + /// Do NOT just use the IP address in this. That is a bad practice and can leave your players vulnerable! + /// + /// Max Length of 128 Bytes + /// + [JsonProperty("spectate", NullValueHandling = NullValueHandling.Ignore)] + public string SpectateSecret + { + get { return _spectateSecret; } + set + { + if (!RichPresence.ValidateString(value, out _spectateSecret, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _spectateSecret; + + + #region Statics + + /// + /// The encoding the secret generator is using + /// + public static Encoding Encoding { get { return Encoding.UTF8; } } + + /// + /// The length of a secret in bytes. + /// + public static int SecretLength { get { return 128; } } + + /// + /// Creates a new secret. This is NOT a cryptographic function and should NOT be used for sensitive information. This is mainly provided as a way to generate quick IDs. + /// + /// The random to use + /// Returns a sized string with random characters from + public static string CreateSecret(Random random) + { + //Prepare an array and fill it with random bytes + // THIS IS NOT SECURE! DO NOT USE THIS FOR PASSWORDS! + byte[] bytes = new byte[SecretLength]; + random.NextBytes(bytes); + + //Return the encoding. Probably should remove invalid characters but cannot be fucked. + return Encoding.GetString(bytes); + } + + + /// + /// Creates a secret word using more readable friendly characters. Useful for debugging purposes. This is not a cryptographic function and should NOT be used for sensitive information. + /// + /// The random used to generate the characters + /// + public static string CreateFriendlySecret(Random random) + { + string charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + string secret = ""; + + for (int i = 0; i < SecretLength; i++) + secret += charset[random.Next(charset.Length)]; + + return secret; + } + #endregion + } + + /// + /// Information about the pictures used in the Rich Presence. + /// + [Serializable] + public class Assets + { + /// + /// Name of the uploaded image for the large profile artwork. + /// Max 32 Bytes. + /// + [JsonProperty("large_image", NullValueHandling = NullValueHandling.Ignore)] + public string LargeImageKey + { + get { return _largeimagekey; } + set + { + if (!RichPresence.ValidateString(value, out _largeimagekey, 32, Encoding.UTF8)) + throw new StringOutOfRangeException(32); + + //Reset the large image ID + _largeimageID = null; + } + } + private string _largeimagekey; + + /// + /// The tooltip for the large square image. For example, "Summoners Rift" or "Horizon Lunar Colony". + /// Max 128 Bytes. + /// + [JsonProperty("large_text", NullValueHandling = NullValueHandling.Ignore)] + public string LargeImageText + { + get { return _largeimagetext; } + set + { + if (!RichPresence.ValidateString(value, out _largeimagetext, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _largeimagetext; + + + /// + /// Name of the uploaded image for the small profile artwork. + /// Max 32 Bytes. + /// + [JsonProperty("small_image", NullValueHandling = NullValueHandling.Ignore)] + public string SmallImageKey + { + get { return _smallimagekey; } + set + { + if (!RichPresence.ValidateString(value, out _smallimagekey, 32, Encoding.UTF8)) + throw new StringOutOfRangeException(32); + + //Reset the small image id + _smallimageID = null; + } + } + private string _smallimagekey; + + /// + /// The tooltip for the small circle image. For example, "LvL 6" or "Ultimate 85%". + /// Max 128 Bytes. + /// + [JsonProperty("small_text", NullValueHandling = NullValueHandling.Ignore)] + public string SmallImageText + { + get { return _smallimagetext; } + set + { + if (!RichPresence.ValidateString(value, out _smallimagetext, 128, Encoding.UTF8)) + throw new StringOutOfRangeException(128); + } + } + private string _smallimagetext; + + /// + /// The ID of the large image. This is only set after Update Presence and will automatically become null when is changed. + /// + [JsonIgnore] + public ulong? LargeImageID { get { return _largeimageID; } } + private ulong? _largeimageID; + + /// + /// The ID of the small image. This is only set after Update Presence and will automatically become null when is changed. + /// + [JsonIgnore] + public ulong? SmallImageID { get { return _smallimageID; } } + private ulong? _smallimageID; + + /// + /// Merges this asset with the other, taking into account for ID's instead of keys. + /// + /// + internal void Merge(Assets other) + { + //Copy over the names + _smallimagetext = other._smallimagetext; + _largeimagetext = other._largeimagetext; + + //Convert large ID + ulong largeID; + if (ulong.TryParse(other._largeimagekey, out largeID)) + { + _largeimageID = largeID; + } + else + { + _largeimagekey = other._largeimagekey; + _largeimageID = null; + } + + //Convert the small ID + ulong smallID; + if (ulong.TryParse(other._smallimagekey, out smallID)) + { + _smallimageID = smallID; + } + else + { + _smallimagekey = other._smallimagekey; + _smallimageID = null; + } + } + } + + /// + /// Structure representing the start and endtimes of a match. + /// + [Serializable] + public class Timestamps + { + /// A new timestamp that starts from the current time. + public static Timestamps Now { get { return new Timestamps(DateTime.UtcNow, end: null); } } + + /// + /// Creates a new timestamp starting at the current time and ending in the supplied timespan + /// + /// How long the Timestamp will last for in seconds. + /// Returns a new timestamp with given duration. + public static Timestamps FromTimeSpan(double seconds) { return FromTimeSpan(TimeSpan.FromSeconds(seconds)); } + + /// + /// Creates a new timestamp starting at current time and ending in the supplied timespan + /// + /// How long the Timestamp will last for. + /// Returns a new timestamp with given duration. + public static Timestamps FromTimeSpan(TimeSpan timespan) + { + return new Timestamps() + { + Start = DateTime.UtcNow, + End = DateTime.UtcNow + timespan + }; + } + + /// + /// The time that match started. When included (not-null), the time in the rich presence will be shown as "00:01 elapsed". + /// + [JsonIgnore] + public DateTime? Start { get; set; } + + /// + /// The time the match will end. When included (not-null), the time in the rich presence will be shown as "00:01 remaining". This will override the "elapsed" to "remaining". + /// + [JsonIgnore] + public DateTime? End { get; set; } + + /// + /// Creates a empty timestamp object + /// + public Timestamps() + { + Start = null; + End = null; + } + + /// + /// Creates a timestamp with the set start or end time. + /// + /// The start time + /// The end time + public Timestamps(DateTime start, DateTime? end = null) + { + Start = start; + End = end; + } + + /// + /// Converts between DateTime and Milliseconds to give the Unix Epoch Time for the . + /// + [JsonProperty("start", NullValueHandling = NullValueHandling.Ignore)] + public ulong? StartUnixMilliseconds + { + get + { + return Start.HasValue ? ToUnixMilliseconds(Start.Value) : (ulong?)null; + } + + set + { + Start = value.HasValue ? FromUnixMilliseconds(value.Value) : (DateTime?)null; + } + } + + + /// + /// Converts between DateTime and Milliseconds to give the Unix Epoch Time for the . + /// + /// + [JsonProperty("end", NullValueHandling = NullValueHandling.Ignore)] + public ulong? EndUnixMilliseconds + { + get + { + return End.HasValue ? ToUnixMilliseconds(End.Value) : (ulong?)null; + } + + set + { + End = value.HasValue ? FromUnixMilliseconds(value.Value) : (DateTime?)null; + } + } + + /// + /// Converts a Unix Epoch time into a . + /// + /// The time in milliseconds since 1970 / 01 / 01 + /// + public static DateTime FromUnixMilliseconds(ulong unixTime) + { + var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + return epoch.AddMilliseconds(Convert.ToDouble(unixTime)); + } + + /// + /// Converts a into a Unix Epoch time (in milliseconds). + /// + /// The datetime to convert + /// + public static ulong ToUnixMilliseconds(DateTime date) + { + var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + return Convert.ToUInt64((date - epoch).TotalMilliseconds); + } + + } + + /// + /// Structure representing the part the player is in. + /// + [Serializable] + public class Party + { + /// + /// A unique ID for the player's current party / lobby / group. If this is not supplied, they player will not be in a party and the rest of the information will not be sent. + /// Max 128 Bytes + /// + [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] + public string ID { get { return _partyid; } set { _partyid = value.GetNullOrString(); } } + private string _partyid; + + /// + /// The current size of the players party / lobby / group. + /// + [JsonIgnore] + public int Size { get; set; } + + /// + /// The maxium size of the party / lobby / group. This is required to be larger than . If it is smaller than the current party size, it will automatically be set too when the presence is sent. + /// + [JsonIgnore] + public int Max { get; set; } + + [JsonProperty("size", NullValueHandling = NullValueHandling.Ignore)] + private int[] _size + { + get + { + //see issue https://github.com/discordapp/discord-rpc/issues/111 + int size = Math.Max(1, Size); + return new int[] { size, Math.Max(size, Max) }; + } + + set + { + if (value.Length != 2) + { + Size = 0; + Max = 0; + } + else + { + Size = value[0]; + Max = value[1]; + } + } + + } + } + + + /// + /// A rich presence that has been parsed from the pipe as a response. + /// + internal class RichPresenceResponse : RichPresence + { + /// + /// ID of the client + /// + [JsonProperty("application_id")] + public string ClientID { get; private set; } + + /// + /// Name of the bot + /// + [JsonProperty("name")] + public string Name { get; private set; } + + } +} diff --git a/DiscordAPI/User.cs b/DiscordAPI/User.cs new file mode 100644 index 0000000..29e9631 --- /dev/null +++ b/DiscordAPI/User.cs @@ -0,0 +1,229 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace DiscordRPC +{ + /// + /// Object representing a Discord user. This is used for join requests. + /// + public class User + { + /// + /// Possible formats for avatars + /// + public enum AvatarFormat + { + /// + /// Portable Network Graphics format (.png) + /// Losses format that supports transparent avatars. Most recommended for stationary formats with wide support from many libraries. + /// + PNG, + + /// + /// Joint Photographic Experts Group format (.jpeg) + /// The format most cameras use. Lossy and does not support transparent avatars. + /// + JPEG, + + /// + /// WebP format (.webp) + /// Picture only version of WebM. Pronounced "weeb p". + /// + WebP, + + /// + /// Graphics Interchange Format (.gif) + /// Animated avatars that Discord Nitro users are able to use. If the user doesn't have an animated avatar, then it will just be a single frame gif. + /// + GIF //Gif, as in gift. + } + + /// + /// Possible square sizes of avatars. + /// + public enum AvatarSize + { + /// 16 x 16 pixels. + x16 = 16, + /// 32 x 32 pixels. + x32 = 32, + /// 64 x 64 pixels. + x64 = 64, + /// 128 x 128 pixels. + x128 = 128, + /// 256 x 256 pixels. + x256 = 256, + /// 512 x 512 pixels. + x512 = 512, + /// 1024 x 1024 pixels. + x1024 = 1024, + /// 2048 x 2048 pixels. + x2048 = 2048 + } + + /// + /// The snowflake ID of the user. + /// + [JsonProperty("id")] + public ulong ID { get; private set; } + + /// + /// The username of the player. + /// + [JsonProperty("username")] + public string Username { get; private set; } + + /// + /// The discriminator of the user. + /// + [JsonProperty("discriminator")] + public int Discriminator { get; private set; } + + /// + /// The avatar hash of the user. Too get a URL for the avatar, use the . This can be null if the user has no avatar. The will account for this and return the discord default. + /// + [JsonProperty("avatar")] + public string Avatar { get; private set; } + + /// + /// The flags on a users account, often represented as a badge. + /// + [JsonProperty("flags")] + public Flag Flags { get; private set; } + + /// + /// A flag on the user account + /// + [Flags] + public enum Flag + { + /// No flag + None = 0, + + /// Staff of Discord. + Employee = 1 << 0, + + /// Partners of Discord. + Partner = 1 << 1, + + /// Original HypeSquad which organise events. + HypeSquad = 1 << 2, + + /// Bug Hunters that found and reported bugs in Discord. + BugHunter = 1 << 3, + + //These 2 are mistery types + //A = 1 << 4, + //B = 1 << 5, + + /// The HypeSquad House of Bravery. + HouseBravery = 1 << 6, + + /// The HypeSquad House of Brilliance. + HouseBrilliance = 1 << 7, + + /// The HypeSquad House of Balance (the best one). + HouseBalance = 1 << 8, + + /// Early Supporter of Discord and had Nitro before the store was released. + EarlySupporter = 1 << 9, + + /// Apart of a team. + /// Unclear if it is reserved for members that share a team with the current application. + /// + TeamUser = 1 << 10 + } + + /// + /// The premium type of the user. + /// + [JsonProperty("premium_type")] + public PremiumType Premium { get; private set; } + + /// + /// Type of premium + /// + public enum PremiumType + { + /// No subscription to any forms of Nitro. + None = 0, + + /// Nitro Classic subscription. Has chat perks and animated avatars. + NitroClassic = 1, + + /// Nitro subscription. Has chat perks, animated avatars, server boosting, and access to free Nitro Games. + Nitro = 2 + } + + /// + /// The endpoint for the CDN. Normally cdn.discordapp.com. + /// + public string CdnEndpoint { get; private set; } + + /// + /// Creates a new User instance. + /// + internal User() + { + CdnEndpoint = "cdn.discordapp.com"; + } + + /// + /// Updates the URL paths to the appropriate configuration + /// + /// The configuration received by the OnReady event. + internal void SetConfiguration(Configuration configuration) + { + this.CdnEndpoint = configuration.CdnHost; + } + + /// + /// Gets a URL that can be used to download the user's avatar. If the user has not yet set their avatar, it will return the default one that discord is using. The default avatar only supports the format. + /// + /// The format of the target avatar + /// The optional size of the avatar you wish for. Defaults to x128. + /// + public string GetAvatarURL(AvatarFormat format, AvatarSize size = AvatarSize.x128) + { + //Prepare the endpoint + string endpoint = "/avatars/" + ID + "/" + Avatar; + + //The user has no avatar, so we better replace it with the default + if (string.IsNullOrEmpty(Avatar)) + { + //Make sure we are only using PNG + if (format != AvatarFormat.PNG) + throw new BadImageFormatException("The user has no avatar and the requested format " + format.ToString() + " is not supported. (Only supports PNG)."); + + //Get the endpoint + int descrim = Discriminator % 5; + endpoint = "/embed/avatars/" + descrim; + } + + //Finish of the endpoint + return string.Format("https://{0}{1}{2}?size={3}", this.CdnEndpoint, endpoint, GetAvatarExtension(format), (int)size); + } + + /// + /// Returns the file extension of the specified format. + /// + /// The format to get the extention off + /// Returns a period prefixed file extension. + public string GetAvatarExtension(AvatarFormat format) + { + return "." + format.ToString().ToLowerInvariant(); + } + + /// + /// Formats the user into username#discriminator + /// + /// + public override string ToString() + { + return Username + "#" + Discriminator.ToString("D4"); + } + } +} diff --git a/DiscordAPI/Web/WebRPC.cs b/DiscordAPI/Web/WebRPC.cs new file mode 100644 index 0000000..1dc7678 --- /dev/null +++ b/DiscordAPI/Web/WebRPC.cs @@ -0,0 +1,191 @@ +using DiscordRPC.Exceptions; +using DiscordRPC.RPC; +using DiscordRPC.RPC.Commands; +using DiscordRPC.RPC.Payload; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Net; + +#if INCLUDE_WEB_RPC +namespace DiscordRPC.Web +{ + /// + /// Handles HTTP Rich Presence Requests + /// + [System.Obsolete("Rich Presence over HTTP is no longer supported by Discord. See offical Rich Presence github for more information.")] + public static class WebRPC + { + /// + /// Sets the Rich Presence over the HTTP protocol. Does not support Join / Spectate and by default is blocking. + /// + /// The presence to send to discord + /// The ID of the application + /// The port the discord client is currently on. Specify this for testing. Will start scanning from supplied port. + /// Returns the rich presence result from the server. This can be null if presence was set to be null, or if there was no valid response from the client. + [System.Obsolete("Setting Rich Presence over HTTP is no longer supported by Discord. See offical Rich Presence github for more information.")] + public static RichPresence SetRichPresence(RichPresence presence, string applicationID, int port = 6463) + { + try + { + RichPresence response; + if (TrySetRichPresence(presence, out response, applicationID, port)) + return response; + + return null; + } + catch (Exception) + { + throw; + } + } + + /// + /// Attempts to set the Rich Presence over the HTTP protocol. Does not support Join / Specate and by default is blocking. + /// + /// The presence to send to discord + /// The response object from the client + /// The ID of the application + /// The port the discord client is currently on. Specify this for testing. Will start scanning from supplied port. + /// True if the response was valid from the server, otherwise false. + [System.Obsolete("Setting Rich Presence over HTTP is no longer supported by Discord. See offical Rich Presence github for more information.")] + public static bool TrySetRichPresence(RichPresence presence, out RichPresence response, string applicationID, int port = 6463) + { + //Validate the presence + if (presence != null) + { + //Send valid presence + //Validate the presence with our settings + if (presence.HasSecrets()) + throw new BadPresenceException("Cannot send a presence with secrets as HTTP endpoint does not suppport events."); + + if (presence.HasParty() && presence.Party.Max < presence.Party.Size) + throw new BadPresenceException("Presence maximum party size cannot be smaller than the current size."); + } + + //Iterate over the ports until the first succesfull one + for (int p = port; p < 6472; p++) + { + //Prepare the url and json + using (WebClient client = new WebClient()) + { + try + { + WebRequest request = PrepareRequest(presence, applicationID, p); + client.Headers.Add("content-type", "application/json"); + + var result = client.UploadString(request.URL, request.Data); + if (TryParseResponse(result, out response)) + return true; + } + catch (Exception) + { + //Something went wrong, but we are just going to ignore it and try the next port. + } + } + } + + //we failed, return null + response = null; + return false; + } + + /// + /// Attempts to parse the response of a Web Request to a rich presence + /// + /// The json data received by the client + /// The parsed rich presence + /// True if the parse was succesfull + public static bool TryParseResponse(string json, out RichPresence response) + { + try + { + //Try to parse the JSON into a event + EventPayload ev = JsonConvert.DeserializeObject(json); + + //We have a result, so parse the rich presence response and return it. + if (ev != null) + { + //Parse the response into a rich presence response + response = ev.GetObject(); + return true; + } + + }catch(Exception) { } + + //We failed. + response = null; + return false; + } + + /// + /// Prepares a struct containing data requried to make a succesful web client request to set the rich presence. + /// + /// The rich presence to set. + /// The ID of the application the presence belongs too. + /// The port the client is located on. The default port for the discord client is 6463, but it may move iteratively upto 6473 if the ports are unavailable. + /// Returns a web request containing nessary data to make a POST request + [System.Obsolete("WebRequests are no longer supported because of the removed HTTP functionality by Discord. See offical Rich Presence github for more information.")] + public static WebRequest PrepareRequest(RichPresence presence, string applicationID, int port = 6463) + { + //Validate the presence + if (presence != null) + { + //Send valid presence + //Validate the presence with our settings + if (presence.HasSecrets()) + throw new BadPresenceException("Cannot send a presence with secrets as HTTP endpoint does not suppport events."); + + if (presence.HasParty() && presence.Party.Max < presence.Party.Size) + throw new BadPresenceException("Presence maximum party size cannot be smaller than the current size."); + } + + //Prepare some params + int pid = System.Diagnostics.Process.GetCurrentProcess().Id; + + //Prepare the payload + PresenceCommand command = new PresenceCommand() { PID = pid, Presence = presence }; + var payload = command.PreparePayload(DateTime.UtcNow.ToFileTime()); + + string json = JsonConvert.SerializeObject(payload); + + string url = "http://127.0.0.1:" + port + "/rpc?v=" + RpcConnection.VERSION + "&client_id=" + applicationID; + return new WebRequest(url, json); + } + } + + /// + /// Details of a HTTP Post request that will set the rich presence. + /// + [System.Obsolete("Web Requests is no longer supported as Discord removed HTTP Rich Presence support. See offical Rich Presence github for more information.")] + public struct WebRequest + { + private string _url; + private string _json; + private Dictionary _headers; + + /// + /// The URL to send the POST request too + /// + public string URL { get { return _url; } } + + /// + /// The JSON formatted body to send with the POST request + /// + public string Data { get { return _json; } } + + /// + /// The headers to send with the body + /// + public Dictionary Headers { get { return _headers; } } + + internal WebRequest(string url, string json) + { + _url = url; + _json = json; + _headers = new Dictionary(); + _headers.Add("content-type", "application/json"); + } + } +} +#endif \ No newline at end of file diff --git a/LogWriter.cs b/LogWriter.cs new file mode 100644 index 0000000..d47f007 --- /dev/null +++ b/LogWriter.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace EnderIce2.SDRSharpPlugin +{ + public static class LogWriter + { + public static void WriteToFile(string Message) + { + if (!SDRSharp.Radio.Utils.GetBooleanSetting("LogRPC", false)) + return; + string path = AppDomain.CurrentDomain.BaseDirectory + "\\RPCLogs\\"; + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + string filepath = AppDomain.CurrentDomain.BaseDirectory + "\\RPCLogs\\DiscordRPCLog_" + DateTime.Now.Date.ToShortDateString().Replace('/', '_') + ".log"; + if (!File.Exists(filepath)) + { + using (StreamWriter sw = File.CreateText(filepath)) + { + sw.WriteLine($"[{DateTime.Now}] {Message}"); + } + } + else + { + using (StreamWriter sw = File.AppendText(filepath)) + { + sw.WriteLine($"[{DateTime.Now}] {Message}"); + } + } + } + } +} diff --git a/MainPlugin.cs b/MainPlugin.cs new file mode 100644 index 0000000..4222d95 --- /dev/null +++ b/MainPlugin.cs @@ -0,0 +1,298 @@ +using System; +using System.Windows.Forms; +using System.ComponentModel; +using SDRSharp.Common; +using SDRSharp.Radio; +using SDRSharp.PanView; +using DiscordRPC.Logging; +using DiscordRPC; +using System.Text; +using System.Threading.Tasks; +using DiscordRPC.Message; +using DiscordRPC.IO; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices.ComTypes; + +namespace EnderIce2.SDRSharpPlugin +{ + public class MainPlugin : ISharpPlugin + { + private const string _displayName = "Discord RPC"; + private SettingsPanel _controlPanel; + private static LogLevel logLevel = LogLevel.Trace; + private static int discordPipe = -1; + bool RPCalreadyLoaded = false; + private ISharpControl _control; + bool playedBefore = false; + private IConfigurationPanelProvider configurationPanelProvider; + private SDRSharp.FrontEnds.SpyServer.ControllerPanel controllerPanel; + public TopWindowMessages windowMessages; + private RichPresence presence = new RichPresence() + { + Details = "Loading...", + State = "Loading...", + Assets = new Assets() + { + LargeImageKey = "image_large", + LargeImageText = "SDRSharp", + SmallImageKey = "image_small", + SmallImageText = $"SDR# RPC plugin v{Assembly.GetEntryAssembly().GetName().Version} by EnderIce2" + }, + Secrets = new Secrets() + { + JoinSecret = "invalid_secret" + }, + Party = new Party() + { + ID = Secrets.CreateFriendlySecret(new Random()), + Size = 1, + Max = 100 + } + }; + private static DiscordRpcClient client; + private static bool isRunning = true; + public string DisplayName + { + get { return _displayName; } + } + public bool HasGui + { + get { return true; } + } + public UserControl Gui + { + get { return _controlPanel; } + } + public void Initialize(ISharpControl control) + { + if (Utils.GetBooleanSetting("ShowWelcomePage", true)) + new WelcomeForm().ShowDialog(); + _controlPanel = new SettingsPanel(); + windowMessages = new TopWindowMessages(); + _control = control; + try + { + _control.RegisterFrontControl(windowMessages, PluginPosition.Top); + } + catch (Exception ex) + { + MessageBox.Show(ex.ToString()); + } + windowMessages.Show(); + if (Utils.GetBooleanSetting("EnableRPC", true)) + { + if (RPCalreadyLoaded) + { + _controlPanel.ChangeStatus = "Restart required"; + return; + } + if (Utils.GetStringSetting("ClientID").Replace(" ", "").Length != 18) + client = new DiscordRpcClient("765213507321856078", pipe: discordPipe) + { + Logger = new ConsoleLogger(logLevel, true) + }; + else + client = new DiscordRpcClient(Utils.GetStringSetting("ClientID"), pipe: discordPipe) + { + Logger = new ConsoleLogger(logLevel, true) + }; + client.RegisterUriScheme(); + client.OnRpcMessage += Client_OnRpcMessage; + client.OnPresenceUpdate += Client_OnPresenceUpdate; + client.OnReady += OnReady; + client.OnClose += OnClose; + client.OnError += OnError; + client.OnConnectionEstablished += OnConnectionEstablished; + client.OnConnectionFailed += OnConnectionFailed; + client.OnSubscribe += OnSubscribe; + client.OnUnsubscribe += OnUnsubscribe; + client.OnJoin += OnJoin; + client.OnJoinRequested += OnJoinRequested; + //client.OnSpectate += OnSpectate; + presence.Timestamps = new Timestamps() + { + Start = DateTime.UtcNow + }; + client.SetSubscription(EventType.Join | EventType.JoinRequest); + client.SetPresence(presence); + client.Initialize(); + try + { + configurationPanelProvider = (IConfigurationPanelProvider)_control.Source; + controllerPanel = (SDRSharp.FrontEnds.SpyServer.ControllerPanel)configurationPanelProvider.Gui; + } + catch (Exception ex) + { + LogWriter.WriteToFile("----> " + ex.ToString()); + MessageBox.Show($"Cannot get Spy Server Network address\n\nError:\n{ex}", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + _ = MainLoop(); + } + else + _controlPanel.ChangeStatus = "RPC is disabled"; + LogWriter.WriteToFile("EOM Initialize"); + } + private void Client_OnPresenceUpdate(object sender, PresenceMessage args) + { + LogWriter.WriteToFile($"[RpcMessage] | Presence state: {args.Presence.State}"); + } + private void Client_OnRpcMessage(object sender, IMessage msg) + { + LogWriter.WriteToFile($"[RpcMessage] | {msg.Type} | {msg}"); + } + private void OnConnectionFailed(object sender, ConnectionFailedMessage args) + { + _controlPanel.ChangeStatus = $"RPC Connection Failed!\n{args.Type} | {args.FailedPipe}"; + } + private void OnConnectionEstablished(object sender, ConnectionEstablishedMessage args) + { + _controlPanel.ChangeStatus = "RPC Connection Established!"; + } + private void OnError(object sender, ErrorMessage args) + { + _controlPanel.ChangeStatus = $"RPC Error:\n{args.Message}"; + windowMessages.ChangeLabel = "SDR# RPC | Internal error"; + } + private void OnClose(object sender, CloseMessage args) + { + _controlPanel.ChangeStatus = "RPC Closed"; + windowMessages.ChangeLabel = "SDR# RPC | Closed"; + Close(); + } + private void OnReady(object sender, ReadyMessage args) + { + _controlPanel.ChangeStatus = "RPC Ready"; + windowMessages.ChangeLabel = "SDR# RPC | Ready"; + } + private void OnSubscribe(object sender, SubscribeMessage args) + { + _controlPanel.ChangeStatus = $"Subscribed: {args.Event}"; + } + private void OnUnsubscribe(object sender, UnsubscribeMessage args) + { + _controlPanel.ChangeStatus = $"Unsubscribed: {args.Event}"; + } + private void OnJoin(object sender, JoinMessage args) + { + presence.Party.Size++; + presence.Secrets.JoinSecret = args.Secret; + MessageBox.Show("OnJoin: " + args.Secret); + _control.StopRadio(); + _control.RefreshSource(true); + Utils.SaveSetting("spyserver.uri", args.Secret); + _control.StartRadio(); + } + private async void OnJoinRequested(object sender, JoinRequestMessage args) + { + try + { + if (await windowMessages.RequestAnswer(client, args)) + { + MessageBox.Show("Accepted RequestAnswer"); + } + else + { + MessageBox.Show("Declined RequestAnswer"); + } + } + catch (Exception ex) + { + MessageBox.Show(ex.ToString()); + } + } + async Task MainLoop() + { + try + { + await Task.Delay(2000); + isRunning = true; + LogWriter.WriteToFile($"MainLoop called {isRunning} {client.IsInitialized}"); + while (client != null && isRunning) + { + LogWriter.WriteToFile("Setting secret..."); + try + { + string sdr_url = "sdr://" + controllerPanel.Host + ":" + controllerPanel.Port + "/"; + LogWriter.WriteToFile(sdr_url); + presence.Secrets.JoinSecret = sdr_url; + } + catch (Exception ex) + { + LogWriter.WriteToFile(ex.ToString()); + } + LogWriter.WriteToFile("Waiting 500ms in loop..."); + await Task.Delay(500); + if (_control.RdsRadioText != null) + { + if (_control.IsPlaying) + { + presence.Assets.SmallImageKey = "play"; + playedBefore = true; + } + else if (!_control.IsPlaying && playedBefore) + presence.Assets.SmallImageKey = "pause"; + if (!playedBefore) + { + presence.Details = "Frequency: Not playing"; + presence.State = "RDS: Not playing"; + } + else + { + try + { + LogWriter.WriteToFile($"Frequency: {_control.Frequency}"); + LogWriter.WriteToFile($"RdsRadioText: {_control.RdsRadioText}"); + LogWriter.WriteToFile($"RdsProgramService: {_control.RdsProgramService}"); + LogWriter.WriteToFile("Setting presence..."); + presence.Details = $"Frequency: {string.Format("{0:#,0,,0 Hz}", _control.Frequency)}"; + if (string.IsNullOrWhiteSpace(_control.RdsRadioText + _control.RdsProgramService)) + presence.State = $"RDS: unknown | "; + else + presence.State = $"RDS: {_control.RdsProgramService} - {_control.RdsRadioText} | "; + } + catch (Exception ex) + { + LogWriter.WriteToFile(ex.ToString()); + } + /*presence.Secrets.JoinSecret = */ + //_control.RegisterFrontControl(Gui, PluginPosition.Top); + } + try + { + client.SetPresence(presence); + } + catch (ObjectDisposedException) {; } + LogWriter.WriteToFile("SetPresence"); + _controlPanel.ChangeStatus = $"Presence Updated {DateTime.UtcNow}"; + } + else + { + LogWriter.WriteToFile("Frequency or Radio Text are null!"); + await Task.Delay(1000); + } + } + if (client == null) + _controlPanel.ChangeStatus = "Client was null"; + else + _controlPanel.ChangeStatus = "Presence stopped"; + } + catch (Exception ex) + { + if (ex.Message.Contains("The process cannot access the file")) + { + _ = MainLoop(); + return; + } + _controlPanel.ChangeStatus = $"RPC Update Error\n{ex.Message}"; + LogWriter.WriteToFile(ex.ToString()); + } + } + public void Close() + { + LogWriter.WriteToFile("Close called"); + isRunning = false; + client.Dispose(); + } + } +} diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..a23a50f --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Show on Discord what are you listening on AIRSPY SDR#")] +[assembly: AssemblyDescription("Show on Discord what are you listening on AIRSPY SDR#")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("SDR# Discord RPC Plugin")] +[assembly: AssemblyCopyright("Copyright © EnderIce2 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("72e8628f-ba39-4915-bf3c-dd48bf477d30")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Resgister.txt b/Resgister.txt new file mode 100644 index 0000000..2fd221c --- /dev/null +++ b/Resgister.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/SDRSharpPlugin.DiscordRPC.csproj b/SDRSharpPlugin.DiscordRPC.csproj new file mode 100644 index 0000000..cc651a0 --- /dev/null +++ b/SDRSharpPlugin.DiscordRPC.csproj @@ -0,0 +1,173 @@ + + + + + Debug + AnyCPU + {72E8628F-BA39-4915-BF3C-DD48BF477D30} + Library + Properties + DiscordRPC + DiscordRPC + v4.6 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + true + + 8.0 + x86 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + true + + 8.0 + + + false + + + + packages\Newtonsoft.Json.12.0.3\lib\net45\Newtonsoft.Json.dll + False + + + False + ..\..\..\Downloads\sdrsharp-x86\SDRSharp.exe + + + False + ..\..\sdrsharp-x86\SDRSharp.Common.dll + + + False + ..\..\sdrsharp-x86\SDRSharp.PanView.dll + + + False + ..\..\sdrsharp-x86\SDRSharp.Radio.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UserControl + + + SettingsPanel.cs + + + UserControl + + + TopWindowMessages.cs + + + Form + + + WelcomeForm.cs + + + + + SettingsPanel.cs + + + TopWindowMessages.cs + + + WelcomeForm.cs + + + + + + + + + + + \ No newline at end of file diff --git a/SDRSharpPlugin.DiscordRPC.sln b/SDRSharpPlugin.DiscordRPC.sln new file mode 100644 index 0000000..c920b94 --- /dev/null +++ b/SDRSharpPlugin.DiscordRPC.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30517.126 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SDRSharpPlugin.DiscordRPC", "SDRSharpPlugin.DiscordRPC.csproj", "{72E8628F-BA39-4915-BF3C-DD48BF477D30}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {72E8628F-BA39-4915-BF3C-DD48BF477D30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72E8628F-BA39-4915-BF3C-DD48BF477D30}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72E8628F-BA39-4915-BF3C-DD48BF477D30}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72E8628F-BA39-4915-BF3C-DD48BF477D30}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DA82DCCB-E6B0-4649-9A0C-CECB0AF41CDD} + EndGlobalSection +EndGlobal diff --git a/SettingsPanel.Designer.cs b/SettingsPanel.Designer.cs new file mode 100644 index 0000000..97cfd94 --- /dev/null +++ b/SettingsPanel.Designer.cs @@ -0,0 +1,135 @@ +namespace EnderIce2.SDRSharpPlugin +{ + partial class SettingsPanel + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.checkBox1 = new System.Windows.Forms.CheckBox(); + this.label1 = new System.Windows.Forms.Label(); + this.textBox1 = new System.Windows.Forms.TextBox(); + this.label2 = new System.Windows.Forms.Label(); + this.button1 = new System.Windows.Forms.Button(); + this.checkBox2 = new System.Windows.Forms.CheckBox(); + this.SuspendLayout(); + // + // checkBox1 + // + this.checkBox1.Checked = true; + this.checkBox1.CheckState = System.Windows.Forms.CheckState.Checked; + this.checkBox1.Location = new System.Drawing.Point(3, 3); + this.checkBox1.Name = "checkBox1"; + this.checkBox1.Size = new System.Drawing.Size(146, 21); + this.checkBox1.TabIndex = 0; + this.checkBox1.Text = "Enable Discord RPC"; + this.checkBox1.UseVisualStyleBackColor = true; + this.checkBox1.CheckedChanged += new System.EventHandler(this.CheckBox1_CheckedChanged); + // + // label1 + // + this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.label1.AutoEllipsis = true; + this.label1.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.label1.Location = new System.Drawing.Point(0, 49); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(196, 88); + this.label1.TabIndex = 1; + this.label1.Text = "Loading status..."; + this.label1.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // textBox1 + // + this.textBox1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.textBox1.BorderStyle = System.Windows.Forms.BorderStyle.None; + this.textBox1.Location = new System.Drawing.Point(56, 143); + this.textBox1.Name = "textBox1"; + this.textBox1.Size = new System.Drawing.Size(134, 13); + this.textBox1.TabIndex = 2; + this.textBox1.KeyDown += new System.Windows.Forms.KeyEventHandler(this.textBox1_KeyDown); + this.textBox1.KeyPress += new System.Windows.Forms.KeyPressEventHandler(this.textBox1_KeyPress); + // + // label2 + // + this.label2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(0, 143); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(50, 13); + this.label2.TabIndex = 3; + this.label2.Text = "Client ID:"; + // + // button1 + // + this.button1.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right))); + this.button1.Location = new System.Drawing.Point(147, 3); + this.button1.Name = "button1"; + this.button1.Size = new System.Drawing.Size(46, 23); + this.button1.TabIndex = 4; + this.button1.Text = "Help"; + this.button1.UseVisualStyleBackColor = true; + this.button1.Click += new System.EventHandler(this.button1_Click); + // + // checkBox2 + // + this.checkBox2.Font = new System.Drawing.Font("Microsoft Sans Serif", 7.25F); + this.checkBox2.Location = new System.Drawing.Point(3, 25); + this.checkBox2.Name = "checkBox2"; + this.checkBox2.Size = new System.Drawing.Size(158, 21); + this.checkBox2.TabIndex = 5; + this.checkBox2.Text = "Log RPC (for debugging)"; + this.checkBox2.UseVisualStyleBackColor = true; + this.checkBox2.CheckedChanged += new System.EventHandler(this.checkBox2_CheckedChanged); + // + // SettingsPanel + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Controls.Add(this.button1); + this.Controls.Add(this.checkBox2); + this.Controls.Add(this.label2); + this.Controls.Add(this.textBox1); + this.Controls.Add(this.label1); + this.Controls.Add(this.checkBox1); + this.Name = "SettingsPanel"; + this.Size = new System.Drawing.Size(196, 163); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.CheckBox checkBox1; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.TextBox textBox1; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Button button1; + private System.Windows.Forms.CheckBox checkBox2; + } +} diff --git a/SettingsPanel.cs b/SettingsPanel.cs new file mode 100644 index 0000000..4b600ce --- /dev/null +++ b/SettingsPanel.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using SDRSharp.Radio; +using System.Security.Policy; +using System.Media; + +namespace EnderIce2.SDRSharpPlugin +{ + public partial class SettingsPanel : UserControl + { + private string _ChangeStatus; + public string ChangeStatus + { + get + { + return _ChangeStatus; + } + set + { + _ChangeStatus = value; + label1.Text = value; + LogWriter.WriteToFile(value); + } + } + public SettingsPanel() + { + InitializeComponent(); + textBox1.Text = Utils.GetStringSetting("ClientID"); + if (Utils.GetBooleanSetting("EnableRPC", true)) + checkBox1.Checked = true; + else + checkBox1.Checked = false; + if (Utils.GetBooleanSetting("LogRPC", false)) + checkBox2.Checked = true; + else + checkBox2.Checked = false; + LogWriter.WriteToFile("SettingsPanel loaded"); + } + + private void CheckBox1_CheckedChanged(object sender, EventArgs e) + { + Utils.SaveSetting("EnableRPC", checkBox1.Checked); + label1.Text = "Restart required"; + LogWriter.WriteToFile($"checkbox on SettingsPanel clicked {checkBox1.Checked}"); + //Utils.GetBooleanSetting("EnableRPC"); + } + + private void button1_Click(object sender, EventArgs e) + { + System.Diagnostics.Process.Start("https://github.com/EnderIce2/SDRSharpRPC"); + } + + private void checkBox2_CheckedChanged(object sender, EventArgs e) + { + Utils.SaveSetting("LogRPC", checkBox2.Checked); + } + + private void textBox1_KeyPress(object sender, KeyPressEventArgs e) + { + if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar) && (e.KeyChar != '.')) + e.Handled = true; + if ((e.KeyChar == '.') && ((sender as TextBox).Text.IndexOf('.') > -1)) + e.Handled = true; + } + + private async void textBox1_KeyDown(object sender, KeyEventArgs e) + { + if (e.KeyCode == Keys.Enter && textBox1.Text.Replace(" ", "").Length == 18) + { + Utils.SaveSetting("ClientID", textBox1.Text); + e.Handled = true; + e.SuppressKeyPress = true; + await Task.Delay(200); + textBox1.Text = Utils.GetStringSetting("ClientID"); + label1.Text = "Saved."; + } + } + } +} diff --git a/SettingsPanel.resx b/SettingsPanel.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/SettingsPanel.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/TopWindowMessages.Designer.cs b/TopWindowMessages.Designer.cs new file mode 100644 index 0000000..3227c16 --- /dev/null +++ b/TopWindowMessages.Designer.cs @@ -0,0 +1,108 @@ +namespace EnderIce2.SDRSharpPlugin +{ + partial class TopWindowMessages + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.button1 = new System.Windows.Forms.Button(); + this.button2 = new System.Windows.Forms.Button(); + this.label1 = new System.Windows.Forms.Label(); + this.SuspendLayout(); + // + // button1 + // + this.button1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Right))); + this.button1.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(50)))), ((int)(((byte)(50))))); + this.button1.FlatAppearance.BorderSize = 0; + this.button1.FlatAppearance.MouseDownBackColor = System.Drawing.Color.Maroon; + this.button1.FlatAppearance.MouseOverBackColor = System.Drawing.Color.FromArgb(((int)(((byte)(64)))), ((int)(((byte)(0)))), ((int)(((byte)(0))))); + this.button1.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.button1.Font = new System.Drawing.Font("Microsoft Sans Serif", 7.25F); + this.button1.Location = new System.Drawing.Point(629, 4); + this.button1.Name = "button1"; + this.button1.Size = new System.Drawing.Size(75, 23); + this.button1.TabIndex = 0; + this.button1.Text = "Decline"; + this.button1.UseVisualStyleBackColor = false; + this.button1.Visible = false; + // + // button2 + // + this.button2.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Right))); + this.button2.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(50)))), ((int)(((byte)(50)))), ((int)(((byte)(50))))); + this.button2.FlatAppearance.BorderSize = 0; + this.button2.FlatAppearance.MouseDownBackColor = System.Drawing.Color.Green; + this.button2.FlatAppearance.MouseOverBackColor = System.Drawing.Color.FromArgb(((int)(((byte)(0)))), ((int)(((byte)(64)))), ((int)(((byte)(0))))); + this.button2.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.button2.Font = new System.Drawing.Font("Microsoft Sans Serif", 7.25F); + this.button2.Location = new System.Drawing.Point(548, 4); + this.button2.Name = "button2"; + this.button2.Size = new System.Drawing.Size(75, 23); + this.button2.TabIndex = 1; + this.button2.Text = "Accept"; + this.button2.UseVisualStyleBackColor = false; + this.button2.Visible = false; + // + // label1 + // + this.label1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.label1.AutoEllipsis = true; + this.label1.Location = new System.Drawing.Point(3, 4); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(304, 23); + this.label1.TabIndex = 2; + this.label1.Text = "Loading"; + this.label1.TextAlign = System.Drawing.ContentAlignment.MiddleLeft; + // + // TopWindowMessages + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.FromArgb(((int)(((byte)(60)))), ((int)(((byte)(60)))), ((int)(((byte)(60))))); + this.Controls.Add(this.label1); + this.Controls.Add(this.button2); + this.Controls.Add(this.button1); + this.Font = new System.Drawing.Font("Microsoft Sans Serif", 7.25F); + this.MaximumSize = new System.Drawing.Size(9999999, 30); + this.MinimumSize = new System.Drawing.Size(0, 30); + this.Name = "TopWindowMessages"; + this.Size = new System.Drawing.Size(707, 30); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.Button button1; + private System.Windows.Forms.Button button2; + private System.Windows.Forms.Label label1; + } +} diff --git a/TopWindowMessages.cs b/TopWindowMessages.cs new file mode 100644 index 0000000..2c0d387 --- /dev/null +++ b/TopWindowMessages.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using DiscordRPC.Message; +using DiscordRPC; + +namespace EnderIce2.SDRSharpPlugin +{ + public partial class TopWindowMessages : UserControl + { + public TopWindowMessages() + { + InitializeComponent(); + } + private string _ChangeLabel; + public string ChangeLabel + { + get + { + return _ChangeLabel; + } + set + { + _ChangeLabel = value; + label1.Text = value; + LogWriter.WriteToFile(value); + } + } + private bool AnswerA = false; + private bool AnswerD = false; + public async Task RequestAnswer(DiscordRpcClient client, JoinRequestMessage args) + { + bool tmpansw = false; + LogWriter.WriteToFile("Incoming RPC request from " + args.User.Username); + button1.Visible = true; + button2.Visible = true; + ChangeLabel = $"SDR# RPC | {args.User.Username} has requested to get Spy Server Network address."; + while (!AnswerA || !AnswerD) // TODO: Rework + { + LogWriter.WriteToFile("waiting..."); + Application.DoEvents(); + await Task.Delay(200); + } + tmpansw = AnswerA; + LogWriter.WriteToFile($"Client sent an answer. {tmpansw}"); + client.Respond(args, tmpansw); + AnswerA = false; + AnswerD = false; + button1.Visible = false; + button2.Visible = false; +#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + SetDefaultTextInLabel(tmpansw); +#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed + return tmpansw; + } + private async Task SetDefaultTextInLabel(bool accepted) + { + if (accepted) + ChangeLabel = $"SDR# RPC | Request accepted"; + else + ChangeLabel = $"SDR# RPC | Request declined"; + await Task.Delay(5000); + ChangeLabel = $"SDR# RPC | Ready"; + } + } +} diff --git a/TopWindowMessages.resx b/TopWindowMessages.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/TopWindowMessages.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/WelcomeForm.Designer.cs b/WelcomeForm.Designer.cs new file mode 100644 index 0000000..3d697da --- /dev/null +++ b/WelcomeForm.Designer.cs @@ -0,0 +1,135 @@ +namespace EnderIce2.SDRSharpPlugin +{ + partial class WelcomeForm + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(WelcomeForm)); + this.richTextBox1 = new System.Windows.Forms.RichTextBox(); + this.button1 = new System.Windows.Forms.Button(); + this.checkBox1 = new System.Windows.Forms.CheckBox(); + this.label1 = new System.Windows.Forms.Label(); + this.label2 = new System.Windows.Forms.Label(); + this.button2 = new System.Windows.Forms.Button(); + this.SuspendLayout(); + // + // richTextBox1 + // + this.richTextBox1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.richTextBox1.Cursor = System.Windows.Forms.Cursors.Default; + this.richTextBox1.Location = new System.Drawing.Point(12, 58); + this.richTextBox1.Name = "richTextBox1"; + this.richTextBox1.Size = new System.Drawing.Size(776, 327); + this.richTextBox1.TabIndex = 0; + this.richTextBox1.Text = resources.GetString("richTextBox1.Text"); + // + // button1 + // + this.button1.Location = new System.Drawing.Point(713, 415); + this.button1.Name = "button1"; + this.button1.Size = new System.Drawing.Size(75, 23); + this.button1.TabIndex = 1; + this.button1.Text = "&OK"; + this.button1.UseVisualStyleBackColor = true; + this.button1.Click += new System.EventHandler(this.button1_Click); + // + // checkBox1 + // + this.checkBox1.AutoSize = true; + this.checkBox1.Location = new System.Drawing.Point(93, 419); + this.checkBox1.Name = "checkBox1"; + this.checkBox1.Size = new System.Drawing.Size(160, 17); + this.checkBox1.TabIndex = 2; + this.checkBox1.Text = "Don\'t show me this next time"; + this.checkBox1.UseVisualStyleBackColor = true; + this.checkBox1.CheckedChanged += new System.EventHandler(this.checkBox1_CheckedChanged); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Font = new System.Drawing.Font("Microsoft Sans Serif", 14.25F); + this.label1.Location = new System.Drawing.Point(12, 9); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(170, 24); + this.label1.TabIndex = 3; + this.label1.Text = "DiscordRPC Plugin"; + // + // label2 + // + this.label2.AutoSize = true; + this.label2.Location = new System.Drawing.Point(9, 42); + this.label2.Name = "label2"; + this.label2.Size = new System.Drawing.Size(52, 13); + this.label2.TabIndex = 4; + this.label2.Text = "Licenses:"; + // + // button2 + // + this.button2.Location = new System.Drawing.Point(12, 415); + this.button2.Name = "button2"; + this.button2.Size = new System.Drawing.Size(75, 23); + this.button2.TabIndex = 5; + this.button2.Text = "Support Me"; + this.button2.UseVisualStyleBackColor = true; + this.button2.Click += new System.EventHandler(this.button2_Click); + // + // WelcomeForm + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(800, 450); + this.Controls.Add(this.button2); + this.Controls.Add(this.label2); + this.Controls.Add(this.label1); + this.Controls.Add(this.checkBox1); + this.Controls.Add(this.button1); + this.Controls.Add(this.richTextBox1); + this.MaximizeBox = false; + this.MaximumSize = new System.Drawing.Size(816, 489); + this.MinimizeBox = false; + this.MinimumSize = new System.Drawing.Size(816, 489); + this.Name = "WelcomeForm"; + this.ShowIcon = false; + this.ShowInTaskbar = false; + this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; + this.Text = "Thank you for installing DiscordRPC by EnderIce2"; + this.TopMost = true; + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.RichTextBox richTextBox1; + private System.Windows.Forms.Button button1; + private System.Windows.Forms.CheckBox checkBox1; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Label label2; + private System.Windows.Forms.Button button2; + } +} \ No newline at end of file diff --git a/WelcomeForm.cs b/WelcomeForm.cs new file mode 100644 index 0000000..9bbb7f8 --- /dev/null +++ b/WelcomeForm.cs @@ -0,0 +1,36 @@ +using SDRSharp.Radio; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace EnderIce2.SDRSharpPlugin +{ + public partial class WelcomeForm : Form + { + public WelcomeForm() + { + InitializeComponent(); + } + + private void button2_Click(object sender, EventArgs e) + { + System.Diagnostics.Process.Start("https://ko-fi.com/enderice2"); + } + + private void button1_Click(object sender, EventArgs e) + { + Close(); + } + + private void checkBox1_CheckedChanged(object sender, EventArgs e) + { + Utils.SaveSetting("ShowWelcomePage", !checkBox1.Checked); + } + } +} diff --git a/WelcomeForm.resx b/WelcomeForm.resx new file mode 100644 index 0000000..836c424 --- /dev/null +++ b/WelcomeForm.resx @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ----------------------------------------------------------------------------------- +URL for discord-rpc-csharp: https://github.com/Lachee/discord-rpc-csharp +License for discord-rpc-csharp: + +MIT License + +Copyright (c) 2018 Lachee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +----------------------------------------------------------------------------------- +URL for Newtonsoft.Json: https://github.com/JamesNK/Newtonsoft.Json +License for Newtonsoft.Json: + +The MIT License (MIT) + +Copyright (c) 2007 James Newton-King + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DE + + \ No newline at end of file diff --git a/packages.config b/packages.config new file mode 100644 index 0000000..43cb3fa --- /dev/null +++ b/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file