"""Thin wrapper around swaymsg and local process launching. Identical in shape to hosts/thin-client/agent/thinclient_agent/sway_control.py and hosts/touch-panel/agent/touchpanel_agent/sway_control.py — duplicated rather than imported across hosts, same convention as the rest of this repo's agents. """ from __future__ import annotations import glob import logging import os import subprocess log = logging.getLogger(__name__) # Contract with configs/sway/config — these strings must match the `set $ws_*` lines, # and with configs/session/media-session and configs/session/session-watcher, which # hardcode the same names in shell. WS_STEAM = "1:steam" WS_GAMES = "2:games" WS_WEB = "3:web" WS_MEDIA = "4:media" WS_MUSIC = "5:music" def runtime_dir() -> str: return os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" class SwayControl: def _socket_path(self) -> str | None: path = os.environ.get("SWAYSOCK") if path and os.path.exists(path): return path # sway names the socket sway-ipc...sock, so the path changes every # time sway restarts. steamtv-agent is a system service that outlives the # session, so the socket is re-resolved per call instead of cached at startup. matches = sorted(glob.glob(os.path.join(runtime_dir(), "sway-ipc.*.sock"))) return matches[-1] if matches else None def session_env(self) -> dict[str, str]: env = dict(os.environ) env["XDG_RUNTIME_DIR"] = runtime_dir() env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path={runtime_dir()}/bus") env.setdefault("WAYLAND_DISPLAY", "wayland-1") sock = self._socket_path() if sock: env["SWAYSOCK"] = sock return env def swaymsg(self, *args: str) -> str | None: if self._socket_path() is None: log.warning("no sway IPC socket found; dropping command %s", " ".join(args)) return None try: result = subprocess.run( ["swaymsg", *args], env=self.session_env(), capture_output=True, text=True, timeout=10, check=False, ) except (OSError, subprocess.TimeoutExpired) as exc: log.warning("swaymsg %s failed: %s", " ".join(args), exc) return None if result.returncode != 0: log.warning("swaymsg %s: %s", " ".join(args), result.stderr.strip()) return None return result.stdout def switch_workspace(self, name: str) -> None: log.info("switching to workspace %s", name) self.swaymsg("workspace", name) def focus_window(self, criteria: str) -> None: self.swaymsg(f"[{criteria}] focus") def is_running(self, pattern: str) -> bool: """Match `pattern` against full command lines (pgrep -f).""" return self._pgrep("-f", pattern) def is_process(self, name: str) -> bool: """Match `name` against process names only (pgrep -x). Separate from is_running() because Steam is the one thing here that needs it: its command line is `/bin/sh /usr/games/steam -gamepadui …` and it spawns a dozen helpers (steamwebhelper, reaper, steamerrorreporter) whose command lines all contain the word "steam". A -f match would report Steam as running long after the client has gone, which would leave the session-mode sensor stuck on "gaming" for the rest of the evening. """ return self._pgrep("-x", name) def _pgrep(self, flag: str, pattern: str) -> bool: try: result = subprocess.run( ["pgrep", "-u", str(os.getuid()), flag, pattern], capture_output=True, timeout=5, check=False, ) except (OSError, subprocess.TimeoutExpired): return False return result.returncode == 0 def launch_app( self, command: list[str], process_pattern: str | None = None, workspace: str | None = None, focus_criteria: str | None = None, ) -> None: if workspace: self.switch_workspace(workspace) if process_pattern and self.is_running(process_pattern): log.info("%s already running; focusing instead of launching", command[0]) if focus_criteria: self.focus_window(focus_criteria) return log.info("launching %s", " ".join(command)) try: subprocess.Popen( command, env=self.session_env(), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except OSError as exc: log.error("could not launch %s: %s", " ".join(command), exc)