Files
qbz/crates/qbz-integrations/src/discord.rs
T
vicrodh 07e86d329d fix(integrations): Discord presence backs off for five minutes after a failed connect
With Discord closed, every now-playing edge (two per track: the immediate
publish and the poll's) tried the IPC socket and the crate logged
"find_pipe: could not find pipe" each time, so a listening session read
as an error stream. A failed connect now sets a five-minute deadline,
logs one line at info, and later edges push nothing until it passes;
opting the integration in again clears the deadline. The crate's own
warning is demoted to error in the default log filter. Pure backoff rule
covered by a unit test.

The Discover preferences store also logged "unavailable" when its lazy
init ran before the session restored; that startup order is normal and
the handle is rebound on activation, so it is a debug line unless a real
user directory fails to open.

cc
2026-09-13 18:35:09 -06:00

266 lines
10 KiB
Rust

//! Discord Rich Presence integration (frontend-agnostic).
//!
//! Ported ~1:1 from the Tauri `discord_rpc.rs` backend, minus the
//! `#[tauri::command]` / `tauri::State` wrappers — the logic is plain Rust so
//! any frontend (Slint, TUI, CLI) drives it through [`DiscordRpc`].
//!
//! Connection is lazy: the IPC client is created on the first [`DiscordRpc::update`]
//! call when the feature is enabled, and dropped if any operation fails. Failure
//! is silent — when Discord is not running, when the sandbox blocks the IPC
//! socket, or when the user has not opted in, the activity simply does not appear.
//!
//! The IPC calls are blocking; call [`DiscordRpc`] methods off the UI thread
//! (e.g. inside `tokio::task::spawn_blocking`).
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use std::time::{Duration, Instant};
/// Public Discord Application ID for QBZ. Public identifier, not a secret.
const DISCORD_APP_ID: &str = "1501835855587708988";
const QBZ_HOMEPAGE: &str = "https://qbz.lol";
/// Bridge our Flatpak sandbox to the Discord-Flatpak IPC socket.
///
/// The `discord-rich-presence` crate searches `$XDG_RUNTIME_DIR/discord-ipc-N`
/// for `N=0..9`. Native Discord (deb / rpm / AUR) writes its socket directly
/// to that path, so the search succeeds out of the box. Discord installed via
/// Flathub writes to `$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N`
/// because each Flatpak app gets its own runtime subdirectory; the crate's
/// default search misses it, and Rich Presence appears broken even though
/// Discord is running.
///
/// Other Flathub apps work around this with a startup wrapper script that
/// unconditionally symlinks every iteration into `$XDG_RUNTIME_DIR` for every
/// user. That conflicts with QBZ's external-service-integration posture: all
/// optional integrations are opt-in (Last.fm, ListenBrainz, MusicBrainz, Plex,
/// Discord here). Running the wrapper for every Flatpak instance — including
/// users who never enabled Discord RPC — silently turns the integration into
/// opt-out at the sandbox layer.
///
/// We make the symlinks here instead, lazily, the first time the user actually
/// connects to Discord. If the user never toggles Discord RPC on, these links
/// are never created and the sandbox stays untouched. The links live for the
/// lifetime of the Flatpak instance (`$XDG_RUNTIME_DIR` is scoped per-instance)
/// so there's no cross-session pollution either.
///
/// Returns silently on every failure path — missing env var, non-Flatpak
/// process, ENOENT on the source dir, EEXIST on the link target, etc.
fn prepare_flatpak_discord_socket_links() {
if std::env::var_os("FLATPAK_ID").is_none() {
return;
}
let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") else {
return;
};
let runtime_dir = std::path::PathBuf::from(runtime_dir);
// discord-rich-presence iterates 0..=9; only bother creating a link when
// the slot in $XDG_RUNTIME_DIR is currently empty AND the corresponding
// path inside the Discord-Flatpak app dir exists. Either condition failing
// is normal (Discord not running, slot already taken by native Discord,
// etc.) and silently skipped.
for i in 0..=9 {
let link_target = runtime_dir.join(format!("discord-ipc-{}", i));
if link_target.exists() || link_target.symlink_metadata().is_ok() {
continue;
}
let source = runtime_dir
.join("app")
.join("com.discordapp.Discord")
.join(format!("discord-ipc-{}", i));
if !source.exists() {
continue;
}
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(&source, &link_target);
}
}
/// The "now listening" snapshot pushed to Discord. Mirrors the params the Tauri
/// `v2_discord_rpc_update` command took.
#[derive(Debug, Clone, Default)]
pub struct NowListening {
pub title: String,
pub artist: String,
pub album: String,
pub is_playing: bool,
/// Current playback position, seconds.
pub current_time: f64,
/// Track duration, seconds (0 = unknown; no end timestamp then).
pub duration: f64,
/// Large-image asset: a Discord asset key OR an http(s) cover URL. `None`
/// falls back to the "cover" asset key.
pub cover_url: Option<String>,
}
/// Discord Rich Presence client holder. Owns the lazy IPC connection + the
/// opt-in flag. Cheap to keep as a process-global (the Slint service does).
#[derive(Default)]
pub struct DiscordRpc {
client: Mutex<Option<DiscordIpcClient>>,
enabled: AtomicBool,
/// Set after a failed IPC connect: no new attempt before this instant.
/// Without it every now-playing edge (two per track: the immediate
/// publish and the poll's) knocked on a socket that is not there and
/// the crate logged a warning each time — with Discord closed, a
/// listening session read as an error stream.
retry_after: Mutex<Option<Instant>>,
}
/// How long a failed IPC connect keeps the integration quiet. A track edge
/// inside the window pushes nothing; the first edge after it retries.
pub const CONNECT_RETRY_AFTER: Duration = Duration::from_secs(300);
/// Pure backoff rule: attempt when no deadline is set or it has passed.
pub fn connect_allowed(now: Instant, retry_after: Option<Instant>) -> bool {
match retry_after {
None => true,
Some(deadline) => now >= deadline,
}
}
impl DiscordRpc {
pub const fn new() -> Self {
Self {
client: Mutex::new(None),
enabled: AtomicBool::new(false),
retry_after: Mutex::new(None),
}
}
/// Whether the integration is currently opted in.
pub fn is_enabled(&self) -> bool {
self.enabled.load(Ordering::SeqCst)
}
/// Flip the opt-in flag. Disabling tears down the live activity + IPC
/// connection so the presence disappears immediately.
pub fn set_enabled(&self, enabled: bool) {
self.enabled.store(enabled, Ordering::SeqCst);
if !enabled {
self.clear();
}
// Opting in (again) is an explicit request: forget any backoff so the
// next update connects right away.
if let Ok(mut retry) = self.retry_after.lock() {
*retry = None;
}
}
/// Clear the activity and drop the IPC connection (no-op when not connected).
pub fn clear(&self) {
if let Ok(mut guard) = self.client.lock() {
if let Some(client) = guard.as_mut() {
let _ = client.clear_activity();
let _ = client.close();
}
*guard = None;
}
}
/// Push the current "now listening" snapshot. Lazily connects on first use
/// when enabled; a no-op (Ok) when disabled. Silent on every IPC failure
/// (Discord closed / sandbox blocked): the connection is dropped so the
/// next update retries cleanly.
pub fn update(&self, meta: &NowListening) {
if !self.enabled.load(Ordering::SeqCst) {
return;
}
let Ok(mut guard) = self.client.lock() else {
return;
};
if guard.is_none() {
let now = Instant::now();
let Ok(mut retry) = self.retry_after.lock() else {
return;
};
if !connect_allowed(now, *retry) {
return;
}
// Bridge the Flatpak sandbox to Discord's IPC socket if we're
// running under Flatpak. No-op outside that context.
prepare_flatpak_discord_socket_links();
// discord-rich-presence 1.x: DiscordIpcClient::new is infallible
// (returns Self directly); failures show up at connect() time.
let mut client = DiscordIpcClient::new(DISCORD_APP_ID);
if client.connect().is_ok() {
*guard = Some(client);
*retry = None;
} else {
*retry = Some(now + CONNECT_RETRY_AFTER);
log::info!(
"[discord] Discord is not reachable (not running, or its socket is not visible); next attempt in {} s",
CONNECT_RETRY_AFTER.as_secs()
);
}
}
let Some(client) = guard.as_mut() else {
return;
};
let state_str = format!("by {}", meta.artist);
let small_text = if meta.is_playing {
"Playing".to_string()
} else {
let mins = (meta.current_time / 60.0).floor() as u32;
let secs = (meta.current_time % 60.0).floor() as u32;
format!("Paused at {:02}:{:02}", mins, secs)
};
let large_image = meta
.cover_url
.clone()
.unwrap_or_else(|| "cover".to_string());
let mut act = activity::Activity::new()
.details(&meta.title)
.state(&state_str)
.activity_type(activity::ActivityType::Listening)
.assets(
activity::Assets::new()
.large_image(&large_image)
.small_image("icon")
.large_text(&meta.album)
.small_text(&small_text),
)
.buttons(vec![activity::Button::new("Get QBZ", QBZ_HOMEPAGE)]);
if meta.is_playing {
if let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) {
let start_time = now.as_secs() as i64 - meta.current_time as i64;
let mut ts = activity::Timestamps::new().start(start_time);
if meta.duration > 0.0 {
ts = ts.end(start_time + meta.duration as i64);
}
act = act.timestamps(ts);
}
}
if client.set_activity(act).is_err() {
*guard = None;
}
}
}
#[cfg(test)]
mod backoff_tests {
use super::*;
#[test]
fn connect_backoff_waits_for_the_deadline_then_retries() {
let now = Instant::now();
assert!(connect_allowed(now, None));
let deadline = now + CONNECT_RETRY_AFTER;
assert!(!connect_allowed(now, Some(deadline)));
assert!(!connect_allowed(now + CONNECT_RETRY_AFTER / 2, Some(deadline)));
assert!(connect_allowed(deadline, Some(deadline)));
assert!(connect_allowed(deadline + Duration::from_secs(1), Some(deadline)));
}
}