"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on failure. These helpers run such commands asynchronously and hand the parsed result back on the main thread. """ from __future__ import annotations import json import shlex from typing import Callable, Sequence import gi gi.require_version("Gio", "2.0") from gi.repository import Gio, GLib # noqa: E402 def _as_argv(cmd: Sequence[str] | str) -> list[str]: return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" try: proc = Gio.Subprocess.new( _as_argv(cmd), Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, ) except GLib.Error as err: GLib.idle_add(cb, False, "", str(err)) return def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: try: _, out, errout = p.communicate_utf8_finish(res) except GLib.Error as err: cb(False, "", str(err)) return cb(p.get_successful(), out or "", errout or "") proc.communicate_utf8_async(None, None, _done) def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: """Run cmd expecting JSON on stdout; call cb(ok, data).""" def _text(ok: bool, out: str, err: str) -> None: if not ok or not out.strip(): cb(False, err.strip() or "no output") return try: cb(True, json.loads(out)) except json.JSONDecodeError as exc: cb(False, f"bad json: {exc}") run_text(cmd, _text) class Poller: """Repeatedly run a command (or callable) on an interval, main-thread callback. Used for lightweight state that has no change signal (open ports, public IP, weather refresh). Modules that back onto an Astal GObject service should prefer connecting to that service's `notify::` signals instead of polling. """ def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False): self.interval_s = interval_s self.cmd = cmd self.cb = cb self.json_mode = json_mode self._source_id: int | None = None self._stopped = False def start(self) -> "Poller": self._stopped = False self._tick() self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick) return self def _tick(self) -> bool: if self._stopped: return GLib.SOURCE_REMOVE if self.json_mode: run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data)) else: run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err)) return GLib.SOURCE_CONTINUE def refresh_now(self) -> None: self._tick() def stop(self) -> None: self._stopped = True if self._source_id is not None: GLib.source_remove(self._source_id) self._source_id = None