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,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)]