3 Commits
Author SHA1 Message Date
Daniele Fucini 047139cb72 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.
2026-11-11 21:06:59 +01:00
Daniele Fucini 3e83c8c5d2 Uniform installation instructions in README 2026-10-20 22:01:12 +02:00
Daniele Fucini 0ab71a369d Update PKGBUILD 2026-10-20 20:53:14 +02:00
4 changed files with 42 additions and 16 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ makedepends=('git')
checkdepends=('python-pytest')
source=("git+$url?signed#tag=$pkgver")
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
sha256sums=('SKIP')
sha256sums=('902f970ee1521e6bb7ca5bec0eb6057f476772b08f76eb23af83ede3f5807cb3')
check()
{
+13 -4
View File
@@ -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
@@ -39,10 +41,17 @@ Discord then shows it the same way as a game it detected itself.
## Installation
Build and install the package with the included PKGBUILD, then enable the user service:
Build and install the package with the included PKGBUILD, which builds the signed release tag set in `pkgver`:
```
makepkg -si
```
Or, with aurutils, build it into a local repository with `aur build` and install it with pacman.
Then enable the user service:
```
systemctl --user enable --now discord-game-presence
```
+10 -8
View File
@@ -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)
+18 -3
View File
@@ -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