From 61d1392f88044eeb8591c1944a944ad2a4f07c91 Mon Sep 17 00:00:00 2001 From: KernelSpecter Date: Thu, 13 Aug 2026 20:24:02 +0530 Subject: [PATCH] Linux: Unix socket transport, installer, and a mock-IPC smoke test UNVERIFIED against a real Linux Discord client. Not released as a binary until someone confirms presence actually appears. See tests/ for what IS checked. The blocker was never packaging. Both endpoints this talks to are Unix domain sockets on Linux, and open() on a socket path fails with ENXIO, so the previous POSIX branches could not have worked - the transport was never written. UnixSocketStream wraps AF_UNIX behind read/write/close/fileno, which is the entire surface PipeReader, pump() and _send() use, so none of them needed a platform branch. bytes_available already had a select() branch that works on a socket via fileno(). open_ipc() picks the transport. discord_ipc_paths() also scans the Flatpak and Snap sandbox subdirectories. Scanning only XDG_RUNTIME_DIR is the usual reason a Linux presence tool looks like it does nothing on those installs. The shim now branches on uname for the two things that actually differ: the mpv binary name, and pipe-vs-socket for --input-ipc-server. install.sh writes ANI_CLI_PLAYER into the shell rc inside sentinel markers, so uninstall removes exactly what was added, and re-running repoints instead of stacking exports. tests/smoke_posix.py stands up a mock Discord and a mock mpv and asserts a real SET_ACTIVITY arrives with the right title and episode. That is the only automated evidence the socket transport works; CI runs it on real Linux and also builds the Linux binary, which PyInstaller cannot cross-compile. Windows is unaffected: discord_ipc_paths() still yields the same 10 named pipes, and the daemon was re-checked after the refactor. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 1 + .github/workflows/build.yml | 101 ++++++++++++++ anirpc.py | 89 ++++++++++-- bin/anirpc-mpv | 32 ++++- install.sh | 99 ++++++++++++++ tests/smoke_posix.py | 260 ++++++++++++++++++++++++++++++++++++ 6 files changed, 570 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100755 install.sh create mode 100644 tests/smoke_posix.py diff --git a/.gitattributes b/.gitattributes index c06fdbe..ac50715 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ # ani-cli then reports as the misleading "Program not found". Must stay LF # on checkout regardless of the cloner's core.autocrlf. bin/anirpc-mpv text eol=lf +install.sh text eol=lf # PowerShell on Windows is conventionally CRLF. *.ps1 text eol=crlf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..032d06d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,101 @@ +name: build + +# Two purposes: +# 1. Build the release binaries. PyInstaller cannot cross-compile, so the +# Linux artifact can only be produced on a Linux runner - this workflow is +# the only way it gets built at all. +# 2. Run the POSIX socket smoke test on real Linux. That is the sole automated +# evidence the Unix-socket transport works; it is not proof that presence +# renders on a real Linux Discord client. + +on: + push: + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke-posix: + name: POSIX socket smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Mock-IPC end-to-end + run: python3 tests/smoke_posix.py + + syntax: + name: Syntax checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.8' # the floor the README claims + - run: python -m py_compile anirpc.py tests/smoke_posix.py + - name: Shell scripts parse + run: | + sh -n install.sh + sh -n bin/anirpc-mpv + - name: Shim is committed executable + # A non-executable shim makes ani-cli report "Program not found", so + # guard the mode bit in CI rather than rediscovering it downstream. + run: test -x bin/anirpc-mpv || { echo "bin/anirpc-mpv is not executable"; exit 1; } + + build: + name: Build ${{ matrix.label }} + needs: [syntax] + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + label: windows-x64 + # Oldest supported runner: PyInstaller binaries are forward- but not + # backward-compatible on glibc, so building here widens the range of + # distros the artifact runs on. + - os: ubuntu-22.04 + label: linux-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install --upgrade pip pyinstaller + + - name: Build + run: pyinstaller --onefile --console --name anirpc anirpc.py + + - name: Stage the payload + shell: bash + run: | + mkdir -p stage/bin + cp dist/anirpc* stage/ 2>/dev/null || cp dist/anirpc stage/ + cp config.json stage/ + cp README.md LICENSE stage/ + cp bin/anirpc-mpv stage/bin/ + chmod +x stage/bin/anirpc-mpv + if [ -f install.ps1 ]; then cp install.ps1 stage/; fi + if [ -f install.sh ]; then cp install.sh stage/; chmod +x stage/install.sh; fi + ls -lR stage + + - name: Package (zip) + if: runner.os == 'Windows' + run: Compress-Archive -Path stage\* -DestinationPath anirpc-${{ matrix.label }}.zip + + - name: Package (tar.gz) + if: runner.os == 'Linux' + # tar, not zip: it preserves the shim's exec bit, which zip drops. + run: tar -czf anirpc-${{ matrix.label }}.tar.gz -C stage . + + - uses: actions/upload-artifact@v4 + with: + name: anirpc-${{ matrix.label }} + path: anirpc-${{ matrix.label }}.* + if-no-files-found: error diff --git a/anirpc.py b/anirpc.py index 42c0abf..fb29d06 100644 --- a/anirpc.py +++ b/anirpc.py @@ -28,6 +28,7 @@ import os import queue import re +import socket import struct import subprocess import sys @@ -189,6 +190,83 @@ def bytes_available(fh): return avail.value +class UnixSocketStream: + """File-like wrapper over an AF_UNIX socket. + + On Linux both of the endpoints this talks to - mpv's --input-ipc-server and + Discord's IPC socket - are Unix domain sockets, and open() on a socket path + fails with ENXIO. So the transport must be a real socket. Everything layered + above it only ever calls read/write/close (and fileno, via the select branch + of bytes_available), which is exactly what this exposes - so PipeReader, + pump() and _send() need no platform branches at all. + """ + + def __init__(self, sock): + self.sock = sock + + def read(self, n): + return self.sock.recv(n) + + def write(self, data): + self.sock.sendall(data) + return len(data) + + def fileno(self): + return self.sock.fileno() + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +def open_ipc(path): + """Connect to an IPC endpoint: named pipe on Windows, AF_UNIX on POSIX. + + Raises OSError when the endpoint does not exist yet, so callers keep their + existing retry loops unchanged. + """ + if os.name == "nt": + return open(path, "r+b", buffering=0) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.connect(path) + except OSError: + sock.close() + raise + return UnixSocketStream(sock) + + +def discord_ipc_paths(): + """Every place a Discord IPC endpoint might live, in preference order. + + Discord exposes discord-ipc-0 through -9. Flatpak and Snap confine theirs to + a sandbox subdirectory of the runtime dir, so scanning only XDG_RUNTIME_DIR + misses those installs entirely - which is the single most common reason + Linux rich-presence tools appear to do nothing. + """ + if os.name == "nt": + for i in range(10): + yield rf"\\.\pipe\discord-ipc-{i}" + return + + base = (os.environ.get("XDG_RUNTIME_DIR") + or os.environ.get("TMPDIR") + or f"/run/user/{os.getuid()}") + roots = ( + base, + os.path.join(base, "app/com.discordapp.Discord"), + os.path.join(base, "app/com.discordapp.DiscordCanary"), + os.path.join(base, "snap.discord"), + os.path.join(base, "snap.discord-canary"), + "/tmp", + ) + for root in roots: + for i in range(10): + yield os.path.join(root, f"discord-ipc-{i}") + + class PipeReader(threading.Thread): """Non-blocking pipe drain on its own thread. @@ -239,7 +317,7 @@ def connect(self, timeout=MPV_CONNECT_TIMEOUT): deadline = time.time() + timeout while time.time() < deadline: try: - self.fh = open(self.pipe, "r+b", buffering=0) + self.fh = open_ipc(self.pipe) break except OSError: time.sleep(0.15) @@ -343,14 +421,9 @@ def __init__(self, client_id): self.connected = False def connect(self): - for i in range(10): - path = (rf"\\.\pipe\discord-ipc-{i}" if os.name == "nt" - else os.path.join( - os.environ.get("XDG_RUNTIME_DIR") - or os.environ.get("TMPDIR") or "/tmp", - f"discord-ipc-{i}")) + for path in discord_ipc_paths(): try: - self.fh = open(path, "r+b", buffering=0) + self.fh = open_ipc(path) except OSError: continue PipeReader(self.fh, self._parse, self.q).start() diff --git a/bin/anirpc-mpv b/bin/anirpc-mpv index 58ceb0e..77f9bf4 100755 --- a/bin/anirpc-mpv +++ b/bin/anirpc-mpv @@ -20,7 +20,18 @@ set -u DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) RUN_DIR="$DIR/run" SESSION="$RUN_DIR/session.json" -REAL_MPV="${ANIRPC_REAL_MPV:-mpv.exe}" +# Windows and Linux differ in exactly two places: the mpv binary name, and the +# shape of the IPC endpoint (a named pipe vs a filesystem socket). +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) ANIRPC_OS=windows ;; + *) ANIRPC_OS=posix ;; +esac + +if [ "$ANIRPC_OS" = windows ]; then + REAL_MPV="${ANIRPC_REAL_MPV:-mpv.exe}" +else + REAL_MPV="${ANIRPC_REAL_MPV:-mpv}" +fi mkdir -p "$RUN_DIR" @@ -57,7 +68,18 @@ if [ -r "/proc/$PPID/cmdline" ]; then fi # --- 3. session file + daemon --------------------------------------------- -PIPE_NAME="anirpc-mpv-$$-$(date +%s)" +if [ "$ANIRPC_OS" = windows ]; then + # Only the endpoint's short name travels in JSON - Python rebuilds + # \\.\pipe\ - which keeps Windows backslashes out of the escaping + # path entirely. + SESSION_PIPE="anirpc-mpv-$$-$(date +%s)" + IPC_ARG="\\\\.\\pipe\\$SESSION_PIPE" +else + # A socket is a real file, so it needs a real directory. The runtime dir is + # per-user and already mode 700; /tmp is the fallback. + SESSION_PIPE="${XDG_RUNTIME_DIR:-/tmp}/anirpc-mpv-$$-$(date +%s).sock" + IPC_ARG="$SESSION_PIPE" +fi json_escape() { printf '%s' "$1" | sed -e 's|\\|\\\\|g' -e 's|"|\\"|g' | tr -d '\000-\037' @@ -70,7 +92,7 @@ cat >"$SESSION.tmp" </dev/null 2>&1 & +elif [ -x "$DIR/anirpc" ]; then + nohup "$DIR/anirpc" --daemon >/dev/null 2>&1 & else PY="${ANIRPC_PYTHON:-}" [ -z "$PY" ] && PY=$(command -v python3 2>/dev/null || command -v python 2>/dev/null) @@ -92,4 +116,4 @@ else fi # --- 4. hand off to the real mpv ------------------------------------------ -exec "$REAL_MPV" --input-ipc-server="\\\\.\\pipe\\$PIPE_NAME" "$@" +exec "$REAL_MPV" --input-ipc-server="$IPC_ARG" "$@" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..dad2218 --- /dev/null +++ b/install.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# install.sh - make ani-cli Rich Presence permanent on Linux. +# +# The Linux counterpart of install.ps1. Same single setting: ANI_CLI_PLAYER, +# which ani-cli reads at line 405. Windows stores that in the user environment +# registry; Linux has no such thing, so it goes into the shell rc file, fenced +# by sentinel markers so --uninstall can remove exactly what was added. +# +# Run: ./install.sh +# Undo: ./install.sh --uninstall + +set -eu + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +SHIM="$DIR/bin/anirpc-mpv" +BEGIN="# >>> anirpc >>>" +END="# <<< anirpc <<<" + +# Pick the rc file for the login shell, since that is what will actually be +# sourced. Fall back to ~/.profile, which every POSIX shell reads. +case "${SHELL:-}" in + */zsh) RC="$HOME/.zshrc" ;; + */bash) RC="$HOME/.bashrc" ;; + */fish) RC="" ;; # fish syntax differs; handled below + *) RC="$HOME/.profile" ;; +esac + +if [ "${1:-}" = "--uninstall" ]; then + if [ -n "$RC" ] && [ -f "$RC" ]; then + # Delete the fenced block in place. A temp file keeps this atomic-ish + # and avoids sed -i, which is not portable across GNU and BSD. + tmp=$(mktemp) + sed "/^${BEGIN}$/,/^${END}$/d" "$RC" >"$tmp" + cat "$tmp" >"$RC" + rm -f "$tmp" + echo "Removed the anirpc block from $RC." + fi + echo "ani-cli is back to plain mpv. Open a new terminal, or run:" + echo " unset ANI_CLI_PLAYER" + exit 0 +fi + +if [ ! -f "$SHIM" ]; then + echo "ERROR: missing $SHIM" >&2 + echo "Extract the whole archive, keeping bin/ next to install.sh." >&2 + exit 1 +fi + +# ani-cli launches the player unquoted (`nohup $player_function ...`, +# ani-cli:318), so a space anywhere in the path breaks it - same constraint as +# on Windows, same failure. +case "$SHIM" in + *[[:space:]]*) + echo "ERROR: this path contains a space:" >&2 + echo " $SHIM" >&2 + echo "ani-cli launches the player unquoted, so this cannot work." >&2 + echo "Move the folder somewhere without spaces and retry." >&2 + exit 1 + ;; +esac + +chmod +x "$SHIM" +# Verify rather than assume: ani-cli gates on `command -v ` +# (ani-cli:519), which reports a non-executable file as "Program not found". +if [ ! -x "$SHIM" ]; then + echo "ERROR: could not make the shim executable: $SHIM" >&2 + exit 1 +fi + +for dep in mpv ani-cli python3; do + command -v "$dep" >/dev/null 2>&1 || echo "WARNING: $dep not found on PATH." +done + +if [ -z "$RC" ]; then + echo "Detected fish. Add this to ~/.config/fish/config.fish yourself:" + echo " set -gx ANI_CLI_PLAYER \"$SHIM\"" + exit 0 +fi + +# Idempotent: strip any previous block before appending, so re-running after a +# move repoints instead of stacking duplicate exports. +if [ -f "$RC" ] && grep -qF "$BEGIN" "$RC"; then + tmp=$(mktemp) + sed "/^${BEGIN}$/,/^${END}$/d" "$RC" >"$tmp" + cat "$tmp" >"$RC" + rm -f "$tmp" +fi + +{ + printf '%s\n' "$BEGIN" + printf 'export ANI_CLI_PLAYER="%s"\n' "$SHIM" + printf '%s\n' "$END" +} >>"$RC" + +echo "Installed." +echo " ANI_CLI_PLAYER = $SHIM" +echo " written to $RC" +echo +echo "Open a NEW terminal (or: source $RC), then just run: ani-cli" diff --git a/tests/smoke_posix.py b/tests/smoke_posix.py new file mode 100644 index 0000000..943d8ea --- /dev/null +++ b/tests/smoke_posix.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for the POSIX (Unix socket) transport. + +Stands up a mock Discord IPC socket and a mock mpv IPC socket, runs a real +anirpc daemon against both, and asserts a SET_ACTIVITY payload arrives with the +expected fields. + +This is the only automated evidence the Linux transport works. It exercises the +real socket code paths -- handshake framing, property observation, presence +assembly -- without needing a Discord client or mpv. It does NOT prove presence +renders on a real Linux Discord; nothing here can. + +Run: python3 tests/smoke_posix.py +""" + +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +TITLE = "Frieren: Beyond Journey's End" + +activities = [] # SET_ACTIVITY payloads the mock Discord received +handshakes = [] # client_ids seen +errors = [] + + +def mock_discord(sock_path, stop): + """Speak just enough Discord IPC: handshake -> READY, then record frames.""" + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(sock_path) + srv.listen(1) + srv.settimeout(0.5) + try: + while not stop.is_set(): + try: + conn, _ = srv.accept() + except socket.timeout: + continue + conn.settimeout(0.5) + buf = b"" + while not stop.is_set(): + try: + chunk = conn.recv(4096) + except socket.timeout: + continue + except OSError: + break + if not chunk: + break + buf += chunk + # Same framing as the daemon: 8-byte (op, len) header + JSON. + while len(buf) >= 8: + op, ln = struct.unpack("