feat(foreground): track topmost process + browser page title
Lint & Test / test (push) Failing after 8s

Adds cross-platform foreground-window tracking and exposes it over REST
(/api/foreground) and the existing WebSocket feed.

- foreground_service.py: Windows probe via ctypes (HANDLE-correct argtypes
  to avoid 64-bit handle truncation); macOS via AppKit; Linux via Xlib
  (Wayland returns unavailable). TTL cache + per-platform fallback.
- browser_url_service.py: when foreground is a recognised browser, extract
  the page title from the window title (browser-name suffix stripped) and
  surface `is_browser` + `browser_page_title`. Optional UIA-based URL
  extraction behind MEDIA_SERVER_BROWSER_UIA env flag (off by default —
  Chromium browsers keep their accessibility tree dormant otherwise).
- websocket_manager: poll foreground every 1s inside the existing status
  loop, broadcast `foreground` on connect and `foreground_update` on
  change. Diff only on user-visible fields to avoid geometry spam.
- WebUI: new editorial card rendered under the monitor list on the
  Display tab — process name, window title, fullscreen/minimized/monitor
  chips, browser block when applicable, exe path, PID, started-ago,
  geometry, platform. 16px inter-section gap matches Settings cadence.
- i18n: 25 new keys added to both en.json and ru.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:11:59 +03:00
parent 0cf49deac0
commit 61cdce9b60
15 changed files with 1571 additions and 3 deletions
@@ -19,6 +19,9 @@ class ConnectionManager:
self._active_connections: set[WebSocket] = set()
self._lock = asyncio.Lock()
self._last_status: dict[str, Any] | None = None
self._last_foreground: dict[str, Any] | None = None
self._foreground_poll_interval: float = 1.0
self._last_foreground_poll: float = 0.0
self._get_status_func: Callable[[], Coroutine[Any, Any, Any]] | None = None
self._broadcast_task: asyncio.Task | None = None
self._poll_interval: float = 0.5 # Internal poll interval for change detection
@@ -54,6 +57,18 @@ class ConnectionManager:
except Exception as e:
logger.debug("Failed to send initial status: %s", e)
# Push a fresh foreground snapshot on connect so the UI can render
# the tile immediately instead of waiting for the next change.
try:
from .foreground_service import get_foreground_info
fg = await asyncio.to_thread(get_foreground_info)
fg_dict = fg.to_dict()
self._last_foreground = fg_dict
await websocket.send_json({"type": "foreground", "data": fg_dict})
except Exception as e:
logger.debug("Failed to send initial foreground snapshot: %s", e)
async def disconnect(self, websocket: WebSocket) -> None:
"""Remove a WebSocket connection. Stops audio capture if last visualizer subscriber."""
should_stop = False
@@ -115,6 +130,35 @@ class ConnectionManager:
await self.broadcast(message)
logger.info("Broadcast sent: links_changed")
def foreground_changed(
self, old: dict[str, Any] | None, new: dict[str, Any]
) -> bool:
"""Detect a meaningful change in the foreground process snapshot.
The probe also returns ``window_geometry`` which jitters on every
pixel of cursor drag — comparing the whole dict would flood clients.
We only diff the fields a user (or HA automation) would actually act
on. ``window_geometry``/``monitor_geometry``/``started_at`` are still
delivered in the payload, but they don't drive broadcast cadence.
"""
if old is None:
return True
diff_fields = (
"pid",
"process_name",
"executable_path",
"window_title",
"is_fullscreen",
"is_minimized",
"monitor_id",
"available",
"error",
)
for f in diff_fields:
if old.get(f) != new.get(f):
return True
return False
async def subscribe_visualizer(self, websocket: WebSocket) -> None:
"""Subscribe a client to audio visualizer data. Starts capture on first subscriber."""
should_start = False
@@ -314,6 +358,10 @@ class ConnectionManager:
get_status_func: Callable[[], Coroutine[Any, Any, Any]],
) -> None:
"""Background loop that polls for status changes and broadcasts."""
# Foreground tracker is imported lazily so unit tests of the WS
# manager don't drag in platform-specific probe code.
from .foreground_service import get_foreground_info
while self._running:
try:
# Only poll if we have connected clients
@@ -340,6 +388,28 @@ class ConnectionManager:
# Update cached status even without broadcast
self._last_status = status_dict
# Foreground process — poll at a coarser interval than media
# status. Broadcasts only fire on a real change, so a quiet
# desktop costs nothing.
now = time.time()
if (
now - self._last_foreground_poll
) >= self._foreground_poll_interval:
self._last_foreground_poll = now
try:
fg = await asyncio.to_thread(get_foreground_info)
fg_dict = fg.to_dict()
if self.foreground_changed(self._last_foreground, fg_dict):
self._last_foreground = fg_dict
await self.broadcast(
{"type": "foreground_update", "data": fg_dict}
)
logger.debug("Broadcast sent: foreground change")
else:
self._last_foreground = fg_dict
except Exception as e:
logger.debug("Foreground poll failed: %s", e)
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError: