From 320c9f3ba71863a7f419b3ff7f796577174909d0 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 30 Mar 2026 08:52:43 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20macOS=20launchd=20service=20=E2=80=94=20?= =?UTF-8?q?modern=20bootstrap=20API,=20auto-start,=20--host=200.0.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deprecated launchctl load/unload with bootstrap/bootout (modern macOS API). Auto-bootout existing service before reinstall (fixes 'I/O error' on reload). Add --host 0.0.0.0 to plist ProgramArguments so the service is actually accessible from the network. install-service now auto-starts the service and prints management commands. upgrade() also uses modern API. doctor() checks if service is actually running. --- muxplex/cli.py | 86 +++++++++++++++++++++++++++++++++------ muxplex/tests/test_cli.py | 70 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/muxplex/cli.py b/muxplex/cli.py index 1d42842..8d49712 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -287,7 +287,18 @@ def doctor() -> None: if sys.platform == "darwin": plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist" if plist.exists(): - print(f" {ok_mark} Service: launchd agent installed ({plist})") + uid = os.getuid() + result = subprocess.run( + ["launchctl", "print", f"gui/{uid}/com.muxplex"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" {ok_mark} Service: launchd agent running") + else: + print( + f" {warn_mark} Service: launchd agent installed but not running ({plist})" + ) else: print( f" {warn_mark} Service: not installed (run: muxplex install-service)" @@ -336,21 +347,25 @@ def _install_launchd(executable: str) -> None: import shutil # Prefer the entry point script ('muxplex') so macOS shows the correct - # process name in Activity Monitor, launchctl list, and login items. - # Fall back to 'python -m muxplex' if the entry point isn't on PATH. + # process name in Activity Monitor and launchctl list. muxplex_bin = shutil.which("muxplex") if muxplex_bin: program_args = f""" {muxplex_bin} + --host + 0.0.0.0 """ else: program_args = f""" {executable} -m muxplex + --host + 0.0.0.0 """ label = "com.muxplex" + uid = os.getuid() plist = f""" @@ -372,12 +387,44 @@ def _install_launchd(executable: str) -> None: """ path = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" path.parent.mkdir(parents=True, exist_ok=True) + + # Unload existing service first (ignore errors if not loaded) + subprocess.run( + ["launchctl", "bootout", f"gui/{uid}/{label}"], + capture_output=True, + ) + path.write_text(plist) print(f"Launch agent written to {path}") - print("Enable with:") - print(f" launchctl load {path}") - print("Disable with:") - print(f" launchctl unload {path}") + print() + + # Load the service using the modern bootstrap API + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(path)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" Service started (launchctl bootstrap gui/{uid})") + else: + # Fallback to legacy load for older macOS + result2 = subprocess.run( + ["launchctl", "load", str(path)], + capture_output=True, + text=True, + ) + if result2.returncode == 0: + print(" Service started (launchctl load)") + else: + print(" Could not auto-start. Try manually:") + print(f" launchctl bootstrap gui/{uid} {path}") + + print() + print("Management commands:") + print(f" Stop: launchctl bootout gui/{uid}/{label}") + print(f" Start: launchctl bootstrap gui/{uid} {path}") + print(f" Status: launchctl print gui/{uid}/{label}") + print(" Logs: tail -f /tmp/muxplex.log") def _install_systemd(executable: str, *, system: bool = False) -> None: @@ -453,10 +500,14 @@ def upgrade(*, force: bool = False) -> None: # 1. Detect platform and stop service if sys.platform == "darwin": - plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist" + label = "com.muxplex" + uid = os.getuid() + plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" if plist.exists(): print(" Stopping launchd service...") - subprocess.run(["launchctl", "unload", str(plist)], capture_output=True) + subprocess.run( + ["launchctl", "bootout", f"gui/{uid}/{label}"], capture_output=True + ) else: print(" No launchd service found (skipping stop)") else: @@ -521,11 +572,22 @@ def upgrade(*, force: bool = False) -> None: # 4. Restart service if sys.platform == "darwin": - plist = Path.home() / "Library" / "LaunchAgents" / "com.muxplex.plist" + label = "com.muxplex" + uid = os.getuid() + plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist" if plist.exists(): print(" Starting launchd service...") - subprocess.run(["launchctl", "load", str(plist)], capture_output=True) - print(" Service started") + result = subprocess.run( + ["launchctl", "bootstrap", f"gui/{uid}", str(plist)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(" Service started") + else: + # Fallback to legacy load for older macOS + subprocess.run(["launchctl", "load", str(plist)], capture_output=True) + print(" Service started (legacy)") else: print(" Service file not found — run: muxplex install-service") else: diff --git a/muxplex/tests/test_cli.py b/muxplex/tests/test_cli.py index 3453fe6..3131fde 100644 --- a/muxplex/tests/test_cli.py +++ b/muxplex/tests/test_cli.py @@ -263,12 +263,18 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch): """install_service() on macOS writes a launchd plist to ~/Library/LaunchAgents/.""" + import subprocess from muxplex.cli import install_service fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) install_service(system=False) @@ -283,12 +289,18 @@ def test_install_service_writes_launchd_plist_on_macos(tmp_path, monkeypatch): def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch): """install_service() on macOS must NOT write a systemd unit file.""" + import subprocess from muxplex.cli import install_service fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) install_service(system=False) @@ -296,6 +308,64 @@ def test_install_service_does_not_write_systemd_on_macos(tmp_path, monkeypatch): assert not systemd_path.exists(), "No systemd unit file should be written on macOS" +def test_install_service_uses_modern_launchctl_on_macos(tmp_path, monkeypatch, capsys): + """install-service on macOS must use launchctl bootstrap (not load).""" + import subprocess + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}") + + calls = [] + + def mock_run(cmd, **kwargs): + calls.append(cmd) + return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() + + monkeypatch.setattr(subprocess, "run", mock_run) + + install_service(system=False) + + launchctl_cmds = [c for c in calls if isinstance(c, list) and "launchctl" in str(c)] + assert any( + "bootout" in str(c) for c in launchctl_cmds + ), "must bootout old service before loading" + assert any( + "bootstrap" in str(c) for c in launchctl_cmds + ), "must use launchctl bootstrap (modern API)" + # Must NOT use deprecated load + assert not any( + c == ["launchctl", "load"] or (isinstance(c, list) and c[:2] == ["launchctl", "load"]) + for c in launchctl_cmds + ), "must NOT use deprecated launchctl load" + + +def test_install_service_plist_includes_host_flag(tmp_path, monkeypatch): + """The generated plist must include --host 0.0.0.0 for network access.""" + import subprocess + from muxplex.cli import install_service + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr(shutil, "which", lambda name: f"/usr/local/bin/{name}") + monkeypatch.setattr( + subprocess, + "run", + lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), + ) + + install_service(system=False) + + plist_path = fake_home / "Library" / "LaunchAgents" / "com.muxplex.plist" + content = plist_path.read_text() + assert "0.0.0.0" in content, "plist must bind to 0.0.0.0 for network access" + + def test_install_service_writes_systemd_on_linux(tmp_path, monkeypatch): """install_service() on Linux writes a systemd unit to ~/.config/systemd/user/.""" from muxplex.cli import install_service