Add Discord Rich Presence support (#1027)

Co-authored-by: Erimel <marioluigivideo@gmail.com>
This commit is contained in:
Uriel
2024-06-14 18:32:00 +02:00
committed by GitHub
parent fbb0b8a460
commit 00ef667c58
10 changed files with 408 additions and 9 deletions

51
Cargo.lock generated
View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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));

View File

@@ -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<Option<DiscordClient>>);
async fn make_client(subs: ds::Subscriptions) -> Result<Option<DiscordClient>> {
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<bool, ()> {
Ok(client_exists(&client).await)
}
#[tauri::command]
pub async fn update_presence(
client: State<'_, ExposedClient>,
timestamp: State<'_, DiscordTimestamp>,
details: String,
state: Option<String>,
small_icon: Option<(String, String)>,
button: Option<ds::activity::Button>,
) -> 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<R: Runtime>(
app: AppHandle<R>,
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<R: Runtime>(
app: tauri::AppHandle<R>,
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::<ExposedClient>();
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<R: Runtime>(app: &tauri::AppHandle<R>) -> 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::<ExposedClient>();
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(())
}

View File

@@ -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 (
<>

View File

@@ -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() {
)}
/>
</div>
<Typography bold>
{l10n.getString('settings-general-interface-discord_presence')}
</Typography>
<div className="flex flex-col pt-1 pb-2">
<Typography color="secondary">
{l10n.getString(
'settings-general-interface-discord_presence-description'
)}
</Typography>
</div>
<div className="grid sm:grid-cols-2 pb-4">
<CheckBox
variant="toggle"
control={control}
outlined
name="notifications.discordPresence"
label={l10n.getString(
'settings-general-interface-discord_presence-label'
)}
/>
</div>
</>
</SettingsPagePaneLayout>

View File

@@ -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<Config, 'devSettings'> = {
useTray: null,
doneManualMounting: false,
mirrorView: true,
discordPresence: false,
};
interface CrossStorage {

View File

@@ -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<boolean> {
return invoke('discord_client_exists');
}
export function createDiscordClient(): Promise<void> {
return invoke('create_discord_client');
}
export function clearDiscordPresence(): Promise<void> {
return invoke('clear_presence');
}
export function updateDiscordPresence(obj: {
details: string;
state?: string;
small_icon?: [string, string];
button?: { label: string; url: string };
}): Promise<void> {
return invoke('update_presence', obj);
}

View File

@@ -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) => {