life-tooling/scripts/tray.py
2026-03-20 09:32:40 -07:00

147 lines
4.3 KiB
Python
Executable file

#!/usr/bin/env python3
"""Life Manager system tray icon.
Uses lilith-tray for cross-platform tray abstraction.
AyatanaAppIndicator3 backend on Linux (auto-detected).
Mode is set via LM_TRAY_MODE env var:
- "dev" → checks ports (API 3700, frontend 5701, postgres 25471, redis 26370)
- "prod" → checks systemd units (life-manager-api, caddy, vram)
"""
import os
import socket
import subprocess
from lilith_tray import TrayApp, TrayConfig, TrayIcon, TrayMenuItem
MODE = os.environ.get("LM_TRAY_MODE", "dev")
DEV_APP_URL = f"http://{socket.gethostname()}.local:5701"
PROD_APP_URL = f"https://{socket.gethostname()}.local:5700"
APP_URL = PROD_APP_URL if MODE == "prod" else DEV_APP_URL
DEV_SERVICES = {
"api": 3700,
"frontend": 5701,
"postgres": 25471,
"redis": 26370,
}
PROD_UNITS = [
"life-manager-api.service",
"life-manager-caddy.service",
"life-manager-vram.service",
]
# Use @lilith/tray-resources generated icons (24px hexagon-hub)
RESOURCES_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "..", "@packages", "@tray", "tray-resources", "generated", "hexagon-hub",
)
def run_cmd(*args: str) -> str:
result = subprocess.run(args, capture_output=True, text=True, timeout=30)
return result.stdout.strip()
def systemctl_cmd(*args: str) -> None:
subprocess.run(["systemctl", "--user", *args], capture_output=True, timeout=30)
def port_open(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(1)
return s.connect_ex(("127.0.0.1", port)) == 0
def open_browser() -> None:
subprocess.Popen(
["xdg-open", APP_URL],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
class LifeManagerTray(TrayApp):
def __init__(self) -> None:
icons = {
"green": TrayIcon.from_file(os.path.join(RESOURCES_DIR, "green-24.png")),
"red": TrayIcon.from_file(os.path.join(RESOURCES_DIR, "red-24.png")),
"yellow": TrayIcon.from_file(os.path.join(RESOURCES_DIR, "yellow-24.png")),
}
menu = [
TrayMenuItem.action("Open Life Manager", open_browser),
TrayMenuItem.separator(),
TrayMenuItem.separator(),
TrayMenuItem.action("Start", self._start_services),
TrayMenuItem.action("Stop", self._stop_services),
TrayMenuItem.action("Restart", self._restart_services),
TrayMenuItem.separator(),
TrayMenuItem.quit("Quit Tray"),
]
config = TrayConfig(
name="life-manager",
icons=icons,
initial_icon="red",
menu=menu,
poll_interval=10,
)
super().__init__(config)
def poll_status(self) -> str:
statuses = self._get_service_statuses()
all_up = all(s == "active" for s in statuses.values())
return "green" if all_up else "red"
def get_status_labels(self) -> dict[str, str]:
return self._get_service_statuses()
def _get_service_statuses(self) -> dict[str, str]:
if MODE == "prod":
return self._check_systemd()
return self._check_ports()
def _check_ports(self) -> dict[str, str]:
statuses = {}
for name, port in DEV_SERVICES.items():
statuses[name] = "active" if port_open(port) else "inactive"
return statuses
def _check_systemd(self) -> dict[str, str]:
statuses = {}
for unit in PROD_UNITS:
try:
status = run_cmd("systemctl", "--user", "is-active", unit)
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
status = "error"
name = unit.replace("life-manager-", "").replace(".service", "")
statuses[name] = status
return statuses
def _start_services(self) -> None:
if MODE == "prod":
for unit in PROD_UNITS:
systemctl_cmd("start", unit)
def _stop_services(self) -> None:
if MODE == "prod":
for unit in PROD_UNITS:
systemctl_cmd("stop", unit)
def _restart_services(self) -> None:
if MODE == "prod":
for unit in PROD_UNITS:
systemctl_cmd("restart", unit)
def main() -> None:
app = LifeManagerTray()
app.run()
if __name__ == "__main__":
main()