feat(discord-rpc): lazy Flatpak socket bridge keeps the integration opt-in

Discord-Flatpak writes its IPC socket to
\$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N rather than
the \$XDG_RUNTIME_DIR/discord-ipc-N path the discord-rich-presence crate
searches by default. The naive way to bridge that gap — a startup
wrapper that unconditionally symlinks every iteration before the binary
launches — works, but it pulls the integration off-script: every other
optional integration in QBZ (Last.fm, ListenBrainz, MusicBrainz, Plex)
is opt-in, with zero telemetry and zero filesystem reach until the user
flips the toggle. A startup wrapper would silently turn Discord RPC
into opt-out at the sandbox layer; the symlinks would exist for users
who never touched the integrations panel.

Move the bridge into the lazy connect path instead. The first time the
user actually connects — i.e. the first track plays after they enabled
the toggle — prepare_flatpak_discord_socket_links runs:

* No-op outside Flatpak (no FLATPAK_ID env var)
* No-op when \$XDG_RUNTIME_DIR/discord-ipc-N already exists (native
  Discord, or a previous lazy run in this Flatpak instance)
* No-op when the Discord-Flatpak source path doesn't exist (Discord
  not running)
* Otherwise, symlinks 0..9 from \$XDG_RUNTIME_DIR/discord-ipc-N to
  \$XDG_RUNTIME_DIR/app/com.discordapp.Discord/discord-ipc-N

Symlinks die with the Flatpak instance — \$XDG_RUNTIME_DIR is per-
instance, so there's no cross-session pollution. Users who never enable
Discord RPC keep the same sandbox surface as before.

Manifest: keep the static xdg-run/discord-ipc-0 permission for native
Discord, add xdg-run/app/com.discordapp.Discord:create so the lazy
symlink path is allowed when the user opts in. The two coexist; the
crate's default search path covers Discord-native, the symlinks cover
Discord-Flatpak.

cc
This commit is contained in:
vicrodh committed 2026-05-09 12:38:25 -06:00
1 parent 0274f2b030
commit e2e4fb8cab
2 files changed
+73 -1

No files matched your search

+61
View File
@@ -13,6 +13,64 @@ use std::time::{SystemTime, UNIX_EPOCH};
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
use tauri::State;
/// 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. Any
/// caller mistake fails over to the crate's default behavior, which is the
/// same fallback the non-Flatpak code path already accepts.
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;
}
let _ = std::os::unix::fs::symlink(&source, &link_target);
}
}
/// Public Discord Application ID for QBZ. Public identifier, not a secret.
const DISCORD_APP_ID: &str = "1501835855587708988";
const QBZ_HOMEPAGE: &str = "https://qbz.lol";
@@ -69,6 +127,9 @@ pub fn v2_discord_rpc_update(
let mut guard = state.client.lock().map_err(|e| e.to_string())?;
if guard.is_none() {
// 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();
if let Ok(mut client) = DiscordIpcClient::new(DISCORD_APP_ID) {
if client.connect().is_ok() {
*guard = Some(client);