cli: add first-party launchd install/uninstall commands for autostart

Introduce built-in launchd management to remove shell-wrapper indirection and
make macOS autostart reliable.

- Add `install-launchagent` and `uninstall-launchagent` CLI subcommands.
- Create launchd helper module to:
  - write `~/Library/LaunchAgents/<label>.plist`
  - invoke app via `sys.executable -m mpd_now_playable.cli` (absolute interpreter path)
  - set `RunAtLoad`, `KeepAlive`
  - run `launchctl bootout/bootstrap/kickstart` for install and `bootout` for uninstall
  - support `--label` and `--force`
- Add tests for subcommand dispatch, plist generation, and launchctl flow.
- Document the new launchd workflow in README.
  - Run markdownlint

This keeps the setup DRY/KISS, avoids PATH/shell expansion pitfalls in launchd,
and gives users a supported and easy autostart path out of the box.
This commit is contained in:
Götz 2026-03-01 13:07:13 -05:00 committed by goetzc
parent eb5a9d5745
commit 491e7dea1f
6 changed files with 308 additions and 35 deletions

View file

@ -1,11 +1,24 @@
# mpd-now-playable [![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](https://badge.fury.io/py/mpd-now-playable)
# mpd-now-playable
This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on MacOS.
[![PyPI version](https://badge.fury.io/py/mpd-now-playable.svg)](https://badge.fury.io/py/mpd-now-playable)
This little Python program turns your MPD server into a [now playable app](https://developer.apple.com/documentation/mediaplayer/becoming_a_now_playable_app) on macOS.
This enables your keyboard's standard media keys to control MPD, as well as more esoteric music control methods like the buttons on your Bluetooth headphones.
## Table of Contents
<!-- toc -->
- [Installation](#installation)
- [Configuration](#configuration)
- [Limitations](#limitations)
<!-- tocstop -->
## Installation
The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/):
```shell
pipx install mpd-now-playable
# or, if you'd like to use a separate cache service, one of these:
@ -15,7 +28,27 @@ pipx install mpd-now-playable[memcached]
Once pipx is done, the `mpd-now-playable` script should be available on your `$PATH` and ready to use.
Most likely, you'll want mpd-now-playable to stay running in the background as a launchd service. [Here's the service plist I use](https://git.00dani.me/00dani/mpd-now-playable/src/branch/main/me.00dani.mpd-now-playable.plist), but it's hardcoded to my `$HOME` so you'll want to customise it.
Most likely, you'll want mpd-now-playable to stay running in the background as a `launchd` service. The CLI can install a per-user LaunchAgent for you:
```shell
mpd-now-playable install-launchagent
```
This writes `~/Library/LaunchAgents/me.00dani.mpd-now-playable.plist`, bootstraps it with `launchctl`, and starts it immediately.
To replace an existing LaunchAgent plist:
```shell
mpd-now-playable install-launchagent --force
```
To remove it later:
```shell
mpd-now-playable uninstall-launchagent
```
You may override the launchd label in both commands with `--label`.
## Configuration
@ -37,6 +70,7 @@ Additionally, mpd-now-playable caches your album artwork, by default simply in m
You may provide a `namespace` query parameter to prefix cache keys if you wish, as well as a `password` query parameter if your service requires a password to access. As with your other environment variables, keep your cache password secure.
One simple secure way to set your environment variables is with a small wrapper script like this:
```shell
#!/bin/sh
export MPD_HOSTNAME=my.cool.mpd.host
@ -45,12 +79,15 @@ export MPD_PASSWORD=swordfish
export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword'
exec mpd-now-playable
```
Make sure this wrapper script is only readable by you, with something like `chmod 700`!
If you're using launchd, you can point `ProgramArguments` in your plist at that wrapper script instead.
## Limitations
mpd-now-playable is currently *very* specific to MacOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature.
mpd-now-playable is currently _very_ specific to macOS. I did my best to keep the generic MPD and extremely Apple parts separate, but it definitely won't work with MPRIS2 or the Windows system media feature.
Chances are my MacOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working.
Chances are my macOS integration code isn't the best, either. This is the first project I've written using PyObjC and it took a lot of fiddling to get working.
I'm very open to contributions to fix any of these things, if you're interested in writing them!

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>me.00dani.mpd-now-playable</string>
<key>ProgramArguments</key>
<array>
<string>/Users/dani/.local/bin/mpd-now-playable</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>

View file

@ -1,3 +1,4 @@
import argparse
import asyncio
import sys
from collections.abc import Iterable
@ -7,6 +8,7 @@ from rich import print as rich_print
from .__version__ import __version__
from .config.load import loadConfig
from .config.model import Config
from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent
from .mpd.listener import MpdStateListener
from .song_receiver import (
Receiver,
@ -24,20 +26,63 @@ async def listen(
def print_help() -> None:
print("Usage: mpd-now-playable [OPTIONS]")
print("")
print("Options:")
print(" -h, --help Show this help message and exit.")
print(" -v, --version Show version and exit.")
parser = build_parser()
parser.print_help()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mpd-now-playable")
parser.add_argument(
"-v",
"--version",
action="store_true",
help="Show version and exit.",
)
subparsers = parser.add_subparsers(dest="command")
install_cmd = subparsers.add_parser(
"install-launchagent",
help="Install and start a per-user launchd service.",
)
install_cmd.add_argument(
"--label",
default=DEFAULT_LABEL,
help=f"launchd label to install (default: {DEFAULT_LABEL}).",
)
install_cmd.add_argument(
"--force",
action="store_true",
help="Replace an existing plist with the same label.",
)
uninstall_cmd = subparsers.add_parser(
"uninstall-launchagent",
help="Unload and remove a per-user launchd service.",
)
uninstall_cmd.add_argument(
"--label",
default=DEFAULT_LABEL,
help=f"launchd label to uninstall (default: {DEFAULT_LABEL}).",
)
return parser
def main() -> None:
args = set(sys.argv[1:])
if "-h" in args or "--help" in args:
print_help()
parser = build_parser()
args = parser.parse_args(sys.argv[1:])
if args.version:
rich_print(f"mpd-now-playable v{__version__}")
return
if "-v" in args or "--version" in args:
print(f"mpd-now-playable v{__version__}")
if args.command == "install-launchagent":
path = install_launchagent(label=args.label, force=args.force)
rich_print(f"Installed LaunchAgent at {path}")
return
if args.command == "uninstall-launchagent":
path = uninstall_launchagent(label=args.label)
rich_print(f"Uninstalled LaunchAgent at {path}")
return
rich_print(f"mpd-now-playable v{__version__}")

View file

@ -0,0 +1,85 @@
import os
import plistlib
import subprocess
import sys
from pathlib import Path
DEFAULT_LABEL = "me.00dani.mpd-now-playable"
def _require_macos() -> None:
if sys.platform != "darwin":
msg = "launchd commands are only supported on macOS"
raise RuntimeError(msg)
def _launch_agents_dir() -> Path:
return Path.home() / "Library" / "LaunchAgents"
def plist_path_for_label(label: str) -> Path:
return _launch_agents_dir() / f"{label}.plist"
def _service_target(label: str) -> str:
return f"gui/{os.getuid()}/{label}"
def _launchctl_binary() -> str:
# Prefer absolute paths to avoid relying on PATH for privileged process control.
for candidate in ("/bin/launchctl", "/usr/bin/launchctl"):
if Path(candidate).exists():
return candidate
return "launchctl"
def _run_launchctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603 - arguments are fixed command tokens, shell is not used.
[_launchctl_binary(), *args],
check=check,
capture_output=True,
text=True,
)
def _plist_bytes(label: str) -> bytes:
payload = {
"Label": label,
"ProgramArguments": [sys.executable, "-m", "mpd_now_playable.cli"],
"RunAtLoad": True,
"KeepAlive": True,
}
return plistlib.dumps(payload, fmt=plistlib.FMT_XML)
def install_launchagent(label: str = DEFAULT_LABEL, force: bool = False) -> Path:
_require_macos()
plist_path = plist_path_for_label(label)
plist_path.parent.mkdir(parents=True, exist_ok=True)
if plist_path.exists() and not force:
msg = f"{plist_path} already exists; rerun with --force to replace it"
raise FileExistsError(msg)
plist_path.write_bytes(_plist_bytes(label))
target = _service_target(label)
# Ignore errors if the service does not already exist.
_run_launchctl("bootout", target, check=False)
_run_launchctl("bootstrap", f"gui/{os.getuid()}", str(plist_path))
_run_launchctl("kickstart", "-k", target)
return plist_path
def uninstall_launchagent(label: str = DEFAULT_LABEL) -> Path:
_require_macos()
plist_path = plist_path_for_label(label)
target = _service_target(label)
# Ignore errors if the service is not loaded.
_run_launchctl("bootout", target, check=False)
if plist_path.exists():
plist_path.unlink()
return plist_path

View file

@ -1,5 +1,7 @@
from types import SimpleNamespace
import pytest
import mpd_now_playable.cli as cli
@ -11,12 +13,15 @@ def test_main_help_prints_usage(monkeypatch, capsys) -> None:
monkeypatch.setattr(cli, "loadConfig", fail_load)
cli.main()
with pytest.raises(SystemExit) as exc:
cli.main()
assert exc.value.code == 0
output = capsys.readouterr().out
assert "Usage: mpd-now-playable [OPTIONS]" in output
assert "-h, --help" in output
assert "-v, --version" in output
assert "usage: mpd-now-playable" in output
assert "--version" in output
assert "install-launchagent" in output
assert "uninstall-launchagent" in output
def test_main_version_prints_version(monkeypatch, capsys) -> None:
@ -93,3 +98,43 @@ def test_main_starts_listener_and_receivers(monkeypatch) -> None:
assert seen["receivers"] == (receiver_1, receiver_2)
assert seen["listen_args"] == (config, listener, (receiver_1, receiver_2))
assert seen["run"] == (fake_coroutine, FakeLoopFactory.make_loop, False)
def test_main_install_launchagent(monkeypatch, capsys) -> None:
seen = {}
def fake_install(*, label: str, force: bool) -> str:
seen["args"] = (label, force)
return "/tmp/example.plist"
monkeypatch.setattr(
cli.sys,
"argv",
["mpd-now-playable", "install-launchagent", "--label", "com.example.test", "--force"],
)
monkeypatch.setattr(cli, "install_launchagent", fake_install)
cli.main()
output = capsys.readouterr().out
assert seen["args"] == ("com.example.test", True)
assert "Installed LaunchAgent at /tmp/example.plist" in output
def test_main_uninstall_launchagent(monkeypatch, capsys) -> None:
seen = {}
def fake_uninstall(*, label: str) -> str:
seen["label"] = label
return "/tmp/example.plist"
monkeypatch.setattr(
cli.sys,
"argv",
["mpd-now-playable", "uninstall-launchagent", "--label", "com.example.test"],
)
monkeypatch.setattr(cli, "uninstall_launchagent", fake_uninstall)
cli.main()
output = capsys.readouterr().out
assert seen["label"] == "com.example.test"
assert "Uninstalled LaunchAgent at /tmp/example.plist" in output

77
tests/test_launchd.py Normal file
View file

@ -0,0 +1,77 @@
import plistlib
from pathlib import Path
import pytest
import mpd_now_playable.launchd as launchd
def test_plist_bytes_uses_absolute_python(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(launchd.sys, "executable", "/abs/python")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
payload = plistlib.loads(launchd._plist_bytes("com.example.test"))
assert payload["Label"] == "com.example.test"
assert payload["ProgramArguments"] == ["/abs/python", "-m", "mpd_now_playable.cli"]
assert payload["RunAtLoad"] is True
assert payload["KeepAlive"] is True
def test_install_launchagent_writes_plist_and_bootstraps(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(launchd.sys, "platform", "darwin")
monkeypatch.setattr(launchd.sys, "executable", "/abs/python")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(launchd.os, "getuid", lambda: 501)
seen: list[tuple[tuple[str, ...], bool]] = []
def fake_run_launchctl(*args: str, check: bool = True) -> object:
seen.append((args, check))
return object()
monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl)
plist_path = launchd.install_launchagent(label="com.example.test", force=False)
assert plist_path == tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
assert plist_path.exists()
assert seen == [
(("bootout", "gui/501/com.example.test"), False),
(("bootstrap", "gui/501", str(plist_path)), True),
(("kickstart", "-k", "gui/501/com.example.test"), True),
]
def test_install_launchagent_refuses_existing_without_force(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(launchd.sys, "platform", "darwin")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
plist.parent.mkdir(parents=True, exist_ok=True)
plist.write_text("already here")
with pytest.raises(FileExistsError):
launchd.install_launchagent(label="com.example.test")
def test_uninstall_launchagent_boots_out_and_removes_file(monkeypatch, tmp_path) -> None:
monkeypatch.setattr(launchd.sys, "platform", "darwin")
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setattr(launchd.os, "getuid", lambda: 501)
seen: list[tuple[tuple[str, ...], bool]] = []
def fake_run_launchctl(*args: str, check: bool = True) -> object:
seen.append((args, check))
return object()
monkeypatch.setattr(launchd, "_run_launchctl", fake_run_launchctl)
plist = tmp_path / "Library" / "LaunchAgents" / "com.example.test.plist"
plist.parent.mkdir(parents=True, exist_ok=True)
plist.write_text("x")
removed = launchd.uninstall_launchagent(label="com.example.test")
assert removed == plist
assert not plist.exists()
assert seen == [(("bootout", "gui/501/com.example.test"), False)]