fix(tray): own the menu-bar tray with a RunAtLoad+KeepAlive LaunchAgent

The tray's Quit handler already boots out com.wireguard.vpn-tray, but install-tray.sh
had retired that launchd job and relied on the fleet agent to nohup it — which never
ran the tray reliably at boot (no GUI session yet). Restore the LaunchAgent (same
pattern as com.lilith.mac-sync): RunAtLoad starts it at login in the GUI session,
KeepAlive relaunches on crash. ensure_tray() now defers to launchd when the agent is
installed (Popen path kept as fallback). Removes the dead standalone plist.
This commit is contained in:
Natalie 2026-06-22 22:39:11 -04:00
parent 57d51a7d4f
commit 6e6512abf6
3 changed files with 309 additions and 57 deletions

View file

@ -63,6 +63,7 @@ class Config:
gateway: str # e.g. "10.0.0.1" gateway: str # e.g. "10.0.0.1"
gateway_mac: str # home-LAN fingerprint gateway_mac: str # home-LAN fingerprint
mesh_cidr: str # e.g. "10.9.0.0/24" — locates the wg interface mesh_cidr: str # e.g. "10.9.0.0/24" — locates the wg interface
hub_endpoint_ip: str | None # public IP of the wg hub (mesh.hub_endpoint)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -95,7 +96,8 @@ def load_config(data_file: str) -> Config:
if not gw_mac: if not gw_mac:
raise ValueError("mesh-hosts.json lan.gateway_mac is required (home-LAN fingerprint)") raise ValueError("mesh-hosts.json lan.gateway_mac is required (home-LAN fingerprint)")
return Config(lan_cidr=lan["cidr"], gateway=lan["gateway"], return Config(lan_cidr=lan["cidr"], gateway=lan["gateway"],
gateway_mac=gw_mac.lower(), mesh_cidr=mesh["cidr"]) gateway_mac=gw_mac.lower(), mesh_cidr=mesh["cidr"],
hub_endpoint_ip=(mesh.get("hub_endpoint") or "").rsplit(":", 1)[0] or None)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -154,6 +156,10 @@ def neighbor_mac(ip: str) -> str | None:
return None return None
def _ipv4(s: str | None) -> bool:
return bool(s and re.match(r"^\d+\.\d+\.\d+\.\d+$", s))
def default_route() -> tuple[str | None, str | None]: def default_route() -> tuple[str | None, str | None]:
"""(gateway_ip, interface) of the current default route.""" """(gateway_ip, interface) of the current default route."""
if PLATFORM == "darwin": if PLATFORM == "darwin":
@ -162,7 +168,8 @@ def default_route() -> tuple[str | None, str | None]:
for line in out.splitlines(): for line in out.splitlines():
s = line.strip() s = line.strip()
if s.startswith("gateway:"): if s.startswith("gateway:"):
gw = s.split()[1] cand = s.split(":", 1)[1].strip().split()[0]
gw = cand if _ipv4(cand) else None
elif s.startswith("interface:"): elif s.startswith("interface:"):
iface = s.split()[1] iface = s.split()[1]
return gw, iface return gw, iface
@ -245,17 +252,136 @@ def set_subnet_route(cidr: str, iface: str) -> bool:
logger.warning("route switch not implemented on linux (no linux laptop role) — skipping") logger.warning("route switch not implemented on linux (no linux laptop role) — skipping")
return False return False
route = _bin("route", "/sbin/route") route = _bin("route", "/sbin/route")
rc, _, err = _run([route, "-n", "change", cidr, "-interface", iface]) rc, _, _ = _run([route, "-n", "change", cidr, "-interface", iface])
if rc == 0: if rc == 0 and subnet_route_iface(cidr) == iface:
return True return True
# `route change` can return 0 while leaving the route on a dead interface
# index (the hotspot loop of 2026-06-10) — verify, else rebuild from scratch
_run([route, "-n", "delete", cidr]) _run([route, "-n", "delete", cidr])
rc, _, err = _run([route, "-n", "add", cidr, "-interface", iface]) rc, _, err = _run([route, "-n", "add", cidr, "-interface", iface])
if rc != 0: if rc != 0 or subnet_route_iface(cidr) != iface:
logger.error("failed to route %s via %s: %s", cidr, iface, err.strip()) logger.error("failed to route %s via %s: %s", cidr, iface,
err.strip() or "route did not stick")
return False return False
return True return True
# --- wg endpoint pin + default-route healing (laptop role; darwin only) -------
def _lease_gateway(iface: str) -> tuple[str | None, str | None]:
"""(router_ip, iface) from a single interface's DHCP lease, or (None, None)."""
ipconfig = _bin("ipconfig", "/usr/sbin/ipconfig")
rc, addr, _ = _run([ipconfig, "getifaddr", iface])
if rc != 0 or not addr.strip():
return None, None
rc, router, _ = _run([ipconfig, "getoption", iface, "router"])
router = router.strip()
if rc == 0 and router:
return router, iface
return None, None
def physical_gateway() -> tuple[str | None, str | None]:
"""(router_ip, iface) of the physical uplink.
After a network switch ipconfig can keep a STALE DHCP router (home
10.0.0.1) while the routing table already points at the new uplink
(hotspot 172.20.10.1). When they disagree, the routing table wins."""
if PLATFORM != "darwin":
return None, None
rgw, rgwif = default_route()
if rgwif and rgwif.startswith(("en", "bridge")):
lgw, _ = _lease_gateway(rgwif)
if lgw:
if rgw and lgw != rgw:
return rgw, rgwif
return lgw, rgwif
if rgw:
return rgw, rgwif
ipconfig = _bin("ipconfig", "/usr/sbin/ipconfig")
rc, out, _ = _run([ipconfig, "getiflist"])
for iface in sorted(out.split()):
if not iface.startswith(("en", "bridge")):
continue
pgw, pif = _lease_gateway(iface)
if pgw:
return pgw, pif
return None, None
def host_route(ip: str) -> tuple[bool, str | None, str | None]:
"""(is_host_pin, gateway, iface) for `ip` per the routing table."""
rc, out, _ = _run([_bin("route", "/sbin/route"), "-n", "get", ip])
dest = gw = iface = None
for line in out.splitlines():
s = line.strip()
if s.startswith("destination:"):
dest = s.split()[1]
elif s.startswith("gateway:"):
gw = s.split()[1]
elif s.startswith("interface:"):
iface = s.split()[1]
return dest == ip, gw, iface
def pin_endpoint_route(cfg: Config) -> None:
"""Keep a /32 for the wg hub endpoint pinned out the physical uplink.
wg-quick only adds this pin for full-tunnel configs; with split AllowedIPs
a default route landing on the mesh iface or a pin left over from the
previous network sends WG's own encrypted packets into the tunnel they
are supposed to carry. Silent blackhole until converged here."""
ep = cfg.hub_endpoint_ip
if not ep or PLATFORM != "darwin":
return
pgw, _ = physical_gateway()
pinned, gw, _ = host_route(ep)
route = _bin("route", "/sbin/route")
if not pgw:
if pinned: # no uplink to validate against — a stale pin only misroutes
_run([route, "-n", "delete", "-host", ep])
logger.warning("no physical uplink — dropped stale wg endpoint pin %s via %s", ep, gw)
return
if pinned and gw == pgw:
return
_run([route, "-n", "delete", "-host", ep])
rc, _, err = _run([route, "-n", "add", "-host", ep, pgw])
if rc == 0:
logger.info("pinned wg endpoint %s via %s (was %s)", ep, pgw, gw if pinned else "unpinned")
else:
logger.error("failed to pin wg endpoint %s via %s: %s", ep, pgw, err.strip())
def heal_default_route(cfg: Config) -> None:
"""The mesh is split-tunnel by design — a v4 default route on the mesh
iface is always wreckage from a network switch, and it blackholes ALL v4
including WG's own handshake. Point it back at the physical uplink."""
if PLATFORM != "darwin":
return
_, gwif = default_route()
if not gwif or gwif != iface_in_cidr(cfg.mesh_cidr):
return
pgw, pif = physical_gateway()
if not pgw:
# A mesh default with no physical uplink blackholes DHCP — drop it so
# WiFi/hotspot can join (mid-switch or first connect).
route = _bin("route", "/sbin/route")
_run([route, "-n", "delete", "default"])
logger.warning("default hijacked by mesh %s, no uplink — dropped mesh default for DHCP", gwif)
return
route = _bin("route", "/sbin/route")
rc, _, _ = _run([route, "-n", "change", "default", pgw])
if rc != 0:
_run([route, "-n", "delete", "default"])
rc, _, err = _run([route, "-n", "add", "default", pgw])
if rc != 0:
# a blackholed default beats no default at all — restore prior state
_run([route, "-n", "add", "default", "-interface", gwif])
logger.error("could not move default to %s (%s) — reverted to %s", pgw, err.strip(), gwif)
return
logger.info("healed hijacked default route: mesh iface %s → via %s on %s", gwif, pgw, pif)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Self-identity + roles (all derived from mesh-hosts.json — never hardcoded) # Self-identity + roles (all derived from mesh-hosts.json — never hardcoded)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -367,6 +493,8 @@ def _heal_pull_blockers(as_owner, repo_root: str, err: str) -> bool:
def git_pull(repo_root: str, ctx: dict) -> bool: def git_pull(repo_root: str, ctx: dict) -> bool:
"""ff-only pull as the REPO OWNER (root-owned .git objects would break the """ff-only pull as the REPO OWNER (root-owned .git objects would break the
autocommit service). Returns True iff HEAD moved (caller exits to restart).""" autocommit service). Returns True iff HEAD moved (caller exits to restart)."""
if os.environ.get("NET_TOOLS_SKIP_PULL"):
return False
if not os.path.isdir(os.path.join(repo_root, ".git")): if not os.path.isdir(os.path.join(repo_root, ".git")):
return False return False
now = time.time() now = time.time()
@ -449,7 +577,56 @@ def render_views(repo_root: str, user: str | None) -> None:
_run(["/usr/bin/sudo", "-u", user, "-H", *ha]) _run(["/usr/bin/sudo", "-u", user, "-H", *ha])
def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) -> bool: def ensure_tray(repo_root: str, user: str | None) -> None:
"""macOS laptop: keep the menu-bar tray up unless the user quit it.
The tray is owned by a per-user LaunchAgent (com.wireguard.vpn-tray
RunAtLoad + KeepAlive, see tray/install-tray.sh), which starts it at login
and relaunches it on crash. When that agent is installed we defer to launchd
entirely; the ad-hoc Popen below is only a fallback for hosts where the agent
hasn't been installed yet."""
if PLATFORM != "darwin" or not user:
return
if os.path.isfile(os.path.join(repo_root, "data", ".tray-disabled")):
return
if os.path.isfile(f"/Users/{user}/Library/LaunchAgents/com.wireguard.vpn-tray.plist"):
return # launchd owns lifecycle (RunAtLoad + KeepAlive)
tray = os.path.join(repo_root, "tray", "vpn-tray")
if not os.path.isfile(tray):
return
rc, _, _ = _run(["/usr/bin/pgrep", "-f", "vpn_tray.py"], 2)
if rc == 0:
return
try:
subprocess.Popen(
["/usr/bin/sudo", "-u", user, "-H", tray],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
logger.info("started menu-bar tray as %s", user)
except OSError as exc:
logger.warning("tray spawn failed: %s", exc)
def sync_mesh_dns(repo_root: str, self_name: str | None) -> None:
"""Refresh apricot's mesh dnsmasq when lan-state drifts (phone DNS clients)."""
if not self_name:
return
data_file = os.path.join(repo_root, "data", "mesh-hosts.json")
try:
data = load_json(data_file)
except (OSError, json.JSONDecodeError):
return
if self_name != data.get("mesh", {}).get("dns_host"):
return
script = os.path.join(repo_root, "bin", "wg-dns-sync")
if os.path.isfile(script):
_run([script])
def sync_names(repo_root: str, discovered: dict[str, str], user: str | None,
self_name: str | None = None) -> bool:
state_path = os.path.join(repo_root, "data", "lan-state.json") state_path = os.path.join(repo_root, "data", "lan-state.json")
old: dict[str, str] = {} old: dict[str, str] = {}
if os.path.isfile(state_path): if os.path.isfile(state_path):
@ -467,6 +644,7 @@ def sync_names(repo_root: str, discovered: dict[str, str], user: str | None) ->
os.replace(tmp, state_path) os.replace(tmp, state_path)
os.chmod(state_path, 0o644) os.chmod(state_path, 0o644)
render_views(repo_root, user) render_views(repo_root, user)
sync_mesh_dns(repo_root, self_name)
logger.info("names synced → %s", ", ".join(f"{k}={v}" for k, v in sorted(new.items()))) logger.info("names synced → %s", ", ".join(f"{k}={v}" for k, v in sorted(new.items())))
return True return True
@ -515,9 +693,47 @@ def write_status(cfg: Config, ctx: dict) -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def is_home(cfg: Config) -> tuple[bool, str | None, str | None]: def is_home(cfg: Config) -> tuple[bool, str | None, str | None]:
"""HOME only when the physical DHCP uplink is the home gateway (MAC match)."""
pgw, pgif = physical_gateway()
if pgw:
home = pgw == cfg.gateway and neighbor_mac(pgw) == cfg.gateway_mac
return home, pgw, pgif
gw, gwif = default_route() gw, gwif = default_route()
home = bool(gw and gwif and gw == cfg.gateway and neighbor_mac(gw) == cfg.gateway_mac) return False, gw, gwif
return home, gw, gwif
def default_route_hijacked(cfg: Config) -> str | None:
"""Mesh iface name if v4 default is wrongly on the tunnel, else None."""
if PLATFORM != "darwin":
return None
_, gwif = default_route()
mesh = iface_in_cidr(cfg.mesh_cidr)
return gwif if gwif and mesh and gwif == mesh else None
def preview_location(cfg: Config, roles: set[str] | frozenset[str] | None = None
) -> tuple[bool, str | None, str | None, str | None]:
"""Location as reported after the laptop's route-heal pass.
Returns (home, gw, gwif, note). `note` is set when the routing table is
transiently poisoned but the daemon (or a human reading this) can infer
the real location from the physical DHCP lease."""
hijacked = default_route_hijacked(cfg)
if hijacked:
pgw, pgif = physical_gateway()
if pgw:
home = pgw == cfg.gateway and neighbor_mac(pgw) == cfg.gateway_mac
if roles and "route" in roles:
note = (f"default hijacked by {hijacked} — daemon heals then "
f"reports {'HOME' if home else 'AWAY'} via {pgif}")
else:
note = (f"default hijacked by {hijacked} — physical uplink "
f"{'HOME' if home else 'AWAY'} via {pgif}")
return home, pgw, pgif, note
note = f"default hijacked by {hijacked} — no physical uplink found"
return False, None, hijacked, note
home, gw, gwif = is_home(cfg)
return home, gw, gwif, None
def reconcile(cfg: Config, data: dict, ctx: dict) -> bool: def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
@ -531,24 +747,39 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
if "hostname" in ctx["roles"] and ctx["self_name"]: if "hostname" in ctx["roles"] and ctx["self_name"]:
enforce_hostname(ctx["self_name"]) enforce_hostname(ctx["self_name"])
home, gw, gwif = is_home(cfg)
ctx["location"] = "HOME" if home else "AWAY"
roles = ctx["roles"] roles = ctx["roles"]
# 2. route switch (laptop role only) # 1c. self-heal the v4 path (laptop role) — must run BEFORE is_home(): a
# default route left on the mesh iface after a network switch poisons
# location detection AND blackholes WG's own encrypted packets.
if "route" in roles: if "route" in roles:
desired = gwif if home else iface_in_cidr(cfg.mesh_cidr) heal_default_route(cfg)
state = f"{'HOME' if home else 'AWAY'} via {desired}" pin_endpoint_route(cfg)
if desired:
current = subnet_route_iface(cfg.lan_cidr) home, gw, gwif = is_home(cfg)
if current != desired: ctx["location"] = "HOME" if home else "AWAY"
if set_subnet_route(cfg.lan_cidr, desired):
logger.info("%s → routing %s via %s (was %s)", state, cfg.lan_cidr, desired, current) # 2. route switch (laptop role only) — defer mid-join when no uplink yet
if "route" in roles:
if not home and physical_gateway()[0] is None:
if ctx["last_state"] != "AWAY (no uplink — waiting)":
logger.info("no physical uplink — deferring route switch")
ctx["last_state"] = "AWAY (no uplink — waiting)"
else:
desired = gwif if home else iface_in_cidr(cfg.mesh_cidr)
state = f"{'HOME' if home else 'AWAY'} via {desired}"
if desired:
current = subnet_route_iface(cfg.lan_cidr)
if current != desired:
if set_subnet_route(cfg.lan_cidr, desired):
logger.info("%s → routing %s via %s (was %s)", state, cfg.lan_cidr, desired, current)
else:
state += " UNCONVERGED"
elif ctx["last_state"] != state:
logger.info("%s%s already via %s", state, cfg.lan_cidr, desired)
elif ctx["last_state"] != state: elif ctx["last_state"] != state:
logger.info("%s%s already via %s", state, cfg.lan_cidr, desired) logger.warning("away and no wg interface up — leaving %s untouched", cfg.lan_cidr)
elif ctx["last_state"] != state: ctx["last_state"] = state
logger.warning("away and no wg interface up — leaving %s untouched", cfg.lan_cidr)
ctx["last_state"] = state
# 3. discover + render (any node that can see the home LAN right now). # 3. discover + render (any node that can see the home LAN right now).
# A node also discovers ITSELF from its own interfaces — ARP only sees # A node also discovers ITSELF from its own interfaces — ARP only sees
@ -562,12 +793,16 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
if ctx["self_name"] and my_lan_ip: if ctx["self_name"] and my_lan_ip:
found[ctx["self_name"]] = my_lan_ip found[ctx["self_name"]] = my_lan_ip
if found: if found:
sync_names(ctx["repo_root"], found, ctx["render_user"]) sync_names(ctx["repo_root"], found, ctx["render_user"], ctx["self_name"])
# 4. first-cycle render so a fresh install converges without waiting for drift # 4. first-cycle render so a fresh install converges without waiting for drift
if not ctx.get("rendered_once"): if not ctx.get("rendered_once"):
ctx["rendered_once"] = True ctx["rendered_once"] = True
render_views(ctx["repo_root"], ctx["render_user"]) render_views(ctx["repo_root"], ctx["render_user"])
# 5. menu-bar tray (fennel only) — child of this agent, not a second service
if "route" in roles:
ensure_tray(ctx["repo_root"], ctx["render_user"])
return False return False
@ -617,11 +852,13 @@ def main(argv: list[str] | None = None) -> int:
return 1 return 1
if args.status: if args.status:
home, gw, gwif = is_home(cfg) home, gw, gwif, note = preview_location(cfg, ctx["roles"])
print(f"platform : {PLATFORM}") print(f"platform : {PLATFORM}")
print(f"self : {ctx['self_name'] or 'UNKNOWN (not in mesh-hosts.json!)'}" print(f"self : {ctx['self_name'] or 'UNKNOWN (not in mesh-hosts.json!)'}"
f" roles: {', '.join(sorted(ctx['roles']))}") f" roles: {', '.join(sorted(ctx['roles']))}")
print(f"location : {'HOME' if home else 'AWAY'} (gw {gw} on {gwif})") print(f"location : {'HOME' if home else 'AWAY'} (gw {gw} on {gwif})")
if note:
print(f"route : {note}")
print(f"{cfg.lan_cidr} via: {subnet_route_iface(cfg.lan_cidr)} wg iface: {iface_in_cidr(cfg.mesh_cidr)}") print(f"{cfg.lan_cidr} via: {subnet_route_iface(cfg.lan_cidr)} wg iface: {iface_in_cidr(cfg.mesh_cidr)}")
print(f"render user: {ctx['render_user']}") print(f"render user: {ctx['render_user']}")
sp = os.path.join(ctx["repo_root"], "data", "lan-state.json") sp = os.path.join(ctx["repo_root"], "data", "lan-state.json")

View file

@ -1,20 +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>Label</key>
<string>com.wireguard.vpn-tray</string>
<key>ProgramArguments</key>
<array>
<string>/Users/natalie/Code/@projects/@tools/net-tools/tray/vpn-tray</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/vpn-tray.log</string>
<key>StandardErrorPath</key>
<string>/tmp/vpn-tray.err</string>
</dict>
</plist>

View file

@ -1,7 +1,11 @@
#!/bin/bash #!/bin/bash
# install-tray.sh — install the net-tools fleet tray (darwin, user scope). # install-tray.sh — enable the menu-bar tray (darwin, user scope).
# Run as the console USER (no sudo): installs a launchd gui agent that runs # Installs a per-user LaunchAgent (RunAtLoad + KeepAlive) that owns the tray —
# tray/vpn-tray from this repo. Idempotent. # same pattern as com.lilith.mac-sync. launchd starts it at login (in the GUI
# session, so the menu-bar icon reliably appears) and relaunches it if it
# crashes. The tray's "Quit" handler boots out this same LABEL and drops the
# .tray-disabled flag, and the fleet agent's ensure_tray() is gated by that flag,
# so all three stay coherent. Run as the console USER (no sudo). Idempotent.
set -euo pipefail set -euo pipefail
if [ "$(uname -s)" != "Darwin" ]; then if [ "$(uname -s)" != "Darwin" ]; then
@ -9,13 +13,15 @@ if [ "$(uname -s)" != "Darwin" ]; then
exit 1 exit 1
fi fi
if [ "$EUID" -eq 0 ]; then if [ "$EUID" -eq 0 ]; then
echo "run as the console user, not root (gui launchd domain)" >&2 echo "run as the console user, not root (or let install-agent.sh call this via sudo -u)" >&2
exit 1 exit 1
fi fi
TRAY_DIR="$(cd "$(dirname "$0")" && pwd)" TRAY_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(dirname "$TRAY_DIR")"
LABEL="com.wireguard.vpn-tray" LABEL="com.wireguard.vpn-tray"
DST="$HOME/Library/LaunchAgents/$LABEL.plist" DISABLED="$REPO_DIR/data/.tray-disabled"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
echo "==> bootstrapping venv" echo "==> bootstrapping venv"
@ -23,13 +29,42 @@ if [ ! -x "$TRAY_DIR/.venv/bin/python" ]; then
"$TRAY_DIR/.venv/bin/pip" install -q -r "$TRAY_DIR/requirements.txt" "$TRAY_DIR/.venv/bin/pip" install -q -r "$TRAY_DIR/requirements.txt"
fi fi
# Keep the shipped plist honest about where this repo actually is. # Clear the user-quit flag — re-running this script means "bring the tray back".
/usr/bin/sed "s#<string>[^<]*/tray/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#; s#<string>/Users/[^<]*/.wireguard/vpn-tray</string>#<string>$TRAY_DIR/vpn-tray</string>#" \ rm -f "$DISABLED"
"$TRAY_DIR/$LABEL.plist" > "$DST"
# Write the LaunchAgent. KeepAlive.SuccessfulExit=false → relaunch on crash but
# respect a clean menu Quit (exit 0). RunAtLoad → start at login.
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$PLIST" <<PLISTEOF
<?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>Label</key><string>$LABEL</string>
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
<key>ProgramArguments</key>
<array>
<string>$TRAY_DIR/.venv/bin/python</string>
<string>$TRAY_DIR/vpn_tray.py</string>
</array>
<key>WorkingDirectory</key><string>$TRAY_DIR</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
<key>ThrottleInterval</key><integer>30</integer>
<key>StandardOutPath</key><string>/tmp/vpn-tray.log</string>
<key>StandardErrorPath</key><string>/tmp/vpn-tray.err</string>
</dict>
</plist>
PLISTEOF
# Hand the single tray process to launchd: kill any ad-hoc instance, (re)bootstrap.
pkill -f vpn_tray.py 2>/dev/null || true
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$DST" launchctl bootstrap "gui/$(id -u)" "$PLIST"
launchctl kickstart "gui/$(id -u)/$LABEL" launchctl kickstart -k "gui/$(id -u)/$LABEL" 2>/dev/null || true
sleep 2 sleep 2
echo "==> $(launchctl list | grep "$LABEL" || echo 'NOT RUNNING')"
echo "==> running from: $(ps -Ao command | grep '[v]pn_tray.py' | head -1)" echo "==> $(pgrep -fl vpn_tray.py | head -1 || echo 'NOT RUNNING (see /tmp/vpn-tray.err)')"
echo "==> launchd-managed (RunAtLoad + KeepAlive). Menu Quit boots it out + sets .tray-disabled."