mpd-now-playable/src/mpd_now_playable/cli.py
Götz 491e7dea1f 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.
2026-03-16 08:47:54 -05:00

104 lines
2.5 KiB
Python

import argparse
import asyncio
import sys
from collections.abc import Iterable
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,
choose_loop_factory,
construct_receiver,
)
async def listen(
config: Config, listener: MpdStateListener, receivers: Iterable[Receiver]
) -> None:
await listener.start(config.mpd)
await asyncio.gather(*(rec.start(listener) for rec in receivers))
await listener.loop(receivers)
def print_help() -> None:
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:
parser = build_parser()
args = parser.parse_args(sys.argv[1:])
if args.version:
rich_print(f"mpd-now-playable v{__version__}")
return
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__}")
config = loadConfig()
rich_print(config)
listener = MpdStateListener(config.cache)
receivers = tuple(construct_receiver(rec_config) for rec_config in config.receivers)
factory = choose_loop_factory(receivers)
asyncio.run(
listen(config, listener, receivers),
loop_factory=factory.make_loop,
debug=False,
)
if __name__ == "__main__":
main()