Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 101 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
89 changes: 81 additions & 8 deletions anirpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import os
import queue
import re
import socket
import struct
import subprocess
import sys
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
32 changes: 28 additions & 4 deletions bin/anirpc-mpv
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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\<name> - 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'
Expand All @@ -70,7 +92,7 @@ cat >"$SESSION.tmp" <<EOF
{"title":"$(json_escape "$title")",
"episode":"$(json_escape "$episode")",
"mode":"$mode",
"pipe":"$PIPE_NAME",
"pipe":"$(json_escape "$SESSION_PIPE")",
"pid":$$,
"started":$(date +%s)}
EOF
Expand All @@ -83,6 +105,8 @@ mv -f "$SESSION.tmp" "$SESSION"
# falling back to the interpreter keeps source checkouts working unchanged.
if [ -f "$DIR/anirpc.exe" ]; then
nohup "$DIR/anirpc.exe" --daemon >/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)
Expand All @@ -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" "$@"
99 changes: 99 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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 <path>`
# (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"
Loading
Loading