d131ba461c
Lint & Test / test (push) Successful in 20s
Security - Default scripts_management, callbacks_management, links_management, and media_folders_management to False so a leaked token cannot escalate to RCE through admin CRUD endpoints. - TokenSpec + scope hierarchy (read | control | admin); legacy bare-string api_tokens entries promote to admin for back-compat. Management endpoints now require admin scope. - WebSocket subprotocol auth (Sec-WebSocket-Protocol: media-server.token.<T>) preferred over ?token= query so the token no longer lands in URL/history/ Referer; query fallback retained for HA integration back-compat. - Origin allow-list check on the WS endpoint (CSWSH defence). - In-process token-bucket rate limiter: 5/min for failed auths, 10/min for /api/scripts/execute and /api/callbacks/execute. - shell=False subprocess path (shlex.split) + per-parameter regex `pattern` in ScriptParameterConfig to harden shell=true scripts against parameter injection (Windows cmd.exe env-var expansion). - CSP gains form-action, worker-src, manifest-src directives. - Refuse cors_origins=["*"] at startup; strip token=... from uvicorn access logs; validate Gitea release tag against strict SemVer regex. - noopener noreferrer + no-referrer referrerpolicy on every outbound link. - icacls hardening of config.yaml on Windows (current user + SYSTEM + Administrators only); 0600 still enforced on POSIX. - WS volume handler clamps input and never drops the socket on bad messages. Performance - Album-art read in windows_media gated by track key — was decoding the WinRT thumbnail twice per second regardless of track changes. - /api/media/artwork returns content-derived ETag + Cache-Control so the browser sends If-None-Match and gets 304s on track repeats. - Foreground-service ctypes argtypes hoisted to one-time module init (was re-declaring ~14 prototypes per probe). - display_service _static_cache keyed by (edid_hash, ...) tuple with eviction of disappeared monitors — fixes stale capabilities on hot-plug swaps where the new topology has the same monitor count. - Visualizer rAF loop paused on document.hidden, resumed on visible. Reliability / bug fixes - Lifespan rewritten as try/yield/finally so a partial-startup failure cannot orphan background tasks or executors. - _run_callback in routes/media.py keeps a strong task ref (GC-safe) and uses the dedicated callback executor instead of the default pool. - macos_media.set_volume() no longer always returns True. - TrayManager._restart_requested initialised in __init__; set before signalling exit so the main thread observes it correctly. - Missing static_dir now logs a WARNING instead of silent UI disable. UX / accessibility / PWA - manifest.json theme_color and background_color match the Studio Reference base (#0E0D0B); added id and scope for PWA installability. - ARIA on mini-player icon buttons; inner SVGs marked aria-hidden. - OS mediaSession API wired so headset / lockscreen / Bluetooth buttons drive play/pause/next/prev/seek and show track metadata + artwork. Observability - X-Request-ID middleware (accept upstream id if it matches a safe regex, otherwise UUID4); request_id_var added to ContextVars and included in every log line alongside the token label. - Audit log (append-only JSONL) for every script + callback execution, including the on_play/on_pause/etc. event callbacks. Background-thread writer; queue capped; flushed in lifespan teardown. Deployment - proxy_headers + forwarded_allow_ips plumbed through Settings → uvicorn.Config for reverse-proxy installs. - HTTPS support via ssl_certfile + ssl_keyfile (+ optional password); startup refuses to launch with only one of the pair set. - Thumbnail cache moved from project-root .cache to %LOCALAPPDATA%/media-server/cache (Windows) and $XDG_CACHE_HOME/media-server/thumbnails (POSIX). Tests - 35 new tests across auth scopes, rate limiter, browser path traversal (../ NUL UNC absolute), script-param validation incl. regex, Gitea tag whitelist, config atomic write + POSIX perms. 47 passed / 4 skipped.
544 lines
20 KiB
Python
544 lines
20 KiB
Python
"""Foreground (topmost) window/process tracking.
|
|
|
|
Reports the process that currently owns the foreground window, plus useful
|
|
metadata (window title, executable path, monitor index, whether the window
|
|
covers a full monitor, process start time).
|
|
|
|
All probes happen behind a short TTL cache so the WebSocket status poll and
|
|
per-entity HA polls don't pay the OS call cost on every tick.
|
|
|
|
Windows uses the Win32 API via ``ctypes`` (no extra dependency) and falls back
|
|
gracefully when individual probes fail. Linux/macOS implementations are
|
|
best-effort and return ``available=False`` when the required tooling is
|
|
missing, so the rest of the stack keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import platform
|
|
import threading
|
|
import time
|
|
from dataclasses import asdict, dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CACHE_TTL = 0.5 # seconds — fast enough for WebSocket broadcast loop
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ForegroundInfo:
|
|
"""Snapshot of the foreground window/process."""
|
|
|
|
available: bool
|
|
pid: int | None = None
|
|
process_name: str | None = None
|
|
executable_path: str | None = None
|
|
window_title: str | None = None
|
|
window_handle: int | None = None
|
|
is_fullscreen: bool = False
|
|
is_minimized: bool = False
|
|
monitor_id: int | None = None
|
|
monitor_geometry: dict[str, int] | None = None
|
|
window_geometry: dict[str, int] | None = None
|
|
started_at: float | None = None
|
|
platform: str = field(default_factory=lambda: platform.system())
|
|
error: str | None = None
|
|
# Populated only when the foreground process is a recognised web
|
|
# browser. ``browser_page_title`` is derived from the window title
|
|
# (suffix stripped); ``browser_url`` requires UIA to succeed.
|
|
is_browser: bool = False
|
|
browser_url: str | None = None
|
|
browser_page_title: str | None = None
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
|
|
_UNAVAILABLE = ForegroundInfo(available=False)
|
|
|
|
|
|
class _Cache:
|
|
"""Single-slot TTL cache shared across callers."""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._value: ForegroundInfo | None = None
|
|
self._fetched_at: float = 0.0
|
|
|
|
def get(self, ttl: float, fetch) -> ForegroundInfo:
|
|
with self._lock:
|
|
now = time.monotonic()
|
|
if self._value is not None and (now - self._fetched_at) < ttl:
|
|
return self._value
|
|
# Fetch outside the lock — OS calls can take tens of ms.
|
|
value = fetch()
|
|
with self._lock:
|
|
self._value = value
|
|
self._fetched_at = time.monotonic()
|
|
return value
|
|
|
|
def invalidate(self) -> None:
|
|
with self._lock:
|
|
self._value = None
|
|
self._fetched_at = 0.0
|
|
|
|
|
|
_cache = _Cache()
|
|
|
|
# Win32 handles + signatures are declared once at module load (when running on
|
|
# Windows). The TTL cache fires this hundreds of times per minute; redoing the
|
|
# DLL load + ~10 argtype assignments per call was the largest chunk of probe
|
|
# cost. Keep these guarded behind a lazy init so non-Windows platforms don't
|
|
# pay the import.
|
|
_WIN32_INITIALIZED = False
|
|
_win32_user32 = None
|
|
_win32_kernel32 = None
|
|
_win32_psapi = None
|
|
|
|
|
|
def _init_win32_apis() -> None:
|
|
"""Declare ctypes argtypes/restype on every Win32 call we make.
|
|
|
|
CRITICAL: ctypes defaults to `c_int` (32-bit) for HANDLE/HWND/HMONITOR
|
|
which silently truncates 64-bit pointer values on x64 — that corrupts the
|
|
handle so `CloseHandle()` can either fail or close the wrong kernel
|
|
object, and pointer-equality comparisons (monitor index lookup) miss.
|
|
"""
|
|
global _WIN32_INITIALIZED, _win32_user32, _win32_kernel32, _win32_psapi
|
|
if _WIN32_INITIALIZED:
|
|
return
|
|
|
|
import ctypes
|
|
import ctypes.wintypes as wt
|
|
|
|
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
psapi = ctypes.WinDLL("psapi", use_last_error=True)
|
|
|
|
user32.GetForegroundWindow.restype = wt.HWND
|
|
user32.GetWindowThreadProcessId.argtypes = [wt.HWND, ctypes.POINTER(wt.DWORD)]
|
|
user32.GetWindowThreadProcessId.restype = wt.DWORD
|
|
user32.GetWindowTextLengthW.argtypes = [wt.HWND]
|
|
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
|
user32.GetWindowTextW.argtypes = [wt.HWND, wt.LPWSTR, ctypes.c_int]
|
|
user32.GetWindowTextW.restype = ctypes.c_int
|
|
user32.IsIconic.argtypes = [wt.HWND]
|
|
user32.IsIconic.restype = wt.BOOL
|
|
user32.GetWindowRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)]
|
|
user32.GetWindowRect.restype = wt.BOOL
|
|
user32.MonitorFromWindow.argtypes = [wt.HWND, wt.DWORD]
|
|
user32.MonitorFromWindow.restype = wt.HMONITOR
|
|
user32.GetMonitorInfoW.argtypes = [wt.HMONITOR, ctypes.c_void_p]
|
|
user32.GetMonitorInfoW.restype = wt.BOOL
|
|
|
|
kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
|
|
kernel32.OpenProcess.restype = wt.HANDLE
|
|
kernel32.CloseHandle.argtypes = [wt.HANDLE]
|
|
kernel32.CloseHandle.restype = wt.BOOL
|
|
kernel32.QueryFullProcessImageNameW.argtypes = [
|
|
wt.HANDLE, wt.DWORD, wt.LPWSTR, ctypes.POINTER(wt.DWORD)
|
|
]
|
|
kernel32.QueryFullProcessImageNameW.restype = wt.BOOL
|
|
kernel32.GetProcessTimes.argtypes = [
|
|
wt.HANDLE,
|
|
ctypes.POINTER(wt.FILETIME),
|
|
ctypes.POINTER(wt.FILETIME),
|
|
ctypes.POINTER(wt.FILETIME),
|
|
ctypes.POINTER(wt.FILETIME),
|
|
]
|
|
kernel32.GetProcessTimes.restype = wt.BOOL
|
|
|
|
psapi.GetModuleFileNameExW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
|
|
psapi.GetModuleFileNameExW.restype = wt.DWORD
|
|
|
|
_win32_user32, _win32_kernel32, _win32_psapi = user32, kernel32, psapi
|
|
_WIN32_INITIALIZED = True
|
|
|
|
|
|
def _probe_windows() -> ForegroundInfo:
|
|
"""Probe foreground window state on Windows via Win32 API."""
|
|
import ctypes
|
|
import ctypes.wintypes as wt
|
|
|
|
_init_win32_apis()
|
|
user32 = _win32_user32
|
|
kernel32 = _win32_kernel32
|
|
psapi = _win32_psapi
|
|
|
|
hwnd = user32.GetForegroundWindow()
|
|
if not hwnd:
|
|
return ForegroundInfo(available=True, error="no foreground window")
|
|
|
|
# PID + window thread.
|
|
pid = wt.DWORD(0)
|
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
|
pid_val = int(pid.value) if pid.value else None
|
|
|
|
# Window title — Unicode.
|
|
length = user32.GetWindowTextLengthW(hwnd)
|
|
title_buf = ctypes.create_unicode_buffer(length + 1)
|
|
user32.GetWindowTextW(hwnd, title_buf, length + 1)
|
|
window_title = title_buf.value or None
|
|
|
|
# Minimized flag.
|
|
is_minimized = bool(user32.IsIconic(hwnd))
|
|
|
|
# Window rect (screen coords).
|
|
rect = wt.RECT()
|
|
window_geometry: dict[str, int] | None = None
|
|
if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
|
|
window_geometry = {
|
|
"left": int(rect.left),
|
|
"top": int(rect.top),
|
|
"right": int(rect.right),
|
|
"bottom": int(rect.bottom),
|
|
"width": int(rect.right - rect.left),
|
|
"height": int(rect.bottom - rect.top),
|
|
}
|
|
|
|
# Monitor under the window + its geometry.
|
|
monitor_geometry: dict[str, int] | None = None
|
|
monitor_id: int | None = None
|
|
is_fullscreen = False
|
|
try:
|
|
MONITOR_DEFAULTTONEAREST = 2
|
|
|
|
class MONITORINFO(ctypes.Structure):
|
|
_fields_ = [
|
|
("cbSize", wt.DWORD),
|
|
("rcMonitor", wt.RECT),
|
|
("rcWork", wt.RECT),
|
|
("dwFlags", wt.DWORD),
|
|
]
|
|
|
|
hmon = user32.MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST)
|
|
if hmon:
|
|
mi = MONITORINFO()
|
|
mi.cbSize = ctypes.sizeof(mi)
|
|
if user32.GetMonitorInfoW(hmon, ctypes.byref(mi)):
|
|
monitor_geometry = {
|
|
"left": int(mi.rcMonitor.left),
|
|
"top": int(mi.rcMonitor.top),
|
|
"right": int(mi.rcMonitor.right),
|
|
"bottom": int(mi.rcMonitor.bottom),
|
|
"width": int(mi.rcMonitor.right - mi.rcMonitor.left),
|
|
"height": int(mi.rcMonitor.bottom - mi.rcMonitor.top),
|
|
}
|
|
# Fullscreen heuristic: window rect equals monitor rect AND
|
|
# not minimized. Many media players (VLC, browser fullscreen)
|
|
# set themselves to exactly the monitor bounds.
|
|
if window_geometry and not is_minimized:
|
|
is_fullscreen = (
|
|
window_geometry["left"] == monitor_geometry["left"]
|
|
and window_geometry["top"] == monitor_geometry["top"]
|
|
and window_geometry["right"] == monitor_geometry["right"]
|
|
and window_geometry["bottom"] == monitor_geometry["bottom"]
|
|
)
|
|
|
|
# Resolve monitor index by enumerating displays in order. Coerce
|
|
# both the foreground hmon and the per-enum hmon to int so the
|
|
# equality compare uses 64-bit values consistently regardless of
|
|
# how ctypes represents the handle internally.
|
|
try:
|
|
indexed: list[int] = []
|
|
|
|
def _cb(hm, _hdc, _rect, _data):
|
|
indexed.append(int(hm) if hm else 0)
|
|
return True
|
|
|
|
MONITORENUMPROC = ctypes.WINFUNCTYPE(
|
|
ctypes.c_int,
|
|
wt.HMONITOR,
|
|
wt.HDC,
|
|
ctypes.POINTER(wt.RECT),
|
|
wt.LPARAM,
|
|
)
|
|
user32.EnumDisplayMonitors.argtypes = [
|
|
wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM
|
|
]
|
|
user32.EnumDisplayMonitors.restype = wt.BOOL
|
|
user32.EnumDisplayMonitors(None, None, MONITORENUMPROC(_cb), 0)
|
|
target = int(hmon) if hmon else 0
|
|
if target and target in indexed:
|
|
monitor_id = indexed.index(target)
|
|
except Exception as e:
|
|
logger.debug("Monitor index resolution failed: %s", e)
|
|
except Exception as e:
|
|
logger.debug("Monitor info probe failed: %s", e)
|
|
|
|
# Process executable path + start time.
|
|
executable_path: str | None = None
|
|
process_name: str | None = None
|
|
started_at: float | None = None
|
|
if pid_val:
|
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
h_proc = kernel32.OpenProcess(
|
|
PROCESS_QUERY_LIMITED_INFORMATION, False, pid_val
|
|
)
|
|
if h_proc:
|
|
try:
|
|
# Image filename — full path. QueryFullProcessImageNameW works
|
|
# across 32/64-bit boundaries, unlike GetModuleFileNameExW.
|
|
buf = ctypes.create_unicode_buffer(1024)
|
|
size = wt.DWORD(len(buf))
|
|
if kernel32.QueryFullProcessImageNameW(
|
|
h_proc, 0, buf, ctypes.byref(size)
|
|
):
|
|
executable_path = buf.value or None
|
|
else:
|
|
# Fallback via psapi. Return value is the length copied
|
|
# into the buffer (0 on failure); ignoring it would leave
|
|
# `executable_path` as an empty string from the freshly
|
|
# allocated buffer instead of None.
|
|
written = psapi.GetModuleFileNameExW(h_proc, None, buf, len(buf))
|
|
if written:
|
|
executable_path = buf.value or None
|
|
else:
|
|
logger.debug(
|
|
"QueryFullProcessImageNameW + psapi fallback both "
|
|
"failed for pid=%s (err=%d)",
|
|
pid_val,
|
|
ctypes.get_last_error(),
|
|
)
|
|
|
|
if executable_path:
|
|
import os
|
|
process_name = os.path.basename(executable_path)
|
|
|
|
# Process creation time (FILETIME, 100ns ticks since 1601).
|
|
creation = wt.FILETIME()
|
|
exit_t = wt.FILETIME()
|
|
kernel_t = wt.FILETIME()
|
|
user_t = wt.FILETIME()
|
|
if kernel32.GetProcessTimes(
|
|
h_proc,
|
|
ctypes.byref(creation),
|
|
ctypes.byref(exit_t),
|
|
ctypes.byref(kernel_t),
|
|
ctypes.byref(user_t),
|
|
):
|
|
ticks = (creation.dwHighDateTime << 32) | creation.dwLowDateTime
|
|
# Convert to Unix epoch seconds (1601-01-01 → 1970-01-01).
|
|
if ticks:
|
|
started_at = (ticks - 116444736000000000) / 10_000_000
|
|
finally:
|
|
kernel32.CloseHandle(h_proc)
|
|
|
|
return ForegroundInfo(
|
|
available=True,
|
|
pid=pid_val,
|
|
process_name=process_name,
|
|
executable_path=executable_path,
|
|
window_title=window_title,
|
|
window_handle=int(hwnd) if hwnd else None,
|
|
is_fullscreen=is_fullscreen,
|
|
is_minimized=is_minimized,
|
|
monitor_id=monitor_id,
|
|
monitor_geometry=monitor_geometry,
|
|
window_geometry=window_geometry,
|
|
started_at=started_at,
|
|
)
|
|
|
|
|
|
def _probe_macos() -> ForegroundInfo:
|
|
"""Best-effort probe on macOS via AppKit (PyObjC).
|
|
|
|
Returns ``available=False`` when PyObjC is not installed — we don't take
|
|
a hard dependency on it because the typical macOS install path uses pip
|
|
+ the standalone wheel.
|
|
"""
|
|
try:
|
|
from AppKit import NSWorkspace # type: ignore
|
|
from Quartz import ( # type: ignore
|
|
CGWindowListCopyWindowInfo,
|
|
kCGNullWindowID,
|
|
kCGWindowListOptionOnScreenOnly,
|
|
)
|
|
except Exception:
|
|
return ForegroundInfo(available=False, error="AppKit/Quartz not available")
|
|
|
|
try:
|
|
ws = NSWorkspace.sharedWorkspace()
|
|
app = ws.frontmostApplication()
|
|
if app is None:
|
|
return ForegroundInfo(available=True, error="no frontmost app")
|
|
|
|
pid = int(app.processIdentifier())
|
|
process_name = str(app.localizedName() or "")
|
|
bundle_url = app.bundleURL()
|
|
executable_path = str(bundle_url.path()) if bundle_url else None
|
|
started_at = None
|
|
launch_date = app.launchDate()
|
|
if launch_date is not None:
|
|
started_at = float(launch_date.timeIntervalSince1970())
|
|
|
|
# Window title — frontmost on-screen window owned by this PID.
|
|
window_title: str | None = None
|
|
try:
|
|
windows = CGWindowListCopyWindowInfo(
|
|
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
|
|
)
|
|
for w in windows or []:
|
|
if int(w.get("kCGWindowOwnerPID", -1)) == pid:
|
|
name = w.get("kCGWindowName")
|
|
if name:
|
|
window_title = str(name)
|
|
break
|
|
except Exception as e:
|
|
logger.debug("CGWindowListCopyWindowInfo failed: %s", e)
|
|
|
|
return ForegroundInfo(
|
|
available=True,
|
|
pid=pid,
|
|
process_name=process_name,
|
|
executable_path=executable_path,
|
|
window_title=window_title,
|
|
started_at=started_at,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("macOS foreground probe failed: %s", e)
|
|
return ForegroundInfo(available=False, error=str(e))
|
|
|
|
|
|
def _probe_linux() -> ForegroundInfo:
|
|
"""Best-effort probe on Linux via Xlib (X11 only).
|
|
|
|
Wayland sessions intentionally hide window/process info from unprivileged
|
|
clients, so this returns ``available=False`` on Wayland. The caller still
|
|
gets a structured response and can render "unavailable" in the UI.
|
|
"""
|
|
import os
|
|
|
|
if os.environ.get("WAYLAND_DISPLAY"):
|
|
return ForegroundInfo(
|
|
available=False, error="Wayland session — foreground probe unavailable"
|
|
)
|
|
|
|
try:
|
|
from Xlib import X, display # type: ignore # noqa: F401
|
|
except Exception:
|
|
return ForegroundInfo(available=False, error="python-xlib not installed")
|
|
|
|
try:
|
|
d = display.Display()
|
|
root = d.screen().root
|
|
NET_ACTIVE_WINDOW = d.intern_atom("_NET_ACTIVE_WINDOW")
|
|
NET_WM_PID = d.intern_atom("_NET_WM_PID")
|
|
NET_WM_NAME = d.intern_atom("_NET_WM_NAME")
|
|
UTF8_STRING = d.intern_atom("UTF8_STRING")
|
|
|
|
active = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType)
|
|
if not active or not active.value:
|
|
return ForegroundInfo(available=True, error="no active window")
|
|
win_id = int(active.value[0])
|
|
win = d.create_resource_object("window", win_id)
|
|
|
|
pid_prop = win.get_full_property(NET_WM_PID, X.AnyPropertyType)
|
|
pid_val = int(pid_prop.value[0]) if pid_prop and pid_prop.value else None
|
|
|
|
name_prop = win.get_full_property(NET_WM_NAME, UTF8_STRING)
|
|
window_title = (
|
|
name_prop.value.decode("utf-8", "replace") if name_prop and name_prop.value else None
|
|
)
|
|
|
|
process_name: str | None = None
|
|
executable_path: str | None = None
|
|
started_at: float | None = None
|
|
if pid_val:
|
|
try:
|
|
exe = os.readlink(f"/proc/{pid_val}/exe")
|
|
executable_path = exe
|
|
process_name = os.path.basename(exe)
|
|
except OSError as e:
|
|
logger.debug("readlink /proc/%d/exe failed: %s", pid_val, e)
|
|
try:
|
|
started_at = os.stat(f"/proc/{pid_val}").st_ctime
|
|
except OSError as e:
|
|
logger.debug("stat /proc/%d failed: %s", pid_val, e)
|
|
|
|
return ForegroundInfo(
|
|
available=True,
|
|
pid=pid_val,
|
|
process_name=process_name,
|
|
executable_path=executable_path,
|
|
window_title=window_title,
|
|
window_handle=win_id,
|
|
started_at=started_at,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Linux foreground probe failed: %s", e)
|
|
return ForegroundInfo(available=False, error=str(e))
|
|
|
|
|
|
def _enrich_browser(info: ForegroundInfo) -> ForegroundInfo:
|
|
"""If ``info`` describes a focused browser, attach URL + page title.
|
|
|
|
The UIA lookup is wrapped in its own try/except so a failure here can't
|
|
take down the rest of the foreground probe.
|
|
"""
|
|
try:
|
|
from . import browser_url_service as bus
|
|
except Exception as e:
|
|
logger.debug("browser_url_service unavailable: %s", e)
|
|
return info
|
|
|
|
if not info.available or not bus.is_browser_process(info.process_name):
|
|
return info
|
|
|
|
try:
|
|
page = bus.get_browser_page(
|
|
hwnd=info.window_handle,
|
|
process_name=info.process_name,
|
|
window_title=info.window_title,
|
|
)
|
|
except Exception as e:
|
|
logger.debug("Browser URL enrichment failed: %s", e)
|
|
return info
|
|
|
|
# ``dataclasses.replace`` keeps the frozen-dataclass contract.
|
|
from dataclasses import replace
|
|
return replace(
|
|
info,
|
|
is_browser=True,
|
|
browser_url=page.url,
|
|
browser_page_title=page.page_title,
|
|
)
|
|
|
|
|
|
def _probe() -> ForegroundInfo:
|
|
system = platform.system()
|
|
try:
|
|
if system == "Windows":
|
|
info = _probe_windows()
|
|
elif system == "Darwin":
|
|
info = _probe_macos()
|
|
elif system == "Linux":
|
|
info = _probe_linux()
|
|
else:
|
|
return ForegroundInfo(
|
|
available=False, error=f"unsupported platform: {system}"
|
|
)
|
|
return _enrich_browser(info)
|
|
except Exception as e:
|
|
logger.warning("Foreground probe crashed: %s", e)
|
|
return ForegroundInfo(available=False, error=str(e))
|
|
|
|
|
|
def get_foreground_info(force_refresh: bool = False) -> ForegroundInfo:
|
|
"""Return the current foreground window/process snapshot.
|
|
|
|
Args:
|
|
force_refresh: bypass the short TTL cache. WebSocket broadcast loop
|
|
should leave this False; the REST endpoint accepts ?refresh=1
|
|
for callers that want a fresh probe.
|
|
"""
|
|
if force_refresh:
|
|
_cache.invalidate()
|
|
return _cache.get(_CACHE_TTL, _probe)
|
|
|
|
|
|
def reset_cache() -> None:
|
|
"""Reset the cache. Useful in tests."""
|
|
_cache.invalidate()
|