diff --git a/Cargo.lock b/Cargo.lock index eec206101..303332157 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,18 @@ version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +[[package]] +name = "app_dirs2" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e7b35733e3a8c1ccb90385088dd5b6eaa61325cb4d1ad56e683b5224ff352e" +dependencies = [ + "jni", + "ndk-context", + "winapi", + "xdg", +] + [[package]] name = "ashpd" version = "0.8.1" @@ -873,6 +885,12 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + [[package]] name = "deranged" version = "0.3.11" @@ -938,6 +956,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "discord-sdk" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3d7a4f9bc39006b732a01d63b34ff1518313313d707cb18cf6187d2124f7f4" +dependencies = [ + "anyhow", + "app_dirs2", + "async-trait", + "bitflags 2.5.0", + "crossbeam-channel", + "data-encoding", + "num-traits", + "parking_lot", + "serde", + "serde_json", + "serde_repr", + "thiserror", + "time", + "tokio", + "tracing", + "url", + "winreg 0.52.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -3578,6 +3621,7 @@ dependencies = [ "color-eyre", "const_format", "dirs-next", + "discord-sdk", "flexi_logger", "glob", "log", @@ -3597,6 +3641,7 @@ dependencies = [ "tauri-plugin-store", "tauri-runtime", "tempfile", + "tokio", "which", "win32job", "winreg 0.52.0", @@ -5374,6 +5419,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xdg" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" + [[package]] name = "xdg-home" version = "1.1.0" diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 002bf9684..5ae38477f 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -448,6 +448,14 @@ settings-general-interface-connected_trackers_warning-label = Connected trackers settings-general-interface-use_tray = Minimize to system tray settings-general-interface-use_tray-description = Lets you close the window without closing the SlimeVR Server so you can continue using it without having the GUI bothering you. settings-general-interface-use_tray-label = Minimize to system tray +settings-general-interface-discord_presence = Share activity on Discord +settings-general-interface-discord_presence-description = Tells your Discord client that you are using SlimeVR along with the number of IMU trackers you are using. +settings-general-interface-discord_presence-label = Share activity on Discord +settings-general-interface-discord_presence-message = { $amount -> + [0] Sliming around + [one] Using 1 tracker + *[other] Using { $amount } trackers +} ## Serial settings settings-serial = Serial Console diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml index ae81dc44e..c5626301c 100644 --- a/gui/src-tauri/Cargo.toml +++ b/gui/src-tauri/Cargo.toml @@ -51,6 +51,8 @@ cfg-if = "1" color-eyre = "0.6" rfd = { version = "0.14", features = ["gtk3"], default-features = false } dirs-next = "2.0.0" +discord-sdk = "0.3.6" +tokio = { version = "1.37.0", features = ["time"] } [target.'cfg(windows)'.dependencies] win32job = "1" diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs index 29ff32df3..89a96afc1 100644 --- a/gui/src-tauri/src/main.rs +++ b/gui/src-tauri/src/main.rs @@ -18,6 +18,7 @@ use crate::util::{ get_launch_path, show_error, valid_java_paths, Cli, JAVA_BIN, MINIMUM_JAVA_VERSION, }; +mod presence; mod state; mod tray; mod util; @@ -177,7 +178,11 @@ fn main() -> Result<()> { erroring, warning, tray::update_translations, - tray::update_tray_text + tray::update_tray_text, + presence::discord_client_exists, + presence::update_presence, + presence::clear_presence, + presence::create_discord_client, ]) .setup(move |app| { let window_state = @@ -204,7 +209,8 @@ fn main() -> Result<()> { #[cfg(desktop)] { let handle = app.handle(); - tray::create_tray(&handle)?; + tray::create_tray(handle)?; + presence::create_presence(handle)?; } app.manage(Mutex::new(window_state)); diff --git a/gui/src-tauri/src/presence.rs b/gui/src-tauri/src/presence.rs new file mode 100644 index 000000000..6b08b6221 --- /dev/null +++ b/gui/src-tauri/src/presence.rs @@ -0,0 +1,217 @@ +use std::time::{Duration, SystemTime}; + +use color_eyre::{eyre::bail, Result}; +use discord_sdk as ds; +use ds::wheel::{UserSpoke, UserState}; +use tauri::{async_runtime::Mutex, AppHandle, Manager, Runtime, State}; + +const APP_ID: ds::AppId = 1237970689009647639; + +pub struct DiscordClient { + pub discord: ds::Discord, + pub user: ds::user::User, + pub wheel: ds::wheel::Wheel, +} + +pub struct DiscordTimestamp(SystemTime); + +pub struct ExposedClient(Mutex>); + +async fn make_client(subs: ds::Subscriptions) -> Result> { + let (wheel, handler) = ds::wheel::Wheel::new(Box::new(|err| { + log::error!(target: "discord_presence", "encountered a discord presence error: {err}"); + })); + + let mut user = wheel.user(); + + let discord = + ds::Discord::new(ds::DiscordApp::PlainId(APP_ID), subs, Box::new(handler))?; + + log::debug!(target: "discord_presence", "waiting for handshake..."); + let Ok(e) = tokio::time::timeout(Duration::from_secs(5), user.0.changed()).await + else { + return Ok(None); + }; + e?; + + let user = match &*user.0.borrow() { + ds::wheel::UserState::Connected(user) => user.clone(), + ds::wheel::UserState::Disconnected(err) => { + bail!("failed to connect to Discord: {err}"); + } + }; + + log::info!(target: "discord_presence", "connected to Discord, local user name is {}", user.username); + + Ok(Some(DiscordClient { + discord, + user, + wheel, + })) +} + +async fn client_exists(client: &State<'_, ExposedClient>) -> bool { + let lock = client.0.lock().await; + lock.is_some() +} + +#[tauri::command] +pub async fn discord_client_exists( + client: State<'_, ExposedClient>, +) -> Result { + Ok(client_exists(&client).await) +} + +#[tauri::command] +pub async fn update_presence( + client: State<'_, ExposedClient>, + timestamp: State<'_, DiscordTimestamp>, + details: String, + state: Option, + small_icon: Option<(String, String)>, + button: Option, +) -> Result<(), ()> { + if !client_exists(&client).await { + return Err(()); + } + + let rp = ds::activity::ActivityBuilder::default() + .details(details) + .start_timestamp(timestamp.0); + let rp = if let Some(state) = state { + rp.state(state) + } else { + rp + }; + let rp = if let Some((id, desc)) = small_icon { + rp.assets( + ds::activity::Assets::default() + .large("icon".to_owned(), Some("SlimeVR".to_owned())) + .small(id, Some(desc)), + ) + } else { + rp.assets( + ds::activity::Assets::default() + .large("icon".to_owned(), Some("SlimeVR".to_owned())), + ) + }; + let rp = if let Some(button) = button { + rp.button(button) + } else { + rp + }; + + let lock = client.0.lock().await; + lock.as_ref() + .unwrap() + .discord + .update_activity(rp) + .await + .map_err(|_e| ())?; + + Ok(()) +} + +#[tauri::command] +pub async fn clear_presence(client: State<'_, ExposedClient>) -> Result<(), String> { + if !client_exists(&client).await { + return Err("Missing discord client".to_owned()); + } + + let lock = client.0.lock().await; + lock.as_ref() + .unwrap() + .discord + .clear_activity() + .await + .map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn create_discord_client( + app: AppHandle, + client: State<'_, ExposedClient>, +) -> Result<(), String> { + if client_exists(&client).await { + return Err("Trying to create a client when there is one already".to_owned()); + } + + let Some(discord_client) = make_client(ds::Subscriptions::ACTIVITY) + .await + .map_err(|e| e.to_string())? + else { + log::debug!(target: "discord_presence", "discord took too long to answer (probably not open)"); + return Ok(()); + }; + let user_wheel = discord_client.wheel.user(); + { + let mut lock = client.0.lock().await; + *lock = Some(discord_client); + } + + tauri::async_runtime::spawn(async move { + drop_client_on_loss(app, user_wheel).await; + }); + Ok(()) +} + +async fn drop_client_on_loss( + app: tauri::AppHandle, + mut user_wheel: UserSpoke, +) { + while let Ok(_) = user_wheel.0.changed().await { + if let UserState::Disconnected(e) = &*user_wheel.0.borrow() { + match e { + ds::Error::NoConnection + | ds::Error::TimedOut + | ds::Error::Close(_) + | ds::Error::CorruptConnection => break, + _ => { + log::error!(target: "discord_presence", "unhandled discord error: {e}") + } + } + } + } + log::info!(target: "discord_presence", "lost connection to discord, dropping client..."); + let mutex = app.state::(); + let opt = { + let mut lock = mutex.0.lock().await; + lock.take() + }; + let Some(client) = opt else { + return; + }; + client.discord.disconnect().await; +} + +pub fn create_presence(app: &tauri::AppHandle) -> tauri::Result<()> { + app.manage(ExposedClient(Mutex::new(None))); + app.manage(DiscordTimestamp(SystemTime::now())); + { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let client = make_client(ds::Subscriptions::ACTIVITY).await; + if let Err(e) = client { + log::error!(target: "discord_presence", "couldn't initialize discord client: {e}"); + return; + } + let Some(client) = client.unwrap() else { + log::debug!(target: "discord_presence", "discord took too long to answer (probably not open)"); + return; + }; + let user_wheel = client.wheel.user(); + { + let mutex = app.state::(); + let mut lock = mutex.0.lock().await; + *lock = Some(client) + } + tauri::async_runtime::spawn(async move { + drop_client_on_loss(app, user_wheel).await; + }); + }); + } + + Ok(()) +} diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 02d09dbd5..6185fd0b5 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -54,6 +54,7 @@ import { error, log } from './utils/logging'; import { AppLayout } from './AppLayout'; import { Preload } from './components/Preload'; import { UnknownDeviceModal } from './components/UnknownDeviceModal'; +import { useDiscordPresence } from './hooks/discord-presence'; export const GH_REPO = 'SlimeVR/SlimeVR-Server'; export const VersionContext = createContext(''); @@ -62,6 +63,7 @@ export const SLIMEVR_DISCORD = 'https://discord.gg/slimevr'; function Layout() { const { isMobile } = useBreakpoint('mobile'); + useDiscordPresence(); return ( <> diff --git a/gui/src/components/settings/pages/InterfaceSettings.tsx b/gui/src/components/settings/pages/InterfaceSettings.tsx index 4c165d766..11cc46d48 100644 --- a/gui/src/components/settings/pages/InterfaceSettings.tsx +++ b/gui/src/components/settings/pages/InterfaceSettings.tsx @@ -30,6 +30,7 @@ interface InterfaceSettingsForm { feedbackSoundVolume: number; connectedTrackersWarning: boolean; useTray: boolean; + discordPresence: boolean; }; } @@ -55,6 +56,8 @@ export function InterfaceSettings() { config?.connectedTrackersWarning ?? defaultConfig.connectedTrackersWarning, useTray: config?.useTray ?? defaultConfig.useTray ?? false, + discordPresence: + config?.discordPresence ?? defaultConfig.discordPresence, }, }, }); @@ -70,6 +73,7 @@ export function InterfaceSettings() { textSize: values.appearance.textSize, connectedTrackersWarning: values.notifications.connectedTrackersWarning, useTray: values.notifications.useTray, + discordPresence: values.notifications.discordPresence, }); }; @@ -204,6 +208,28 @@ export function InterfaceSettings() { )} /> + + + {l10n.getString('settings-general-interface-discord_presence')} + +
+ + {l10n.getString( + 'settings-general-interface-discord_presence-description' + )} + +
+
+ +
diff --git a/gui/src/hooks/config.ts b/gui/src/hooks/config.ts index c37309c5c..639c9e1f8 100644 --- a/gui/src/hooks/config.ts +++ b/gui/src/hooks/config.ts @@ -29,6 +29,7 @@ export interface Config { useTray: boolean | null; doneManualMounting: boolean; mirrorView: boolean; + discordPresence: boolean; } export interface ConfigContext { @@ -54,6 +55,7 @@ export const defaultConfig: Omit = { useTray: null, doneManualMounting: false, mirrorView: true, + discordPresence: false, }; interface CrossStorage { diff --git a/gui/src/hooks/discord-presence.ts b/gui/src/hooks/discord-presence.ts new file mode 100644 index 000000000..8f9e8191a --- /dev/null +++ b/gui/src/hooks/discord-presence.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect } from 'react'; +import { useConfig } from './config'; +import { useInterval } from './timeout'; +import { useTrackers } from './tracker'; +import { invoke } from '@tauri-apps/api/core'; +import { warn } from '@/utils/logging'; +import { useLocalization } from '@fluent/react'; + +export function useDiscordPresence() { + const { config } = useConfig(); + const { useConnectedIMUTrackers } = useTrackers(); + const { l10n } = useLocalization(); + const imuTrackers = useConnectedIMUTrackers(); + + const updatePresence = useCallback(() => { + (async () => { + try { + if (await checkDiscordClient()) { + // If discord client exists, try updating presence + await updateDiscordPresence({ + details: l10n.getString( + 'settings-general-interface-discord_presence-message', + { amount: imuTrackers.length } + ), + }); + } else { + // else, try creating a discord client + await createDiscordClient(); + } + } catch (e) { + warn(`failed to update presence, error: ${e}`); + } + })(); + }, [imuTrackers.length, l10n]); + + // Update presence every 6.9 seconds + useInterval(updatePresence, config?.discordPresence ? 6900 : null); + + // Clear presence on config being disabled + useEffect(() => { + if (config?.discordPresence !== false) return; + + (async () => { + if (!(await checkDiscordClient())) return; + clearDiscordPresence().catch((e) => + warn(`failed to clear discord presence, error: ${e}`) + ); + })(); + }, [config?.discordPresence]); +} + +export function checkDiscordClient(): Promise { + return invoke('discord_client_exists'); +} + +export function createDiscordClient(): Promise { + return invoke('create_discord_client'); +} + +export function clearDiscordPresence(): Promise { + return invoke('clear_presence'); +} + +export function updateDiscordPresence(obj: { + details: string; + state?: string; + small_icon?: [string, string]; + button?: { label: string; url: string }; +}): Promise { + return invoke('update_presence', obj); +} diff --git a/gui/src/hooks/timeout.ts b/gui/src/hooks/timeout.ts index 39af2a317..bb1c74302 100644 --- a/gui/src/hooks/timeout.ts +++ b/gui/src/hooks/timeout.ts @@ -1,17 +1,31 @@ -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect, useRef } from 'react'; + +export function useTimeout(fn: () => void, delay: number | null) { + const saved = useRef(fn); + + useLayoutEffect(() => { + saved.current = fn; + }, [fn]); -export function useTimeout(fn: () => void, delay: number) { useEffect(() => { - const id = setTimeout(fn, delay); + if (delay === null) return; + const id = setTimeout(() => saved.current(), delay); return () => clearTimeout(id); - }); + }, [delay]); } -export function useInterval(fn: () => void, delay: number) { +export function useInterval(fn: () => void, delay: number | null) { + const saved = useRef(fn); + + useLayoutEffect(() => { + saved.current = fn; + }, [fn]); + useEffect(() => { - const id = setInterval(fn, delay); + if (delay === null) return; + const id = setInterval(() => saved.current(), delay); return () => clearInterval(id); - }); + }, [delay]); } export const useDebouncedEffect = (effect: () => void, deps: any[], delay: number) => {