From 047139cb7210f7cbc6946f3e82040dcab5d4328e Mon Sep 17 00:00:00 2001 From: Daniele Fucini Date: Wed, 11 Nov 2026 21:06:59 +0100 Subject: [PATCH] Support Flatpak and Snap Discord; keep the timer when a game changes process Look for discord-ipc sockets in the Flatpak and Snap runtime folders too. When a game moves to another process (e.g. at Steam launch), keep its first start time and don't resend the activity. Bump version to 0.2. --- README.md | 8 +++++--- discord_game_presence.py | 18 ++++++++++-------- tests/test_discord_game_presence.py | 21 ++++++++++++++++++--- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 69ecc45..fc9d729 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # discord-game-presence Shows the game you're playing in Discord ("Playing Genshin Impact", with the game's icon and a timer) when Discord -can't detect it by itself, for example because Discord runs in a firejail sandbox. +can't detect it by itself because it runs in a sandbox: Flatpak, Snap or firejail. Discord normally detects games by looking at running processes. In a sandbox with its own PID namespace it sees only its own processes, so it never notices a game. discord-game-presence runs outside the sandbox, watches `/proc`, and @@ -26,8 +26,10 @@ Discord then shows it the same way as a game it detected itself. ## Requirements - `python` (3.11 or newer; standard library only) -- Discord's IPC socket (`$XDG_RUNTIME_DIR/discord-ipc-0`) reachable from outside the sandbox. With firejail's - `discord` profile, add this to `/etc/firejail/discord.local` (the blacklists keep the keyring private): +- Discord's IPC socket reachable from outside the sandbox. Flatpak (`$XDG_RUNTIME_DIR/app/com.discordapp.Discord/`) + and Snap (`$XDG_RUNTIME_DIR/snap.discord/`) sockets are found as they are. firejail's `discord` profile hides + `$XDG_RUNTIME_DIR/discord-ipc-0`; add this to `/etc/firejail/discord.local` (the blacklists keep the keyring + private): ``` ignore include whitelist-runuser-common.inc diff --git a/discord_game_presence.py b/discord_game_presence.py index eb27bcc..884844f 100644 --- a/discord_game_presence.py +++ b/discord_game_presence.py @@ -16,17 +16,18 @@ import tomllib import urllib.request import uuid from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any -__version__ = "0.1" +__version__ = "0.2" NAME = "discord-game-presence" DETECTABLE_URL = "https://discord.com/api/v9/applications/detectable" CACHE_MAX_AGE = 7 * 24 * 3600 # seconds before the game list is downloaded again CACHE_CHECK = 3600 # seconds between cache age checks in the daemon CACHE_RETRY = 600 # seconds between download attempts while there is no game list at all +SOCKET_DIRS = ("", "app/com.discordapp.Discord", "snap.discord") # under XDG_RUNTIME_DIR: host/firejail, Flatpak, Snap OP_HANDSHAKE, OP_FRAME, OP_CLOSE, OP_PING, OP_PONG = range(5) @@ -208,11 +209,11 @@ def scan(index: GameIndex, proc: Path = Path("/proc")) -> list[Game]: def choose(games: list[Game], current: Game | None) -> Game | None: - """Keep showing the current game while it runs, else the oldest one.""" + """Keep showing the current game while it runs, timed from its first process, else the oldest one.""" if current is not None: for game in games: if game.app.id == current.app.id: - return game + return replace(game, start=min(game.start, current.start)) return games[0] if games else None @@ -272,8 +273,7 @@ class DiscordIPC: @classmethod def connect(cls, client_id: str, runtime_dir: Path, timeout: float = 5.0) -> "DiscordIPC": error: OSError = FileNotFoundError(f"no discord-ipc socket in {runtime_dir}") - for i in range(10): - path = runtime_dir / f"discord-ipc-{i}" + for path in (runtime_dir / d / f"discord-ipc-{i}" for d in SOCKET_DIRS for i in range(10)): if not path.exists(): continue sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) @@ -363,8 +363,8 @@ class Presence: if game is None: self.failing = False return - if game == self.shown: - return + if self.shown is not None and game.start == self.shown.start: + return # same app and timer: only the process changed try: if self.ipc is None: self.ipc = DiscordIPC.connect(game.app.id, self.runtime_dir) @@ -406,6 +406,8 @@ def run(config: Config, cache: Path, runtime_dir: Path, stop: threading.Event, p log.info("%s exited", current.app.name) if game is not None and (current is None or game.app.id != current.app.id): log.info("Detected %s (pid %d)", game.app.name, game.pid) + elif game is not None and current is not None and game.pid != current.pid: + log.debug("%s now pid %d", game.app.name, game.pid) current = game presence.update(game) stop.wait(config.interval) diff --git a/tests/test_discord_game_presence.py b/tests/test_discord_game_presence.py index 1b66708..ad0ec7c 100644 --- a/tests/test_discord_game_presence.py +++ b/tests/test_discord_game_presence.py @@ -2,6 +2,7 @@ import json import os import socket import struct +import tempfile import threading import time from collections.abc import Iterator @@ -158,6 +159,9 @@ def test_choose() -> None: assert dgp.choose([], None) is None assert dgp.choose([a, b], None) == a assert dgp.choose([a, b], b) == b + # same app on a newer process: keep the first start so the timer doesn't restart + moved = dgp.Game(dgp.App("2", "B"), 3, 205) + assert dgp.choose([a, moved], b) == dgp.Game(dgp.App("2", "B"), 3, 200) assert dgp.choose([a], b) == a @@ -291,9 +295,10 @@ class FakeDiscord: @pytest.fixture -def runtime_dir(tmp_path: Path) -> Iterator[Path]: - # AF_UNIX paths are limited to ~108 bytes, so don't nest deeply - yield tmp_path +def runtime_dir() -> Iterator[Path]: + # AF_UNIX paths are limited to 107 bytes: tmp_path plus the Flatpak subdir can exceed it + with tempfile.TemporaryDirectory(prefix="dgp-", dir="/tmp") as d: + yield Path(d) def test_ipc_set_activity(runtime_dir: Path) -> None: @@ -316,6 +321,15 @@ def test_ipc_later_socket(runtime_dir: Path) -> None: dgp.DiscordIPC.connect("123", runtime_dir).close() +@pytest.mark.parametrize("subdir", ["app/com.discordapp.Discord", "snap.discord"]) +def test_ipc_sandbox_socket(runtime_dir: Path, subdir: str) -> None: + (runtime_dir / subdir).mkdir(parents=True) + discord = FakeDiscord(runtime_dir / subdir / "discord-ipc-0") + dgp.DiscordIPC.connect("123", runtime_dir).close() + assert discord.received[0] == (dgp.OP_HANDSHAKE, {"v": 1, "client_id": "123"}) + discord.stop() + + def test_ipc_no_socket(runtime_dir: Path) -> None: with pytest.raises(FileNotFoundError): dgp.DiscordIPC.connect("123", runtime_dir) @@ -352,6 +366,7 @@ def test_presence(runtime_dir: Path) -> None: presence.update(genshin) assert presence.shown == genshin presence.update(genshin) # unchanged: nothing sent + presence.update(dgp.Game(genshin.app, 11, 100)) # another process, same timer: nothing sent assert len(discord.received) == 2 presence.update(neverwinter) # another app needs a new connection