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.
This commit is contained in:
Daniele Fucini
2026-11-11 21:06:59 +01:00
parent 3e83c8c5d2
commit 047139cb72
3 changed files with 33 additions and 14 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
# discord-game-presence # discord-game-presence
Shows the game you're playing in Discord ("Playing Genshin Impact", with the game's icon and a timer) when Discord 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 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 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 ## Requirements
- `python` (3.11 or newer; standard library only) - `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's IPC socket reachable from outside the sandbox. Flatpak (`$XDG_RUNTIME_DIR/app/com.discordapp.Discord/`)
`discord` profile, add this to `/etc/firejail/discord.local` (the blacklists keep the keyring private): 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 ignore include whitelist-runuser-common.inc
+10 -8
View File
@@ -16,17 +16,18 @@ import tomllib
import urllib.request import urllib.request
import uuid import uuid
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
__version__ = "0.1" __version__ = "0.2"
NAME = "discord-game-presence" NAME = "discord-game-presence"
DETECTABLE_URL = "https://discord.com/api/v9/applications/detectable" DETECTABLE_URL = "https://discord.com/api/v9/applications/detectable"
CACHE_MAX_AGE = 7 * 24 * 3600 # seconds before the game list is downloaded again 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_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 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) 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: 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: if current is not None:
for game in games: for game in games:
if game.app.id == current.app.id: 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 return games[0] if games else None
@@ -272,8 +273,7 @@ class DiscordIPC:
@classmethod @classmethod
def connect(cls, client_id: str, runtime_dir: Path, timeout: float = 5.0) -> "DiscordIPC": 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}") error: OSError = FileNotFoundError(f"no discord-ipc socket in {runtime_dir}")
for i in range(10): for path in (runtime_dir / d / f"discord-ipc-{i}" for d in SOCKET_DIRS for i in range(10)):
path = runtime_dir / f"discord-ipc-{i}"
if not path.exists(): if not path.exists():
continue continue
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -363,8 +363,8 @@ class Presence:
if game is None: if game is None:
self.failing = False self.failing = False
return return
if game == self.shown: if self.shown is not None and game.start == self.shown.start:
return return # same app and timer: only the process changed
try: try:
if self.ipc is None: if self.ipc is None:
self.ipc = DiscordIPC.connect(game.app.id, self.runtime_dir) 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) log.info("%s exited", current.app.name)
if game is not None and (current is None or game.app.id != current.app.id): 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) 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 current = game
presence.update(game) presence.update(game)
stop.wait(config.interval) stop.wait(config.interval)
+18 -3
View File
@@ -2,6 +2,7 @@ import json
import os import os
import socket import socket
import struct import struct
import tempfile
import threading import threading
import time import time
from collections.abc import Iterator from collections.abc import Iterator
@@ -158,6 +159,9 @@ def test_choose() -> None:
assert dgp.choose([], None) is None assert dgp.choose([], None) is None
assert dgp.choose([a, b], None) == a assert dgp.choose([a, b], None) == a
assert dgp.choose([a, b], b) == b 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 assert dgp.choose([a], b) == a
@@ -291,9 +295,10 @@ class FakeDiscord:
@pytest.fixture @pytest.fixture
def runtime_dir(tmp_path: Path) -> Iterator[Path]: def runtime_dir() -> Iterator[Path]:
# AF_UNIX paths are limited to ~108 bytes, so don't nest deeply # AF_UNIX paths are limited to 107 bytes: tmp_path plus the Flatpak subdir can exceed it
yield tmp_path with tempfile.TemporaryDirectory(prefix="dgp-", dir="/tmp") as d:
yield Path(d)
def test_ipc_set_activity(runtime_dir: Path) -> None: 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() 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: def test_ipc_no_socket(runtime_dir: Path) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
dgp.DiscordIPC.connect("123", runtime_dir) dgp.DiscordIPC.connect("123", runtime_dir)
@@ -352,6 +366,7 @@ def test_presence(runtime_dir: Path) -> None:
presence.update(genshin) presence.update(genshin)
assert presence.shown == genshin assert presence.shown == genshin
presence.update(genshin) # unchanged: nothing sent 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 assert len(discord.received) == 2
presence.update(neverwinter) # another app needs a new connection presence.update(neverwinter) # another app needs a new connection