Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
047139cb72
|
||
|
|
3e83c8c5d2
|
||
|
|
0ab71a369d
|
@@ -12,7 +12,7 @@ makedepends=('git')
|
|||||||
checkdepends=('python-pytest')
|
checkdepends=('python-pytest')
|
||||||
source=("git+$url?signed#tag=$pkgver")
|
source=("git+$url?signed#tag=$pkgver")
|
||||||
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
validpgpkeys=('7E12BC1FF3B6EDB2CD8053EB981A2B2A3BBF5514')
|
||||||
sha256sums=('SKIP')
|
sha256sums=('902f970ee1521e6bb7ca5bec0eb6057f476772b08f76eb23af83ede3f5807cb3')
|
||||||
|
|
||||||
check()
|
check()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,10 +41,17 @@ Discord then shows it the same way as a game it detected itself.
|
|||||||
|
|
||||||
## Installation
|
## 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
|
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
|
systemctl --user enable --now discord-game-presence
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user