Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047139cb72
|
||
|
|
3e83c8c5d2
|
||
|
|
0ab71a369d
|
@@ -12,7 +12,7 @@ makedepends=('git')
|
||||
checkdepends=('python-pytest')
|
||||
source=("git+$url?signed#tag=$pkgver")
|
||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||
sha256sums=('SKIP')
|
||||
sha256sums=('902f970ee1521e6bb7ca5bec0eb6057f476772b08f76eb23af83ede3f5807cb3')
|
||||
|
||||
check()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user