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:
parent
eb5a9d5745
commit
491e7dea1f
6 changed files with 308 additions and 35 deletions
47
README.md
47
README.md
|
|
@ -1,11 +1,24 @@
|
||||||
# mpd-now-playable [](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.
|
[](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.
|
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
|
## Installation
|
||||||
|
|
||||||
The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/):
|
The recommended way to install mpd-now-playable and its dependencies is with [pipx](https://pypa.github.io/pipx/):
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
pipx install mpd-now-playable
|
pipx install mpd-now-playable
|
||||||
# or, if you'd like to use a separate cache service, one of these:
|
# 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.
|
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
|
## 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.
|
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:
|
One simple secure way to set your environment variables is with a small wrapper script like this:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
export MPD_HOSTNAME=my.cool.mpd.host
|
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'
|
export MPD_NOW_PLAYABLE_CACHE='redis://localhost:6379/0?namespace=mpd-now-playable&password=fishsword'
|
||||||
exec mpd-now-playable
|
exec mpd-now-playable
|
||||||
```
|
```
|
||||||
|
|
||||||
Make sure this wrapper script is only readable by you, with something like `chmod 700`!
|
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
|
## 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!
|
I'm very open to contributions to fix any of these things, if you're interested in writing them!
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
@ -7,6 +8,7 @@ from rich import print as rich_print
|
||||||
from .__version__ import __version__
|
from .__version__ import __version__
|
||||||
from .config.load import loadConfig
|
from .config.load import loadConfig
|
||||||
from .config.model import Config
|
from .config.model import Config
|
||||||
|
from .launchd import DEFAULT_LABEL, install_launchagent, uninstall_launchagent
|
||||||
from .mpd.listener import MpdStateListener
|
from .mpd.listener import MpdStateListener
|
||||||
from .song_receiver import (
|
from .song_receiver import (
|
||||||
Receiver,
|
Receiver,
|
||||||
|
|
@ -24,20 +26,63 @@ async def listen(
|
||||||
|
|
||||||
|
|
||||||
def print_help() -> None:
|
def print_help() -> None:
|
||||||
print("Usage: mpd-now-playable [OPTIONS]")
|
parser = build_parser()
|
||||||
print("")
|
parser.print_help()
|
||||||
print("Options:")
|
|
||||||
print(" -h, --help Show this help message and exit.")
|
|
||||||
print(" -v, --version Show version and exit.")
|
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:
|
def main() -> None:
|
||||||
args = set(sys.argv[1:])
|
parser = build_parser()
|
||||||
if "-h" in args or "--help" in args:
|
args = parser.parse_args(sys.argv[1:])
|
||||||
print_help()
|
|
||||||
|
if args.version:
|
||||||
|
rich_print(f"mpd-now-playable v{__version__}")
|
||||||
return
|
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
|
return
|
||||||
|
|
||||||
rich_print(f"mpd-now-playable v{__version__}")
|
rich_print(f"mpd-now-playable v{__version__}")
|
||||||
|
|
|
||||||
85
src/mpd_now_playable/launchd.py
Normal file
85
src/mpd_now_playable/launchd.py
Normal 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
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import mpd_now_playable.cli as cli
|
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)
|
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
|
output = capsys.readouterr().out
|
||||||
|
|
||||||
assert "Usage: mpd-now-playable [OPTIONS]" in output
|
assert "usage: mpd-now-playable" in output
|
||||||
assert "-h, --help" in output
|
assert "--version" in output
|
||||||
assert "-v, --version" in output
|
assert "install-launchagent" in output
|
||||||
|
assert "uninstall-launchagent" in output
|
||||||
|
|
||||||
|
|
||||||
def test_main_version_prints_version(monkeypatch, capsys) -> None:
|
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["receivers"] == (receiver_1, receiver_2)
|
||||||
assert seen["listen_args"] == (config, listener, (receiver_1, receiver_2))
|
assert seen["listen_args"] == (config, listener, (receiver_1, receiver_2))
|
||||||
assert seen["run"] == (fake_coroutine, FakeLoopFactory.make_loop, False)
|
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
77
tests/test_launchd.py
Normal 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)]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue