feat(foreground): track topmost process + browser page title
Lint & Test / test (push) Failing after 8s
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user