From f070a34d73e01856400ae31a24eceed6a6c0a1b7 Mon Sep 17 00:00:00 2001 From: Evan Pratten Date: Tue, 22 Mar 2022 09:47:20 -0400 Subject: [PATCH] Add Discord RPC support --- game/game_logic/src/discord/ipc.rs | 80 ++++++++++++++++++++ game/game_logic/src/discord/mod.rs | 83 +++++++++++++++++++++ game/game_logic/src/discord/signal.rs | 4 +- game/game_logic/src/lib.rs | 27 ++++++- game/game_logic/src/project_constants.rs | 2 +- game/game_logic/src/rendering/event_loop.rs | 4 +- 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 game/game_logic/src/discord/ipc.rs diff --git a/game/game_logic/src/discord/ipc.rs b/game/game_logic/src/discord/ipc.rs new file mode 100644 index 00000000..4402c8a6 --- /dev/null +++ b/game/game_logic/src/discord/ipc.rs @@ -0,0 +1,80 @@ +//! Discord Rich Presence utilities + +use discord_sdk::{ + activity::{Activity, ActivityBuilder}, + user::User, + wheel::Wheel, + Discord, DiscordApp, Subscriptions, +}; +use tokio::time::error::Elapsed; + +#[derive(Debug, thiserror::Error)] +pub enum DiscordError { + #[error(transparent)] + Sdk(#[from] discord_sdk::Error), + #[error(transparent)] + AwaitConnection(#[from] tokio::sync::watch::error::RecvError), + #[error("Could not connect")] + Connection, + #[error(transparent)] + ConnectionTimeout(#[from] Elapsed), +} + +/// The client wrapper for Discord RPC +pub struct DiscordRpcClient { + pub discord: Discord, + pub user: User, + pub wheel: Wheel, +} + +impl DiscordRpcClient { + /// Creates a new `DiscordRpcClient` + pub async fn new(app_id: i64, subscriptions: Subscriptions) -> Result { + // Create a new wheel + let (wheel, handler) = Wheel::new(Box::new(|err| { + error!("Encountered an error: {}", err); + })); + let mut user = wheel.user(); + + // Create the client + let discord = Discord::new( + DiscordApp::PlainId(app_id), + subscriptions, + Box::new(handler), + )?; + + // Wait for the discord handshake + info!("Waiting for Discord client handshake"); + user.0.changed().await?; + info!("Discord handshake success"); + + // Fetch the final user object + let user = match &*user.0.borrow() { + discord_sdk::wheel::UserState::Connected(u) => Ok(u.clone()), + discord_sdk::wheel::UserState::Disconnected(_) => Err(DiscordError::Connection), + }?; + + Ok(Self { + discord, + user, + wheel, + }) + } + + /// Clears the user rich presence + #[profiling::function] + #[allow(dead_code)] + pub async fn clear_rich_presence(&self) -> Result, discord_sdk::Error> { + self.discord + .update_activity(ActivityBuilder::default()) + .await + } + + /// Sets the user rich presence + pub async fn set_rich_presence( + &self, + activity: ActivityBuilder, + ) -> Result, discord_sdk::Error> { + self.discord.update_activity(activity).await + } +} diff --git a/game/game_logic/src/discord/mod.rs b/game/game_logic/src/discord/mod.rs index 74413437..ae15b1db 100644 --- a/game/game_logic/src/discord/mod.rs +++ b/game/game_logic/src/discord/mod.rs @@ -1,4 +1,87 @@ //! This module contains code needed for interacting with a local Discord instance. mod signal; +use std::time::Duration; + pub use signal::DiscordRpcSignal; +use tokio::{ + sync::{mpsc::Receiver, mpsc::Sender}, + task::JoinHandle, +}; + +use self::{ipc::DiscordRpcClient, signal::StatefulDiscordRpcSignalHandler}; +mod ipc; +pub use ipc::DiscordError; + +/// How long to wait before we give up on connecting to Discord. +const DISCORD_CONNECT_TIMEOUT_SECONDS: u64 = 5; + +/// A cross-thread communication channel for sending Discord RPC events. +pub type DiscordChannel = Sender; + +pub struct DiscordRpcThreadHandle { + tx_chan: DiscordChannel, + rx_chan: Receiver, + internal_client: Option, + state: StatefulDiscordRpcSignalHandler, +} + +impl DiscordRpcThreadHandle { + /// Construct a new `DiscordRpcThreadHandle` + pub async fn new(app_id: i64) -> Result { + // Create the Discord client + info!("Trying to locate and connect to a local Discord process for RPC. Will wait up to {} seconds before timing out", DISCORD_CONNECT_TIMEOUT_SECONDS); + let rpc_client = match tokio::time::timeout( + Duration::from_secs(DISCORD_CONNECT_TIMEOUT_SECONDS), + DiscordRpcClient::new(app_id, discord_sdk::Subscriptions::ACTIVITY), + ) + .await + { + Ok(client) => Some(client?), + Err(t) => { + error!( + "Timed out trying to connect to Discord RPC. Duration: {}", + t + ); + None + } + }; + info!("Successfully connected to Discord"); + + // Set up channels + let (tx, rx) = tokio::sync::mpsc::channel(5); + + Ok(Self { + tx_chan: tx, + rx_chan: rx, + internal_client: rpc_client, + state: StatefulDiscordRpcSignalHandler::default(), + }) + } + + /// Get access to the inter-thread channel for communicating to discord + pub fn get_channel(&self) -> DiscordChannel { + self.tx_chan.clone() + } + + /// Run the inner communication task in an async context + pub fn begin_thread_non_blocking(mut self) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + // Handle any possible incoming events + match self.rx_chan.try_recv() { + Ok(signal) => match self.internal_client { + Some(ref client) => { + client + .set_rich_presence(self.state.apply(signal)) + .await + .unwrap(); + } + None => { /* The client could not connect */ } + }, + Err(_) => { /* Do Nothing */ } + } + } + }) + } +} diff --git a/game/game_logic/src/discord/signal.rs b/game/game_logic/src/discord/signal.rs index 2bb9004c..d3d71c68 100644 --- a/game/game_logic/src/discord/signal.rs +++ b/game/game_logic/src/discord/signal.rs @@ -6,9 +6,10 @@ //! The game thread can then send `DiscordRpcSignal` values through an `mpsc` sender, which will be received by the Discord RPC client thread. use chrono::Utc; -use discord_sdk::activity::{ActivityBuilder, Assets, IntoTimestamp}; +use discord_sdk::activity::{ActivityBuilder, Assets}; /// Definitions of signals that can be sent to the Discord RPC thread to control how discord displays game status. +#[derive(Debug, Clone)] pub enum DiscordRpcSignal { /// Signal to begin a game timer (Discord will display `XX:XX elapsed`) BeginGameTimer, @@ -50,7 +51,6 @@ pub struct StatefulDiscordRpcSignalHandler { } impl StatefulDiscordRpcSignalHandler { - /// Apply a signal to generate a new activity pub fn apply(&mut self, signal: DiscordRpcSignal) -> ActivityBuilder { // Fill in the data based on the contents of the signal diff --git a/game/game_logic/src/lib.rs b/game/game_logic/src/lib.rs index dcbff8a8..9fb34ff3 100644 --- a/game/game_logic/src/lib.rs +++ b/game/game_logic/src/lib.rs @@ -1,9 +1,15 @@ //! This file is the main entry point for the game logic. -use crate::{asset_manager::json::load_json_structure, project_constants::ProjectConstants}; +use crate::{ + asset_manager::json::load_json_structure, + discord::{DiscordRpcSignal, DiscordRpcThreadHandle}, + project_constants::ProjectConstants, +}; #[macro_use] extern crate approx; // For the macro `relative_eq!` +#[macro_use] +extern crate log; // For the `info!`, `warn!`, etc. macros pub mod asset_manager; pub mod discord; @@ -32,6 +38,24 @@ pub async fn entrypoint(force_recreate_savefiles: bool) { persistent::save_state::GameSaveState::load_or_create(force_recreate_savefiles) .expect("Failed to parse game save state from disk. Possibly corrupt file?"); + // Connect to Discord + let discord = DiscordRpcThreadHandle::new(project_constants.discord_app_id) + .await + .expect("Failed to connect to Discord RPC"); + let event_loop_discord_tx = discord.get_channel(); + let _discord_task_handle = discord.begin_thread_non_blocking(); + + // Set a base activity to show in Discord + { + event_loop_discord_tx + .send(DiscordRpcSignal::ChangeDetails { + details: "Probably loading something IDK.".to_string(), + party_status: None, + }) + .await + .expect("Failed to send Discord RPC event"); + } + // Blocking call to the graphics rendering loop. rendering::event_loop::handle_graphics_blocking( |builder| { @@ -43,6 +67,7 @@ pub async fn entrypoint(force_recreate_savefiles: bool) { .width(project_constants.base_window_size.0 as i32); }, settings.target_fps, + event_loop_discord_tx, ); // Clean up any resources diff --git a/game/game_logic/src/project_constants.rs b/game/game_logic/src/project_constants.rs index 8c7a15c2..6bb2eb8e 100644 --- a/game/game_logic/src/project_constants.rs +++ b/game/game_logic/src/project_constants.rs @@ -10,7 +10,7 @@ pub struct ProjectConstants { pub base_window_size: (u32, u32), /// The Discord application ID - pub discord_app_id: u64, + pub discord_app_id: i64, /// The target framerate of the game pub target_fps: u32, diff --git a/game/game_logic/src/rendering/event_loop.rs b/game/game_logic/src/rendering/event_loop.rs index d0dc3279..e6919ac9 100644 --- a/game/game_logic/src/rendering/event_loop.rs +++ b/game/game_logic/src/rendering/event_loop.rs @@ -1,7 +1,9 @@ use raylib::RaylibBuilder; +use crate::discord::DiscordChannel; + /// Will begin rendering graphics. Returns when the window closes -pub fn handle_graphics_blocking(config: ConfigBuilder, target_frames_per_second: u32) +pub fn handle_graphics_blocking(config: ConfigBuilder, target_frames_per_second: u32, discord_signaling: DiscordChannel) where ConfigBuilder: FnOnce(&mut RaylibBuilder), {