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:
parent
57d51a7d4f
commit
6e6512abf6
3 changed files with 309 additions and 57 deletions
|
|
@ -63,6 +63,7 @@ class Config:
|
|||
gateway: str # e.g. "10.0.0.1"
|
||||
gateway_mac: str # home-LAN fingerprint
|
||||
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:
|
||||
raise ValueError("mesh-hosts.json lan.gateway_mac is required (home-LAN fingerprint)")
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""(gateway_ip, interface) of the current default route."""
|
||||
if PLATFORM == "darwin":
|
||||
|
|
@ -162,7 +168,8 @@ def default_route() -> tuple[str | None, str | None]:
|
|||
for line in out.splitlines():
|
||||
s = line.strip()
|
||||
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:"):
|
||||
iface = s.split()[1]
|
||||
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")
|
||||
return False
|
||||
route = _bin("route", "/sbin/route")
|
||||
rc, _, err = _run([route, "-n", "change", cidr, "-interface", iface])
|
||||
if rc == 0:
|
||||
rc, _, _ = _run([route, "-n", "change", cidr, "-interface", iface])
|
||||
if rc == 0 and subnet_route_iface(cidr) == iface:
|
||||
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])
|
||||
rc, _, err = _run([route, "-n", "add", cidr, "-interface", iface])
|
||||
if rc != 0:
|
||||
logger.error("failed to route %s via %s: %s", cidr, iface, err.strip())
|
||||
if rc != 0 or subnet_route_iface(cidr) != iface:
|
||||
logger.error("failed to route %s via %s: %s", cidr, iface,
|
||||
err.strip() or "route did not stick")
|
||||
return False
|
||||
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
|
||||
# Wi‑Fi/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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -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:
|
||||
"""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)."""
|
||||
if os.environ.get("NET_TOOLS_SKIP_PULL"):
|
||||
return False
|
||||
if not os.path.isdir(os.path.join(repo_root, ".git")):
|
||||
return False
|
||||
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])
|
||||
|
||||
|
||||
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")
|
||||
old: dict[str, str] = {}
|
||||
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.chmod(state_path, 0o644)
|
||||
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())))
|
||||
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]:
|
||||
"""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()
|
||||
home = bool(gw and gwif and gw == cfg.gateway and neighbor_mac(gw) == cfg.gateway_mac)
|
||||
return home, gw, gwif
|
||||
return False, 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:
|
||||
|
|
@ -531,24 +747,39 @@ def reconcile(cfg: Config, data: dict, ctx: dict) -> bool:
|
|||
if "hostname" in ctx["roles"] and ctx["self_name"]:
|
||||
enforce_hostname(ctx["self_name"])
|
||||
|
||||
home, gw, gwif = is_home(cfg)
|
||||
ctx["location"] = "HOME" if home else "AWAY"
|
||||
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:
|
||||
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)
|
||||
heal_default_route(cfg)
|
||||
pin_endpoint_route(cfg)
|
||||
|
||||
home, gw, gwif = is_home(cfg)
|
||||
ctx["location"] = "HOME" if home else "AWAY"
|
||||
|
||||
# 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:
|
||||
logger.info("%s → %s already via %s", state, cfg.lan_cidr, desired)
|
||||
elif ctx["last_state"] != state:
|
||||
logger.warning("away and no wg interface up — leaving %s untouched", cfg.lan_cidr)
|
||||
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).
|
||||
# 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:
|
||||
found[ctx["self_name"]] = my_lan_ip
|
||||
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
|
||||
if not ctx.get("rendered_once"):
|
||||
ctx["rendered_once"] = True
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -617,11 +852,13 @@ def main(argv: list[str] | None = None) -> int:
|
|||
return 1
|
||||
|
||||
if args.status:
|
||||
home, gw, gwif = is_home(cfg)
|
||||
home, gw, gwif, note = preview_location(cfg, ctx["roles"])
|
||||
print(f"platform : {PLATFORM}")
|
||||
print(f"self : {ctx['self_name'] or 'UNKNOWN (not in mesh-hosts.json!)'}"
|
||||
f" roles: {', '.join(sorted(ctx['roles']))}")
|
||||
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"render user: {ctx['render_user']}")
|
||||
sp = os.path.join(ctx["repo_root"], "data", "lan-state.json")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
#!/bin/bash
|
||||
# install-tray.sh — install the net-tools fleet tray (darwin, user scope).
|
||||
# Run as the console USER (no sudo): installs a launchd gui agent that runs
|
||||
# tray/vpn-tray from this repo. Idempotent.
|
||||
# install-tray.sh — enable the menu-bar tray (darwin, user scope).
|
||||
# Installs a per-user LaunchAgent (RunAtLoad + KeepAlive) that owns the tray —
|
||||
# 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
|
||||
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
|
|
@ -9,13 +13,15 @@ if [ "$(uname -s)" != "Darwin" ]; then
|
|||
exit 1
|
||||
fi
|
||||
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
|
||||
fi
|
||||
|
||||
TRAY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_DIR="$(dirname "$TRAY_DIR")"
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
# Keep the shipped plist honest about where this repo actually is.
|
||||
/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>#" \
|
||||
"$TRAY_DIR/$LABEL.plist" > "$DST"
|
||||
# Clear the user-quit flag — re-running this script means "bring the tray back".
|
||||
rm -f "$DISABLED"
|
||||
|
||||
# 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 bootstrap "gui/$(id -u)" "$DST"
|
||||
launchctl kickstart "gui/$(id -u)/$LABEL"
|
||||
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
||||
launchctl kickstart -k "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
||||
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."
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue