mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
Add Discord Rich Presence support (#1027)
Co-authored-by: Erimel <marioluigivideo@gmail.com>
This commit is contained in:
51
Cargo.lock
generated
51
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
|
||||
217
gui/src-tauri/src/presence.rs
Normal file
217
gui/src-tauri/src/presence.rs
Normal 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(())
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
71
gui/src/hooks/discord-presence.ts
Normal file
71
gui/src/hooks/discord-presence.ts
Normal 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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user