diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 6011f1a..cc48247 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -60,6 +60,8 @@ jobs:
| Windows (installer) | \`MediaServer-{tag}-setup.exe\` |
| Windows (portable) | \`MediaServer-{tag}-win-x64.zip\` |
| Linux | \`MediaServer-{tag}-linux-x64.tar.gz\` |
+ | macOS (Apple Silicon) | \`MediaServer-{tag}-macos-arm64.tar.gz\` |
+ | macOS (Intel) | \`MediaServer-{tag}-macos-x86_64.tar.gz\` |
''').strip())
print(json.dumps('\n\n'.join(sections)))
@@ -187,6 +189,13 @@ jobs:
with:
python-version: '3.11'
+ - name: Install native deps for dbus-python + PyGObject
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libdbus-1-dev libglib2.0-dev pkg-config \
+ libcairo2-dev libgirepository1.0-dev
+
- name: Build Linux distribution
run: |
chmod +x build-dist-linux.sh
@@ -226,3 +235,62 @@ jobs:
-H "Authorization: token $DEPLOY_TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$FILE"
+
+ # --- Build macOS tarball (best-effort; requires a macos runner) ---
+ # PyObjC wheels are macOS-only, so this job must run on a real Mac.
+ # If your Gitea instance has no macos runners, the job is skipped at
+ # scheduling time and the rest of the release still ships.
+ build-macos:
+ needs: create-release
+ runs-on: macos-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Build frontend
+ run: npm ci && npm run build
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Build macOS distribution
+ run: |
+ chmod +x build-dist-macos.sh
+ ./build-dist-macos.sh "${{ gitea.ref_name }}"
+
+ - name: Upload assets to release
+ env:
+ DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
+ run: |
+ RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
+ BASE_URL="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
+
+ FILE=$(ls build/MediaServer-*-macos-*.tar.gz | head -1)
+ NAME=$(basename "$FILE")
+
+ EXISTING=$(curl -s "$BASE_URL/releases/$RELEASE_ID/assets" \
+ -H "Authorization: token $DEPLOY_TOKEN")
+ ASSET_ID=$(echo "$EXISTING" | python3 -c "
+ import sys, json
+ assets = json.load(sys.stdin)
+ for a in assets:
+ if a['name'] == '$NAME':
+ print(a['id'])
+ break
+ " 2>/dev/null || true)
+
+ if [ -n "$ASSET_ID" ]; then
+ curl -s -X DELETE "$BASE_URL/releases/$RELEASE_ID/assets/$ASSET_ID" \
+ -H "Authorization: token $DEPLOY_TOKEN"
+ fi
+
+ curl -s -X POST \
+ "$BASE_URL/releases/$RELEASE_ID/assets?name=$NAME" \
+ -H "Authorization: token $DEPLOY_TOKEN" \
+ -H "Content-Type: application/octet-stream" \
+ --data-binary "@$FILE"
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
index 3c0fe3d..ab008ed 100644
--- a/.gitea/workflows/test.yml
+++ b/.gitea/workflows/test.yml
@@ -34,3 +34,64 @@ jobs:
- name: Test
run: pytest --tb=short -q || test $? -eq 5
+
+ # Linux smoke test: install the linux extra in the same way build-dist-linux.sh
+ # does, then boot the server and hit /api/health. Catches dependency-resolution
+ # and import-time regressions for the Linux distribution path.
+ linux-smoke:
+ if: "!startsWith(github.event.head_commit.message, 'chore: release')"
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Build frontend
+ run: npm ci && npm run build
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install Linux system deps for dbus-python + PyGObject
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ libdbus-1-dev libglib2.0-dev pkg-config \
+ libcairo2-dev libgirepository1.0-dev
+
+ - name: Install with linux extra
+ run: |
+ pip install --upgrade pip
+ pip install ".[linux]"
+
+ - name: Smoke — server boots and /api/health responds
+ run: |
+ # Headless Linux runners have no PulseAudio and no display
+ # server, so we disable the visualizer + update checker, and we
+ # use `dbus-run-session` to give LinuxMediaController a real
+ # session bus to talk to (otherwise dbus.SessionBus() would
+ # raise during startup). This isn't a full MPRIS integration
+ # test — it only proves the dispatcher selects the Linux
+ # controller, all imports resolve, and /api/health returns 200.
+ sudo apt-get install -y --no-install-recommends dbus-x11
+ export MEDIA_SERVER_VISUALIZER_ENABLED=false
+ export MEDIA_SERVER_UPDATE_CHECK_ENABLED=false
+ dbus-run-session -- bash -c '
+ python -m media_server.main --no-tray --port 18765 &
+ SERVER_PID=$!
+ for i in $(seq 1 30); do
+ if curl -sf "http://127.0.0.1:18765/api/health" >/dev/null; then
+ echo "Health check passed"
+ kill $SERVER_PID
+ wait $SERVER_PID 2>/dev/null || true
+ exit 0
+ fi
+ sleep 0.5
+ done
+ echo "Server did not respond within 15s"
+ kill $SERVER_PID 2>/dev/null || true
+ exit 1
+ '
diff --git a/README.md b/README.md
index e176994..a23f9a4 100644
--- a/README.md
+++ b/README.md
@@ -285,37 +285,44 @@ All connected WebSocket clients receive a `links_changed` notification when link
## Installation
+Dependencies are declared in `pyproject.toml`. Pick the extra that matches
+your OS — the Python deps differ enough between Windows / Linux / macOS
+that there's no single `pip install` line.
+
### Installing on Windows
```bash
-pip install -r requirements.txt
+pip install ".[windows]"
```
-Required packages: `winsdk`, `pywin32`, `pycaw`, `comtypes`
+Pulls in `winsdk`, `pywin32`, `pycaw`, `comtypes`, `pystray`, etc.
### Installing on Linux
```bash
-# Install system dependencies
-sudo apt-get install python3-dbus python3-gi libdbus-1-dev libglib2.0-dev
+# System packages required to build dbus-python + PyGObject from sdist.
+sudo apt-get install -y python3-pip python3-venv \
+ libdbus-1-dev libglib2.0-dev pkg-config
-pip install -r requirements.txt
+pip install ".[linux]"
```
### Installing on macOS
```bash
-pip install -r requirements.txt
+pip install ".[macos]"
```
-No additional dependencies - uses built-in `osascript`.
+Pulls in `pyobjc-framework-Cocoa` + `pyobjc-framework-Quartz` for the
+foreground-window probe; AppleScript-based media control uses the
+built-in `osascript`.
### Installing on Android (Termux)
```bash
# In Termux
pkg install python termux-api
-pip install -r requirements.txt
+pip install "."
```
Requires Termux and Termux:API apps from F-Droid.
@@ -835,11 +842,19 @@ Install:
sudo ./service/install_linux.sh install
```
-Enable and start for your user:
+**Enable user lingering** — required so `/run/user/$UID/bus` (the D-Bus
+session socket needed for MPRIS) exists even when no graphical session
+is active. Without this the server boots but every `/api/media/*` call
+silently returns idle.
```bash
-sudo systemctl enable media-server@$USER
-sudo systemctl start media-server@$USER
+sudo loginctl enable-linger $USER
+```
+
+Enable and start the templated unit for your user:
+
+```bash
+sudo systemctl enable --now media-server@$USER
```
View logs:
@@ -848,6 +863,39 @@ View logs:
journalctl -u media-server@$USER -f
```
+**Troubleshooting:**
+
+- *"`/api/media/status` always returns `idle`"* — check the service log for
+ `D-Bus session bus not available`. Most commonly: lingering isn't
+ enabled, or the unit is using the wrong `XDG_RUNTIME_DIR` (`%U` must
+ expand to the user's numeric UID).
+- *"Visualizer permanently unavailable"* — PulseAudio/PipeWire must
+ expose monitor sources. `pactl list sources short | grep monitor`
+ should list at least one entry; if not, install `pipewire-pulse` and
+ restart your session.
+- *"Volume control silently fails"* — `pactl` must be on `PATH` and the
+ user's PulseAudio/PipeWire server must be reachable
+ (`PULSE_RUNTIME_PATH=/run/user/$UID/pulse`).
+- *"Foreground window is always `null`"* — expected under Wayland; the
+ compositor hides window info from unprivileged clients. X11 sessions
+ work normally.
+
+### macOS (LaunchAgent)
+
+The distribution tarball ships an installer:
+
+```bash
+./install-launchagent.sh
+```
+
+This drops `~/Library/LaunchAgents/com.dolgolyov.media-server.plist`,
+starts the service immediately, and re-launches it at every login. Logs
+go to `~/Library/Logs/media-server/{stdout,stderr}.log`. To stop:
+
+```bash
+./uninstall-launchagent.sh
+```
+
## Command Line Options
```text
diff --git a/build-dist-linux.sh b/build-dist-linux.sh
index a931c69..4c011fa 100644
--- a/build-dist-linux.sh
+++ b/build-dist-linux.sh
@@ -16,12 +16,38 @@ BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-linux-x64"
clean_dist "${DIST_DIR}" build
verify_frontend
+# --- Sanity check: native deps required for dbus-python + PyGObject ---
+# These compile from sdist on Linux. We don't try to install them via the
+# distro package manager here (the CI image must already have them); fail
+# with a clear message instead of cryptic gcc/pkg-config errors deep
+# inside pip.
+need_pkg() {
+ local pkg="$1"
+ if ! command -v pkg-config >/dev/null 2>&1; then
+ echo "ERROR: pkg-config is required (apt: pkg-config / dnf: pkgconf-pkg-config)" >&2
+ exit 1
+ fi
+ if ! pkg-config --exists "$pkg" 2>/dev/null; then
+ echo "ERROR: $pkg headers missing — install libdbus-1-dev libglib2.0-dev (apt)" >&2
+ echo " or dbus-devel glib2-devel (dnf) before running this script." >&2
+ exit 1
+ fi
+}
+need_pkg dbus-1
+need_pkg glib-2.0
+
# --- Create self-contained virtualenv ---
echo "Creating virtualenv..."
python3 -m venv "${DIST_DIR}/venv"
source "${DIST_DIR}/venv/bin/activate"
pip install --quiet --upgrade pip
-pip install --quiet "."
+# Install with the linux extra so dbus-python / PyGObject / python-xlib land
+# in the venv. Falls back to base deps if the extra is unsupported (shouldn't
+# happen post-0.3.2 but keep the fallback for older tags re-built here).
+if ! pip install --quiet ".[linux]"; then
+ echo "WARN: '.[linux]' extra unavailable; installing base deps only" >&2
+ pip install --quiet "."
+fi
# Remove the installed package (app source is on PYTHONPATH via launcher)
rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
@@ -47,6 +73,11 @@ LAUNCHER
chmod +x "${DIST_DIR}/media-server.sh"
# --- Create systemd service installer ---
+# Emits a non-templated unit that runs as the invoking user with the env
+# bits MPRIS / PulseAudio need: DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR
+# must be set or DBus.SessionBus() either fails or talks to the wrong bus.
+# We also relax ProtectHome to read-write for ~/.config/media-server +
+# ~/.cache/media-server so audit.log and thumbnail cache writes succeed.
cat > "${DIST_DIR}/install-service.sh" << 'SERVICE'
#!/usr/bin/env bash
set -euo pipefail
@@ -61,6 +92,9 @@ if [ "$EUID" -ne 0 ]; then
fi
REAL_USER="${SUDO_USER:-$USER}"
+USER_UID="$(id -u "$REAL_USER")"
+USER_HOME="$(getent passwd "$REAL_USER" | cut -d: -f6)"
+RUNTIME_DIR="/run/user/${USER_UID}"
cat > "$SERVICE_FILE" << EOF
[Unit]
@@ -74,17 +108,36 @@ WorkingDirectory=${SCRIPT_DIR}
ExecStart=${SCRIPT_DIR}/media-server.sh
Restart=on-failure
RestartSec=5
+
+# Required by MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
Environment=PYTHONUNBUFFERED=1
+Environment=HOME=${USER_HOME}
+Environment=XDG_RUNTIME_DIR=${RUNTIME_DIR}
+Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=${RUNTIME_DIR}/bus
+
+# Light sandboxing — keep ~/.config/media-server + ~/.cache/media-server
+# writable so audit.log / thumbnails / config CRUD continue to work.
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=read-only
+ReadWritePaths=${USER_HOME}/.config/media-server ${USER_HOME}/.cache/media-server
[Install]
WantedBy=multi-user.target
EOF
+# Pre-create state dirs so ReadWritePaths actually has something to grant.
+sudo -u "$REAL_USER" mkdir -p \
+ "${USER_HOME}/.config/media-server" \
+ "${USER_HOME}/.cache/media-server"
+
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl start "${SERVICE_NAME}"
echo "Service '${SERVICE_NAME}' installed and started."
echo "Check status: systemctl status ${SERVICE_NAME}"
+echo "Tail logs: journalctl -u ${SERVICE_NAME} -f"
SERVICE
chmod +x "${DIST_DIR}/install-service.sh"
diff --git a/build-dist-macos.sh b/build-dist-macos.sh
new file mode 100644
index 0000000..0fa0a98
--- /dev/null
+++ b/build-dist-macos.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Build macOS distribution (self-contained venv + tarball)
+# Usage: ./build-dist-macos.sh [VERSION]
+#
+# Must be run on macOS (PyObjC wheels are platform-specific). For CI use
+# the github-hosted macos-latest runner.
+
+source "$(dirname "$0")/build-common.sh"
+
+if [ "$(uname -s)" != "Darwin" ]; then
+ echo "ERROR: build-dist-macos.sh must run on macOS (uname=$(uname -s))" >&2
+ exit 1
+fi
+
+detect_version "${1:-}"
+echo "Building Media Server v${VERSION_CLEAN} for macOS"
+
+# Detect host architecture for archive naming (arm64 = Apple Silicon, x86_64 = Intel).
+ARCH="$(uname -m)"
+DIST_DIR="dist/media-server"
+BUILD_OUTPUT="build/MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
+
+clean_dist "${DIST_DIR}" build
+verify_frontend
+
+# --- Create self-contained virtualenv ---
+echo "Creating virtualenv..."
+python3 -m venv "${DIST_DIR}/venv"
+# shellcheck disable=SC1091
+source "${DIST_DIR}/venv/bin/activate"
+pip install --quiet --upgrade pip
+if ! pip install --quiet ".[macos]"; then
+ echo "WARN: '.[macos]' extra unavailable; installing base deps only" >&2
+ pip install --quiet "."
+fi
+
+# Remove the installed package (app source is on PYTHONPATH via launcher)
+rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*
+rm -rf "${DIST_DIR}"/venv/lib/python*/site-packages/media_server*.dist-info
+
+deactivate
+
+# Trim venv site-packages — macOS native ext is .so, dylibs are .dylib
+MAC_SP=$(echo "${DIST_DIR}"/venv/lib/python*/site-packages)
+cleanup_site_packages "$MAC_SP" "so" "dylib"
+
+copy_app_files "$DIST_DIR"
+
+# --- Launcher ---
+cat > "${DIST_DIR}/media-server.sh" << 'LAUNCHER'
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+export PYTHONPATH="$SCRIPT_DIR/app"
+# shellcheck disable=SC1091
+source "$SCRIPT_DIR/venv/bin/activate"
+exec python -m media_server.main "$@"
+LAUNCHER
+chmod +x "${DIST_DIR}/media-server.sh"
+
+# --- LaunchAgent installer ---
+# LaunchAgents run as the user, with the user's GUI session — exactly what
+# we want for AppleScript / Music.app / Spotify control. KeepAlive auto-
+# restarts on crash; RunAtLoad starts at login.
+cat > "${DIST_DIR}/install-launchagent.sh" << 'LAUNCHAGENT'
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+LABEL="com.dolgolyov.media-server"
+PLIST_DIR="${HOME}/Library/LaunchAgents"
+PLIST="${PLIST_DIR}/${LABEL}.plist"
+LOG_DIR="${HOME}/Library/Logs/media-server"
+
+mkdir -p "$PLIST_DIR" "$LOG_DIR"
+
+cat > "$PLIST" << EOF
+
+
+
+
+ Label
+ ${LABEL}
+ ProgramArguments
+
+ ${SCRIPT_DIR}/media-server.sh
+ --no-tray
+
+ WorkingDirectory
+ ${SCRIPT_DIR}
+ RunAtLoad
+
+ KeepAlive
+
+ SuccessfulExit
+
+
+ StandardOutPath
+ ${LOG_DIR}/stdout.log
+ StandardErrorPath
+ ${LOG_DIR}/stderr.log
+ EnvironmentVariables
+
+ PYTHONUNBUFFERED
+ 1
+
+
+
+EOF
+
+# Reload if already loaded, then load fresh.
+launchctl unload "$PLIST" 2>/dev/null || true
+launchctl load -w "$PLIST"
+
+echo "LaunchAgent '${LABEL}' installed and started."
+echo "Plist: $PLIST"
+echo "Logs: $LOG_DIR/{stdout,stderr}.log"
+echo "Stop: launchctl unload \"$PLIST\""
+echo "Start: launchctl load -w \"$PLIST\""
+LAUNCHAGENT
+chmod +x "${DIST_DIR}/install-launchagent.sh"
+
+# Convenience uninstaller.
+cat > "${DIST_DIR}/uninstall-launchagent.sh" << 'UNINSTALL'
+#!/usr/bin/env bash
+set -euo pipefail
+LABEL="com.dolgolyov.media-server"
+PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
+launchctl unload "$PLIST" 2>/dev/null || true
+rm -f "$PLIST"
+echo "LaunchAgent '${LABEL}' removed (config preserved under ~/.config/media-server)."
+UNINSTALL
+chmod +x "${DIST_DIR}/uninstall-launchagent.sh"
+
+# --- Package ---
+echo "Creating archive..."
+cp -r "${DIST_DIR}" "${BUILD_OUTPUT}"
+tar -czf "${BUILD_OUTPUT}.tar.gz" -C build "MediaServer-v${VERSION_CLEAN}-macos-${ARCH}"
+
+echo "Build complete: ${BUILD_OUTPUT}.tar.gz"
diff --git a/config.example.yaml b/config.example.yaml
index 0df2c17..13a8ddf 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -21,109 +21,140 @@ host: "127.0.0.1"
port: 8765
# allow_lan_without_auth: true # uncomment + change host to 0.0.0.0 for LAN-open mode
-# Custom scripts
+# ─── Custom scripts ─────────────────────────────────────────────────────
+#
+# Examples below are platform-specific. Uncomment the block that matches
+# your OS — YAML keys must be unique, so don't ship multiple
+# `lock_screen:` entries.
+
scripts:
- lock_screen:
- command: "rundll32.exe user32.dll,LockWorkStation"
- label: "Lock Screen"
- description: "Lock the workstation"
- icon: "mdi:lock"
- timeout: 5
- shell: true
+ # ── Windows ─────────────────────────────────────────────────────────
+ # lock_screen:
+ # command: "rundll32.exe user32.dll,LockWorkStation"
+ # label: "Lock Screen"
+ # description: "Lock the workstation"
+ # icon: "mdi:lock"
+ # timeout: 5
+ # shell: true
+ # sleep:
+ # command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
+ # label: "Sleep"
+ # icon: "mdi:sleep"
+ # timeout: 10
+ # shell: true
+ # shutdown:
+ # command: "shutdown /s /t 0"
+ # label: "Shutdown"
+ # icon: "mdi:power"
+ # timeout: 10
+ # shell: true
- hibernate:
- command: "shutdown /h"
- label: "Hibernate"
- description: "Hibernate the PC"
- icon: "mdi:power-sleep"
+ # ── Linux (systemd / xdg) ───────────────────────────────────────────
+ # lock_screen:
+ # command: "loginctl lock-session" # or: xdg-screensaver lock
+ # label: "Lock Screen"
+ # icon: "mdi:lock"
+ # timeout: 5
+ # shell: true
+ # sleep:
+ # command: "systemctl suspend"
+ # label: "Sleep"
+ # icon: "mdi:sleep"
+ # timeout: 10
+ # shell: true
+ # shutdown:
+ # command: "systemctl poweroff"
+ # label: "Shutdown"
+ # icon: "mdi:power"
+ # timeout: 10
+ # shell: true
+
+ # ── macOS ───────────────────────────────────────────────────────────
+ # lock_screen:
+ # command: "pmset displaysleepnow"
+ # label: "Lock Screen"
+ # icon: "mdi:lock"
+ # timeout: 5
+ # shell: true
+ # sleep:
+ # command: "pmset sleepnow"
+ # label: "Sleep"
+ # icon: "mdi:sleep"
+ # timeout: 5
+ # shell: true
+ # shutdown:
+ # command: "osascript -e 'tell application \"System Events\" to shut down'"
+ # label: "Shutdown"
+ # icon: "mdi:power"
+ # timeout: 10
+ # shell: true
+
+ # Cross-platform smoke test (always defined so first-run users see the UI populate).
+ example_script:
+ command: "echo Hello from Media Server!"
+ description: "Example script - echoes a message"
timeout: 10
shell: true
- sleep:
- command: "rundll32.exe powrprof.dll,SetSuspendState 0,1,0"
- label: "Sleep"
- description: "Put PC to sleep"
- icon: "mdi:sleep"
- timeout: 10
- shell: true
-
- shutdown:
- command: "shutdown /s /t 0"
- label: "Shutdown"
- description: "Shutdown the PC immediately"
- icon: "mdi:power"
- timeout: 10
- shell: true
-
- restart:
- command: "shutdown /r /t 0"
- label: "Restart"
- description: "Restart the PC immediately"
- icon: "mdi:restart"
- timeout: 10
- shell: true
-
-# Media folder management from Web UI (default: true)
+# Media folder management from Web UI (default: false)
# When enabled, media folders can be added, edited, and deleted from the Settings tab.
-# Set to false to disable folder management from the UI.
-# media_folders_management: false
+# media_folders_management: true
+
+# ─── Callback scripts (executed by integration events) ──────────────────
+#
+# Callbacks are OS-agnostic — if you want side effects, fill in commands
+# below. The defaults are no-op echos so first-run logs don't error out.
-# Callback scripts (executed after media actions)
-# All callbacks are optional - if not defined, the action runs without callback
callbacks:
- # Media control callbacks (run after successful action)
on_play:
command: "echo Play triggered"
timeout: 10
shell: true
-
on_pause:
command: "echo Pause triggered"
timeout: 10
shell: true
-
on_stop:
command: "echo Stop triggered"
timeout: 10
shell: true
-
on_next:
command: "echo Next track"
timeout: 10
shell: true
-
on_previous:
command: "echo Previous track"
timeout: 10
shell: true
-
on_volume:
command: "echo Volume changed"
timeout: 10
shell: true
-
on_mute:
command: "echo Mute toggled"
timeout: 10
shell: true
-
on_seek:
command: "echo Seek triggered"
timeout: 10
shell: true
- # Turn on/off/toggle (callback-only actions, no default behavior)
+ # Turn on/off/toggle (callback-only actions, no default behavior).
+ # The Windows-only example used to ship as the default for `on_turn_off`,
+ # which silently failed on Linux/macOS. Defaults are now no-ops — pick a
+ # platform-appropriate command below.
on_turn_on:
command: "echo Turn on callback"
timeout: 10
shell: true
-
on_turn_off:
- command: "rundll32.exe user32.dll,LockWorkStation"
+ # Windows: "rundll32.exe user32.dll,LockWorkStation"
+ # Linux: "loginctl lock-session"
+ # macOS: "pmset displaysleepnow"
+ command: "echo Turn off callback"
timeout: 5
shell: true
-
on_toggle:
command: "echo Toggle callback"
timeout: 10
- shell: true
\ No newline at end of file
+ shell: true
diff --git a/media_server/main.py b/media_server/main.py
index 13b23e8..7474ce4 100644
--- a/media_server/main.py
+++ b/media_server/main.py
@@ -112,6 +112,30 @@ async def lifespan(app: FastAPI):
else:
logger.warning("No API tokens configured — authentication is DISABLED")
+ # Linux preflight: most MPRIS / PulseAudio failures are environmental
+ # (no DBUS_SESSION_BUS_ADDRESS, missing XDG_RUNTIME_DIR, systemd service
+ # started before logind). Surface that early so the failure mode is a
+ # warning at boot instead of silent "/api/media/status returns idle".
+ import os
+ import platform as _platform
+ if _platform.system() == "Linux":
+ missing = [
+ v for v in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR")
+ if not os.environ.get(v)
+ ]
+ if missing:
+ logger.warning(
+ "Linux preflight: %s not set — MPRIS / PulseAudio may be unavailable."
+ " Under systemd, run `loginctl enable-linger ` and ensure the"
+ " service unit sets DBUS_SESSION_BUS_ADDRESS + XDG_RUNTIME_DIR.",
+ ", ".join(missing),
+ )
+ if os.environ.get("WAYLAND_DISPLAY"):
+ logger.info(
+ "Wayland session detected — foreground-window probe is intentionally"
+ " disabled (Wayland hides window info from unprivileged clients)."
+ )
+
update_checker = None
cleanup_task: asyncio.Task | None = None
analyzer = None
diff --git a/media_server/routes/media.py b/media_server/routes/media.py
index c1db1e9..e382e32 100644
--- a/media_server/routes/media.py
+++ b/media_server/routes/media.py
@@ -18,7 +18,7 @@ from fastapi.responses import Response
from ..auth import verify_token, verify_token_or_query
from ..config import settings
from ..models import MediaStatus, SeekRequest, VolumeRequest
-from ..services import get_current_album_art, get_media_controller
+from ..services import get_current_album_art_async, get_media_controller
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
@@ -287,7 +287,7 @@ async def get_artwork(
Returns the bytes with a content-derived ETag so the browser can serve a
304 when the same track is re-requested.
"""
- art_bytes = get_current_album_art()
+ art_bytes = await get_current_album_art_async()
if art_bytes is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -326,14 +326,43 @@ async def get_artwork(
@router.get("/visualizer/status")
async def visualizer_status(_: str = Depends(verify_token)) -> dict:
- """Check if audio visualizer is available and running."""
+ """Check if audio visualizer is available and running.
+
+ ``available`` is True only when both numpy + soundcard import.
+ ``unavailable_reason`` carries a short human-readable hint when False
+ or when a usable loopback device was not found — useful on Linux where
+ PulseAudio/PipeWire may not expose monitor sources to a headless
+ systemd-as-user service.
+ """
+ import platform as _platform
+
from ..services.audio_analyzer import get_audio_analyzer
analyzer = get_audio_analyzer()
+ reason: str | None = None
+ if not analyzer.available:
+ reason = "soundcard or numpy is not installed"
+ elif getattr(analyzer, "_unavailable", False):
+ if _platform.system() == "Linux":
+ reason = (
+ "No loopback audio device found. On Linux this requires a "
+ "running PulseAudio/PipeWire session with monitor sources "
+ "(systemd user service: ensure DBUS_SESSION_BUS_ADDRESS + "
+ "XDG_RUNTIME_DIR are set)."
+ )
+ elif _platform.system() == "Darwin":
+ reason = (
+ "No loopback audio device found. macOS does not expose system "
+ "loopback by default — install BlackHole or Soundflower."
+ )
+ else:
+ reason = "No loopback audio device found"
+
return {
"available": analyzer.available,
"running": analyzer.running,
"current_device": analyzer.current_device,
+ "unavailable_reason": reason,
}
diff --git a/media_server/service/install_linux.sh b/media_server/service/install_linux.sh
index d5f6b12..3cbb039 100644
--- a/media_server/service/install_linux.sh
+++ b/media_server/service/install_linux.sh
@@ -57,25 +57,38 @@ install_service() {
# Create installation directory
mkdir -p "$INSTALL_DIR"
- # Copy source files
- cp -r "$(dirname "$0")/../"* "$INSTALL_DIR/"
+ # Resolve the source-tree root (two levels up from this script:
+ # media_server/service/install_linux.sh → repo root).
+ SOURCE_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
+
+ # Copy the source tree (pyproject.toml + media_server/ package)
+ cp -r "$SOURCE_ROOT/." "$INSTALL_DIR/"
# Create virtual environment
echo_info "Creating Python virtual environment..."
python3 -m venv "$INSTALL_DIR/venv"
- # Install Python dependencies
+ # Install Python dependencies from pyproject.toml (with linux extra).
+ # cd into the install dir so pip resolves `.[linux]` against the local
+ # pyproject — passing a path-with-extras (`$INSTALL_DIR[linux]`) trips
+ # the requirement-spec parser on some pip versions.
echo_info "Installing Python dependencies..."
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
- "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt"
+ (cd "$INSTALL_DIR" && "$INSTALL_DIR/venv/bin/pip" install ".[linux]")
- # Install systemd service file
+ # Install systemd service file (templated unit)
echo_info "Installing systemd service..."
- cp "$INSTALL_DIR/service/media-server.service" "$SERVICE_FILE"
+ cp "$INSTALL_DIR/media_server/service/media-server.service" "$SERVICE_FILE"
# Reload systemd
systemctl daemon-reload
+ # Pre-create writable state dirs so the unit's ReadWritePaths grant
+ # actually has something to grant.
+ sudo -u "$SUDO_USER" mkdir -p \
+ "/home/$SUDO_USER/.config/media-server" \
+ "/home/$SUDO_USER/.cache/media-server"
+
# Generate config if not exists
if [[ ! -f "/home/$SUDO_USER/.config/media-server/config.yaml" ]]; then
echo_info "Generating configuration file..."
diff --git a/media_server/service/media-server.service b/media_server/service/media-server.service
index ba06537..65817be 100644
--- a/media_server/service/media-server.service
+++ b/media_server/service/media-server.service
@@ -3,34 +3,38 @@ Description=Media Server - REST API for controlling system media playback
After=network.target sound.target
Wants=sound.target
+# Templated unit. Enable as:
+# sudo systemctl enable --now media-server@$USER
+# %i is the user name supplied after the '@'; %U is the matching numeric UID.
+
[Service]
Type=simple
User=%i
Group=%i
-# Environment variables (optional - can also use config file)
-# Environment=MEDIA_SERVER_HOST=0.0.0.0
-# Environment=MEDIA_SERVER_PORT=8765
-# Environment=MEDIA_SERVER_API_TOKEN=your-secret-token
-
-# Working directory
+# Working directory (override via drop-in if you install elsewhere)
WorkingDirectory=/opt/media-server
-# Start command - adjust path to your Python environment
-ExecStart=/opt/media-server/venv/bin/python -m media_server.main
+# Start command — adjust to match where you installed the venv. --no-tray
+# avoids pulling pystray into a headless service environment.
+ExecStart=/opt/media-server/venv/bin/python -m media_server.main --no-tray
-# Restart policy
-Restart=always
+Restart=on-failure
RestartSec=10
-# Security hardening
+# Required for MPRIS (dbus.SessionBus) and PulseAudio/PipeWire loopback.
+Environment=PYTHONUNBUFFERED=1
+Environment=XDG_RUNTIME_DIR=/run/user/%U
+Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
+
+# Light sandboxing. ProtectHome=read-only by itself blocks the app's own
+# audit.log / thumbnail cache writes — ReadWritePaths re-opens just the
+# two state dirs the server owns.
NoNewPrivileges=true
+PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
-PrivateTmp=true
-
-# Required for D-Bus access (MPRIS)
-Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%U/bus
+ReadWritePaths=/home/%i/.config/media-server /home/%i/.cache/media-server
[Install]
WantedBy=multi-user.target
diff --git a/media_server/services/__init__.py b/media_server/services/__init__.py
index 39de409..3a65eec 100644
--- a/media_server/services/__init__.py
+++ b/media_server/services/__init__.py
@@ -65,7 +65,12 @@ def get_media_controller() -> "MediaController":
def get_current_album_art() -> bytes | None:
- """Get the current album art bytes (Windows only for now)."""
+ """Get the current album art bytes (synchronous, Windows-cached path).
+
+ Windows pre-populates a module-level cache via the WinRT polling thread,
+ so this stays sync. For Linux/macOS the controller fetches on demand —
+ use ``get_current_album_art_async()`` from FastAPI handlers instead.
+ """
system = platform.system()
if system == "Windows":
from .windows_media import get_current_album_art as _get_art
@@ -73,6 +78,22 @@ def get_current_album_art() -> bytes | None:
return None
+async def get_current_album_art_async() -> bytes | None:
+ """Cross-platform album art fetch. Awaits the controller's impl.
+
+ Falls back to the sync Windows cache when running on Windows so we don't
+ pay an extra coroutine hop for the existing path.
+ """
+ system = platform.system()
+ if system == "Windows":
+ return get_current_album_art()
+ controller = get_media_controller()
+ try:
+ return await controller.get_album_art()
+ except Exception: # noqa: BLE001 — art is best-effort; never break the route
+ return None
+
+
def get_audio_devices() -> list[dict[str, str]]:
"""Get list of available audio output devices (Windows only for now)."""
system = platform.system()
@@ -82,4 +103,9 @@ def get_audio_devices() -> list[dict[str, str]]:
return []
-__all__ = ["get_media_controller", "get_current_album_art", "get_audio_devices"]
+__all__ = [
+ "get_media_controller",
+ "get_current_album_art",
+ "get_current_album_art_async",
+ "get_audio_devices",
+]
diff --git a/media_server/services/linux_media.py b/media_server/services/linux_media.py
index b022677..1c217d1 100644
--- a/media_server/services/linux_media.py
+++ b/media_server/services/linux_media.py
@@ -2,14 +2,23 @@
import asyncio
import logging
+import os
import subprocess
+import threading
from typing import Any, Optional
+from urllib.parse import unquote, urlparse
from ..models import MediaState, MediaStatus
from .media_controller import MediaController
logger = logging.getLogger(__name__)
+# Cap remote artwork downloads so a hostile / huge Spotify image can't
+# blow up RAM. Real album art rarely exceeds ~2 MB; 8 MB is a comfortable
+# upper bound that also covers loss-less PNGs.
+_MAX_ART_BYTES = 8 * 1024 * 1024
+_ART_FETCH_TIMEOUT = 5.0 # seconds
+
# Linux-specific imports
try:
import dbus
@@ -35,13 +44,54 @@ class LinuxMediaController(MediaController):
"Linux media control requires dbus-python package. "
"Install with: sudo apt-get install python3-dbus"
)
- DBusGMainLoop(set_as_default=True)
- self._bus = dbus.SessionBus()
+ # The session-bus connection is deferred until first use. Connecting
+ # in __init__ raised during app startup whenever the user's session
+ # bus wasn't ready yet — common under systemd (service starts
+ # before logind set up /run/user//bus), under SSH-without-X11,
+ # and in headless CI. Failing here killed the whole lifespan; now
+ # MPRIS calls return "idle" until the bus appears, and other
+ # endpoints (health, scripts, browser, …) keep working.
+ self._bus_lock = threading.Lock()
+ self._bus = None # type: ignore[assignment]
+ self._bus_init_logged = False
+ # Cached art bytes keyed by the mpris:artUrl currently in flight.
+ # Lock guards the swap from the status thread vs the artwork handler.
+ self._art_lock = threading.Lock()
+ self._art_url: Optional[str] = None
+ self._art_bytes: Optional[bytes] = None
+
+ def _get_bus(self):
+ """Lazily connect to the session bus; returns None if unavailable."""
+ if self._bus is not None:
+ return self._bus
+ with self._bus_lock:
+ if self._bus is not None:
+ return self._bus
+ try:
+ DBusGMainLoop(set_as_default=True)
+ self._bus = dbus.SessionBus()
+ logger.info("Connected to D-Bus session bus")
+ return self._bus
+ except Exception as e:
+ # Log once at INFO to avoid log spam if every status poll fails.
+ if not self._bus_init_logged:
+ logger.info(
+ "D-Bus session bus not available (%s). "
+ "MPRIS calls will return 'idle' until DBUS_SESSION_BUS_ADDRESS"
+ " is set and the bus is reachable. Under systemd, ensure"
+ " `loginctl enable-linger ` is set.",
+ e,
+ )
+ self._bus_init_logged = True
+ return None
def _get_active_player(self) -> Optional[str]:
"""Find an active MPRIS media player on the bus."""
+ bus = self._get_bus()
+ if bus is None:
+ return None
try:
- bus_names = self._bus.list_names()
+ bus_names = bus.list_names()
mpris_players = [
name for name in bus_names if name.startswith(self.MPRIS_PREFIX)
]
@@ -181,7 +231,15 @@ class LinuxMediaController(MediaController):
if artists:
status.artist = str(artists[0]) if isinstance(artists, list) else str(artists)
status.album = str(metadata.get("xesam:album", "")) or None
- status.album_art_url = str(metadata.get("mpris:artUrl", "")) or None
+ art_url = str(metadata.get("mpris:artUrl", "")) or None
+ status.album_art_url = art_url
+ # Invalidate cached bytes when the track changes. Real fetch
+ # happens lazily in get_album_art() so the status hot path
+ # never blocks on HTTP.
+ with self._art_lock:
+ if art_url != self._art_url:
+ self._art_url = art_url
+ self._art_bytes = None
length = metadata.get("mpris:length", 0)
if length:
status.duration = int(length) / 1_000_000
@@ -273,3 +331,61 @@ class LinuxMediaController(MediaController):
except Exception as e:
logger.error(f"Failed to open file {file_path}: {e}")
return False
+
+ def _fetch_art_sync(self, url: str) -> Optional[bytes]:
+ """Resolve an ``mpris:artUrl`` to raw bytes (file://, http(s)://).
+
+ Other schemes (data:, ftp:, …) are rejected — we only support the
+ two cases real-world MPRIS providers use. The HTTP path is capped
+ at _MAX_ART_BYTES and the file path is read with a size guard so a
+ symlink to /dev/zero can't OOM the server.
+ """
+ try:
+ parsed = urlparse(url)
+ except ValueError:
+ return None
+
+ scheme = parsed.scheme.lower()
+ if scheme == "file":
+ path = unquote(parsed.path)
+ try:
+ size = os.stat(path).st_size
+ if size <= 0 or size > _MAX_ART_BYTES:
+ return None
+ with open(path, "rb") as f:
+ return f.read(_MAX_ART_BYTES)
+ except OSError as e:
+ logger.debug("Could not read local art %s: %s", path, e)
+ return None
+
+ if scheme in ("http", "https"):
+ import urllib.error
+ import urllib.request
+
+ req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
+ try:
+ with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
+ # Cap reads to defend against unbounded responses.
+ return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
+ logger.debug("Could not fetch remote art %s: %s", url, e)
+ return None
+
+ logger.debug("Unsupported art URL scheme: %s", scheme)
+ return None
+
+ async def get_album_art(self) -> Optional[bytes]:
+ """Return cached MPRIS art, fetching on first access per track."""
+ with self._art_lock:
+ url = self._art_url
+ cached = self._art_bytes
+ if cached is not None:
+ return cached
+ if not url:
+ return None
+ data = await asyncio.to_thread(self._fetch_art_sync, url)
+ # Store even on None so we don't re-hammer a 404 every second.
+ with self._art_lock:
+ if url == self._art_url:
+ self._art_bytes = data
+ return data
diff --git a/media_server/services/macos_media.py b/media_server/services/macos_media.py
index 86b1642..238e1f7 100644
--- a/media_server/services/macos_media.py
+++ b/media_server/services/macos_media.py
@@ -3,6 +3,7 @@
import asyncio
import logging
import subprocess
+import threading
from typing import Optional
from ..models import MediaState, MediaStatus
@@ -10,10 +11,20 @@ from .media_controller import MediaController
logger = logging.getLogger(__name__)
+# Cap remote artwork downloads (Spotify's artwork url is http(s)://).
+_MAX_ART_BYTES = 8 * 1024 * 1024
+_ART_FETCH_TIMEOUT = 5.0 # seconds
+
class MacOSMediaController(MediaController):
"""Media controller for macOS using osascript and system commands."""
+ def __init__(self) -> None:
+ # Cached art bytes keyed by the active art URL.
+ self._art_lock = threading.Lock()
+ self._art_url: Optional[str] = None
+ self._art_bytes: Optional[bytes] = None
+
def _run_osascript(self, script: str) -> Optional[str]:
"""Run an AppleScript and return the output."""
try:
@@ -193,12 +204,60 @@ class MacOSMediaController(MediaController):
status.album = info.get("album")
status.duration = info.get("duration")
status.position = info.get("position")
- status.album_art_url = info.get("art_url")
+ art_url = info.get("art_url")
+ status.album_art_url = art_url
+ # Track changes invalidate the cached image bytes — actual
+ # fetch happens lazily in get_album_art().
+ with self._art_lock:
+ if art_url != self._art_url:
+ self._art_url = art_url
+ self._art_bytes = None
else:
status.state = MediaState.IDLE
return status
+ def _fetch_art_sync(self, url: str) -> Optional[bytes]:
+ """Resolve a Spotify/Music art URL (http(s)://) to bytes.
+
+ File-scheme URLs aren't expected on macOS (AppleScript apps return
+ artwork as remote URLs), so only http(s) is supported.
+ """
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(url)
+ except ValueError:
+ return None
+
+ if parsed.scheme.lower() not in ("http", "https"):
+ return None
+
+ import urllib.error
+ import urllib.request
+
+ req = urllib.request.Request(url, headers={"User-Agent": "media-server/0.x"})
+ try:
+ with urllib.request.urlopen(req, timeout=_ART_FETCH_TIMEOUT) as resp:
+ return resp.read(_MAX_ART_BYTES + 1)[:_MAX_ART_BYTES] or None
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e:
+ logger.debug("Could not fetch macOS art %s: %s", url, e)
+ return None
+
+ async def get_album_art(self) -> Optional[bytes]:
+ """Return cached art bytes, fetching once per track URL."""
+ with self._art_lock:
+ url = self._art_url
+ cached = self._art_bytes
+ if cached is not None:
+ return cached
+ if not url:
+ return None
+ data = await asyncio.to_thread(self._fetch_art_sync, url)
+ with self._art_lock:
+ if url == self._art_url:
+ self._art_bytes = data
+ return data
+
async def play(self) -> bool:
"""Resume playback using media key simulation."""
# Use system media key
diff --git a/media_server/services/media_controller.py b/media_server/services/media_controller.py
index d69ac55..008ace7 100644
--- a/media_server/services/media_controller.py
+++ b/media_server/services/media_controller.py
@@ -106,3 +106,12 @@ class MediaController(ABC):
True if successful, False otherwise
"""
pass
+
+ async def get_album_art(self) -> bytes | None:
+ """Return the current album art bytes, or ``None`` when unavailable.
+
+ Default impl returns ``None`` — controllers that can produce art
+ (Windows via SMTC thumbnail, Linux via mpris:artUrl, macOS via the
+ Spotify/Music artwork-url field) override this.
+ """
+ return None
diff --git a/media_server/tray.py b/media_server/tray.py
index 9d27b10..cd01a8e 100644
--- a/media_server/tray.py
+++ b/media_server/tray.py
@@ -3,6 +3,7 @@
import ctypes
import io
import logging
+import sys
import webbrowser
from pathlib import Path
from typing import Callable
@@ -30,11 +31,24 @@ _IDYES = 6
def _confirm(title: str, message: str) -> bool:
- """Show a Yes/No dialog using native Windows MessageBox."""
- result = ctypes.windll.user32.MessageBoxW(
- 0, message, title, _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND
- )
- return result == _IDYES
+ """Show a Yes/No dialog before a destructive tray action.
+
+ Uses the native Windows MessageBox on win32; on Linux/macOS we don't
+ have a no-dependency GUI dialog available (Tk pulls in tkinter, gtk
+ pulls in PyGObject), so we log + auto-confirm — the tray menu items
+ themselves require a deliberate click already.
+ """
+ if sys.platform == "win32":
+ result = ctypes.windll.user32.MessageBoxW(
+ 0,
+ message,
+ title,
+ _MB_YESNO | _MB_ICONQUESTION | _MB_TOPMOST | _MB_SETFOREGROUND,
+ )
+ return result == _IDYES
+
+ logger.info("Tray confirm (auto-yes on non-Windows): %s — %s", title, message)
+ return True
def _create_icon_image(size: int = 64) -> Image.Image:
diff --git a/pyproject.toml b/pyproject.toml
index a730d50..43717f8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,19 +34,36 @@ dependencies = [
"pillow>=10.0.0",
"soundcard>=0.4.0",
"numpy>=1.24.0,<2.0",
+ # screen-brightness-control + monitorcontrol are cross-platform
+ # (Windows / Linux i2c-dev). Kept in base deps so a plain
+ # `pip install media-server` yields a working /api/display on any
+ # platform that has the underlying hardware support.
+ "screen-brightness-control>=0.20.0",
+ "monitorcontrol>=3.0.0",
]
[project.optional-dependencies]
windows = [
"winsdk>=1.0.0b10",
- "pywin32>=306",
- "comtypes>=1.2.0",
- "pycaw>=20230407",
- "screen-brightness-control>=0.20.0",
- "wmi>=1.5.1",
- "monitorcontrol>=3.0.0",
+ "pywin32>=306; sys_platform == 'win32'",
+ "comtypes>=1.2.0; sys_platform == 'win32'",
+ "pycaw>=20230407; sys_platform == 'win32'",
+ "wmi>=1.5.1; sys_platform == 'win32'",
"pystray>=0.19.0",
]
+linux = [
+ # MPRIS / D-Bus media control. Requires libdbus-1-dev, libglib2.0-dev,
+ # and pkg-config installed on the host before pip install.
+ "dbus-python>=1.3.2; sys_platform == 'linux'",
+ "PyGObject>=3.46; sys_platform == 'linux'",
+ # X11 foreground-window probe (Wayland sessions degrade gracefully).
+ "python-xlib>=0.33; sys_platform == 'linux'",
+]
+macos = [
+ # PyObjC for the foreground-window + active-app probes.
+ "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'",
+ "pyobjc-framework-Quartz>=10.0; sys_platform == 'darwin'",
+]
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",